feat: Support HA event entities as triggers (#2506)
This commit is contained in:
committed by
dermotduffy
parent
f1fad59a89
commit
4b72fcd629
@@ -148,18 +148,25 @@ human interaction with the card; this behavior can be configured via the
|
|||||||
> action is taken immediately. If multiple cameras are triggered at startup, they
|
> action is taken immediately. If multiple cameras are triggered at startup, they
|
||||||
> are all marked as triggered, but the action is only taken for the first one.
|
> are all marked as triggered, but the action is only taken for the first one.
|
||||||
|
|
||||||
| Option | Default | Description |
|
| Option | Default | Description |
|
||||||
| ------------------------- | ------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
| ------------------------- | ------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
| `actions` | | The actions to take when a camera is triggered. See [Trigger action configuration](#trigger-action-configuration). |
|
| `actions` | | The actions to take when a camera is triggered. See [Trigger action configuration](#trigger-action-configuration). |
|
||||||
| `filter_selected_camera` | `true` | If set to `true` will only trigger on the currently selected camera. |
|
| `filter_selected_camera` | `true` | If set to `true` will only trigger on the currently selected camera. |
|
||||||
| `show_trigger_status` | `false` | Whether or not the `live` view should show a visual indication that it is triggered (a pulsing border around the camera edge). |
|
| `show_trigger_status` | `false` | Whether or not the `live` view should show a visual indication that it is triggered (a pulsing border around the camera edge). |
|
||||||
| `untrigger_delay_seconds` | `0` | The number of seconds to continue to consider the camera triggered after the entity/event/state has reset, before considering the camera untriggered and taking the configured `untrigger` action. |
|
| `untrigger_delay_seconds` | `0` | The number of seconds to continue to consider the camera triggered after the source ends, before taking the configured `untrigger` action. For instantaneous trigger sources (e.g. HA `event.*` entities, or a doorbell button that only briefly pulses on) this is effectively the entire visible "active" duration — the source itself provides no on-period of its own. |
|
||||||
| `untrigger_force_seconds` | `0` | The number of seconds after a camera first triggers before force untriggering that camera. Set to `0` to disable. |
|
| `untrigger_force_seconds` | `0` | The number of seconds after a camera first triggers before force untriggering that camera. Set to `0` to disable. |
|
||||||
|
|
||||||
> [!WARNING] If `untrigger_force_seconds` is used to untrigger a camera, the
|
> [!WARNING] If `untrigger_force_seconds` is used to untrigger a camera, the
|
||||||
> state will need to 'reset' (e.g. an entity would need to change state to
|
> state will need to 'reset' (e.g. an entity would need to change state to
|
||||||
> `off`) before it will trigger again.
|
> `off`) before it will trigger again.
|
||||||
|
|
||||||
|
> [!TIP] When pairing `trigger: call` with `untrigger: call` (the
|
||||||
|
> "ring-then-end-if-unanswered" pattern), the ring lasts until the source ends
|
||||||
|
> plus `untrigger_delay_seconds`. With an instantaneous trigger source (event
|
||||||
|
> entity, brief switch pulse) and the default of `0`, the call would start and
|
||||||
|
> end in the same tick. In these cases, set a positive value (e.g. `30`) for a
|
||||||
|
> meaningful ring duration.
|
||||||
|
|
||||||
### Trigger action configuration
|
### Trigger action configuration
|
||||||
|
|
||||||
| Option | Default | Description |
|
| Option | Default | Description |
|
||||||
|
|||||||
+10
-9
@@ -361,7 +361,9 @@ elements:
|
|||||||
|
|
||||||
### Inbound call on doorbell press
|
### Inbound call on doorbell press
|
||||||
|
|
||||||
This example uses [`view.triggers.actions.trigger: call`](configuration/view.md?id=triggers) to turn an dashboard into a phone-like ringer when somebody presses the doorbell. The intended deployment is a wall-mounted tablet sitting on the dashboard.
|
This example uses [`view.triggers.actions.trigger: call`](configuration/view.md?id=triggers) to turn a dashboard into a phone-like ringer when somebody presses the doorbell. The intended deployment is a wall-mounted tablet sitting on the dashboard.
|
||||||
|
|
||||||
|
The trigger entity is an [HA `event.*` entity](https://www.home-assistant.io/integrations/event/#device-class) with `device_class: doorbell` — the officially supported way modern integrations (ONVIF, UniFi Protect, Reolink, MQTT, etc.) expose a doorbell press. A press fires the entity instantaneously; the card treats it as a momentary signal and rings for `untrigger_delay_seconds`. A `switch.*` or `binary_sensor.*` entity that goes on/off with each press works will also work fine.
|
||||||
|
|
||||||
The Frigate camera's stock event triggers (`occupancy`, `motion`, `events`) are explicitly turned off so casual motion doesn't make the card ring — only an actual doorbell press does.
|
The Frigate camera's stock event triggers (`occupancy`, `motion`, `events`) are explicitly turned off so casual motion doesn't make the card ring — only an actual doorbell press does.
|
||||||
|
|
||||||
@@ -378,22 +380,21 @@ cameras:
|
|||||||
motion: false
|
motion: false
|
||||||
events: []
|
events: []
|
||||||
entities:
|
entities:
|
||||||
- switch.door_bell
|
- event.front_door_doorbell
|
||||||
view:
|
view:
|
||||||
default: live
|
default: live
|
||||||
triggers:
|
triggers:
|
||||||
show_trigger_status: true
|
show_trigger_status: true
|
||||||
# Keeps the trigger "active" this long after the switch goes off.
|
# How long the chime keeps ringing after a press before the call is
|
||||||
# A doorbell button typically gives a brief ON pulse, so the chime
|
# auto-ended (if still unanswered). A doorbell press is instantaneous,
|
||||||
# would stop almost immediately if this were small -- this value
|
# so this value is effectively the ring duration.
|
||||||
# is effectively how long the chime keeps ringing after the press.
|
|
||||||
untrigger_delay_seconds: 30
|
untrigger_delay_seconds: 30
|
||||||
actions:
|
actions:
|
||||||
# Call when triggered.
|
# Call when triggered.
|
||||||
trigger: call
|
trigger: call
|
||||||
# On release (after the untrigger_delay): end the call if it is
|
# When the trigger naturally ends (after untrigger_delay_seconds): end
|
||||||
# still ringing. An answered call survives this and must be ended
|
# the call if it's still ringing. An answered call survives this and
|
||||||
# manually.
|
# must be ended manually.
|
||||||
untrigger: call
|
untrigger: call
|
||||||
# Ring even when somebody is actively using the card.
|
# Ring even when somebody is actively using the card.
|
||||||
interaction_mode: all
|
interaction_mode: all
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { StateWatcherSubscriptionInterface } from '../card-controller/hass/state
|
|||||||
import { PTZAction, PTZActionPhase } from '../config/schema/actions/custom/ptz';
|
import { PTZAction, PTZActionPhase } from '../config/schema/actions/custom/ptz';
|
||||||
import { CameraConfig } from '../config/schema/cameras';
|
import { CameraConfig } from '../config/schema/cameras';
|
||||||
import { EnabledProxyConfig, resolveProxyConfig } from '../config/schema/common/proxy';
|
import { EnabledProxyConfig, resolveProxyConfig } from '../config/schema/common/proxy';
|
||||||
import { isTriggeredState } from '../ha/is-triggered-state';
|
import { getTriggerEventType } from '../ha/get-trigger-event-type';
|
||||||
import { HassStateDifference, HomeAssistant } from '../ha/types';
|
import { HassStateDifference, HomeAssistant } from '../ha/types';
|
||||||
import { localize } from '../localize/localize';
|
import { localize } from '../localize/localize';
|
||||||
import { CapabilitiesRaw, CapabilityKey, Endpoint } from '../types';
|
import { CapabilitiesRaw, CapabilityKey, Endpoint } from '../types';
|
||||||
@@ -262,10 +262,14 @@ export class Camera {
|
|||||||
}
|
}
|
||||||
|
|
||||||
protected _stateChangeHandler = (difference: HassStateDifference): void => {
|
protected _stateChangeHandler = (difference: HassStateDifference): void => {
|
||||||
|
const type = getTriggerEventType(difference);
|
||||||
|
if (type === null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
this._eventCallback?.({
|
this._eventCallback?.({
|
||||||
cameraID: this.getID(),
|
cameraID: this.getID(),
|
||||||
id: difference.entityID,
|
id: difference.entityID,
|
||||||
type: isTriggeredState(difference.newState.state) ? 'new' : 'end',
|
type,
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -157,7 +157,8 @@ export interface CameraEvent {
|
|||||||
| 'new' // A new event has started.
|
| 'new' // A new event has started.
|
||||||
| 'update' // An update for an event is available (except GenAI).
|
| 'update' // An update for an event is available (except GenAI).
|
||||||
| 'end' // An event has ended.
|
| 'end' // An event has ended.
|
||||||
| 'genai'; // An AI based update is available.
|
| 'genai' // An AI based update is available.
|
||||||
|
| 'signal'; // A momentary signal (no start/end, just an instant).
|
||||||
|
|
||||||
// When fidelity is `high`, the engine is assumed to provide exact details of
|
// When fidelity is `high`, the engine is assumed to provide exact details of
|
||||||
// what new media is available. Otherwise all media types are assumed to be
|
// what new media is available. Otherwise all media types are assumed to be
|
||||||
|
|||||||
@@ -110,6 +110,17 @@ export class TriggersManager {
|
|||||||
return this._handleEndEvent(ev);
|
return this._handleEndEvent(ev);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (ev.type === 'signal') {
|
||||||
|
// A signal is momentary -- handled as a matched new+end so the existing
|
||||||
|
// untrigger_delay_seconds machinery gives it visible duration, and so
|
||||||
|
// concurrent continuous sources still gate untriggering correctly.
|
||||||
|
const handled = await this.handleCameraEvent({ ...ev, type: 'new' }, options);
|
||||||
|
if (handled) {
|
||||||
|
await this.handleCameraEvent({ ...ev, type: 'end' });
|
||||||
|
}
|
||||||
|
return handled;
|
||||||
|
}
|
||||||
|
|
||||||
// Ignore stale updates for force-untriggered IDs before doing any further
|
// Ignore stale updates for force-untriggered IDs before doing any further
|
||||||
// processing to avoid re-activating muted IDs.
|
// processing to avoid re-activating muted IDs.
|
||||||
if (this._isIgnoredUpdateEvent(ev)) {
|
if (this._isIgnoredUpdateEvent(ev)) {
|
||||||
|
|||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import { computeDomain } from './compute-domain';
|
||||||
|
import { isTriggeredState } from './is-triggered-state';
|
||||||
|
import { HassStateDifference } from './types';
|
||||||
|
|
||||||
|
// New state must be a real timestamp: `unavailable` means the entity went
|
||||||
|
// offline, `unknown` means it has no recorded fire -- neither is a fresh fire.
|
||||||
|
const isUsableNewState = (state: string): boolean =>
|
||||||
|
state !== 'unavailable' && state !== 'unknown';
|
||||||
|
|
||||||
|
// Old state must be defined and not `unavailable`. `unknown` is allowed -- it
|
||||||
|
// means the entity was alive but had never fired, so the new timestamp is a
|
||||||
|
// real first fire (not a restored last-fire-time after reconnect).
|
||||||
|
const isUsableOldState = (state: string | undefined): boolean =>
|
||||||
|
state !== undefined && state !== 'unavailable';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Map a watched entity's state change to the `CameraEvent` type to dispatch
|
||||||
|
* for it, or `null` to skip.
|
||||||
|
*
|
||||||
|
* Most entities have an "on"/"off" state (e.g. `binary_sensor`, `switch`) and
|
||||||
|
* produce a single `'new'` or `'end'`.
|
||||||
|
*
|
||||||
|
* HA `event.*` entities are different: each fire just updates `state` to a new
|
||||||
|
* ISO timestamp, with no continuous on/off. Those map to `signal` -- the
|
||||||
|
* instantaneous-event discriminator. Transitions are skipped when the old state
|
||||||
|
* is undefined (entity not previously observed) or `unavailable` (entity
|
||||||
|
* reconnecting -- new state could be restored, not fresh), or when the new
|
||||||
|
* state isn't a real timestamp.
|
||||||
|
*/
|
||||||
|
export const getTriggerEventType = (
|
||||||
|
difference: HassStateDifference,
|
||||||
|
): 'new' | 'end' | 'signal' | null => {
|
||||||
|
if (computeDomain(difference.entityID) === 'event') {
|
||||||
|
return isUsableOldState(difference.oldState?.state) &&
|
||||||
|
isUsableNewState(difference.newState.state)
|
||||||
|
? 'signal'
|
||||||
|
: null;
|
||||||
|
}
|
||||||
|
return isTriggeredState(difference.newState.state) ? 'new' : 'end';
|
||||||
|
};
|
||||||
@@ -474,6 +474,77 @@ describe('Camera', () => {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
it('should not dispatch when the helper returns null', async () => {
|
||||||
|
vi.spyOn(global.console, 'warn').mockReturnValue(undefined);
|
||||||
|
|
||||||
|
const eventCallback = vi.fn();
|
||||||
|
const camera = new Camera(
|
||||||
|
createCameraConfig({
|
||||||
|
id: 'camera_1',
|
||||||
|
triggers: {
|
||||||
|
entities: ['event.front_door_doorbell'],
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
new GenericCameraManagerEngine(mock<StateWatcherSubscriptionInterface>()),
|
||||||
|
{
|
||||||
|
eventCallback: eventCallback,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const stateWatcher = mock<StateWatcherSubscriptionInterface>();
|
||||||
|
await camera.initialize({
|
||||||
|
hass: createHASS(),
|
||||||
|
stateWatcher: stateWatcher,
|
||||||
|
capabilityOptions: { capabilities: createCapabilities({ trigger: true }) },
|
||||||
|
});
|
||||||
|
|
||||||
|
callStateWatcherCallback(stateWatcher, {
|
||||||
|
entityID: 'event.front_door_doorbell',
|
||||||
|
oldState: createStateEntity({ state: 'unavailable' }),
|
||||||
|
newState: createStateEntity({ state: '2026-05-24T12:00:05.123+00:00' }),
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(eventCallback).not.toBeCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should dispatch a signal for an event entity fire', async () => {
|
||||||
|
vi.spyOn(global.console, 'warn').mockReturnValue(undefined);
|
||||||
|
|
||||||
|
const eventCallback = vi.fn();
|
||||||
|
const camera = new Camera(
|
||||||
|
createCameraConfig({
|
||||||
|
id: 'camera_1',
|
||||||
|
triggers: {
|
||||||
|
entities: ['event.front_door_doorbell'],
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
new GenericCameraManagerEngine(mock<StateWatcherSubscriptionInterface>()),
|
||||||
|
{
|
||||||
|
eventCallback: eventCallback,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const stateWatcher = mock<StateWatcherSubscriptionInterface>();
|
||||||
|
await camera.initialize({
|
||||||
|
hass: createHASS(),
|
||||||
|
stateWatcher: stateWatcher,
|
||||||
|
capabilityOptions: { capabilities: createCapabilities({ trigger: true }) },
|
||||||
|
});
|
||||||
|
|
||||||
|
callStateWatcherCallback(stateWatcher, {
|
||||||
|
entityID: 'event.front_door_doorbell',
|
||||||
|
oldState: createStateEntity({ state: '2026-05-24T12:00:00.000+00:00' }),
|
||||||
|
newState: createStateEntity({ state: '2026-05-24T12:00:05.123+00:00' }),
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(eventCallback).toBeCalledTimes(1);
|
||||||
|
expect(eventCallback).toBeCalledWith({
|
||||||
|
cameraID: 'camera_1',
|
||||||
|
id: 'event.front_door_doorbell',
|
||||||
|
type: 'signal',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it('should not trigger without trigger capability', async () => {
|
it('should not trigger without trigger capability', async () => {
|
||||||
const eventCallback = vi.fn();
|
const eventCallback = vi.fn();
|
||||||
const camera = new Camera(
|
const camera = new Camera(
|
||||||
|
|||||||
@@ -808,6 +808,82 @@ describe('TriggersManager', () => {
|
|||||||
expect(manager.isTriggered()).toBeFalsy();
|
expect(manager.isTriggered()).toBeFalsy();
|
||||||
expect(api.getViewManager().setViewDefaultWithNewQuery).toBeCalledTimes(1);
|
expect(api.getViewManager().setViewDefaultWithNewQuery).toBeCalledTimes(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should auto-untrigger after the delay on a signal event', async () => {
|
||||||
|
const api = createTriggerAPI({
|
||||||
|
config: {
|
||||||
|
actions: { trigger: 'none', untrigger: 'default' },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const manager = new TriggersManager(api);
|
||||||
|
|
||||||
|
await manager.handleCameraEvent({
|
||||||
|
cameraID: 'camera_1',
|
||||||
|
id: 'event.doorbell',
|
||||||
|
type: 'signal',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(manager.isTriggered()).toBeTruthy();
|
||||||
|
expect(api.getViewManager().setViewDefaultWithNewQuery).not.toBeCalled();
|
||||||
|
|
||||||
|
vi.setSystemTime(add(start, { seconds: 10 }));
|
||||||
|
vi.runOnlyPendingTimers();
|
||||||
|
await flushPromises();
|
||||||
|
|
||||||
|
expect(manager.isTriggered()).toBeFalsy();
|
||||||
|
expect(api.getViewManager().setViewDefaultWithNewQuery).toBeCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should auto-untrigger immediately on a signal when delay is 0', async () => {
|
||||||
|
const api = createTriggerAPI({
|
||||||
|
config: {
|
||||||
|
untrigger_delay_seconds: 0,
|
||||||
|
actions: { trigger: 'none', untrigger: 'default' },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const manager = new TriggersManager(api);
|
||||||
|
|
||||||
|
await manager.handleCameraEvent({
|
||||||
|
cameraID: 'camera_1',
|
||||||
|
id: 'event.doorbell',
|
||||||
|
type: 'signal',
|
||||||
|
});
|
||||||
|
await flushPromises();
|
||||||
|
|
||||||
|
expect(manager.isTriggered()).toBeFalsy();
|
||||||
|
expect(api.getViewManager().setViewDefaultWithNewQuery).toBeCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not auto-untrigger from a signal while a continuous source remains active', async () => {
|
||||||
|
const api = createTriggerAPI({
|
||||||
|
config: {
|
||||||
|
actions: { trigger: 'none', untrigger: 'default' },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const manager = new TriggersManager(api);
|
||||||
|
|
||||||
|
// Continuous source comes on first.
|
||||||
|
await manager.handleCameraEvent({
|
||||||
|
cameraID: 'camera_1',
|
||||||
|
id: 'binary_sensor.motion',
|
||||||
|
type: 'new',
|
||||||
|
});
|
||||||
|
|
||||||
|
// Signal fires while motion is still on.
|
||||||
|
await manager.handleCameraEvent({
|
||||||
|
cameraID: 'camera_1',
|
||||||
|
id: 'event.doorbell',
|
||||||
|
type: 'signal',
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.setSystemTime(add(start, { seconds: 10 }));
|
||||||
|
vi.runOnlyPendingTimers();
|
||||||
|
await flushPromises();
|
||||||
|
|
||||||
|
// Continuous source keeps the trigger alive.
|
||||||
|
expect(manager.isTriggered()).toBeTruthy();
|
||||||
|
expect(api.getViewManager().setViewDefaultWithNewQuery).not.toBeCalled();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('condition state management', () => {
|
describe('condition state management', () => {
|
||||||
@@ -1164,6 +1240,56 @@ describe('TriggersManager', () => {
|
|||||||
});
|
});
|
||||||
expect(manager.isTriggered()).toBeTruthy();
|
expect(manager.isTriggered()).toBeTruthy();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should not reset the untrigger timer when a filtered-out signal arrives', async () => {
|
||||||
|
// Guards the `if (handled)` branch on the signal synthesis: a
|
||||||
|
// filter-rejected signal must not call the internal 'end', which would
|
||||||
|
// otherwise reset an already-running untrigger-delay timer.
|
||||||
|
const api = createTriggerAPI({
|
||||||
|
config: {
|
||||||
|
filter_selected_camera: true,
|
||||||
|
actions: { trigger: 'none', untrigger: 'default' },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const manager = new TriggersManager(api);
|
||||||
|
|
||||||
|
// Trigger camera_1 normally, then end it -- starts the delay timer.
|
||||||
|
await manager.handleCameraEvent({
|
||||||
|
cameraID: 'camera_1',
|
||||||
|
id: 'binary_sensor.motion',
|
||||||
|
type: 'new',
|
||||||
|
});
|
||||||
|
await manager.handleCameraEvent({
|
||||||
|
cameraID: 'camera_1',
|
||||||
|
id: 'binary_sensor.motion',
|
||||||
|
type: 'end',
|
||||||
|
});
|
||||||
|
expect(manager.isTriggered()).toBeTruthy();
|
||||||
|
|
||||||
|
// 5s in, switch the view to an unrelated camera so the filter
|
||||||
|
// will reject events for camera_1.
|
||||||
|
vi.setSystemTime(add(start, { seconds: 5 }));
|
||||||
|
vi.mocked(api.getViewManager().getView).mockReturnValue(
|
||||||
|
createView({ camera: 'camera_OTHER' as const }),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Signal for camera_1 -- rejected by the filter.
|
||||||
|
await manager.handleCameraEvent({
|
||||||
|
cameraID: 'camera_1',
|
||||||
|
id: 'event.doorbell',
|
||||||
|
type: 'signal',
|
||||||
|
});
|
||||||
|
|
||||||
|
// At t=10 (the original delay's deadline), the timer should fire.
|
||||||
|
// If the guard were missing, the internal 'end' would have restarted
|
||||||
|
// the timer at t=5, so it'd still be triggered here.
|
||||||
|
vi.setSystemTime(add(start, { seconds: 10 }));
|
||||||
|
vi.runOnlyPendingTimers();
|
||||||
|
await flushPromises();
|
||||||
|
|
||||||
|
expect(manager.isTriggered()).toBeFalsy();
|
||||||
|
expect(api.getViewManager().setViewDefaultWithNewQuery).toBeCalled();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('should handle initial camera triggers', () => {
|
describe('should handle initial camera triggers', () => {
|
||||||
|
|||||||
@@ -0,0 +1,91 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { getTriggerEventType } from '../../src/ha/get-trigger-event-type';
|
||||||
|
import { createStateEntity } from '../test-utils';
|
||||||
|
|
||||||
|
describe('getTriggerEventType', () => {
|
||||||
|
describe('for non-event entities', () => {
|
||||||
|
it.each([['on'], ['open'], ['unlocked']])(
|
||||||
|
'returns "new" when the new state is %s',
|
||||||
|
(state: string) => {
|
||||||
|
expect(
|
||||||
|
getTriggerEventType({
|
||||||
|
entityID: 'binary_sensor.motion',
|
||||||
|
oldState: createStateEntity({ state: 'off' }),
|
||||||
|
newState: createStateEntity({ state }),
|
||||||
|
}),
|
||||||
|
).toBe('new');
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
['off'],
|
||||||
|
['closed'],
|
||||||
|
['locked'],
|
||||||
|
['unavailable'],
|
||||||
|
['unknown'],
|
||||||
|
['anything-else'],
|
||||||
|
])('returns "end" when the new state is %s', (state: string) => {
|
||||||
|
expect(
|
||||||
|
getTriggerEventType({
|
||||||
|
entityID: 'binary_sensor.motion',
|
||||||
|
oldState: createStateEntity({ state: 'on' }),
|
||||||
|
newState: createStateEntity({ state }),
|
||||||
|
}),
|
||||||
|
).toBe('end');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('for event entities', () => {
|
||||||
|
it('returns "signal" for a transition between two real timestamps', () => {
|
||||||
|
expect(
|
||||||
|
getTriggerEventType({
|
||||||
|
entityID: 'event.front_door_doorbell',
|
||||||
|
oldState: createStateEntity({ state: '2026-05-24T12:00:00.000+00:00' }),
|
||||||
|
newState: createStateEntity({ state: '2026-05-24T12:00:05.123+00:00' }),
|
||||||
|
}),
|
||||||
|
).toBe('signal');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns null when there is no old state', () => {
|
||||||
|
expect(
|
||||||
|
getTriggerEventType({
|
||||||
|
entityID: 'event.front_door_doorbell',
|
||||||
|
newState: createStateEntity({ state: '2026-05-24T12:00:05.123+00:00' }),
|
||||||
|
}),
|
||||||
|
).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns null when the old state is unavailable (entity reconnecting)', () => {
|
||||||
|
expect(
|
||||||
|
getTriggerEventType({
|
||||||
|
entityID: 'event.front_door_doorbell',
|
||||||
|
oldState: createStateEntity({ state: 'unavailable' }),
|
||||||
|
newState: createStateEntity({ state: '2026-05-24T12:00:05.123+00:00' }),
|
||||||
|
}),
|
||||||
|
).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns "signal" when the old state is unknown (first fire after startup)', () => {
|
||||||
|
expect(
|
||||||
|
getTriggerEventType({
|
||||||
|
entityID: 'event.front_door_doorbell',
|
||||||
|
oldState: createStateEntity({ state: 'unknown' }),
|
||||||
|
newState: createStateEntity({ state: '2026-05-24T12:00:05.123+00:00' }),
|
||||||
|
}),
|
||||||
|
).toBe('signal');
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([['unavailable'], ['unknown']])(
|
||||||
|
'returns null when the new state is %s',
|
||||||
|
(state: string) => {
|
||||||
|
expect(
|
||||||
|
getTriggerEventType({
|
||||||
|
entityID: 'event.front_door_doorbell',
|
||||||
|
oldState: createStateEntity({ state: '2026-05-24T12:00:00.000+00:00' }),
|
||||||
|
newState: createStateEntity({ state }),
|
||||||
|
}),
|
||||||
|
).toBeNull();
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user