diff --git a/docs/configuration/view.md b/docs/configuration/view.md index 42f1177a..2a14c310 100644 --- a/docs/configuration/view.md +++ b/docs/configuration/view.md @@ -99,7 +99,11 @@ Allows overriding of any CSS value, can be used to tweak theming parameters. ## `triggers` -The `triggers` block controls how the card reacts when a camera is triggered (note that _what_ triggers the camera is controlled by the [`triggers`](cameras/README.md?id=triggers) block within the config for a given camera). This can be used for a variety of purposes, such as allowing the card to automatically change to `live` for a camera that triggers. +The `triggers` block controls how the card reacts when a camera is triggered +(note that _what_ triggers the camera is controlled by the +[`triggers`](cameras/README.md?id=triggers) block within the config for a given +camera). This can be used for a variety of purposes, such as allowing the card +to automatically change to `live` for a camera that triggers. All configuration is under: @@ -109,23 +113,33 @@ view: # [...] ``` -When a camera untriggers (e.g. an entity state returning to something other than -`on` or `open`), an action can also be taken with an optional number of seconds -to wait prior to the acting (see `untrigger_seconds`). By default, triggering is -only allowed when there is no ongoing human interaction with the card. This -behavior can be controlled by the `interaction_mode` parameter. +When all trigger sources for a camera end (e.g. an entity state returns to +something other than `on` or `open`), an untrigger action can be taken. -If the card starts when a trigger entity is already in a triggered state, the -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. +The triggered state can be extended by a number of seconds after the source ends +(see `untrigger_delay_seconds`). Alternatively, a camera can be forcibly +untriggered after a fixed duration regardless of the state of the trigger +sources (see `untrigger_force_seconds`). -| Option | Default | Description | -| ------------------------ | ------- | ---------------------------------------------------------------------------------------------------------------------------------- | -| `actions` | | The actions to take when a camera is triggered. See below. | -| `filter_selected_camera` | `false` | 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_seconds` | `0` | The number of seconds to wait after a camera untriggers before considering the card untriggered and taking the `untrigger` action. | +By default, trigger/untrigger actions are only taken when there is no ongoing +human interaction with the card; this behavior can be configured via the +`interaction_mode` parameter. + +> [!TIP] If a camera is already in a triggered state when the card starts, the trigger +> 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 below. | +| `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. | + +> [!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. ### Trigger action configuration @@ -181,7 +195,8 @@ view: triggers: show_trigger_status: false filter_selected_camera: true - untrigger_seconds: 0 + untrigger_delay_seconds: 0 + untrigger_force_seconds: 0 actions: interaction_mode: inactive trigger: update diff --git a/src/camera-manager/frigate/camera.ts b/src/camera-manager/frigate/camera.ts index 24e322c0..d6a0a583 100644 --- a/src/camera-manager/frigate/camera.ts +++ b/src/camera-manager/frigate/camera.ts @@ -569,6 +569,7 @@ export class FrigateCamera extends Camera { this._eventCallback?.({ cameraID, + id: review.after.id, fidelity: 'high', type: review.type, review: true, diff --git a/src/card-controller/triggers-manager.ts b/src/card-controller/triggers-manager.ts index 9288d243..652f18d0 100644 --- a/src/card-controller/triggers-manager.ts +++ b/src/card-controller/triggers-manager.ts @@ -13,8 +13,17 @@ interface CameraTriggerState { // IDs). sources: Set; + // The set of ignored event IDs (e.g. events that have been forcibly + // untriggered). + ignoredSources: Set; + // A timer used to delay the untrigger action. untriggerDelayTimer?: Timer; + + // A one-shot timer used to force untriggering a camera if no end event is + // seen within a configured duration. This timer starts when the camera first + // triggers and is not reset by subsequent trigger events. + untriggerForceTimer?: Timer; } export class TriggersManager { @@ -55,6 +64,7 @@ export class TriggersManager { const hass = this._api.getHASSManager().getHASS(); let triggered = false; let startupActionEvent: CameraEvent | null = null; + this._states.clear(); for (const [cameraID, camera] of this._api .getCameraManager() @@ -97,12 +107,13 @@ export class TriggersManager { ): 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; + return this._handleEndEvent(ev); + } + + // Ignore stale updates for force-untriggered IDs before doing any further + // processing to avoid re-activating muted IDs. + if (this._isIgnoredUpdateEvent(ev)) { + return false; } const config = this._api.getConfigManager().getConfig(); @@ -122,20 +133,15 @@ export class TriggersManager { return false; } - 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(); - } - + const state = this._getOrCreateState(ev.cameraID); + state.lastTriggerTime = new Date(); state.sources.add(ev.id); this._deleteUntriggerDelayTimer(ev.cameraID); + this._startForceUntriggerTimerIfNecessary( + ev.cameraID, + triggersConfig.untrigger_force_seconds, + ); this._setConditionStateIfNecessary(); if (!skipAction) { await this._throttledTriggerAction(ev); @@ -143,6 +149,24 @@ export class TriggersManager { return true; } + protected async _handleEndEvent(ev: CameraEvent): Promise { + this._deleteIgnoredEventID(ev.cameraID, ev.id); + + const state = this._states.get(ev.cameraID); + state?.sources.delete(ev.id); + if (!state?.sources.size) { + await this._startUntrigger(ev.cameraID); + } + return true; + } + + protected _isIgnoredUpdateEvent(ev: CameraEvent): boolean { + return ( + (ev.type === 'update' || ev.type === 'genai') && + this._hasIgnoredEventID(ev.cameraID, ev.id) + ); + } + protected _hasAllowableInteractionStateForAction(): boolean { const triggersConfig = this._api.getConfigManager().getConfig()?.view.triggers; const hasInteraction = this._api.getInteractionManager().hasInteraction(); @@ -247,9 +271,10 @@ export class TriggersManager { protected async _untriggerAction(cameraID: string): Promise { this._deleteUntriggerDelayTimer(cameraID); + this._deleteForceUntriggerTimer(cameraID); await this._executeUntriggerAction(); - this._states.delete(cameraID); + this._deleteStateIfIdle(cameraID); this._setConditionStateIfNecessary(); @@ -259,6 +284,7 @@ export class TriggersManager { protected async _startUntrigger(cameraID: string): Promise { this._deleteUntriggerDelayTimer(cameraID); + this._deleteForceUntriggerTimer(cameraID); const state = this._states.get(cameraID); if (!state) { @@ -266,11 +292,11 @@ export class TriggersManager { } const config = this._api.getConfigManager().getConfig(); - const untriggerSeconds = config?.view?.triggers.untrigger_seconds ?? 0; + const untriggerDelaySeconds = config?.view?.triggers.untrigger_delay_seconds ?? 0; - if (untriggerSeconds > 0) { + if (untriggerDelaySeconds > 0) { state.untriggerDelayTimer = new Timer(); - state.untriggerDelayTimer.start(untriggerSeconds, async () => { + state.untriggerDelayTimer.start(untriggerDelaySeconds, async () => { await this._untriggerAction(cameraID); }); } else { @@ -278,6 +304,81 @@ export class TriggersManager { } } + protected _startForceUntriggerTimerIfNecessary( + cameraID: string, + forceUntriggerSeconds: number, + ): void { + if (forceUntriggerSeconds <= 0) { + return; + } + + const state = this._states.get(cameraID); + if (!state || state.untriggerForceTimer) { + return; + } + + const timer = new Timer(); + state.untriggerForceTimer = timer; + timer.start(forceUntriggerSeconds, async () => { + await this._forceUntrigger(state, cameraID); + }); + } + + protected async _forceUntrigger( + state: CameraTriggerState, + cameraID: string, + ): Promise { + state.sources.forEach((id) => this._addIgnoredEventID(cameraID, id)); + state.sources.clear(); + this._deleteForceUntriggerTimer(cameraID); + await this._startUntrigger(cameraID); + } + + protected _addIgnoredEventID(cameraID: string, eventID: string): void { + const state = this._getOrCreateState(cameraID); + state.ignoredSources.add(eventID); + } + + protected _deleteIgnoredEventID(cameraID: string, eventID: string): void { + const state = this._states.get(cameraID); + if (!state) { + return; + } + + state.ignoredSources.delete(eventID); + this._deleteStateIfIdle(cameraID); + } + + protected _hasIgnoredEventID(cameraID: string, eventID: string): boolean { + return !!this._states.get(cameraID)?.ignoredSources.has(eventID); + } + + protected _getOrCreateState(cameraID: string): CameraTriggerState { + let state = this._states.get(cameraID); + if (!state) { + state = { + lastTriggerTime: new Date(), + sources: new Set(), + ignoredSources: new Set(), + }; + this._states.set(cameraID, state); + } + return state; + } + + protected _deleteStateIfIdle(cameraID: string): void { + const state = this._states.get(cameraID); + if ( + state && + !state.sources.size && + !state.ignoredSources.size && + !state.untriggerDelayTimer && + !state.untriggerForceTimer + ) { + this._states.delete(cameraID); + } + } + protected _deleteUntriggerDelayTimer(cameraID: string): void { const state = this._states.get(cameraID); if (state?.untriggerDelayTimer) { @@ -286,6 +387,14 @@ export class TriggersManager { } } + protected _deleteForceUntriggerTimer(cameraID: string): void { + const state = this._states.get(cameraID); + if (state?.untriggerForceTimer) { + state.untriggerForceTimer.stop(); + delete state.untriggerForceTimer; + } + } + protected _isStateTriggered(state: CameraTriggerState): boolean { return !!(state.sources.size || state.untriggerDelayTimer); } diff --git a/src/config/management.ts b/src/config/management.ts index 02d9bfaa..ac71c3a8 100644 --- a/src/config/management.ts +++ b/src/config/management.ts @@ -19,6 +19,7 @@ import { CONF_VIEW_TRIGGERS_ACTIONS_TRIGGER, CONF_VIEW_TRIGGERS_ACTIONS_UNTRIGGER, CONF_VIEW_TRIGGERS_FILTER_SELECTED_CAMERA, + CONF_VIEW_TRIGGERS_UNTRIGGER_DELAY_SECONDS, } from '../const'; import { arrayify } from '../utils/basic'; import { AdvancedCameraCardCondition } from './schema/conditions/types'; @@ -960,4 +961,8 @@ const UPGRADES = [ }, }, ), + upgradeMoveToWithOverrides( + 'view.triggers.untrigger_seconds', + CONF_VIEW_TRIGGERS_UNTRIGGER_DELAY_SECONDS, + ), ]; diff --git a/src/config/schema/view.ts b/src/config/schema/view.ts index 1a6d8df3..ea865790 100644 --- a/src/config/schema/view.ts +++ b/src/config/schema/view.ts @@ -67,7 +67,8 @@ export const viewConfigDefault = { trigger: 'update' as const, untrigger: 'none' as const, }, - untrigger_seconds: 0, + untrigger_delay_seconds: 0, + untrigger_force_seconds: 0, }, keyboard_shortcuts: keyboardShortcutsDefault, }; @@ -93,7 +94,12 @@ export const triggersSchema = z.object({ show_trigger_status: z .boolean() .default(viewConfigDefault.triggers.show_trigger_status), - untrigger_seconds: z.number().default(viewConfigDefault.triggers.untrigger_seconds), + untrigger_delay_seconds: z + .number() + .default(viewConfigDefault.triggers.untrigger_delay_seconds), + untrigger_force_seconds: z + .number() + .default(viewConfigDefault.triggers.untrigger_force_seconds), }); export type TriggersOptions = z.infer; diff --git a/src/const.ts b/src/const.ts index 214417bd..8c709131 100644 --- a/src/const.ts +++ b/src/const.ts @@ -180,8 +180,10 @@ export const CONF_VIEW_TRIGGERS_SHOW_TRIGGER_STATUS = `${CONF_VIEW_TRIGGERS}.show_trigger_status` as const; export const CONF_VIEW_TRIGGERS_FILTER_SELECTED_CAMERA = `${CONF_VIEW_TRIGGERS}.filter_selected_camera` as const; -export const CONF_VIEW_TRIGGERS_UNTRIGGER_SECONDS = - `${CONF_VIEW_TRIGGERS}.untrigger_seconds` as const; +export const CONF_VIEW_TRIGGERS_UNTRIGGER_DELAY_SECONDS = + `${CONF_VIEW_TRIGGERS}.untrigger_delay_seconds` as const; +export const CONF_VIEW_TRIGGERS_UNTRIGGER_FORCE_SECONDS = + `${CONF_VIEW_TRIGGERS}.untrigger_force_seconds` as const; export const CONF_VIEW_TRIGGERS_ACTIONS = `${CONF_VIEW_TRIGGERS}.actions` as const; export const CONF_VIEW_TRIGGERS_ACTIONS_TRIGGER = `${CONF_VIEW_TRIGGERS_ACTIONS}.trigger` as const; diff --git a/src/editor.ts b/src/editor.ts index c9987fd2..bfc1e05d 100644 --- a/src/editor.ts +++ b/src/editor.ts @@ -255,7 +255,8 @@ import { CONF_VIEW_TRIGGERS_ACTIONS_UNTRIGGER, CONF_VIEW_TRIGGERS_FILTER_SELECTED_CAMERA, CONF_VIEW_TRIGGERS_SHOW_TRIGGER_STATUS, - CONF_VIEW_TRIGGERS_UNTRIGGER_SECONDS, + CONF_VIEW_TRIGGERS_UNTRIGGER_DELAY_SECONDS, + CONF_VIEW_TRIGGERS_UNTRIGGER_FORCE_SECONDS, FOLDERS_CONFIGURATION_URL, MEDIA_CHUNK_SIZE_MAX, } from './const.js'; @@ -1390,8 +1391,11 @@ export class AdvancedCameraCardEditor extends LitElement implements LovelaceCard label: localize(`config.${CONF_VIEW_TRIGGERS_SHOW_TRIGGER_STATUS}`), }, )} - ${this._renderNumberInput(CONF_VIEW_TRIGGERS_UNTRIGGER_SECONDS, { - default: this._defaults.view.triggers.untrigger_seconds, + ${this._renderNumberInput(CONF_VIEW_TRIGGERS_UNTRIGGER_DELAY_SECONDS, { + default: this._defaults.view.triggers.untrigger_delay_seconds, + })} + ${this._renderNumberInput(CONF_VIEW_TRIGGERS_UNTRIGGER_FORCE_SECONDS, { + default: this._defaults.view.triggers.untrigger_force_seconds, })} ${this._putInSubmenu( MENU_VIEW_TRIGGERS_ACTIONS, diff --git a/src/localize/languages/ca.json b/src/localize/languages/ca.json index 614533de..628c3d15 100644 --- a/src/localize/languages/ca.json +++ b/src/localize/languages/ca.json @@ -395,7 +395,7 @@ "editor_label": "Comportament quan s'activa una càmera", "filter_selected_camera": "Activa només a la càmera seleccionada", "show_trigger_status": "Mostra la vora intermitent quan s'activa", - "untrigger_seconds": "Segons després del canvi d'estat inactiu a desactivat" + "untrigger_delay_seconds": "Segons després del canvi d'estat inactiu a desactivat" }, "views": { "clip": "Clip més recent", diff --git a/src/localize/languages/en.json b/src/localize/languages/en.json index 8782f559..ca038547 100644 --- a/src/localize/languages/en.json +++ b/src/localize/languages/en.json @@ -625,7 +625,8 @@ "editor_label": "Trigger behavior", "filter_selected_camera": "Only trigger on selected camera", "show_trigger_status": "Show pulsing border when triggered", - "untrigger_seconds": "Seconds after inactive state change to untrigger" + "untrigger_delay_seconds": "Seconds delay after trigger state change before untrigger", + "untrigger_force_seconds": "Seconds before forced untrigger" }, "views": { "auto": "Automatic", diff --git a/src/localize/languages/fr.json b/src/localize/languages/fr.json index 7b73f21b..fd670592 100644 --- a/src/localize/languages/fr.json +++ b/src/localize/languages/fr.json @@ -542,7 +542,7 @@ "editor_label": "Comportement en cas de déclenchement d'une caméra", "filter_selected_camera": "Déclenchement uniquement sur la caméra sélectionnée", "show_trigger_status": "Afficher la bordure clignotante lors du déclenchement", - "untrigger_seconds": "Quelques secondes après le changement d'état inactif pour débloquer" + "untrigger_delay_seconds": "Quelques secondes après le changement d'état inactif pour débloquer" }, "views": { "clip": "Clip le plus récent", diff --git a/src/localize/languages/it.json b/src/localize/languages/it.json index 23a8bdea..08b8c4a7 100644 --- a/src/localize/languages/it.json +++ b/src/localize/languages/it.json @@ -283,7 +283,7 @@ }, "triggers": { "show_trigger_status": "Mostra bordo pulsante quando attivato", - "untrigger_seconds": "Reimposta la vista ai valori predefiniti dopo aver annullato l'attivazione" + "untrigger_delay_seconds": "Reimposta la vista ai valori predefiniti dopo aver annullato l'attivazione" }, "views": { "clip": "Clip più recente", diff --git a/src/localize/languages/pl.json b/src/localize/languages/pl.json index 7a9dc937..2c3f219c 100644 --- a/src/localize/languages/pl.json +++ b/src/localize/languages/pl.json @@ -592,7 +592,7 @@ "editor_label": "Zachowanie wyzwalaczy", "filter_selected_camera": "Wyzwalaj tylko na wybranej kamerze", "show_trigger_status": "Pokaż pulsującą ramkę przy wyzwoleniu", - "untrigger_seconds": "Sekundy po ustaniu stanu, aby dezaktywować" + "untrigger_delay_seconds": "Sekundy po ustaniu stanu, aby dezaktywować" }, "views": { "clip": "Ostatni klip", diff --git a/src/localize/languages/pt-BR.json b/src/localize/languages/pt-BR.json index b0bb4c48..f3f83be0 100644 --- a/src/localize/languages/pt-BR.json +++ b/src/localize/languages/pt-BR.json @@ -286,7 +286,7 @@ }, "triggers": { "show_trigger_status": "Pulsar borda quando acionado", - "untrigger_seconds": "Segundos após a mudar para o estado inativo para desacionar" + "untrigger_delay_seconds": "Segundos após a mudar para o estado inativo para desacionar" }, "views": { "clip": "Clipe mais recente", diff --git a/src/localize/languages/pt-PT.json b/src/localize/languages/pt-PT.json index 4a0882e9..9469f529 100644 --- a/src/localize/languages/pt-PT.json +++ b/src/localize/languages/pt-PT.json @@ -283,7 +283,7 @@ }, "triggers": { "show_trigger_status": "Exibir estado do gatilho", - "untrigger_seconds": "Segundos após a mudar para o estado inativo para desacionar" + "untrigger_delay_seconds": "Segundos após a mudar para o estado inativo para desacionar" }, "views": { "clip": "Clipe mais recente", diff --git a/tests/camera-manager/frigate/camera.test.ts b/tests/camera-manager/frigate/camera.test.ts index 418c67cb..333aa2d6 100644 --- a/tests/camera-manager/frigate/camera.test.ts +++ b/tests/camera-manager/frigate/camera.test.ts @@ -1280,6 +1280,7 @@ describe('FrigateCamera', () => { expect(eventCallback).toBeCalledWith({ type: 'new', cameraID: 'CAMERA_1', + id: '123', fidelity: 'high', review: true, }); @@ -1351,6 +1352,7 @@ describe('FrigateCamera', () => { expect(eventCallback).toBeCalledWith({ type: 'update', cameraID: 'CAMERA_1', + id: '123', fidelity: 'high', review: true, }); @@ -1422,6 +1424,7 @@ describe('FrigateCamera', () => { expect(eventCallback).toBeCalledWith({ type: 'update', cameraID: 'CAMERA_1', + id: '123', fidelity: 'high', review: true, }); @@ -1785,6 +1788,7 @@ describe('FrigateCamera', () => { expect(eventCallback).toBeCalledWith({ type: 'end', cameraID: 'CAMERA_1', + id: '123', fidelity: 'high', review: true, }); diff --git a/tests/card-controller/triggers-manager.test.ts b/tests/card-controller/triggers-manager.test.ts index a90ab8ac..198e9745 100644 --- a/tests/card-controller/triggers-manager.test.ts +++ b/tests/card-controller/triggers-manager.test.ts @@ -23,7 +23,8 @@ vi.mock('lodash-es', async () => ({ })); const baseTriggersConfig: TriggersOptions = { - untrigger_seconds: 10, + untrigger_delay_seconds: 10, + untrigger_force_seconds: 0, filter_selected_camera: false, show_trigger_status: false, actions: { @@ -515,10 +516,36 @@ describe('TriggersManager', () => { expect(manager.getTriggeredCameraIDs()).toEqual(new Set()); }); - it('should untrigger immediately when untrigger_seconds is 0', async () => { + it('should stay triggered during the untrigger delay', async () => { const api = createTriggerAPI({ config: { - untrigger_seconds: 0, + untrigger_delay_seconds: 10, + }, + }); + const manager = new TriggersManager(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 untrigger immediately when untrigger_delay_seconds is 0', async () => { + const api = createTriggerAPI({ + config: { + untrigger_delay_seconds: 0, }, }); const manager = new TriggersManager(api); @@ -537,6 +564,119 @@ describe('TriggersManager', () => { expect(manager.isTriggered()).toBeFalsy(); expect(api.getViewManager().setViewDefaultWithNewQuery).toBeCalled(); }); + + 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 TriggersManager(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).toBeCalledTimes(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 TriggersManager(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).toBeCalledTimes(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 TriggersManager(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).toBeCalledTimes(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 TriggersManager(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).toBeCalledTimes(1); + }); }); describe('condition state management', () => { @@ -1178,7 +1318,7 @@ describe('TriggersManager', () => { 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, + untrigger_delay_seconds: 0, }, }); const manager = new TriggersManager(api); @@ -1218,4 +1358,168 @@ describe('TriggersManager', () => { // 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 TriggersManager(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 TriggersManager(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 TriggersManager(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 TriggersManager(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 TriggersManager(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 TriggersManager(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'); + }); }); diff --git a/tests/config/management.test.ts b/tests/config/management.test.ts index df7663ee..c15b8203 100644 --- a/tests/config/management.test.ts +++ b/tests/config/management.test.ts @@ -3690,5 +3690,62 @@ describe('should handle version specific upgrades', () => { postUpgradeChecks(config); }); }); + + it('view.triggers.untrigger_seconds -> view.triggers.untrigger_delay_seconds', () => { + const config = { + type: 'custom:advanced-camera-card', + cameras: [{}], + view: { + triggers: { + untrigger_seconds: 42, + }, + }, + overrides: [ + { + conditions: [ + { + condition: 'media_loaded' as const, + media_loaded: true, + }, + ], + merge: { + view: { + triggers: { + untrigger_seconds: 7, + }, + }, + }, + }, + ], + }; + expect(upgradeConfig(config)).toBeTruthy(); + expect(config).toEqual({ + type: 'custom:advanced-camera-card', + cameras: [{}], + view: { + triggers: { + untrigger_delay_seconds: 42, + }, + }, + overrides: [ + { + conditions: [ + { + condition: 'media_loaded' as const, + media_loaded: true, + }, + ], + merge: { + view: { + triggers: { + untrigger_delay_seconds: 7, + }, + }, + }, + }, + ], + }); + postUpgradeChecks(config); + }); }); }); diff --git a/tests/config/types.test.ts b/tests/config/types.test.ts index cb23c97b..2f753685 100644 --- a/tests/config/types.test.ts +++ b/tests/config/types.test.ts @@ -408,7 +408,8 @@ describe('config defaults', () => { }, triggers: { show_trigger_status: false, - untrigger_seconds: 0, + untrigger_delay_seconds: 0, + untrigger_force_seconds: 0, actions: { trigger: 'update', untrigger: 'none',