feat: Add ability to untrigger after fixed number of seconds (#2350)

- Related: #2342 


BREAKING CHANGE: Renames: `untrigger_seconds` to
`untrigger_delay_seconds`
This commit is contained in:
Dermot Duffy
2026-02-16 12:25:56 -08:00
committed by GitHub
parent 584521c78e
commit 47880e4306
18 changed files with 566 additions and 57 deletions
+32 -17
View File
@@ -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
+1
View File
@@ -569,6 +569,7 @@ export class FrigateCamera extends Camera {
this._eventCallback?.({
cameraID,
id: review.after.id,
fidelity: 'high',
type: review.type,
review: true,
+130 -21
View File
@@ -13,8 +13,17 @@ interface CameraTriggerState {
// IDs).
sources: Set<string>;
// The set of ignored event IDs (e.g. events that have been forcibly
// untriggered).
ignoredSources: Set<string>;
// 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<boolean> {
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<boolean> {
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<void> {
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<void> {
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<void> {
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);
}
+5
View File
@@ -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,
),
];
+8 -2
View File
@@ -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<typeof triggersSchema>;
+4 -2
View File
@@ -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;
+7 -3
View File
@@ -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,
+1 -1
View File
@@ -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",
+2 -1
View File
@@ -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",
+1 -1
View File
@@ -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",
+1 -1
View File
@@ -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",
+1 -1
View File
@@ -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",
+1 -1
View File
@@ -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",
+1 -1
View File
@@ -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",
@@ -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,
});
+308 -4
View File
@@ -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');
});
});
+57
View File
@@ -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);
});
});
});
+2 -1
View File
@@ -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',