diff --git a/docs/configuration/view.md b/docs/configuration/view.md index 7d016e4c..42f1177a 100644 --- a/docs/configuration/view.md +++ b/docs/configuration/view.md @@ -116,7 +116,9 @@ only allowed when there is no ongoing human interaction with the card. This behavior can be controlled by the `interaction_mode` parameter. If the card starts when a trigger entity is already in a triggered state, the -action will be taken on card startup. +action will be taken on card startup. If multiple cameras are triggered at +startup, all are marked as triggered, but the startup action is taken for the +first triggered camera only. | Option | Default | Description | | ------------------------ | ------- | ---------------------------------------------------------------------------------------------------------------------------------- | diff --git a/src/camera-manager/camera.ts b/src/camera-manager/camera.ts index 7646bdfe..41d3294e 100644 --- a/src/camera-manager/camera.ts +++ b/src/camera-manager/camera.ts @@ -222,6 +222,7 @@ export class Camera { protected _stateChangeHandler = (difference: HassStateDifference): void => { this._eventCallback?.({ cameraID: this.getID(), + id: difference.entityID, type: isTriggeredState(difference.newState.state) ? 'new' : 'end', }); }; diff --git a/src/camera-manager/frigate/camera.ts b/src/camera-manager/frigate/camera.ts index ad6a011c..24e322c0 100644 --- a/src/camera-manager/frigate/camera.ts +++ b/src/camera-manager/frigate/camera.ts @@ -477,7 +477,7 @@ export class FrigateCamera extends Camera { this._eventCallback?.({ cameraID, - + id: ev.after.id, fidelity: 'high', type: ev.type, // In cases where there are both clip and snapshot media, ensure to only diff --git a/src/camera-manager/frigate/types.ts b/src/camera-manager/frigate/types.ts index 36e478ba..fda44992 100644 --- a/src/camera-manager/frigate/types.ts +++ b/src/camera-manager/frigate/types.ts @@ -98,6 +98,7 @@ export type PTZInfo = z.infer; const frigateEventChangeBeforeAfterSchema = z.object({ camera: z.string(), + id: z.string(), snapshot: z .object({ frame_time: z.number(), diff --git a/src/camera-manager/types.ts b/src/camera-manager/types.ts index cf65cf82..db091693 100644 --- a/src/camera-manager/types.ts +++ b/src/camera-manager/types.ts @@ -151,6 +151,10 @@ export interface EngineOptions { export interface CameraEvent { cameraID: string; + // Source ID (e.g. entity ID or Frigate event ID), used to determine what has + // triggered/untriggered. + id: string; + type: | 'new' // A new event has started. | 'update' // An update for an event is available (except GenAI). diff --git a/src/card-controller/triggers-manager.ts b/src/card-controller/triggers-manager.ts index 7051c31f..9288d243 100644 --- a/src/card-controller/triggers-manager.ts +++ b/src/card-controller/triggers-manager.ts @@ -1,14 +1,25 @@ -import { orderBy, throttle } from 'lodash-es'; +import { maxBy, throttle } from 'lodash-es'; import { CameraEvent } from '../camera-manager/types'; import { isTriggeredState } from '../ha/is-triggered-state'; import { Timer } from '../utils/timer'; import { CardTriggersAPI } from './types'; +interface CameraTriggerState { + // The time of the most recent trigger event. Used to determine the most + // recently triggered camera. + lastTriggerTime: Date; + + // The set of active trigger source IDs (e.g. entity IDs or Frigate event + // IDs). + sources: Set; + + // A timer used to delay the untrigger action. + untriggerDelayTimer?: Timer; +} + export class TriggersManager { protected _api: CardTriggersAPI; - - protected _triggeredCameras: Map = new Map(); - protected _triggeredCameraTimers: Map = new Map(); + protected _states: Map = new Map(); protected _throttledTriggerAction = throttle(this._triggerAction.bind(this), 1000, { trailing: true, @@ -19,53 +30,87 @@ export class TriggersManager { } public getTriggeredCameraIDs(): Set { - return new Set(this._triggeredCameras.keys()); + const ids = new Set(); + this._states.forEach((state, cameraID) => { + if (this._isStateTriggered(state)) { + ids.add(cameraID); + } + }); + return ids; } public isTriggered(): boolean { - return !!this._triggeredCameras.size; + return [...this._states.values()].some((state) => this._isStateTriggered(state)); } public getMostRecentlyTriggeredCameraID(): string | null { - const sorted = orderBy( - [...this._triggeredCameras.entries()], - (entry) => entry[1].getTime(), - 'desc', + const mostRecent = maxBy( + [...this._states.entries()].filter(([, state]) => this._isStateTriggered(state)), + ([, state]) => state.lastTriggerTime.getTime(), ); - return sorted.length ? sorted[0][0] : null; + return mostRecent?.[0] ?? null; } public handleInitialCameraTriggers = async (): Promise => { const hass = this._api.getHASSManager().getHASS(); let triggered = false; + let startupActionEvent: CameraEvent | null = null; for (const [cameraID, camera] of this._api .getCameraManager() .getStore() .getCameras()) { - if ( - camera - .getConfig() - .triggers.entities.some((entityID) => - isTriggeredState(hass?.states[entityID]?.state), - ) - ) { - triggered = true; - await this.handleCameraEvent({ - cameraID, - type: 'new', - }); + for (const entityID of camera.getConfig().triggers.entities) { + if (isTriggeredState(hass?.states[entityID]?.state)) { + triggered = true; + const event: CameraEvent = { + cameraID, + id: entityID, + type: 'new', + }; + if ( + await this.handleCameraEvent(event, { + skipAction: true, + }) + ) { + startupActionEvent ??= event; + } + } } } + + if (startupActionEvent) { + await this._throttledTriggerAction(startupActionEvent); + } + return triggered; }; - public async handleCameraEvent(ev: CameraEvent): Promise { - const triggersConfig = this._api.getConfigManager().getConfig()?.view.triggers; + // Returns true if the event was accepted into trigger state processing. + // Returns false if it was ignored (e.g. missing config/view or camera filter + // mismatch). + public async handleCameraEvent( + ev: CameraEvent, + options?: { + skipAction?: boolean; + }, + ): Promise { + const skipAction = options?.skipAction ?? false; + if (ev.type === 'end') { + const state = this._states.get(ev.cameraID); + state?.sources.delete(ev.id); + if (!state?.sources.size) { + await this._startUntrigger(ev.cameraID); + } + return true; + } + + const config = this._api.getConfigManager().getConfig(); + const triggersConfig = config?.view?.triggers; const selectedCameraID = this._api.getViewManager().getView()?.camera; if (!triggersConfig || !selectedCameraID) { - return; + return false; } const dependentCameraIDs = this._api @@ -74,17 +119,28 @@ export class TriggersManager { .getAllDependentCameras(selectedCameraID); if (triggersConfig.filter_selected_camera && !dependentCameraIDs.has(ev.cameraID)) { - return; + return false; } - if (ev.type === 'end') { - this._startUntriggerTimer(ev.cameraID); - return; + let state = this._states.get(ev.cameraID); + if (!state) { + state = { + lastTriggerTime: new Date(), + sources: new Set(), + }; + this._states.set(ev.cameraID, state); + } else { + state.lastTriggerTime = new Date(); } - this._triggeredCameras.set(ev.cameraID, new Date()); + state.sources.add(ev.id); + + this._deleteUntriggerDelayTimer(ev.cameraID); this._setConditionStateIfNecessary(); - await this._throttledTriggerAction(ev); + if (!skipAction) { + await this._throttledTriggerAction(ev); + } + return true; } protected _hasAllowableInteractionStateForAction(): boolean { @@ -100,9 +156,9 @@ export class TriggersManager { } protected async _triggerAction(ev: CameraEvent): Promise { - const triggerAction = this._api.getConfigManager().getConfig()?.view.triggers - .actions.trigger; - const defaultView = this._api.getConfigManager().getConfig()?.view.default; + const config = this._api.getConfigManager().getConfig(); + const triggerAction = config?.view?.triggers.actions.trigger; + const defaultView = config?.view?.default; // Early exit guard: If this is a high-fidelity event where we are certain // about new media, don't take action unless it's to change to live (Frigate @@ -169,47 +225,68 @@ export class TriggersManager { } protected _setConditionStateIfNecessary(): void { - const triggeredCameraIDs = new Set(this._triggeredCameras.keys()); - const triggeredState = triggeredCameraIDs.size ? triggeredCameraIDs : undefined; - + const triggeredCameraIDs = this.getTriggeredCameraIDs(); this._api.getConditionStateManager().setState({ - triggered: triggeredState, + triggered: triggeredCameraIDs.size ? triggeredCameraIDs : undefined, }); } - protected async _untriggerAction(cameraID: string): Promise { - const action = this._api.getConfigManager().getConfig()?.view.triggers + protected async _executeUntriggerAction(): Promise { + const action = this._api.getConfigManager().getConfig()?.view?.triggers .actions.untrigger; - if (action === 'default' && this._hasAllowableInteractionStateForAction()) { + if (!action || action === 'none') { + return true; + } + + if (this._hasAllowableInteractionStateForAction()) { await this._api.getViewManager().setViewDefaultWithNewQuery(); } - this._triggeredCameras.delete(cameraID); - this._deleteTimer(cameraID); + return true; + } + + protected async _untriggerAction(cameraID: string): Promise { + this._deleteUntriggerDelayTimer(cameraID); + + await this._executeUntriggerAction(); + this._states.delete(cameraID); + this._setConditionStateIfNecessary(); // Must update master element to remove border pulsing from live view. this._api.getCardElementManager().update(); } - protected _startUntriggerTimer(cameraID: string): void { - this._deleteTimer(cameraID); + protected async _startUntrigger(cameraID: string): Promise { + this._deleteUntriggerDelayTimer(cameraID); - const timer = new Timer(); - this._triggeredCameraTimers.set(cameraID, timer); - timer.start( - /* istanbul ignore next: the case of config being null here cannot be - reached, as there's no way to have the untrigger call happen without - a config. -- @preserve */ - this._api.getConfigManager().getConfig()?.view.triggers.untrigger_seconds ?? 0, - async () => { + const state = this._states.get(cameraID); + if (!state) { + return; + } + + const config = this._api.getConfigManager().getConfig(); + const untriggerSeconds = config?.view?.triggers.untrigger_seconds ?? 0; + + if (untriggerSeconds > 0) { + state.untriggerDelayTimer = new Timer(); + state.untriggerDelayTimer.start(untriggerSeconds, async () => { await this._untriggerAction(cameraID); - }, - ); + }); + } else { + await this._untriggerAction(cameraID); + } } - protected _deleteTimer(cameraID: string): void { - this._triggeredCameraTimers.get(cameraID)?.stop(); - this._triggeredCameraTimers.delete(cameraID); + protected _deleteUntriggerDelayTimer(cameraID: string): void { + const state = this._states.get(cameraID); + if (state?.untriggerDelayTimer) { + state.untriggerDelayTimer.stop(); + delete state.untriggerDelayTimer; + } + } + + protected _isStateTriggered(state: CameraTriggerState): boolean { + return !!(state.sources.size || state.untriggerDelayTimer); } } diff --git a/tests/camera-manager/camera.test.ts b/tests/camera-manager/camera.test.ts index 087eb594..0c397e42 100644 --- a/tests/camera-manager/camera.test.ts +++ b/tests/camera-manager/camera.test.ts @@ -201,6 +201,7 @@ describe('Camera', () => { expect(eventCallback).toBeCalledWith({ cameraID: 'camera_1', + id: 'sensor.force_update', type: eventType, }); }, diff --git a/tests/camera-manager/frigate/camera.test.ts b/tests/camera-manager/frigate/camera.test.ts index c634eba2..418c67cb 100644 --- a/tests/camera-manager/frigate/camera.test.ts +++ b/tests/camera-manager/frigate/camera.test.ts @@ -920,6 +920,7 @@ describe('FrigateCamera', () => { callEventWatcherCallback(eventWatcher, { type: 'new', before: { + id: 'event-1', camera: 'camera.front_door', snapshot: null, has_clip: false, @@ -928,6 +929,7 @@ describe('FrigateCamera', () => { current_zones: [], }, after: { + id: 'event-1', camera: 'camera.front_door', snapshot: null, has_clip: hasClip, @@ -941,6 +943,7 @@ describe('FrigateCamera', () => { expect(eventCallback).toBeCalledWith({ type: 'new', cameraID: 'CAMERA_1', + id: 'event-1', clip: hasClip && events.includes('clips'), snapshot: hasSnapshot && events.includes('snapshots'), fidelity: 'high', @@ -989,6 +992,7 @@ describe('FrigateCamera', () => { callEventWatcherCallback(eventWatcher, { type: 'new', before: { + id: 'event-1', camera: 'camera.front_door', snapshot: null, has_clip: false, @@ -997,6 +1001,7 @@ describe('FrigateCamera', () => { current_zones: [], }, after: { + id: 'event-1', camera: 'camera.front_door', snapshot: null, has_clip: false, @@ -1046,6 +1051,7 @@ describe('FrigateCamera', () => { callEventWatcherCallback(eventWatcher, { type: 'new', before: { + id: 'event-1', camera: 'camera.front_door', snapshot: null, has_clip: false, @@ -1056,6 +1062,7 @@ describe('FrigateCamera', () => { current_zones: [], }, after: { + id: 'event-1', camera: 'camera.front_door', snapshot: null, has_clip: false, @@ -1100,6 +1107,7 @@ describe('FrigateCamera', () => { callEventWatcherCallback(eventWatcher, { type: 'new', before: { + id: 'event-1', camera: 'camera.front_door', snapshot: null, has_clip: false, @@ -1108,6 +1116,7 @@ describe('FrigateCamera', () => { current_zones: [], }, after: { + id: 'event-1', camera: 'camera.front_door', snapshot: null, has_clip: false, diff --git a/tests/camera-manager/frigate/watcher.test.ts b/tests/camera-manager/frigate/watcher.test.ts index 4025267e..444d19cc 100644 --- a/tests/camera-manager/frigate/watcher.test.ts +++ b/tests/camera-manager/frigate/watcher.test.ts @@ -14,6 +14,7 @@ const createEventChange = (): FrigateEventChange => { return { type: 'new', before: { + id: '1234.5678', camera: 'front_door', snapshot: null, has_clip: false, @@ -22,6 +23,7 @@ const createEventChange = (): FrigateEventChange => { current_zones: [], }, after: { + id: '1234.5678', camera: 'front_door', snapshot: null, has_clip: true, diff --git a/tests/camera-manager/manager.test.ts b/tests/camera-manager/manager.test.ts index b6e9b631..7cdf1eef 100644 --- a/tests/camera-manager/manager.test.ts +++ b/tests/camera-manager/manager.test.ts @@ -439,6 +439,7 @@ describe('CameraManager', () => { const cameraEvent: CameraEvent = { cameraID: 'camera', + id: 'event-1', type: 'new', }; eventCallback?.(cameraEvent); diff --git a/tests/card-controller/triggers-manager.test.ts b/tests/card-controller/triggers-manager.test.ts index 3eda3cdd..a90ab8ac 100644 --- a/tests/card-controller/triggers-manager.test.ts +++ b/tests/card-controller/triggers-manager.test.ts @@ -1,4 +1,5 @@ import { add } from 'date-fns'; +import { PartialDeep } from 'type-fest'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import { CardController } from '../../src/card-controller/controller'; import { TriggersManager } from '../../src/card-controller/triggers-manager'; @@ -32,10 +33,8 @@ const baseTriggersConfig: TriggersOptions = { }, }; -// Creating and mocking a trigger API is a lot of boilerplate, this convenience -// function reduces it. const createTriggerAPI = (options?: { - config?: Partial; + config?: PartialDeep; default?: AdvancedCameraCardView; interaction?: boolean; }): CardController => { @@ -44,7 +43,14 @@ const createTriggerAPI = (options?: { createConfig({ view: { triggers: options?.config - ? triggersSchema.parse(options.config) + ? triggersSchema.parse({ + ...baseTriggersConfig, + ...options.config, + actions: { + ...baseTriggersConfig.actions, + ...options.config.actions, + }, + }) : baseTriggersConfig, ...(options?.default && { default: options.default }), }, @@ -90,19 +96,35 @@ describe('TriggersManager', () => { expect(manager.isTriggered()).toBeFalsy(); }); - it('should not trigger without a triggers config', () => { + it('should not trigger if triggers config is empty', async () => { + const api = createTriggerAPI({ + config: { + actions: { + trigger: 'none', + }, + }, + }); + const manager = new TriggersManager(api); + + await manager.handleCameraEvent({ + cameraID: 'camera_1', + id: 'event-1', + type: 'new', + }); + + expect(manager.isTriggered()).toBeTruthy(); + expect(api.getViewManager().setViewDefaultWithNewQuery).not.toBeCalled(); + }); + + it('should not trigger if there is no config', async () => { const api = createTriggerAPI(); - vi.mocked(api.getViewManager().getView).mockReturnValue( - createView({ - camera: 'camera_1' as const, - }), - ); vi.mocked(api.getConfigManager().getConfig).mockReturnValue(null); const manager = new TriggersManager(api); - manager.handleCameraEvent({ + await manager.handleCameraEvent({ cameraID: 'camera_1', + id: 'event-1', type: 'new', }); @@ -110,7 +132,7 @@ describe('TriggersManager', () => { }); describe('trigger actions', () => { - it('update', async () => { + it('should handle trigger action set to update', async () => { const api = createTriggerAPI({ config: { ...baseTriggersConfig, @@ -125,6 +147,7 @@ describe('TriggersManager', () => { await manager.handleCameraEvent({ cameraID: 'camera_1', + id: 'event-1', type: 'new', }); @@ -134,7 +157,7 @@ describe('TriggersManager', () => { }); }); - it('default', async () => { + it('should handle trigger action set to default', async () => { const api = createTriggerAPI({ config: { ...baseTriggersConfig, @@ -147,7 +170,11 @@ describe('TriggersManager', () => { const manager = new TriggersManager(api); - await manager.handleCameraEvent({ cameraID: 'camera_1', type: 'new' }); + await manager.handleCameraEvent({ + cameraID: 'camera_1', + id: 'event-1', + type: 'new', + }); expect(manager.isTriggered()).toBeTruthy(); expect(api.getViewManager().setViewDefaultWithNewQuery).toBeCalledWith({ @@ -157,7 +184,7 @@ describe('TriggersManager', () => { }); }); - it('live', () => { + it('should handle trigger action set to live', async () => { const api = createTriggerAPI({ config: { ...baseTriggersConfig, @@ -170,7 +197,11 @@ describe('TriggersManager', () => { const manager = new TriggersManager(api); - manager.handleCameraEvent({ cameraID: 'camera_1', type: 'new' }); + await manager.handleCameraEvent({ + cameraID: 'camera_1', + id: 'event-1', + type: 'new', + }); expect(manager.isTriggered()).toBeTruthy(); expect(api.getViewManager().setViewByParametersWithNewQuery).toBeCalledWith({ @@ -209,8 +240,9 @@ describe('TriggersManager', () => { }); const manager = new TriggersManager(api); - manager.handleCameraEvent({ + await manager.handleCameraEvent({ cameraID: 'camera_1', + id: 'event-1', type: 'new', fidelity: 'high', review: hasReview, @@ -235,7 +267,7 @@ describe('TriggersManager', () => { ); }); - it('none', () => { + it('should handle trigger action set to none', async () => { const api = createTriggerAPI({ config: { ...baseTriggersConfig, @@ -248,7 +280,11 @@ describe('TriggersManager', () => { const manager = new TriggersManager(api); - manager.handleCameraEvent({ cameraID: 'camera_1', type: 'new' }); + await manager.handleCameraEvent({ + cameraID: 'camera_1', + id: 'event-1', + type: 'new', + }); expect(manager.isTriggered()).toBeTruthy(); expect(api.getViewManager().setViewDefaultWithNewQuery).not.toBeCalled(); @@ -257,7 +293,7 @@ describe('TriggersManager', () => { }); describe('untrigger actions', () => { - it('none', () => { + it('should handle untrigger action set to none with no trigger actions', async () => { const api = createTriggerAPI({ config: { ...baseTriggersConfig, @@ -270,11 +306,20 @@ describe('TriggersManager', () => { }); const manager = new TriggersManager(api); - manager.handleCameraEvent({ cameraID: 'camera_1', type: 'new' }); - manager.handleCameraEvent({ cameraID: 'camera_1', type: 'end' }); + await manager.handleCameraEvent({ + cameraID: 'camera_1', + id: 'event-1', + type: 'new', + }); + await manager.handleCameraEvent({ + cameraID: 'camera_1', + id: 'event-1', + type: 'end', + }); vi.setSystemTime(add(start, { seconds: 10 })); vi.runOnlyPendingTimers(); + await flushPromises(); expect(manager.isTriggered()).toBeFalsy(); @@ -282,7 +327,7 @@ describe('TriggersManager', () => { expect(api.getViewManager().setViewByParametersWithNewQuery).not.toBeCalled(); }); - it('default', async () => { + it('should handle untrigger action set to default', async () => { const api = createTriggerAPI({ config: { ...baseTriggersConfig, @@ -295,8 +340,16 @@ describe('TriggersManager', () => { }); const manager = new TriggersManager(api); - await manager.handleCameraEvent({ cameraID: 'camera_1', type: 'new' }); - await manager.handleCameraEvent({ cameraID: 'camera_1', type: 'end' }); + await manager.handleCameraEvent({ + cameraID: 'camera_1', + id: 'event-1', + type: 'new', + }); + await manager.handleCameraEvent({ + cameraID: 'camera_1', + id: 'event-1', + type: 'end', + }); vi.setSystemTime(add(start, { seconds: 10 })); vi.runOnlyPendingTimers(); @@ -306,43 +359,232 @@ describe('TriggersManager', () => { expect(api.getViewManager().setViewDefaultWithNewQuery).toBeCalled(); }); + + it('should handle untrigger call with no state', async () => { + const api = createTriggerAPI(); + const manager = new TriggersManager(api); + + await manager.handleCameraEvent({ + cameraID: 'camera_1', + id: 'unknown-id', + type: 'end', + }); + + expect(api.getViewManager().setViewDefaultWithNewQuery).not.toBeCalled(); + }); + + it('should not untrigger if other sources are still active', async () => { + const api = createTriggerAPI(); + const manager = new TriggersManager(api); + + await manager.handleCameraEvent({ + cameraID: 'camera_1', + id: 'entity_1', + type: 'new', + }); + await manager.handleCameraEvent({ + cameraID: 'camera_1', + id: 'entity_2', + type: 'new', + }); + + expect(manager.isTriggered()).toBeTruthy(); + + await manager.handleCameraEvent({ + cameraID: 'camera_1', + id: 'entity_1', + type: 'end', + }); + + vi.setSystemTime(add(start, { seconds: 10 })); + vi.runOnlyPendingTimers(); + await flushPromises(); + + // Should still be triggered because entity_2 is active. + expect(manager.isTriggered()).toBeTruthy(); + + await manager.handleCameraEvent({ + cameraID: 'camera_1', + id: 'entity_2', + type: 'end', + }); + + vi.setSystemTime(add(start, { seconds: 20 })); + vi.runOnlyPendingTimers(); + await flushPromises(); + + expect(manager.isTriggered()).toBeFalsy(); + }); + + it('should cancel untrigger timer if a new trigger starts', async () => { + const api = createTriggerAPI(); + const manager = new TriggersManager(api); + + await manager.handleCameraEvent({ + cameraID: 'camera_1', + id: 'entity_1', + type: 'new', + }); + await manager.handleCameraEvent({ + cameraID: 'camera_1', + id: 'entity_1', + type: 'end', + }); + + // Move time forward by 5s (the untrigger delay is 10s). + vi.setSystemTime(add(start, { seconds: 5 })); + + await manager.handleCameraEvent({ + cameraID: 'camera_1', + id: 'entity_1', + type: 'new', + }); + + vi.setSystemTime(add(start, { seconds: 15 })); + vi.runOnlyPendingTimers(); + await flushPromises(); + + // Should still be triggered because the second 'new' event should have cancelled the first timer. + expect(manager.isTriggered()).toBeTruthy(); + expect(api.getViewManager().setViewDefaultWithNewQuery).not.toBeCalled(); + }); + + it('should untrigger each camera independently', async () => { + const api = createTriggerAPI(); + vi.mocked(api.getCameraManager().getStore).mockReturnValue( + createStore([ + { + cameraID: 'camera_1', + config: createCameraConfig({ + triggers: { entities: ['binary_sensor.motion_1'] }, + }), + }, + { + cameraID: 'camera_2', + config: createCameraConfig({ + triggers: { entities: ['binary_sensor.motion_2'] }, + }), + }, + ]), + ); + + const manager = new TriggersManager(api); + + await manager.handleCameraEvent({ + cameraID: 'camera_1', + type: 'new', + id: 'motion_1', + }); + await manager.handleCameraEvent({ + cameraID: 'camera_2', + type: 'new', + id: 'motion_2', + }); + + expect(manager.getTriggeredCameraIDs()).toEqual(new Set(['camera_1', 'camera_2'])); + + // Untrigger camera 1. + await manager.handleCameraEvent({ + cameraID: 'camera_1', + type: 'end', + id: 'motion_1', + }); + + vi.setSystemTime(add(start, { seconds: 10 })); + vi.runOnlyPendingTimers(); + await flushPromises(); + + // Camera 1 untriggered: setViewDefaultWithNewQuery is called. + expect(api.getViewManager().setViewDefaultWithNewQuery).toBeCalledTimes(1); + // Camera 2 is still triggered. + expect(manager.getTriggeredCameraIDs()).toEqual(new Set(['camera_2'])); + + // Untrigger camera 2. + await manager.handleCameraEvent({ + cameraID: 'camera_2', + type: 'end', + id: 'motion_2', + }); + + vi.setSystemTime(add(start, { seconds: 20 })); + vi.runOnlyPendingTimers(); + await flushPromises(); + + // Camera 2 untriggered: setViewDefaultWithNewQuery is called again. + expect(api.getViewManager().setViewDefaultWithNewQuery).toBeCalledTimes(2); + expect(manager.getTriggeredCameraIDs()).toEqual(new Set()); + }); + + it('should untrigger immediately when untrigger_seconds is 0', async () => { + const api = createTriggerAPI({ + config: { + untrigger_seconds: 0, + }, + }); + const manager = new TriggersManager(api); + await manager.handleCameraEvent({ + cameraID: 'camera_1', + id: 'event-1', + type: 'new', + }); + await manager.handleCameraEvent({ + cameraID: 'camera_1', + id: 'event-1', + type: 'end', + }); + await flushPromises(); + + expect(manager.isTriggered()).toBeFalsy(); + expect(api.getViewManager().setViewDefaultWithNewQuery).toBeCalled(); + }); }); - it('should manage condition state', () => { - const api = createTriggerAPI({ - config: { - ...baseTriggersConfig, - actions: { - ...baseTriggersConfig.actions, - trigger: 'none', - untrigger: 'none', + describe('condition state management', () => { + it('should manage condition state', async () => { + const api = createTriggerAPI({ + config: { + ...baseTriggersConfig, + actions: { + ...baseTriggersConfig.actions, + trigger: 'none', + untrigger: 'none', + }, }, - }, - }); + }); - const manager = new TriggersManager(api); + const manager = new TriggersManager(api); - manager.handleCameraEvent({ cameraID: 'camera_1', type: 'new' }); + await manager.handleCameraEvent({ + cameraID: 'camera_1', + id: 'event-1', + type: 'new', + }); - expect(api.getConditionStateManager().setState).toHaveBeenLastCalledWith({ - triggered: new Set(['camera_1']), - }); - vi.mocked(api.getConditionStateManager().getState).mockReturnValue({ - triggered: new Set(['camera_1']), - }); + expect(api.getConditionStateManager().setState).toHaveBeenLastCalledWith({ + triggered: new Set(['camera_1']), + }); + vi.mocked(api.getConditionStateManager().getState).mockReturnValue({ + triggered: new Set(['camera_1']), + }); - manager.handleCameraEvent({ cameraID: 'camera_1', type: 'end' }); + await manager.handleCameraEvent({ + cameraID: 'camera_1', + id: 'event-1', + type: 'end', + }); - vi.setSystemTime(add(start, { seconds: 10 })); - vi.runOnlyPendingTimers(); + vi.setSystemTime(add(start, { seconds: 10 })); + vi.runOnlyPendingTimers(); + await flushPromises(); - expect(api.getConditionStateManager().setState).toHaveBeenLastCalledWith({ - triggered: undefined, + expect(api.getConditionStateManager().setState).toHaveBeenLastCalledWith({ + triggered: undefined, + }); }); }); describe('should take no actions with high-fidelity event', () => { - it('with non-live action', () => { + it('should ignore high-fidelity events when trigger action is not live', async () => { const api = createTriggerAPI({ config: { ...baseTriggersConfig, @@ -356,13 +598,18 @@ describe('TriggersManager', () => { const manager = new TriggersManager(api); - manager.handleCameraEvent({ cameraID: 'camera_1', type: 'new', fidelity: 'high' }); + await manager.handleCameraEvent({ + cameraID: 'camera_1', + id: 'event-1', + type: 'new', + fidelity: 'high', + }); expect(api.getViewManager().setViewDefaultWithNewQuery).not.toBeCalled(); expect(api.getViewManager().setViewByParametersWithNewQuery).not.toBeCalled(); }); - it('with non-live default', () => { + it('should ignore high-fidelity events when default view is not live', async () => { const api = createTriggerAPI({ config: { ...baseTriggersConfig, @@ -376,22 +623,28 @@ describe('TriggersManager', () => { const manager = new TriggersManager(api); - manager.handleCameraEvent({ cameraID: 'camera_1', type: 'new', fidelity: 'high' }); + await manager.handleCameraEvent({ + cameraID: 'camera_1', + id: 'event-1', + type: 'new', + fidelity: 'high', + }); expect(api.getViewManager().setViewDefaultWithNewQuery).not.toBeCalled(); expect(api.getViewManager().setViewByParametersWithNewQuery).not.toBeCalled(); }); }); - it('should take no actions with human interactions', () => { + it('should take no actions with human interactions', async () => { const api = createTriggerAPI({ // Interaction present. interaction: true, }); const manager = new TriggersManager(api); - manager.handleCameraEvent({ + await manager.handleCameraEvent({ cameraID: 'camera_1', + id: 'event-1', type: 'new', }); @@ -400,8 +653,9 @@ describe('TriggersManager', () => { expect(api.getViewManager().setViewDefaultWithNewQuery).not.toBeCalled(); expect(api.getViewManager().setViewByParametersWithNewQuery).not.toBeCalled(); - manager.handleCameraEvent({ + await manager.handleCameraEvent({ cameraID: 'camera_1', + id: 'event-1', type: 'end', }); @@ -414,7 +668,7 @@ describe('TriggersManager', () => { expect(api.getViewManager().setViewByParametersWithNewQuery).not.toBeCalled(); }); - it('should take no actions when actions are set to none', () => { + it('should take no actions when actions are set to none', async () => { const api = createTriggerAPI({ config: { actions: { @@ -425,16 +679,18 @@ describe('TriggersManager', () => { }, }); const manager = new TriggersManager(api); - manager.handleCameraEvent({ + await manager.handleCameraEvent({ cameraID: 'camera_1', + id: 'event-1', type: 'new', }); expect(manager.isTriggered()).toBeTruthy(); expect(api.getViewManager().setViewDefaultWithNewQuery).not.toBeCalled(); expect(api.getViewManager().setViewByParametersWithNewQuery).not.toBeCalled(); - manager.handleCameraEvent({ + await manager.handleCameraEvent({ cameraID: 'camera_1', + id: 'event-1', type: 'end', }); @@ -462,6 +718,7 @@ describe('TriggersManager', () => { const manager = new TriggersManager(api); await manager.handleCameraEvent({ cameraID: 'camera_1', + id: 'event-1', type: 'new', }); @@ -475,6 +732,7 @@ describe('TriggersManager', () => { await manager.handleCameraEvent({ cameraID: 'camera_1', + id: 'event-1', type: 'end', }); @@ -518,10 +776,12 @@ describe('TriggersManager', () => { await manager.handleCameraEvent({ cameraID: 'camera_1', + id: 'event-1', type: 'new', }); await manager.handleCameraEvent({ cameraID: 'camera_2', + id: 'event-2', type: 'new', }); @@ -535,6 +795,7 @@ describe('TriggersManager', () => { await manager.handleCameraEvent({ cameraID: 'camera_1', + id: 'event-1', type: 'end', }); @@ -548,7 +809,7 @@ describe('TriggersManager', () => { }); describe('should filter triggers by camera', () => { - it('no dependencies', () => { + it('should filter triggers when there are no dependencies', async () => { const api = createTriggerAPI({ config: { ...baseTriggersConfig, @@ -564,8 +825,9 @@ describe('TriggersManager', () => { }); vi.mocked(api.getViewManager().getView).mockReturnValue(otherCameraSelected); - manager.handleCameraEvent({ + await manager.handleCameraEvent({ cameraID: 'camera_1', + id: 'event-1', type: 'new', }); expect(manager.isTriggered()).toBeFalsy(); @@ -575,14 +837,15 @@ describe('TriggersManager', () => { }); vi.mocked(api.getViewManager().getView).mockReturnValue(thisCameraSelected); - manager.handleCameraEvent({ + await manager.handleCameraEvent({ cameraID: 'camera_1', + id: 'event-1', type: 'new', }); expect(manager.isTriggered()).toBeTruthy(); }); - it('dependencies', () => { + it('should not filter triggers when there are dependencies', async () => { const api = createTriggerAPI({ config: { ...baseTriggersConfig, @@ -623,8 +886,9 @@ describe('TriggersManager', () => { // Events for the secondary will still trigger when filter_selected_camera // is true. - manager.handleCameraEvent({ + await manager.handleCameraEvent({ cameraID: 'camera_secondary', + id: 'event-secondary', type: 'new', }); expect(manager.isTriggered()).toBeTruthy(); @@ -717,5 +981,241 @@ describe('TriggersManager', () => { expect(result).toBeTruthy(); expect(manager.isTriggered()).toBeTruthy(); }); + + it('should only execute a single trigger action at startup for multiple triggered sources', async () => { + const api = createTriggerAPI(); + vi.mocked(api.getCameraManager().getStore).mockReturnValue( + createStore([ + { + cameraID: 'camera_1', + config: createCameraConfig({ + triggers: { + entities: ['binary_sensor.motion', 'binary_sensor.occupancy'], + }, + }), + }, + ]), + ); + + const hass = createHASS({ + 'binary_sensor.motion': createStateEntity({ + state: 'on', + }), + 'binary_sensor.occupancy': createStateEntity({ + state: 'open', + }), + }); + vi.mocked(api.getHASSManager().getHASS).mockReturnValue(hass); + + const manager = new TriggersManager(api); + const result = await manager.handleInitialCameraTriggers(); + + expect(result).toBeTruthy(); + expect(manager.isTriggered()).toBeTruthy(); + expect(api.getViewManager().setViewByParametersWithNewQuery).toBeCalledTimes(1); + }); + + it('should prioritize the first triggered camera action at startup', async () => { + const api = createTriggerAPI({ + config: { + actions: { + trigger: 'live', + }, + }, + }); + vi.mocked(api.getCameraManager().getStore).mockReturnValue( + createStore([ + { + cameraID: 'camera_1', + config: createCameraConfig({ + triggers: { + entities: ['binary_sensor.motion_1'], + }, + }), + }, + { + cameraID: 'camera_2', + config: createCameraConfig({ + triggers: { + entities: ['binary_sensor.motion_2'], + }, + }), + }, + ]), + ); + + const hass = createHASS({ + 'binary_sensor.motion_1': createStateEntity({ + state: 'on', + }), + 'binary_sensor.motion_2': createStateEntity({ + state: 'on', + }), + }); + vi.mocked(api.getHASSManager().getHASS).mockReturnValue(hass); + + const manager = new TriggersManager(api); + const result = await manager.handleInitialCameraTriggers(); + + expect(result).toBeTruthy(); + expect(manager.getTriggeredCameraIDs()).toEqual(new Set(['camera_1', 'camera_2'])); + expect(api.getViewManager().setViewByParametersWithNewQuery).toBeCalledTimes(1); + expect(api.getViewManager().setViewByParametersWithNewQuery).toHaveBeenCalledWith({ + params: { + view: 'live', + camera: 'camera_1', + }, + }); + }); + + it('should not execute startup action if triggered cameras are filtered out', async () => { + const api = createTriggerAPI({ + config: { + filter_selected_camera: true, + }, + }); + vi.mocked(api.getCameraManager().getStore).mockReturnValue( + createStore([ + { + cameraID: 'camera_2', + config: createCameraConfig({ + triggers: { + entities: ['binary_sensor.motion_2'], + }, + }), + }, + ]), + ); + + const hass = createHASS({ + 'binary_sensor.motion_2': createStateEntity({ + state: 'on', + }), + }); + vi.mocked(api.getHASSManager().getHASS).mockReturnValue(hass); + + const manager = new TriggersManager(api); + const result = await manager.handleInitialCameraTriggers(); + + // A trigger entity was active... + expect(result).toBeTruthy(); + // ...but the camera was filtered out, so no trigger state/action was applied. + expect(manager.isTriggered()).toBeFalsy(); + expect(api.getViewManager().setViewByParametersWithNewQuery).not.toBeCalled(); + expect(api.getViewManager().setViewDefaultWithNewQuery).not.toBeCalled(); + }); + }); + + it('should take actions with human interactions when interaction mode is active', async () => { + const api = createTriggerAPI({ + // Interaction present. + interaction: true, + config: { + ...baseTriggersConfig, + actions: { + trigger: 'live' as const, + untrigger: 'default' as const, + interaction_mode: 'active', + }, + }, + }); + const manager = new TriggersManager(api); + await manager.handleCameraEvent({ + cameraID: 'camera_1', + id: 'event-1', + type: 'new', + }); + + expect(manager.isTriggered()).toBeTruthy(); + expect(api.getViewManager().setViewByParametersWithNewQuery).toBeCalledWith({ + params: { + view: 'live' as const, + camera: 'camera_1' as const, + }, + }); + + await manager.handleCameraEvent({ + cameraID: 'camera_1', + id: 'event-1', + type: 'end', + }); + + vi.setSystemTime(add(start, { seconds: 10 })); + vi.runOnlyPendingTimers(); + await flushPromises(); + + expect(manager.isTriggered()).toBeFalsy(); + + expect(api.getViewManager().setViewDefaultWithNewQuery).toBeCalled(); + }); + + it('should ignore untrigger actions during non-allowable interaction but still untrigger camera', async () => { + const api = createTriggerAPI({ + interaction: true, + config: { + actions: { + interaction_mode: 'inactive', + untrigger: 'default', + }, + }, + }); + const manager = new TriggersManager(api); + + await manager.handleCameraEvent({ cameraID: 'camera_1', id: 'e1', type: 'new' }); + await manager.handleCameraEvent({ cameraID: 'camera_1', id: 'e1', type: 'end' }); + + vi.setSystemTime(add(start, { seconds: 15 })); + vi.runOnlyPendingTimers(); + await flushPromises(); + + // Camera is untriggered... + expect(manager.isTriggered()).toBe(false); + + // ...but the action was skipped. + expect(api.getViewManager().setViewDefaultWithNewQuery).not.toHaveBeenCalled(); + }); + + it('should not include cameras with no active sources in triggered IDs even before untrigger action completes', async () => { + const api = createTriggerAPI({ + config: { + untrigger_seconds: 0, + }, + }); + const manager = new TriggersManager(api); + + // Trigger then end, but don't await the end event so we can check + // triggered IDs while the camera is in the map but no longer triggered. + await manager.handleCameraEvent({ cameraID: 'camera_1', id: 'e1', type: 'new' }); + const untriggerPromise = manager.handleCameraEvent({ + cameraID: 'camera_1', + id: 'e1', + type: 'end', + }); + + expect(manager.getTriggeredCameraIDs()).toEqual(new Set()); + + await untriggerPromise; + }); + + it('should handle newly missing configuration', async () => { + const api = createTriggerAPI(); + const manager = new TriggersManager(api); + + // 1. Trigger the camera with valid config. + await manager.handleCameraEvent({ cameraID: 'camera_1', id: 'e1', type: 'new' }); + + // 2. Mock getConfig to return null. + vi.mocked(api.getConfigManager().getConfig).mockReturnValue(null); + + // 3. End the trigger. This should now hit the fallback '?? 0' in + // _startUntrigger because getConfig() is null. + await manager.handleCameraEvent({ cameraID: 'camera_1', id: 'e1', type: 'end' }); + + // Should NOT have triggered a view reset (since config is missing, no + // untrigger action is defined). + expect(api.getViewManager().setViewDefaultWithNewQuery).not.toHaveBeenCalled(); + + // But the camera should be untriggered. + expect(manager.isTriggered()).toBe(false); }); });