diff --git a/docs/configuration/view.md b/docs/configuration/view.md index 724f8ab3..5d8579f6 100644 --- a/docs/configuration/view.md +++ b/docs/configuration/view.md @@ -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 > are all marked as triggered, but the action is only taken for the first one. -| Option | Default | Description | -| ------------------------- | ------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `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. | -| `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_force_seconds` | `0` | The number of seconds after a camera first triggers before force untriggering that camera. Set to `0` to disable. | +| Option | Default | Description | +| ------------------------- | ------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `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. | +| `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 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. | > [!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 > `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 | Option | Default | Description | diff --git a/docs/examples.md b/docs/examples.md index 12e0f231..dfe9f79f 100644 --- a/docs/examples.md +++ b/docs/examples.md @@ -361,7 +361,9 @@ elements: ### 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. @@ -378,22 +380,21 @@ cameras: motion: false events: [] entities: - - switch.door_bell + - event.front_door_doorbell view: default: live triggers: show_trigger_status: true - # Keeps the trigger "active" this long after the switch goes off. - # A doorbell button typically gives a brief ON pulse, so the chime - # would stop almost immediately if this were small -- this value - # is effectively how long the chime keeps ringing after the press. + # How long the chime keeps ringing after a press before the call is + # auto-ended (if still unanswered). A doorbell press is instantaneous, + # so this value is effectively the ring duration. untrigger_delay_seconds: 30 actions: # Call when triggered. trigger: call - # On release (after the untrigger_delay): end the call if it is - # still ringing. An answered call survives this and must be ended - # manually. + # When the trigger naturally ends (after untrigger_delay_seconds): end + # the call if it's still ringing. An answered call survives this and + # must be ended manually. untrigger: call # Ring even when somebody is actively using the card. interaction_mode: all diff --git a/src/camera-manager/camera.ts b/src/camera-manager/camera.ts index 6cca62b7..ba30378f 100644 --- a/src/camera-manager/camera.ts +++ b/src/camera-manager/camera.ts @@ -3,7 +3,7 @@ import { StateWatcherSubscriptionInterface } from '../card-controller/hass/state import { PTZAction, PTZActionPhase } from '../config/schema/actions/custom/ptz'; import { CameraConfig } from '../config/schema/cameras'; 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 { localize } from '../localize/localize'; import { CapabilitiesRaw, CapabilityKey, Endpoint } from '../types'; @@ -262,10 +262,14 @@ export class Camera { } protected _stateChangeHandler = (difference: HassStateDifference): void => { + const type = getTriggerEventType(difference); + if (type === null) { + return; + } this._eventCallback?.({ cameraID: this.getID(), id: difference.entityID, - type: isTriggeredState(difference.newState.state) ? 'new' : 'end', + type, }); }; diff --git a/src/camera-manager/types.ts b/src/camera-manager/types.ts index 71c8c24f..65009982 100644 --- a/src/camera-manager/types.ts +++ b/src/camera-manager/types.ts @@ -157,7 +157,8 @@ export interface CameraEvent { | 'new' // A new event has started. | 'update' // An update for an event is available (except GenAI). | '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 // what new media is available. Otherwise all media types are assumed to be diff --git a/src/card-controller/triggers-manager.ts b/src/card-controller/triggers-manager.ts index 760aaec4..846ab047 100644 --- a/src/card-controller/triggers-manager.ts +++ b/src/card-controller/triggers-manager.ts @@ -110,6 +110,17 @@ export class TriggersManager { 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 // processing to avoid re-activating muted IDs. if (this._isIgnoredUpdateEvent(ev)) { diff --git a/src/ha/get-trigger-event-type.ts b/src/ha/get-trigger-event-type.ts new file mode 100644 index 00000000..5066a3d8 --- /dev/null +++ b/src/ha/get-trigger-event-type.ts @@ -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'; +}; diff --git a/tests/camera-manager/camera.test.ts b/tests/camera-manager/camera.test.ts index 5c47a353..07c57d9a 100644 --- a/tests/camera-manager/camera.test.ts +++ b/tests/camera-manager/camera.test.ts @@ -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()), + { + eventCallback: eventCallback, + }, + ); + + const stateWatcher = mock(); + 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()), + { + eventCallback: eventCallback, + }, + ); + + const stateWatcher = mock(); + 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 () => { const eventCallback = vi.fn(); const camera = new Camera( diff --git a/tests/card-controller/triggers-manager.test.ts b/tests/card-controller/triggers-manager.test.ts index d0f3a28e..fedc459d 100644 --- a/tests/card-controller/triggers-manager.test.ts +++ b/tests/card-controller/triggers-manager.test.ts @@ -808,6 +808,82 @@ describe('TriggersManager', () => { expect(manager.isTriggered()).toBeFalsy(); 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', () => { @@ -1164,6 +1240,56 @@ describe('TriggersManager', () => { }); 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', () => { diff --git a/tests/ha/get-trigger-event-type.test.ts b/tests/ha/get-trigger-event-type.test.ts new file mode 100644 index 00000000..00d20bb9 --- /dev/null +++ b/tests/ha/get-trigger-event-type.test.ts @@ -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(); + }, + ); + }); +});