import { add } from 'date-fns'; import type { PartialDeep } from 'type-fest'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import { CameraTriggersManager } from '../../src/card-controller/camera-triggers-manager'; import type { CardController } from '../../src/card-controller/controller'; import type { AdvancedCameraCardView } from '../../src/config/schema/common/const'; import { triggersSchema, type TriggersOptions } from '../../src/config/schema/view'; import { createCameraManager, createCapabilities, createStore, } from '../camera-manager/test-utils'; import { createCameraConfig, createConfig } from '../config/test-utils'; import { createCardAPI, createHASS, createStateEntity, flushPromises, } from '../test-utils'; import { createView } from '../view/test-utils'; vi.mock('lodash-es', async () => ({ ...(await vi.importActual('lodash-es')), throttle: vi.fn((fn) => Object.assign(fn, { cancel: vi.fn() })), })); const baseTriggersConfig: TriggersOptions = { untrigger_delay_seconds: 10, untrigger_force_seconds: 0, event_hold_seconds: 0, filter_selected_camera: false, show_trigger_status: false, actions: { trigger: 'update' as const, untrigger: 'default' as const, interaction_mode: 'inactive' as const, }, }; const createTriggerAPI = (options?: { config?: PartialDeep; default?: AdvancedCameraCardView; interaction?: boolean; }): CardController => { const api = createCardAPI(); vi.mocked(api.getConfigManager().getConfig).mockReturnValue( createConfig({ view: { triggers: options?.config ? triggersSchema.parse({ ...baseTriggersConfig, ...options.config, actions: { ...baseTriggersConfig.actions, ...options.config.actions, }, }) : baseTriggersConfig, ...(options?.default && { default: options.default }), }, }), ); vi.mocked(api.getConditionStateManager().getState).mockReturnValue({}); vi.mocked(api.getCameraManager).mockReturnValue(createCameraManager()); vi.mocked(api.getCameraManager().getStore).mockReturnValue( createStore([ { cameraID: 'camera_1', config: createCameraConfig({ triggers: { entities: ['binary_sensor.motion'], }, }), }, ]), ); vi.mocked(api.getInteractionManager().hasInteraction).mockReturnValue( options?.interaction ?? false, ); vi.mocked(api.getViewManager().getView).mockReturnValue( createView({ camera: 'camera_1' as const, }), ); return api; }; // @vitest-environment jsdom describe('CameraTriggersManager', () => { const start = new Date('2023-10-01T17:14'); beforeEach(() => { vi.useFakeTimers(); vi.setSystemTime(start); }); it('should not be triggered by default', () => { const manager = new CameraTriggersManager(createCardAPI()); expect(manager.isTriggered()).toBeFalsy(); }); it('should not trigger if triggers config is empty', async () => { const api = createTriggerAPI({ config: { actions: { trigger: 'none', }, }, }); const manager = new CameraTriggersManager(api); await manager.handleCameraEvent({ cameraID: 'camera_1', id: 'event-1', type: 'new', }); expect(manager.isTriggered()).toBeTruthy(); expect(api.getViewManager().setViewDefaultWithNewQuery).not.toHaveBeenCalled(); }); it('should not trigger if there is no config', async () => { const api = createTriggerAPI(); vi.mocked(api.getConfigManager().getConfig).mockReturnValue(null); const manager = new CameraTriggersManager(api); await manager.handleCameraEvent({ cameraID: 'camera_1', id: 'event-1', type: 'new', }); expect(manager.isTriggered()).toBeFalsy(); }); describe('trigger actions', () => { it('should handle trigger action set to update', async () => { const api = createTriggerAPI({ config: { ...baseTriggersConfig, actions: { ...baseTriggersConfig.actions, trigger: 'update', }, }, }); const manager = new CameraTriggersManager(api); await manager.handleCameraEvent({ cameraID: 'camera_1', id: 'event-1', type: 'new', }); expect(manager.isTriggered()).toBeTruthy(); expect(api.getViewManager().setViewByParametersWithNewQuery).toHaveBeenCalledWith({ queryExecutorOptions: { useCache: false }, }); }); it('should handle trigger action set to default', async () => { const api = createTriggerAPI({ config: { ...baseTriggersConfig, actions: { ...baseTriggersConfig.actions, trigger: 'default', }, }, }); const manager = new CameraTriggersManager(api); await manager.handleCameraEvent({ cameraID: 'camera_1', id: 'event-1', type: 'new', }); expect(manager.isTriggered()).toBeTruthy(); expect(api.getViewManager().setViewDefaultWithNewQuery).toHaveBeenCalledWith({ params: { camera: 'camera_1', }, }); }); it('should handle trigger action set to live', async () => { const api = createTriggerAPI({ config: { ...baseTriggersConfig, actions: { ...baseTriggersConfig.actions, trigger: 'live', }, }, }); const manager = new CameraTriggersManager(api); await manager.handleCameraEvent({ cameraID: 'camera_1', id: 'event-1', type: 'new', }); expect(manager.isTriggered()).toBeTruthy(); expect(api.getViewManager().setViewByParametersWithNewQuery).toHaveBeenCalledWith({ params: { view: 'live', camera: 'camera_1', }, }); }); describe('media', () => { it.each([ // [review, snapshot, clip, expectedView] [false, false, false, null], [false, false, true, 'clip' as const], [false, true, false, 'snapshot' as const], [false, true, true, 'clip' as const], [true, false, false, 'review' as const], [true, true, true, 'review' as const], ])( 'with review %s, snapshot %s and clip %s', async ( hasReview: boolean, hasSnapshot: boolean, hasClip: boolean, viewName: 'review' | 'clip' | 'snapshot' | null, ) => { const api = createTriggerAPI({ config: { actions: { interaction_mode: 'all', trigger: 'media', untrigger: 'none', }, }, }); const manager = new CameraTriggersManager(api); await manager.handleCameraEvent({ cameraID: 'camera_1', id: 'event-1', type: 'new', fidelity: 'high', review: hasReview, snapshot: hasSnapshot, clip: hasClip, }); if (!viewName) { expect( api.getViewManager().setViewByParametersWithNewQuery, ).not.toHaveBeenCalled(); } else { expect(manager.isTriggered()).toBeTruthy(); expect( api.getViewManager().setViewByParametersWithNewQuery, ).toHaveBeenCalledWith({ params: { camera: 'camera_1', view: viewName, }, }); } }, ); }); it('should handle trigger action set to none', async () => { const api = createTriggerAPI({ config: { ...baseTriggersConfig, actions: { ...baseTriggersConfig.actions, trigger: 'none', }, }, }); const manager = new CameraTriggersManager(api); await manager.handleCameraEvent({ cameraID: 'camera_1', id: 'event-1', type: 'new', }); expect(manager.isTriggered()).toBeTruthy(); expect(api.getViewManager().setViewDefaultWithNewQuery).not.toHaveBeenCalled(); expect( api.getViewManager().setViewByParametersWithNewQuery, ).not.toHaveBeenCalled(); }); it('should handle trigger action set to call', async () => { const api = createTriggerAPI({ config: { actions: { trigger: 'call' }, }, }); const manager = new CameraTriggersManager(api); await manager.handleCameraEvent({ cameraID: 'camera_1', id: 'event-1', type: 'new', }); expect(manager.isTriggered()).toBeTruthy(); // `start()` is called with the triggered camera and the inbound flag -- // view navigation is delegated to CallManager itself. expect(api.getCallManager().start).toHaveBeenCalledWith({ cameraID: 'camera_1', inbound: true, }); expect( api.getViewManager().setViewByParametersWithNewQuery, ).not.toHaveBeenCalled(); }); it('should start a call on a high-fidelity event with no media', async () => { // The high-fidelity-no-media skip-guard intentionally lets `call` // through -- calls don't depend on a new media item being available. const api = createTriggerAPI({ config: { actions: { trigger: 'call' }, }, }); const manager = new CameraTriggersManager(api); await manager.handleCameraEvent({ cameraID: 'camera_1', id: 'event-1', type: 'new', fidelity: 'high', }); expect(api.getCallManager().start).toHaveBeenCalledWith({ cameraID: 'camera_1', inbound: true, }); }); }); describe('untrigger actions', () => { it('should handle untrigger action set to none with no trigger actions', async () => { const api = createTriggerAPI({ config: { ...baseTriggersConfig, actions: { ...baseTriggersConfig.actions, trigger: 'none', untrigger: 'none', }, }, }); const manager = new CameraTriggersManager(api); 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(); expect(api.getViewManager().setViewDefaultWithNewQuery).not.toHaveBeenCalled(); expect( api.getViewManager().setViewByParametersWithNewQuery, ).not.toHaveBeenCalled(); }); it('should handle untrigger action set to default', async () => { const api = createTriggerAPI({ config: { ...baseTriggersConfig, actions: { ...baseTriggersConfig.actions, trigger: 'none', untrigger: 'default', }, }, }); const manager = new CameraTriggersManager(api); 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(); expect(api.getViewManager().setViewDefaultWithNewQuery).toHaveBeenCalled(); }); it('should handle untrigger action set to call', async () => { const api = createTriggerAPI({ config: { actions: { trigger: 'none', untrigger: 'call' }, }, }); const manager = new CameraTriggersManager(api); 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(); expect(api.getCallManager().endIf).toHaveBeenCalledWith({ cameraID: 'camera_1', inbound: true, answered: false, }); expect(api.getViewManager().setViewDefaultWithNewQuery).not.toHaveBeenCalled(); }); it('should handle untrigger call with no state', async () => { const api = createTriggerAPI(); const manager = new CameraTriggersManager(api); await manager.handleCameraEvent({ cameraID: 'camera_1', id: 'unknown-id', type: 'end', }); expect(api.getViewManager().setViewDefaultWithNewQuery).not.toHaveBeenCalled(); }); it('should not untrigger if other sources are still active', async () => { const api = createTriggerAPI(); const manager = new CameraTriggersManager(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 CameraTriggersManager(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 // canceled the first timer. expect(manager.isTriggered()).toBeTruthy(); expect(api.getViewManager().setViewDefaultWithNewQuery).not.toHaveBeenCalled(); }); 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 CameraTriggersManager(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).toHaveBeenCalledTimes(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).toHaveBeenCalledTimes(2); expect(manager.getTriggeredCameraIDs()).toEqual(new Set()); }); it('should stay triggered during the untrigger delay', async () => { const api = createTriggerAPI({ config: { untrigger_delay_seconds: 10, }, }); const manager = new CameraTriggersManager(api); // 1. Trigger the camera. await manager.handleCameraEvent({ cameraID: 'camera_1', id: 'e1', type: 'new' }); expect(manager.isTriggered()).toBe(true); // 2. End the event. await manager.handleCameraEvent({ cameraID: 'camera_1', id: 'e1', type: 'end' }); // 3. Verify it's STILL triggered during the delay. expect(manager.isTriggered()).toBe(true); // 4. Fast forward and verify it untriggers. vi.setSystemTime(add(start, { seconds: 15 })); vi.runOnlyPendingTimers(); await flushPromises(); expect(manager.isTriggered()).toBe(false); }); it('should stop timers on reset while untrigger delay is pending', async () => { const api = createTriggerAPI({ config: { untrigger_delay_seconds: 10, }, }); const manager = new CameraTriggersManager(api); // Trigger then end to start the untrigger delay timer. await manager.handleCameraEvent({ cameraID: 'camera_1', id: 'e1', type: 'new' }); await manager.handleCameraEvent({ cameraID: 'camera_1', id: 'e1', type: 'end' }); expect(manager.isTriggered()).toBe(true); // Reset clears all states and timers. manager.reset(); expect(manager.isTriggered()).toBe(false); // Advancing past the delay should not cause errors or state changes. vi.setSystemTime(add(start, { seconds: 15 })); vi.runOnlyPendingTimers(); await flushPromises(); expect(manager.isTriggered()).toBe(false); }); it('should stop force untrigger timer on reset', async () => { const api = createTriggerAPI({ config: { untrigger_delay_seconds: 0, untrigger_force_seconds: 10, }, }); const manager = new CameraTriggersManager(api); // Trigger to start the force untrigger timer. await manager.handleCameraEvent({ cameraID: 'camera_1', id: 'e1', type: 'new' }); expect(manager.isTriggered()).toBe(true); // Reset clears all states and timers. manager.reset(); expect(manager.isTriggered()).toBe(false); // Advancing past the force timer should not cause errors. vi.setSystemTime(add(start, { seconds: 15 })); vi.runOnlyPendingTimers(); await flushPromises(); expect(manager.isTriggered()).toBe(false); }); it('should untrigger immediately when untrigger_delay_seconds is 0', async () => { const api = createTriggerAPI({ config: { untrigger_delay_seconds: 0, }, }); const manager = new CameraTriggersManager(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).toHaveBeenCalled(); }); it('should force untrigger when untrigger_force_seconds expires', async () => { const api = createTriggerAPI({ config: { untrigger_delay_seconds: 0, untrigger_force_seconds: 5, }, }); const manager = new CameraTriggersManager(api); await manager.handleCameraEvent({ cameraID: 'camera_1', id: 'event-1', type: 'new', }); vi.advanceTimersByTime(5000); await flushPromises(); expect(manager.isTriggered()).toBeFalsy(); expect(api.getViewManager().setViewDefaultWithNewQuery).toHaveBeenCalledTimes(1); }); it('should not reset force timer on source update', async () => { const api = createTriggerAPI({ config: { untrigger_delay_seconds: 0, untrigger_force_seconds: 10, }, }); const manager = new CameraTriggersManager(api); await manager.handleCameraEvent({ cameraID: 'camera_1', id: 'event-1', type: 'new', }); vi.advanceTimersByTime(5000); await manager.handleCameraEvent({ cameraID: 'camera_1', id: 'event-1', type: 'update', }); vi.advanceTimersByTime(4900); await flushPromises(); expect(manager.isTriggered()).toBeTruthy(); vi.advanceTimersByTime(100); await flushPromises(); expect(manager.isTriggered()).toBeFalsy(); expect(api.getViewManager().setViewDefaultWithNewQuery).toHaveBeenCalledTimes(1); }); it('should force untrigger all sources when one force timer expires', async () => { const api = createTriggerAPI({ config: { untrigger_delay_seconds: 0, untrigger_force_seconds: 5, }, }); const manager = new CameraTriggersManager(api); await manager.handleCameraEvent({ cameraID: 'camera_1', id: 'source-1', type: 'new', }); await manager.handleCameraEvent({ cameraID: 'camera_1', id: 'source-2', type: 'new', }); vi.advanceTimersByTime(5000); await flushPromises(); expect(manager.isTriggered()).toBeFalsy(); expect(api.getViewManager().setViewDefaultWithNewQuery).toHaveBeenCalledTimes(1); }); it('should not extend force timer when a second source triggers later', async () => { const api = createTriggerAPI({ config: { untrigger_delay_seconds: 0, untrigger_force_seconds: 10, }, }); const manager = new CameraTriggersManager(api); await manager.handleCameraEvent({ cameraID: 'camera_1', id: 'source-1', type: 'new', }); vi.advanceTimersByTime(5000); await manager.handleCameraEvent({ cameraID: 'camera_1', id: 'source-2', type: 'new', }); vi.advanceTimersByTime(4900); await flushPromises(); expect(manager.isTriggered()).toBeTruthy(); vi.advanceTimersByTime(100); await flushPromises(); expect(manager.isTriggered()).toBeFalsy(); expect(api.getViewManager().setViewDefaultWithNewQuery).toHaveBeenCalledTimes(1); }); it('should auto-untrigger after the delay on a momentary event', async () => { const api = createTriggerAPI({ config: { actions: { trigger: 'none', untrigger: 'default' }, }, }); const manager = new CameraTriggersManager(api); await manager.handleCameraEvent({ cameraID: 'camera_1', id: 'event.doorbell', type: 'momentary', }); expect(manager.isTriggered()).toBeTruthy(); expect(api.getViewManager().setViewDefaultWithNewQuery).not.toHaveBeenCalled(); vi.setSystemTime(add(start, { seconds: 10 })); vi.runOnlyPendingTimers(); await flushPromises(); expect(manager.isTriggered()).toBeFalsy(); expect(api.getViewManager().setViewDefaultWithNewQuery).toHaveBeenCalled(); }); it('should auto-untrigger immediately on a momentary event when delay is 0', async () => { const api = createTriggerAPI({ config: { untrigger_delay_seconds: 0, actions: { trigger: 'none', untrigger: 'default' }, }, }); const manager = new CameraTriggersManager(api); await manager.handleCameraEvent({ cameraID: 'camera_1', id: 'event.doorbell', type: 'momentary', }); await flushPromises(); expect(manager.isTriggered()).toBeFalsy(); expect(api.getViewManager().setViewDefaultWithNewQuery).toHaveBeenCalled(); }); it('should not auto-untrigger from a momentary event while a continuous source remains active', async () => { const api = createTriggerAPI({ config: { actions: { trigger: 'none', untrigger: 'default' }, }, }); const manager = new CameraTriggersManager(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: 'momentary', }); 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.toHaveBeenCalled(); }); it('should add event_hold_seconds on top of untrigger_delay_seconds for momentary events', async () => { const api = createTriggerAPI({ config: { untrigger_delay_seconds: 5, event_hold_seconds: 30, actions: { trigger: 'none', untrigger: 'default' }, }, }); const manager = new CameraTriggersManager(api); await manager.handleCameraEvent({ cameraID: 'camera_1', id: 'event.doorbell', type: 'momentary', }); // At 34s the additive 35s window is still active. vi.setSystemTime(add(start, { seconds: 34 })); vi.advanceTimersByTime(34_000); await flushPromises(); expect(manager.isTriggered()).toBeTruthy(); // At 35s the window expires and untrigger fires. vi.setSystemTime(add(start, { seconds: 35 })); vi.advanceTimersByTime(1_000); await flushPromises(); expect(manager.isTriggered()).toBeFalsy(); expect(api.getViewManager().setViewDefaultWithNewQuery).toHaveBeenCalled(); }); it('should not apply event_hold_seconds to non-momentary events', async () => { const api = createTriggerAPI({ config: { untrigger_delay_seconds: 5, event_hold_seconds: 30, actions: { trigger: 'none', untrigger: 'default' }, }, }); const manager = new CameraTriggersManager(api); await manager.handleCameraEvent({ cameraID: 'camera_1', id: 'binary_sensor.motion', type: 'new', }); await manager.handleCameraEvent({ cameraID: 'camera_1', id: 'binary_sensor.motion', type: 'end', }); // Only untrigger_delay_seconds (5s) applies to a stateful end event. vi.setSystemTime(add(start, { seconds: 4 })); vi.advanceTimersByTime(4_000); await flushPromises(); expect(manager.isTriggered()).toBeTruthy(); vi.setSystemTime(add(start, { seconds: 5 })); vi.advanceTimersByTime(1_000); await flushPromises(); expect(manager.isTriggered()).toBeFalsy(); }); }); 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 CameraTriggersManager(api); 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']), }); await manager.handleCameraEvent({ cameraID: 'camera_1', id: 'event-1', type: 'end', }); vi.setSystemTime(add(start, { seconds: 10 })); vi.runOnlyPendingTimers(); await flushPromises(); expect(api.getConditionStateManager().setState).toHaveBeenLastCalledWith({ triggered: undefined, }); }); }); describe('should take no actions with high-fidelity event', () => { it('should ignore high-fidelity events when trigger action is not live', async () => { const api = createTriggerAPI({ config: { ...baseTriggersConfig, actions: { ...baseTriggersConfig.actions, trigger: 'media', }, }, default: 'live', }); const manager = new CameraTriggersManager(api); await manager.handleCameraEvent({ cameraID: 'camera_1', id: 'event-1', type: 'new', fidelity: 'high', }); expect(api.getViewManager().setViewDefaultWithNewQuery).not.toHaveBeenCalled(); expect( api.getViewManager().setViewByParametersWithNewQuery, ).not.toHaveBeenCalled(); }); it('should ignore high-fidelity events when default view is not live', async () => { const api = createTriggerAPI({ config: { ...baseTriggersConfig, actions: { ...baseTriggersConfig.actions, trigger: 'default', }, }, default: 'clips', }); const manager = new CameraTriggersManager(api); await manager.handleCameraEvent({ cameraID: 'camera_1', id: 'event-1', type: 'new', fidelity: 'high', }); expect(api.getViewManager().setViewDefaultWithNewQuery).not.toHaveBeenCalled(); expect( api.getViewManager().setViewByParametersWithNewQuery, ).not.toHaveBeenCalled(); }); }); it('should take no actions with human interactions', async () => { const api = createTriggerAPI({ // Interaction present. interaction: true, }); const manager = new CameraTriggersManager(api); await manager.handleCameraEvent({ cameraID: 'camera_1', id: 'event-1', type: 'new', }); expect(manager.isTriggered()).toBeTruthy(); expect(api.getViewManager().setViewDefaultWithNewQuery).not.toHaveBeenCalled(); expect(api.getViewManager().setViewByParametersWithNewQuery).not.toHaveBeenCalled(); await manager.handleCameraEvent({ cameraID: 'camera_1', id: 'event-1', type: 'end', }); vi.setSystemTime(add(start, { seconds: 10 })); vi.runOnlyPendingTimers(); expect(manager.isTriggered()).toBeFalsy(); expect(api.getViewManager().setViewDefaultWithNewQuery).not.toHaveBeenCalled(); expect(api.getViewManager().setViewByParametersWithNewQuery).not.toHaveBeenCalled(); }); it('should take no actions when actions are set to none', async () => { const api = createTriggerAPI({ config: { actions: { interaction_mode: 'all', trigger: 'none', untrigger: 'none', }, }, }); const manager = new CameraTriggersManager(api); await manager.handleCameraEvent({ cameraID: 'camera_1', id: 'event-1', type: 'new', }); expect(manager.isTriggered()).toBeTruthy(); expect(api.getViewManager().setViewDefaultWithNewQuery).not.toHaveBeenCalled(); expect(api.getViewManager().setViewByParametersWithNewQuery).not.toHaveBeenCalled(); await manager.handleCameraEvent({ cameraID: 'camera_1', id: 'event-1', type: 'end', }); vi.setSystemTime(add(start, { seconds: 10 })); vi.runOnlyPendingTimers(); expect(manager.isTriggered()).toBeFalsy(); expect(api.getViewManager().setViewDefaultWithNewQuery).not.toHaveBeenCalled(); expect(api.getViewManager().setViewByParametersWithNewQuery).not.toHaveBeenCalled(); }); 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 CameraTriggersManager(api); await manager.handleCameraEvent({ cameraID: 'camera_1', id: 'event-1', type: 'new', }); expect(manager.isTriggered()).toBeTruthy(); expect(api.getViewManager().setViewByParametersWithNewQuery).toHaveBeenCalledWith({ 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).toHaveBeenCalled(); }); it('should report multiple triggered cameras', async () => { const api = createTriggerAPI(); vi.mocked(api.getCameraManager().getStore).mockReturnValue( createStore([ { cameraID: 'camera_1', config: createCameraConfig({ triggers: { entities: ['binary_sensor.one'], }, }), }, { cameraID: 'camera_2', config: createCameraConfig({ triggers: { entities: ['binary_sensor.two'], }, }), }, ]), ); const manager = new CameraTriggersManager(api); expect(manager.isTriggered()).toBeFalsy(); expect(manager.getMostRecentlyTriggeredCameraID()).toBeNull(); expect(manager.getTriggeredCameraIDs()).toEqual(new Set()); await manager.handleCameraEvent({ cameraID: 'camera_1', id: 'event-1', type: 'new', }); await manager.handleCameraEvent({ cameraID: 'camera_2', id: 'event-2', type: 'new', }); expect(manager.isTriggered()).toBeTruthy(); expect(manager.getTriggeredCameraIDs()).toEqual(new Set(['camera_1', 'camera_2'])); // Either is the most recently triggered. expect(['camera_1', 'camera_2']).toContain( manager.getMostRecentlyTriggeredCameraID(), ); await manager.handleCameraEvent({ cameraID: 'camera_1', id: 'event-1', type: 'end', }); vi.setSystemTime(add(start, { seconds: 10 })); vi.runOnlyPendingTimers(); await flushPromises(); expect(manager.getTriggeredCameraIDs()).toEqual(new Set(['camera_2'])); expect(manager.getMostRecentlyTriggeredCameraID()).toBe('camera_2'); }); describe('should filter triggers by camera', () => { it('should filter triggers when there are no dependencies', async () => { const api = createTriggerAPI({ config: { ...baseTriggersConfig, // Filter triggers to selected camera only. filter_selected_camera: true, }, }); const manager = new CameraTriggersManager(api); expect(manager.isTriggered()).toBeFalsy(); const otherCameraSelected = createView({ camera: 'camera_SOME_OTHER_CAMERA' as const, }); vi.mocked(api.getViewManager().getView).mockReturnValue(otherCameraSelected); await manager.handleCameraEvent({ cameraID: 'camera_1', id: 'event-1', type: 'new', }); expect(manager.isTriggered()).toBeFalsy(); const thisCameraSelected = createView({ camera: 'camera_1' as const, }); vi.mocked(api.getViewManager().getView).mockReturnValue(thisCameraSelected); await manager.handleCameraEvent({ cameraID: 'camera_1', id: 'event-1', type: 'new', }); expect(manager.isTriggered()).toBeTruthy(); }); it('should not filter triggers when there are dependencies', async () => { const api = createTriggerAPI({ config: { ...baseTriggersConfig, // Filter triggers to selected camera only. filter_selected_camera: true, }, }); vi.mocked(api.getCameraManager().getStore).mockReturnValue( createStore([ { cameraID: 'camera_primary', config: createCameraConfig({ triggers: { entities: ['binary_sensor.motion'], }, dependencies: { all_cameras: true, }, }), }, { cameraID: 'camera_secondary', config: createCameraConfig({ triggers: { entities: ['binary_sensor.motion'], }, }), }, ]), ); const manager = new CameraTriggersManager(api); const primaryCameraView = createView({ camera: 'camera_primary' as const, }); vi.mocked(api.getViewManager().getView).mockReturnValue(primaryCameraView); // Events for the secondary will still trigger when filter_selected_camera // is true. await manager.handleCameraEvent({ cameraID: 'camera_secondary', id: 'event-secondary', type: 'new', }); expect(manager.isTriggered()).toBeTruthy(); }); it('should not reset the untrigger timer when a filtered-out momentary event arrives', async () => { // Guards the `if (handled)` branch on the momentary-event synthesis: a // filter-rejected momentary event 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 CameraTriggersManager(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: 'momentary', }); // 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).toHaveBeenCalled(); }); }); describe('should handle initial camera triggers', () => { it('should not trigger if no cameras have trigger entities', async () => { const api = createTriggerAPI(); vi.mocked(api.getCameraManager().getStore).mockReturnValue( createStore([ { cameraID: 'camera_1', config: createCameraConfig({ triggers: { entities: [], }, }), }, ]), ); const manager = new CameraTriggersManager(api); const result = await manager.handleInitialCameraTriggers(); expect(result).toBeFalsy(); expect(manager.isTriggered()).toBeFalsy(); }); it('should not trigger if no cameras have trigger state', 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: 'off', }), 'binary_sensor.occupancy': createStateEntity({ state: 'off', }), }); vi.mocked(api.getHASSManager().getHASS).mockReturnValue(hass); const manager = new CameraTriggersManager(api); const result = await manager.handleInitialCameraTriggers(); expect(result).toBeFalsy(); expect(manager.isTriggered()).toBeFalsy(); }); it('should trigger if cameras are triggered', 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: 'off', }), 'binary_sensor.occupancy': createStateEntity({ state: 'on', }), }); vi.mocked(api.getHASSManager().getHASS).mockReturnValue(hass); const manager = new CameraTriggersManager(api); const result = await manager.handleInitialCameraTriggers(); 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 CameraTriggersManager(api); const result = await manager.handleInitialCameraTriggers(); expect(result).toBeTruthy(); expect(manager.isTriggered()).toBeTruthy(); expect(api.getViewManager().setViewByParametersWithNewQuery).toHaveBeenCalledTimes( 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 CameraTriggersManager(api); const result = await manager.handleInitialCameraTriggers(); expect(result).toBeTruthy(); expect(manager.getTriggeredCameraIDs()).toEqual(new Set(['camera_1', 'camera_2'])); expect(api.getViewManager().setViewByParametersWithNewQuery).toHaveBeenCalledTimes( 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 CameraTriggersManager(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.toHaveBeenCalled(); expect(api.getViewManager().setViewDefaultWithNewQuery).not.toHaveBeenCalled(); }); it('should not trigger a camera without the trigger capability', async () => { const api = createTriggerAPI(); vi.mocked(api.getCameraManager().getStore).mockReturnValue( createStore([ { cameraID: 'camera_1', capabilities: createCapabilities({ trigger: false }), config: createCameraConfig({ triggers: { entities: ['binary_sensor.motion'], }, }), }, ]), ); const hass = createHASS({ 'binary_sensor.motion': createStateEntity({ state: 'on', }), }); vi.mocked(api.getHASSManager().getHASS).mockReturnValue(hass); const manager = new CameraTriggersManager(api); const result = await manager.handleInitialCameraTriggers(); expect(result).toBeFalsy(); expect(manager.isTriggered()).toBeFalsy(); expect(api.getViewManager().setViewByParametersWithNewQuery).not.toBeCalled(); }); it('should target the startup action at a camera that has the trigger capability', async () => { const api = createTriggerAPI({ config: { actions: { trigger: 'live', }, }, }); vi.mocked(api.getCameraManager().getStore).mockReturnValue( createStore([ { cameraID: 'substream', capabilities: createCapabilities({ substream: true, trigger: false }), config: createCameraConfig({ triggers: { entities: ['binary_sensor.motion'], }, }), }, { cameraID: 'camera_1', config: createCameraConfig({ dependencies: { cameras: ['substream'], }, triggers: { entities: ['binary_sensor.motion'], }, }), }, ]), ); const hass = createHASS({ 'binary_sensor.motion': createStateEntity({ state: 'on', }), }); vi.mocked(api.getHASSManager().getHASS).mockReturnValue(hass); const manager = new CameraTriggersManager(api); const result = await manager.handleInitialCameraTriggers(); expect(result).toBeTruthy(); expect(manager.getTriggeredCameraIDs()).toEqual(new Set(['camera_1'])); expect(api.getViewManager().setViewByParametersWithNewQuery).toHaveBeenCalledWith({ params: { view: 'live', camera: 'camera_1', }, }); }); }); 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 CameraTriggersManager(api); await manager.handleCameraEvent({ cameraID: 'camera_1', id: 'event-1', type: 'new', }); expect(manager.isTriggered()).toBeTruthy(); expect(api.getViewManager().setViewByParametersWithNewQuery).toHaveBeenCalledWith({ 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).toHaveBeenCalled(); }); 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 CameraTriggersManager(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_delay_seconds: 0, }, }); const manager = new CameraTriggersManager(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 CameraTriggersManager(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); }); describe('ignore list behavior', () => { it('should ignore updates for event IDs that have been forcibly untriggered', async () => { const api = createTriggerAPI({ config: { untrigger_delay_seconds: 0, untrigger_force_seconds: 10, }, }); const manager = new CameraTriggersManager(api); // 1. Initial trigger. await manager.handleCameraEvent({ cameraID: 'camera_1', id: 'e1', type: 'new' }); // 2. Fast forward to force untrigger. vi.setSystemTime(add(start, { seconds: 15 })); vi.runOnlyPendingTimers(); await flushPromises(); expect(manager.isTriggered()).toBe(false); // 3. Send an update for the same ID. It should be ignored. const result = await manager.handleCameraEvent({ cameraID: 'camera_1', id: 'e1', type: 'update', }); expect(result).toBe(false); expect(manager.isTriggered()).toBe(false); }); it('should un-ignore an event ID once an end event arrives', async () => { const api = createTriggerAPI({ config: { untrigger_force_seconds: 10, }, }); const manager = new CameraTriggersManager(api); // 1. Trigger and force untrigger. await manager.handleCameraEvent({ cameraID: 'camera_1', id: 'e1', type: 'new' }); vi.setSystemTime(add(start, { seconds: 15 })); vi.runOnlyPendingTimers(); await flushPromises(); // 2. Send 'end'. This should clear from ignore list even if state was already deleted. await manager.handleCameraEvent({ cameraID: 'camera_1', id: 'e1', type: 'end' }); // 3. Send a new trigger for the same ID. It should be accepted. const result = await manager.handleCameraEvent({ cameraID: 'camera_1', id: 'e1', type: 'new', }); expect(result).toBe(true); expect(manager.isTriggered()).toBe(true); }); it('should process updates for event IDs that were never ignored', async () => { const api = createTriggerAPI({ config: { actions: { trigger: 'update', }, }, }); const manager = new CameraTriggersManager(api); // Simulate an update for a brand new ID (e.g. from an engine we just switched to). // Even without a 'new' event, if it's not in our ignore list, it should be processed. const result = await manager.handleCameraEvent({ cameraID: 'camera_1', id: 'new-engine-event', type: 'update', }); expect(result).toBe(true); expect(manager.isTriggered()).toBe(true); }); it('should not suppress same event ID on a different camera', async () => { const api = createTriggerAPI({ config: { untrigger_delay_seconds: 0, untrigger_force_seconds: 10, }, }); const manager = new CameraTriggersManager(api); // Force-ignore e1 on camera_1. await manager.handleCameraEvent({ cameraID: 'camera_1', id: 'e1', type: 'new' }); vi.setSystemTime(add(start, { seconds: 15 })); vi.runOnlyPendingTimers(); await flushPromises(); // Same ID on a different camera should not be ignored. const result = await manager.handleCameraEvent({ cameraID: 'camera_2', id: 'e1', type: 'update', }); expect(result).toBe(true); expect(manager.getTriggeredCameraIDs()).toContain('camera_2'); }); it('should correctly prune ignore list for a camera with multiple ignored IDs', async () => { const api = createTriggerAPI({ config: { untrigger_delay_seconds: 0, untrigger_force_seconds: 10, }, }); const manager = new CameraTriggersManager(api); // Force-ignore e1 and e2 on camera_1. await manager.handleCameraEvent({ cameraID: 'camera_1', id: 'e1', type: 'new' }); await manager.handleCameraEvent({ cameraID: 'camera_1', id: 'e2', type: 'new' }); vi.setSystemTime(add(start, { seconds: 15 })); vi.runOnlyPendingTimers(); await flushPromises(); // End e1. The set should still exist because e2 is still ignored. await manager.handleCameraEvent({ cameraID: 'camera_1', id: 'e1', type: 'end' }); // Verify e2 is still ignored. const result = await manager.handleCameraEvent({ cameraID: 'camera_1', id: 'e2', type: 'update', }); expect(result).toBe(false); // End e2. The set should now be deleted. await manager.handleCameraEvent({ cameraID: 'camera_1', id: 'e2', type: 'end' }); }); }); it('should safely handle events with unknown camera IDs', async () => { const api = createTriggerAPI({ config: { untrigger_delay_seconds: 0, }, }); const manager = new CameraTriggersManager(api); // Call handleCameraEvent with an unknown camera ID to ensure guard behavior. const result = await manager.handleCameraEvent({ cameraID: 'unknown', id: 'e1', type: 'new', }); expect(result).toBe(true); expect(manager.getTriggeredCameraIDs()).toContain('unknown'); await manager.handleCameraEvent({ cameraID: 'unknown', id: 'e1', type: 'end', }); expect(manager.getTriggeredCameraIDs()).not.toContain('unknown'); }); });