fix: Improve state handling to avoid stuck triggered cameras (#2348)

- Related:
https://github.com/dermotduffy/advanced-camera-card/issues/2342
This commit is contained in:
Dermot Duffy
2026-02-15 19:26:56 -08:00
committed by GitHub
parent be61d7fa24
commit b1467dc4bd
11 changed files with 723 additions and 127 deletions
+3 -1
View File
@@ -116,7 +116,9 @@ only allowed when there is no ongoing human interaction with the card. This
behavior can be controlled by the `interaction_mode` parameter.
If the card starts when a trigger entity is already in a triggered state, the
action will be taken on card startup.
action will be taken on card startup. If multiple cameras are triggered at
startup, all are marked as triggered, but the startup action is taken for the
first triggered camera only.
| Option | Default | Description |
| ------------------------ | ------- | ---------------------------------------------------------------------------------------------------------------------------------- |
+1
View File
@@ -222,6 +222,7 @@ export class Camera {
protected _stateChangeHandler = (difference: HassStateDifference): void => {
this._eventCallback?.({
cameraID: this.getID(),
id: difference.entityID,
type: isTriggeredState(difference.newState.state) ? 'new' : 'end',
});
};
+1 -1
View File
@@ -471,7 +471,7 @@ export class FrigateCamera extends Camera {
this._eventCallback?.({
cameraID,
id: ev.after.id,
fidelity: 'high',
type: ev.type,
// In cases where there are both clip and snapshot media, ensure to only
+1
View File
@@ -85,6 +85,7 @@ export type PTZInfo = z.infer<typeof ptzInfoSchema>;
const frigateEventChangeBeforeAfterSchema = z.object({
camera: z.string(),
id: z.string(),
snapshot: z
.object({
frame_time: z.number(),
+4
View File
@@ -140,6 +140,10 @@ export interface EngineOptions {
export interface CameraEvent {
cameraID: string;
// Source ID (e.g. entity ID or Frigate event ID), used to determine what has
// triggered/untriggered.
id: string;
type: 'new' | 'update' | 'end';
// When fidelity is `high`, the engine is assumed to provide exact details of
+136 -59
View File
@@ -1,14 +1,25 @@
import { orderBy, throttle } from 'lodash-es';
import { maxBy, throttle } from 'lodash-es';
import { CameraEvent } from '../camera-manager/types';
import { isTriggeredState } from '../ha/is-triggered-state';
import { Timer } from '../utils/timer';
import { CardTriggersAPI } from './types';
interface CameraTriggerState {
// The time of the most recent trigger event. Used to determine the most
// recently triggered camera.
lastTriggerTime: Date;
// The set of active trigger source IDs (e.g. entity IDs or Frigate event
// IDs).
sources: Set<string>;
// A timer used to delay the untrigger action.
untriggerDelayTimer?: Timer;
}
export class TriggersManager {
protected _api: CardTriggersAPI;
protected _triggeredCameras: Map<string, Date> = new Map();
protected _triggeredCameraTimers: Map<string, Timer> = new Map();
protected _states: Map<string, CameraTriggerState> = new Map();
protected _throttledTriggerAction = throttle(this._triggerAction.bind(this), 1000, {
trailing: true,
@@ -19,53 +30,87 @@ export class TriggersManager {
}
public getTriggeredCameraIDs(): Set<string> {
return new Set(this._triggeredCameras.keys());
const ids = new Set<string>();
this._states.forEach((state, cameraID) => {
if (this._isStateTriggered(state)) {
ids.add(cameraID);
}
});
return ids;
}
public isTriggered(): boolean {
return !!this._triggeredCameras.size;
return [...this._states.values()].some((state) => this._isStateTriggered(state));
}
public getMostRecentlyTriggeredCameraID(): string | null {
const sorted = orderBy(
[...this._triggeredCameras.entries()],
(entry) => entry[1].getTime(),
'desc',
const mostRecent = maxBy(
[...this._states.entries()].filter(([, state]) => this._isStateTriggered(state)),
([, state]) => state.lastTriggerTime.getTime(),
);
return sorted.length ? sorted[0][0] : null;
return mostRecent?.[0] ?? null;
}
public handleInitialCameraTriggers = async (): Promise<boolean> => {
const hass = this._api.getHASSManager().getHASS();
let triggered = false;
let startupActionEvent: CameraEvent | null = null;
for (const [cameraID, camera] of this._api
.getCameraManager()
.getStore()
.getCameras()) {
if (
camera
.getConfig()
.triggers.entities.some((entityID) =>
isTriggeredState(hass?.states[entityID]?.state),
)
) {
triggered = true;
await this.handleCameraEvent({
cameraID,
type: 'new',
});
for (const entityID of camera.getConfig().triggers.entities) {
if (isTriggeredState(hass?.states[entityID]?.state)) {
triggered = true;
const event: CameraEvent = {
cameraID,
id: entityID,
type: 'new',
};
if (
await this.handleCameraEvent(event, {
skipAction: true,
})
) {
startupActionEvent ??= event;
}
}
}
}
if (startupActionEvent) {
await this._throttledTriggerAction(startupActionEvent);
}
return triggered;
};
public async handleCameraEvent(ev: CameraEvent): Promise<void> {
const triggersConfig = this._api.getConfigManager().getConfig()?.view.triggers;
// Returns true if the event was accepted into trigger state processing.
// Returns false if it was ignored (e.g. missing config/view or camera filter
// mismatch).
public async handleCameraEvent(
ev: CameraEvent,
options?: {
skipAction?: boolean;
},
): Promise<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;
}
const config = this._api.getConfigManager().getConfig();
const triggersConfig = config?.view?.triggers;
const selectedCameraID = this._api.getViewManager().getView()?.camera;
if (!triggersConfig || !selectedCameraID) {
return;
return false;
}
const dependentCameraIDs = this._api
@@ -74,17 +119,28 @@ export class TriggersManager {
.getAllDependentCameras(selectedCameraID);
if (triggersConfig.filter_selected_camera && !dependentCameraIDs.has(ev.cameraID)) {
return;
return false;
}
if (ev.type === 'end') {
this._startUntriggerTimer(ev.cameraID);
return;
let state = this._states.get(ev.cameraID);
if (!state) {
state = {
lastTriggerTime: new Date(),
sources: new Set(),
};
this._states.set(ev.cameraID, state);
} else {
state.lastTriggerTime = new Date();
}
this._triggeredCameras.set(ev.cameraID, new Date());
state.sources.add(ev.id);
this._deleteUntriggerDelayTimer(ev.cameraID);
this._setConditionStateIfNecessary();
await this._throttledTriggerAction(ev);
if (!skipAction) {
await this._throttledTriggerAction(ev);
}
return true;
}
protected _hasAllowableInteractionStateForAction(): boolean {
@@ -100,9 +156,9 @@ export class TriggersManager {
}
protected async _triggerAction(ev: CameraEvent): Promise<void> {
const triggerAction = this._api.getConfigManager().getConfig()?.view.triggers
.actions.trigger;
const defaultView = this._api.getConfigManager().getConfig()?.view.default;
const config = this._api.getConfigManager().getConfig();
const triggerAction = config?.view?.triggers.actions.trigger;
const defaultView = config?.view?.default;
// If this is a high-fidelity event where we are certain about new media,
// don't take action unless it's to change to live (Frigate engine may pump
@@ -153,47 +209,68 @@ export class TriggersManager {
}
protected _setConditionStateIfNecessary(): void {
const triggeredCameraIDs = new Set(this._triggeredCameras.keys());
const triggeredState = triggeredCameraIDs.size ? triggeredCameraIDs : undefined;
const triggeredCameraIDs = this.getTriggeredCameraIDs();
this._api.getConditionStateManager().setState({
triggered: triggeredState,
triggered: triggeredCameraIDs.size ? triggeredCameraIDs : undefined,
});
}
protected async _untriggerAction(cameraID: string): Promise<void> {
const action = this._api.getConfigManager().getConfig()?.view.triggers
protected async _executeUntriggerAction(): Promise<boolean> {
const action = this._api.getConfigManager().getConfig()?.view?.triggers
.actions.untrigger;
if (action === 'default' && this._hasAllowableInteractionStateForAction()) {
if (!action || action === 'none') {
return true;
}
if (this._hasAllowableInteractionStateForAction()) {
await this._api.getViewManager().setViewDefaultWithNewQuery();
}
this._triggeredCameras.delete(cameraID);
this._deleteTimer(cameraID);
return true;
}
protected async _untriggerAction(cameraID: string): Promise<void> {
this._deleteUntriggerDelayTimer(cameraID);
await this._executeUntriggerAction();
this._states.delete(cameraID);
this._setConditionStateIfNecessary();
// Must update master element to remove border pulsing from live view.
this._api.getCardElementManager().update();
}
protected _startUntriggerTimer(cameraID: string): void {
this._deleteTimer(cameraID);
protected async _startUntrigger(cameraID: string): Promise<void> {
this._deleteUntriggerDelayTimer(cameraID);
const timer = new Timer();
this._triggeredCameraTimers.set(cameraID, timer);
timer.start(
/* istanbul ignore next: the case of config being null here cannot be
reached, as there's no way to have the untrigger call happen without
a config. -- @preserve */
this._api.getConfigManager().getConfig()?.view.triggers.untrigger_seconds ?? 0,
async () => {
const state = this._states.get(cameraID);
if (!state) {
return;
}
const config = this._api.getConfigManager().getConfig();
const untriggerSeconds = config?.view?.triggers.untrigger_seconds ?? 0;
if (untriggerSeconds > 0) {
state.untriggerDelayTimer = new Timer();
state.untriggerDelayTimer.start(untriggerSeconds, async () => {
await this._untriggerAction(cameraID);
},
);
});
} else {
await this._untriggerAction(cameraID);
}
}
protected _deleteTimer(cameraID: string): void {
this._triggeredCameraTimers.get(cameraID)?.stop();
this._triggeredCameraTimers.delete(cameraID);
protected _deleteUntriggerDelayTimer(cameraID: string): void {
const state = this._states.get(cameraID);
if (state?.untriggerDelayTimer) {
state.untriggerDelayTimer.stop();
delete state.untriggerDelayTimer;
}
}
protected _isStateTriggered(state: CameraTriggerState): boolean {
return !!(state.sources.size || state.untriggerDelayTimer);
}
}
+1
View File
@@ -201,6 +201,7 @@ describe('Camera', () => {
expect(eventCallback).toBeCalledWith({
cameraID: 'camera_1',
id: 'sensor.force_update',
type: eventType,
});
},
@@ -879,6 +879,7 @@ describe('FrigateCamera', () => {
callEventWatcherCallback(eventWatcher, {
type: 'new',
before: {
id: 'event-1',
camera: 'camera.front_door',
snapshot: null,
has_clip: false,
@@ -887,6 +888,7 @@ describe('FrigateCamera', () => {
current_zones: [],
},
after: {
id: 'event-1',
camera: 'camera.front_door',
snapshot: null,
has_clip: hasClip,
@@ -900,6 +902,7 @@ describe('FrigateCamera', () => {
expect(eventCallback).toBeCalledWith({
type: 'new',
cameraID: 'CAMERA_1',
id: 'event-1',
clip: hasClip && events.includes('clips'),
snapshot: hasSnapshot && events.includes('snapshots'),
fidelity: 'high',
@@ -944,6 +947,7 @@ describe('FrigateCamera', () => {
callEventWatcherCallback(eventWatcher, {
type: 'new',
before: {
id: 'event-1',
camera: 'camera.front_door',
snapshot: null,
has_clip: false,
@@ -952,6 +956,7 @@ describe('FrigateCamera', () => {
current_zones: [],
},
after: {
id: 'event-1',
camera: 'camera.front_door',
snapshot: null,
has_clip: false,
@@ -997,6 +1002,7 @@ describe('FrigateCamera', () => {
callEventWatcherCallback(eventWatcher, {
type: 'new',
before: {
id: 'event-1',
camera: 'camera.front_door',
snapshot: null,
has_clip: false,
@@ -1007,6 +1013,7 @@ describe('FrigateCamera', () => {
current_zones: [],
},
after: {
id: 'event-1',
camera: 'camera.front_door',
snapshot: null,
has_clip: false,
@@ -1047,6 +1054,7 @@ describe('FrigateCamera', () => {
callEventWatcherCallback(eventWatcher, {
type: 'new',
before: {
id: 'event-1',
camera: 'camera.front_door',
snapshot: null,
has_clip: false,
@@ -1055,6 +1063,7 @@ describe('FrigateCamera', () => {
current_zones: [],
},
after: {
id: 'event-1',
camera: 'camera.front_door',
snapshot: null,
has_clip: false,
@@ -8,6 +8,7 @@ const createEventChange = (): FrigateEventChange => {
return {
type: 'new',
before: {
id: '1234.5678',
camera: 'front_door',
snapshot: null,
has_clip: false,
@@ -16,6 +17,7 @@ const createEventChange = (): FrigateEventChange => {
current_zones: [],
},
after: {
id: '1234.5678',
camera: 'front_door',
snapshot: null,
has_clip: true,
+2 -3
View File
@@ -11,8 +11,6 @@ import {
QueryResultClassifier,
} from '../../src/camera-manager/manager.js';
import {
CameraEndpoints,
CameraEndpointsContext,
CameraEvent,
CameraManagerCameraMetadata,
Engine,
@@ -32,13 +30,13 @@ import { ViewFolder, ViewItem, ViewMedia } from '../../src/view/item.js';
import { ViewItemCapabilities } from '../../src/view/types.js';
import {
TestViewMedia,
createInitializedCamera,
createCameraConfig,
createCapabilities,
createCardAPI,
createConfig,
createFolder,
createHASS,
createInitializedCamera,
generateViewMediaArray,
} from '../test-utils.js';
@@ -386,6 +384,7 @@ describe('CameraManager', async () => {
const cameraEvent: CameraEvent = {
cameraID: 'camera',
id: 'event-1',
type: 'new',
};
eventCallback?.(cameraEvent);
+563 -63
View File
@@ -1,4 +1,5 @@
import { add } from 'date-fns';
import { PartialDeep } from 'type-fest';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { CardController } from '../../src/card-controller/controller';
import { TriggersManager } from '../../src/card-controller/triggers-manager';
@@ -32,10 +33,8 @@ const baseTriggersConfig: TriggersOptions = {
},
};
// Creating and mocking a trigger API is a lot of boilerplate, this convenience
// function reduces it.
const createTriggerAPI = (options?: {
config?: Partial<TriggersOptions>;
config?: PartialDeep<TriggersOptions>;
default?: AdvancedCameraCardView;
interaction?: boolean;
}): CardController => {
@@ -44,7 +43,14 @@ const createTriggerAPI = (options?: {
createConfig({
view: {
triggers: options?.config
? triggersSchema.parse(options.config)
? triggersSchema.parse({
...baseTriggersConfig,
...options.config,
actions: {
...baseTriggersConfig.actions,
...options.config.actions,
},
})
: baseTriggersConfig,
...(options?.default && { default: options.default }),
},
@@ -90,19 +96,35 @@ describe('TriggersManager', () => {
expect(manager.isTriggered()).toBeFalsy();
});
it('should not trigger without a triggers config', () => {
it('should not trigger if triggers config is empty', async () => {
const api = createTriggerAPI({
config: {
actions: {
trigger: 'none',
},
},
});
const manager = new TriggersManager(api);
await manager.handleCameraEvent({
cameraID: 'camera_1',
id: 'event-1',
type: 'new',
});
expect(manager.isTriggered()).toBeTruthy();
expect(api.getViewManager().setViewDefaultWithNewQuery).not.toBeCalled();
});
it('should not trigger if there is no config', async () => {
const api = createTriggerAPI();
vi.mocked(api.getViewManager().getView).mockReturnValue(
createView({
camera: 'camera_1' as const,
}),
);
vi.mocked(api.getConfigManager().getConfig).mockReturnValue(null);
const manager = new TriggersManager(api);
manager.handleCameraEvent({
await manager.handleCameraEvent({
cameraID: 'camera_1',
id: 'event-1',
type: 'new',
});
@@ -110,7 +132,7 @@ describe('TriggersManager', () => {
});
describe('trigger actions', () => {
it('update', async () => {
it('should handle trigger action set to update', async () => {
const api = createTriggerAPI({
config: {
...baseTriggersConfig,
@@ -125,6 +147,7 @@ describe('TriggersManager', () => {
await manager.handleCameraEvent({
cameraID: 'camera_1',
id: 'event-1',
type: 'new',
});
@@ -134,7 +157,7 @@ describe('TriggersManager', () => {
});
});
it('default', async () => {
it('should handle trigger action set to default', async () => {
const api = createTriggerAPI({
config: {
...baseTriggersConfig,
@@ -147,7 +170,11 @@ describe('TriggersManager', () => {
const manager = new TriggersManager(api);
await manager.handleCameraEvent({ cameraID: 'camera_1', type: 'new' });
await manager.handleCameraEvent({
cameraID: 'camera_1',
id: 'event-1',
type: 'new',
});
expect(manager.isTriggered()).toBeTruthy();
expect(api.getViewManager().setViewDefaultWithNewQuery).toBeCalledWith({
@@ -157,7 +184,7 @@ describe('TriggersManager', () => {
});
});
it('live', () => {
it('should handle trigger action set to live', async () => {
const api = createTriggerAPI({
config: {
...baseTriggersConfig,
@@ -170,7 +197,11 @@ describe('TriggersManager', () => {
const manager = new TriggersManager(api);
manager.handleCameraEvent({ cameraID: 'camera_1', type: 'new' });
await manager.handleCameraEvent({
cameraID: 'camera_1',
id: 'event-1',
type: 'new',
});
expect(manager.isTriggered()).toBeTruthy();
expect(api.getViewManager().setViewByParametersWithNewQuery).toBeCalledWith({
@@ -205,8 +236,9 @@ describe('TriggersManager', () => {
});
const manager = new TriggersManager(api);
manager.handleCameraEvent({
await manager.handleCameraEvent({
cameraID: 'camera_1',
id: 'event-1',
type: 'new',
fidelity: 'high',
snapshot: hasSnapshot,
@@ -230,7 +262,7 @@ describe('TriggersManager', () => {
);
});
it('none', () => {
it('should handle trigger action set to none', async () => {
const api = createTriggerAPI({
config: {
...baseTriggersConfig,
@@ -243,7 +275,11 @@ describe('TriggersManager', () => {
const manager = new TriggersManager(api);
manager.handleCameraEvent({ cameraID: 'camera_1', type: 'new' });
await manager.handleCameraEvent({
cameraID: 'camera_1',
id: 'event-1',
type: 'new',
});
expect(manager.isTriggered()).toBeTruthy();
expect(api.getViewManager().setViewDefaultWithNewQuery).not.toBeCalled();
@@ -252,7 +288,7 @@ describe('TriggersManager', () => {
});
describe('untrigger actions', () => {
it('none', () => {
it('should handle untrigger action set to none with no trigger actions', async () => {
const api = createTriggerAPI({
config: {
...baseTriggersConfig,
@@ -265,11 +301,20 @@ describe('TriggersManager', () => {
});
const manager = new TriggersManager(api);
manager.handleCameraEvent({ cameraID: 'camera_1', type: 'new' });
manager.handleCameraEvent({ cameraID: 'camera_1', type: 'end' });
await manager.handleCameraEvent({
cameraID: 'camera_1',
id: 'event-1',
type: 'new',
});
await manager.handleCameraEvent({
cameraID: 'camera_1',
id: 'event-1',
type: 'end',
});
vi.setSystemTime(add(start, { seconds: 10 }));
vi.runOnlyPendingTimers();
await flushPromises();
expect(manager.isTriggered()).toBeFalsy();
@@ -277,7 +322,7 @@ describe('TriggersManager', () => {
expect(api.getViewManager().setViewByParametersWithNewQuery).not.toBeCalled();
});
it('default', async () => {
it('should handle untrigger action set to default', async () => {
const api = createTriggerAPI({
config: {
...baseTriggersConfig,
@@ -290,8 +335,16 @@ describe('TriggersManager', () => {
});
const manager = new TriggersManager(api);
await manager.handleCameraEvent({ cameraID: 'camera_1', type: 'new' });
await manager.handleCameraEvent({ cameraID: 'camera_1', type: 'end' });
await manager.handleCameraEvent({
cameraID: 'camera_1',
id: 'event-1',
type: 'new',
});
await manager.handleCameraEvent({
cameraID: 'camera_1',
id: 'event-1',
type: 'end',
});
vi.setSystemTime(add(start, { seconds: 10 }));
vi.runOnlyPendingTimers();
@@ -301,43 +354,232 @@ describe('TriggersManager', () => {
expect(api.getViewManager().setViewDefaultWithNewQuery).toBeCalled();
});
it('should handle untrigger call with no state', async () => {
const api = createTriggerAPI();
const manager = new TriggersManager(api);
await manager.handleCameraEvent({
cameraID: 'camera_1',
id: 'unknown-id',
type: 'end',
});
expect(api.getViewManager().setViewDefaultWithNewQuery).not.toBeCalled();
});
it('should not untrigger if other sources are still active', async () => {
const api = createTriggerAPI();
const manager = new TriggersManager(api);
await manager.handleCameraEvent({
cameraID: 'camera_1',
id: 'entity_1',
type: 'new',
});
await manager.handleCameraEvent({
cameraID: 'camera_1',
id: 'entity_2',
type: 'new',
});
expect(manager.isTriggered()).toBeTruthy();
await manager.handleCameraEvent({
cameraID: 'camera_1',
id: 'entity_1',
type: 'end',
});
vi.setSystemTime(add(start, { seconds: 10 }));
vi.runOnlyPendingTimers();
await flushPromises();
// Should still be triggered because entity_2 is active.
expect(manager.isTriggered()).toBeTruthy();
await manager.handleCameraEvent({
cameraID: 'camera_1',
id: 'entity_2',
type: 'end',
});
vi.setSystemTime(add(start, { seconds: 20 }));
vi.runOnlyPendingTimers();
await flushPromises();
expect(manager.isTriggered()).toBeFalsy();
});
it('should cancel untrigger timer if a new trigger starts', async () => {
const api = createTriggerAPI();
const manager = new TriggersManager(api);
await manager.handleCameraEvent({
cameraID: 'camera_1',
id: 'entity_1',
type: 'new',
});
await manager.handleCameraEvent({
cameraID: 'camera_1',
id: 'entity_1',
type: 'end',
});
// Move time forward by 5s (the untrigger delay is 10s).
vi.setSystemTime(add(start, { seconds: 5 }));
await manager.handleCameraEvent({
cameraID: 'camera_1',
id: 'entity_1',
type: 'new',
});
vi.setSystemTime(add(start, { seconds: 15 }));
vi.runOnlyPendingTimers();
await flushPromises();
// Should still be triggered because the second 'new' event should have cancelled the first timer.
expect(manager.isTriggered()).toBeTruthy();
expect(api.getViewManager().setViewDefaultWithNewQuery).not.toBeCalled();
});
it('should untrigger each camera independently', async () => {
const api = createTriggerAPI();
vi.mocked(api.getCameraManager().getStore).mockReturnValue(
createStore([
{
cameraID: 'camera_1',
config: createCameraConfig({
triggers: { entities: ['binary_sensor.motion_1'] },
}),
},
{
cameraID: 'camera_2',
config: createCameraConfig({
triggers: { entities: ['binary_sensor.motion_2'] },
}),
},
]),
);
const manager = new TriggersManager(api);
await manager.handleCameraEvent({
cameraID: 'camera_1',
type: 'new',
id: 'motion_1',
});
await manager.handleCameraEvent({
cameraID: 'camera_2',
type: 'new',
id: 'motion_2',
});
expect(manager.getTriggeredCameraIDs()).toEqual(new Set(['camera_1', 'camera_2']));
// Untrigger camera 1.
await manager.handleCameraEvent({
cameraID: 'camera_1',
type: 'end',
id: 'motion_1',
});
vi.setSystemTime(add(start, { seconds: 10 }));
vi.runOnlyPendingTimers();
await flushPromises();
// Camera 1 untriggered: setViewDefaultWithNewQuery is called.
expect(api.getViewManager().setViewDefaultWithNewQuery).toBeCalledTimes(1);
// Camera 2 is still triggered.
expect(manager.getTriggeredCameraIDs()).toEqual(new Set(['camera_2']));
// Untrigger camera 2.
await manager.handleCameraEvent({
cameraID: 'camera_2',
type: 'end',
id: 'motion_2',
});
vi.setSystemTime(add(start, { seconds: 20 }));
vi.runOnlyPendingTimers();
await flushPromises();
// Camera 2 untriggered: setViewDefaultWithNewQuery is called again.
expect(api.getViewManager().setViewDefaultWithNewQuery).toBeCalledTimes(2);
expect(manager.getTriggeredCameraIDs()).toEqual(new Set());
});
it('should untrigger immediately when untrigger_seconds is 0', async () => {
const api = createTriggerAPI({
config: {
untrigger_seconds: 0,
},
});
const manager = new TriggersManager(api);
await manager.handleCameraEvent({
cameraID: 'camera_1',
id: 'event-1',
type: 'new',
});
await manager.handleCameraEvent({
cameraID: 'camera_1',
id: 'event-1',
type: 'end',
});
await flushPromises();
expect(manager.isTriggered()).toBeFalsy();
expect(api.getViewManager().setViewDefaultWithNewQuery).toBeCalled();
});
});
it('should manage condition state', () => {
const api = createTriggerAPI({
config: {
...baseTriggersConfig,
actions: {
...baseTriggersConfig.actions,
trigger: 'none',
untrigger: 'none',
describe('condition state management', () => {
it('should manage condition state', async () => {
const api = createTriggerAPI({
config: {
...baseTriggersConfig,
actions: {
...baseTriggersConfig.actions,
trigger: 'none',
untrigger: 'none',
},
},
},
});
});
const manager = new TriggersManager(api);
const manager = new TriggersManager(api);
manager.handleCameraEvent({ cameraID: 'camera_1', type: 'new' });
await manager.handleCameraEvent({
cameraID: 'camera_1',
id: 'event-1',
type: 'new',
});
expect(api.getConditionStateManager().setState).toHaveBeenLastCalledWith({
triggered: new Set(['camera_1']),
});
vi.mocked(api.getConditionStateManager().getState).mockReturnValue({
triggered: new Set(['camera_1']),
});
expect(api.getConditionStateManager().setState).toHaveBeenLastCalledWith({
triggered: new Set(['camera_1']),
});
vi.mocked(api.getConditionStateManager().getState).mockReturnValue({
triggered: new Set(['camera_1']),
});
manager.handleCameraEvent({ cameraID: 'camera_1', type: 'end' });
await manager.handleCameraEvent({
cameraID: 'camera_1',
id: 'event-1',
type: 'end',
});
vi.setSystemTime(add(start, { seconds: 10 }));
vi.runOnlyPendingTimers();
vi.setSystemTime(add(start, { seconds: 10 }));
vi.runOnlyPendingTimers();
await flushPromises();
expect(api.getConditionStateManager().setState).toHaveBeenLastCalledWith({
triggered: undefined,
expect(api.getConditionStateManager().setState).toHaveBeenLastCalledWith({
triggered: undefined,
});
});
});
describe('should take no actions with high-fidelity event', () => {
it('with non-live action', () => {
it('should ignore high-fidelity events when trigger action is not live', async () => {
const api = createTriggerAPI({
config: {
...baseTriggersConfig,
@@ -351,13 +593,18 @@ describe('TriggersManager', () => {
const manager = new TriggersManager(api);
manager.handleCameraEvent({ cameraID: 'camera_1', type: 'new', fidelity: 'high' });
await manager.handleCameraEvent({
cameraID: 'camera_1',
id: 'event-1',
type: 'new',
fidelity: 'high',
});
expect(api.getViewManager().setViewDefaultWithNewQuery).not.toBeCalled();
expect(api.getViewManager().setViewByParametersWithNewQuery).not.toBeCalled();
});
it('with non-live default', () => {
it('should ignore high-fidelity events when default view is not live', async () => {
const api = createTriggerAPI({
config: {
...baseTriggersConfig,
@@ -371,22 +618,28 @@ describe('TriggersManager', () => {
const manager = new TriggersManager(api);
manager.handleCameraEvent({ cameraID: 'camera_1', type: 'new', fidelity: 'high' });
await manager.handleCameraEvent({
cameraID: 'camera_1',
id: 'event-1',
type: 'new',
fidelity: 'high',
});
expect(api.getViewManager().setViewDefaultWithNewQuery).not.toBeCalled();
expect(api.getViewManager().setViewByParametersWithNewQuery).not.toBeCalled();
});
});
it('should take no actions with human interactions', () => {
it('should take no actions with human interactions', async () => {
const api = createTriggerAPI({
// Interaction present.
interaction: true,
});
const manager = new TriggersManager(api);
manager.handleCameraEvent({
await manager.handleCameraEvent({
cameraID: 'camera_1',
id: 'event-1',
type: 'new',
});
@@ -395,8 +648,9 @@ describe('TriggersManager', () => {
expect(api.getViewManager().setViewDefaultWithNewQuery).not.toBeCalled();
expect(api.getViewManager().setViewByParametersWithNewQuery).not.toBeCalled();
manager.handleCameraEvent({
await manager.handleCameraEvent({
cameraID: 'camera_1',
id: 'event-1',
type: 'end',
});
@@ -409,7 +663,7 @@ describe('TriggersManager', () => {
expect(api.getViewManager().setViewByParametersWithNewQuery).not.toBeCalled();
});
it('should take no actions when actions are set to none', () => {
it('should take no actions when actions are set to none', async () => {
const api = createTriggerAPI({
config: {
actions: {
@@ -420,16 +674,18 @@ describe('TriggersManager', () => {
},
});
const manager = new TriggersManager(api);
manager.handleCameraEvent({
await manager.handleCameraEvent({
cameraID: 'camera_1',
id: 'event-1',
type: 'new',
});
expect(manager.isTriggered()).toBeTruthy();
expect(api.getViewManager().setViewDefaultWithNewQuery).not.toBeCalled();
expect(api.getViewManager().setViewByParametersWithNewQuery).not.toBeCalled();
manager.handleCameraEvent({
await manager.handleCameraEvent({
cameraID: 'camera_1',
id: 'event-1',
type: 'end',
});
@@ -457,6 +713,7 @@ describe('TriggersManager', () => {
const manager = new TriggersManager(api);
await manager.handleCameraEvent({
cameraID: 'camera_1',
id: 'event-1',
type: 'new',
});
@@ -470,6 +727,7 @@ describe('TriggersManager', () => {
await manager.handleCameraEvent({
cameraID: 'camera_1',
id: 'event-1',
type: 'end',
});
@@ -513,10 +771,12 @@ describe('TriggersManager', () => {
await manager.handleCameraEvent({
cameraID: 'camera_1',
id: 'event-1',
type: 'new',
});
await manager.handleCameraEvent({
cameraID: 'camera_2',
id: 'event-2',
type: 'new',
});
@@ -530,6 +790,7 @@ describe('TriggersManager', () => {
await manager.handleCameraEvent({
cameraID: 'camera_1',
id: 'event-1',
type: 'end',
});
@@ -543,7 +804,7 @@ describe('TriggersManager', () => {
});
describe('should filter triggers by camera', () => {
it('no dependencies', () => {
it('should filter triggers when there are no dependencies', async () => {
const api = createTriggerAPI({
config: {
...baseTriggersConfig,
@@ -559,8 +820,9 @@ describe('TriggersManager', () => {
});
vi.mocked(api.getViewManager().getView).mockReturnValue(otherCameraSelected);
manager.handleCameraEvent({
await manager.handleCameraEvent({
cameraID: 'camera_1',
id: 'event-1',
type: 'new',
});
expect(manager.isTriggered()).toBeFalsy();
@@ -570,14 +832,15 @@ describe('TriggersManager', () => {
});
vi.mocked(api.getViewManager().getView).mockReturnValue(thisCameraSelected);
manager.handleCameraEvent({
await manager.handleCameraEvent({
cameraID: 'camera_1',
id: 'event-1',
type: 'new',
});
expect(manager.isTriggered()).toBeTruthy();
});
it('dependencies', () => {
it('should not filter triggers when there are dependencies', async () => {
const api = createTriggerAPI({
config: {
...baseTriggersConfig,
@@ -618,8 +881,9 @@ describe('TriggersManager', () => {
// Events for the secondary will still trigger when filter_selected_camera
// is true.
manager.handleCameraEvent({
await manager.handleCameraEvent({
cameraID: 'camera_secondary',
id: 'event-secondary',
type: 'new',
});
expect(manager.isTriggered()).toBeTruthy();
@@ -712,5 +976,241 @@ describe('TriggersManager', () => {
expect(result).toBeTruthy();
expect(manager.isTriggered()).toBeTruthy();
});
it('should only execute a single trigger action at startup for multiple triggered sources', async () => {
const api = createTriggerAPI();
vi.mocked(api.getCameraManager().getStore).mockReturnValue(
createStore([
{
cameraID: 'camera_1',
config: createCameraConfig({
triggers: {
entities: ['binary_sensor.motion', 'binary_sensor.occupancy'],
},
}),
},
]),
);
const hass = createHASS({
'binary_sensor.motion': createStateEntity({
state: 'on',
}),
'binary_sensor.occupancy': createStateEntity({
state: 'open',
}),
});
vi.mocked(api.getHASSManager().getHASS).mockReturnValue(hass);
const manager = new TriggersManager(api);
const result = await manager.handleInitialCameraTriggers();
expect(result).toBeTruthy();
expect(manager.isTriggered()).toBeTruthy();
expect(api.getViewManager().setViewByParametersWithNewQuery).toBeCalledTimes(1);
});
it('should prioritize the first triggered camera action at startup', async () => {
const api = createTriggerAPI({
config: {
actions: {
trigger: 'live',
},
},
});
vi.mocked(api.getCameraManager().getStore).mockReturnValue(
createStore([
{
cameraID: 'camera_1',
config: createCameraConfig({
triggers: {
entities: ['binary_sensor.motion_1'],
},
}),
},
{
cameraID: 'camera_2',
config: createCameraConfig({
triggers: {
entities: ['binary_sensor.motion_2'],
},
}),
},
]),
);
const hass = createHASS({
'binary_sensor.motion_1': createStateEntity({
state: 'on',
}),
'binary_sensor.motion_2': createStateEntity({
state: 'on',
}),
});
vi.mocked(api.getHASSManager().getHASS).mockReturnValue(hass);
const manager = new TriggersManager(api);
const result = await manager.handleInitialCameraTriggers();
expect(result).toBeTruthy();
expect(manager.getTriggeredCameraIDs()).toEqual(new Set(['camera_1', 'camera_2']));
expect(api.getViewManager().setViewByParametersWithNewQuery).toBeCalledTimes(1);
expect(api.getViewManager().setViewByParametersWithNewQuery).toHaveBeenCalledWith({
params: {
view: 'live',
camera: 'camera_1',
},
});
});
it('should not execute startup action if triggered cameras are filtered out', async () => {
const api = createTriggerAPI({
config: {
filter_selected_camera: true,
},
});
vi.mocked(api.getCameraManager().getStore).mockReturnValue(
createStore([
{
cameraID: 'camera_2',
config: createCameraConfig({
triggers: {
entities: ['binary_sensor.motion_2'],
},
}),
},
]),
);
const hass = createHASS({
'binary_sensor.motion_2': createStateEntity({
state: 'on',
}),
});
vi.mocked(api.getHASSManager().getHASS).mockReturnValue(hass);
const manager = new TriggersManager(api);
const result = await manager.handleInitialCameraTriggers();
// A trigger entity was active...
expect(result).toBeTruthy();
// ...but the camera was filtered out, so no trigger state/action was applied.
expect(manager.isTriggered()).toBeFalsy();
expect(api.getViewManager().setViewByParametersWithNewQuery).not.toBeCalled();
expect(api.getViewManager().setViewDefaultWithNewQuery).not.toBeCalled();
});
});
it('should take actions with human interactions when interaction mode is active', async () => {
const api = createTriggerAPI({
// Interaction present.
interaction: true,
config: {
...baseTriggersConfig,
actions: {
trigger: 'live' as const,
untrigger: 'default' as const,
interaction_mode: 'active',
},
},
});
const manager = new TriggersManager(api);
await manager.handleCameraEvent({
cameraID: 'camera_1',
id: 'event-1',
type: 'new',
});
expect(manager.isTriggered()).toBeTruthy();
expect(api.getViewManager().setViewByParametersWithNewQuery).toBeCalledWith({
params: {
view: 'live' as const,
camera: 'camera_1' as const,
},
});
await manager.handleCameraEvent({
cameraID: 'camera_1',
id: 'event-1',
type: 'end',
});
vi.setSystemTime(add(start, { seconds: 10 }));
vi.runOnlyPendingTimers();
await flushPromises();
expect(manager.isTriggered()).toBeFalsy();
expect(api.getViewManager().setViewDefaultWithNewQuery).toBeCalled();
});
it('should ignore untrigger actions during non-allowable interaction but still untrigger camera', async () => {
const api = createTriggerAPI({
interaction: true,
config: {
actions: {
interaction_mode: 'inactive',
untrigger: 'default',
},
},
});
const manager = new TriggersManager(api);
await manager.handleCameraEvent({ cameraID: 'camera_1', id: 'e1', type: 'new' });
await manager.handleCameraEvent({ cameraID: 'camera_1', id: 'e1', type: 'end' });
vi.setSystemTime(add(start, { seconds: 15 }));
vi.runOnlyPendingTimers();
await flushPromises();
// Camera is untriggered...
expect(manager.isTriggered()).toBe(false);
// ...but the action was skipped.
expect(api.getViewManager().setViewDefaultWithNewQuery).not.toHaveBeenCalled();
});
it('should not include cameras with no active sources in triggered IDs even before untrigger action completes', async () => {
const api = createTriggerAPI({
config: {
untrigger_seconds: 0,
},
});
const manager = new TriggersManager(api);
// Trigger then end, but don't await the end event so we can check
// triggered IDs while the camera is in the map but no longer triggered.
await manager.handleCameraEvent({ cameraID: 'camera_1', id: 'e1', type: 'new' });
const untriggerPromise = manager.handleCameraEvent({
cameraID: 'camera_1',
id: 'e1',
type: 'end',
});
expect(manager.getTriggeredCameraIDs()).toEqual(new Set());
await untriggerPromise;
});
it('should handle newly missing configuration', async () => {
const api = createTriggerAPI();
const manager = new TriggersManager(api);
// 1. Trigger the camera with valid config.
await manager.handleCameraEvent({ cameraID: 'camera_1', id: 'e1', type: 'new' });
// 2. Mock getConfig to return null.
vi.mocked(api.getConfigManager().getConfig).mockReturnValue(null);
// 3. End the trigger. This should now hit the fallback '?? 0' in
// _startUntrigger because getConfig() is null.
await manager.handleCameraEvent({ cameraID: 'camera_1', id: 'e1', type: 'end' });
// Should NOT have triggered a view reset (since config is missing, no
// untrigger action is defined).
expect(api.getViewManager().setViewDefaultWithNewQuery).not.toHaveBeenCalled();
// But the camera should be untriggered.
expect(manager.isTriggered()).toBe(false);
});
});