Merge branch 'main' into dev

This commit is contained in:
dermotduffy
2026-02-15 19:28:38 -08:00
11 changed files with 722 additions and 124 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. behavior can be controlled by the `interaction_mode` parameter.
If the card starts when a trigger entity is already in a triggered state, the 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 | | Option | Default | Description |
| ------------------------ | ------- | ---------------------------------------------------------------------------------------------------------------------------------- | | ------------------------ | ------- | ---------------------------------------------------------------------------------------------------------------------------------- |
+1
View File
@@ -222,6 +222,7 @@ export class Camera {
protected _stateChangeHandler = (difference: HassStateDifference): void => { protected _stateChangeHandler = (difference: HassStateDifference): void => {
this._eventCallback?.({ this._eventCallback?.({
cameraID: this.getID(), cameraID: this.getID(),
id: difference.entityID,
type: isTriggeredState(difference.newState.state) ? 'new' : 'end', type: isTriggeredState(difference.newState.state) ? 'new' : 'end',
}); });
}; };
+1 -1
View File
@@ -477,7 +477,7 @@ export class FrigateCamera extends Camera {
this._eventCallback?.({ this._eventCallback?.({
cameraID, cameraID,
id: ev.after.id,
fidelity: 'high', fidelity: 'high',
type: ev.type, type: ev.type,
// In cases where there are both clip and snapshot media, ensure to only // In cases where there are both clip and snapshot media, ensure to only
+1
View File
@@ -98,6 +98,7 @@ export type PTZInfo = z.infer<typeof ptzInfoSchema>;
const frigateEventChangeBeforeAfterSchema = z.object({ const frigateEventChangeBeforeAfterSchema = z.object({
camera: z.string(), camera: z.string(),
id: z.string(),
snapshot: z snapshot: z
.object({ .object({
frame_time: z.number(), frame_time: z.number(),
+4
View File
@@ -151,6 +151,10 @@ export interface EngineOptions {
export interface CameraEvent { export interface CameraEvent {
cameraID: string; cameraID: string;
// Source ID (e.g. entity ID or Frigate event ID), used to determine what has
// triggered/untriggered.
id: string;
type: type:
| 'new' // A new event has started. | 'new' // A new event has started.
| 'update' // An update for an event is available (except GenAI). | 'update' // An update for an event is available (except GenAI).
+133 -56
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 { CameraEvent } from '../camera-manager/types';
import { isTriggeredState } from '../ha/is-triggered-state'; import { isTriggeredState } from '../ha/is-triggered-state';
import { Timer } from '../utils/timer'; import { Timer } from '../utils/timer';
import { CardTriggersAPI } from './types'; 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 { export class TriggersManager {
protected _api: CardTriggersAPI; protected _api: CardTriggersAPI;
protected _states: Map<string, CameraTriggerState> = new Map();
protected _triggeredCameras: Map<string, Date> = new Map();
protected _triggeredCameraTimers: Map<string, Timer> = new Map();
protected _throttledTriggerAction = throttle(this._triggerAction.bind(this), 1000, { protected _throttledTriggerAction = throttle(this._triggerAction.bind(this), 1000, {
trailing: true, trailing: true,
@@ -19,53 +30,87 @@ export class TriggersManager {
} }
public getTriggeredCameraIDs(): Set<string> { 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 { public isTriggered(): boolean {
return !!this._triggeredCameras.size; return [...this._states.values()].some((state) => this._isStateTriggered(state));
} }
public getMostRecentlyTriggeredCameraID(): string | null { public getMostRecentlyTriggeredCameraID(): string | null {
const sorted = orderBy( const mostRecent = maxBy(
[...this._triggeredCameras.entries()], [...this._states.entries()].filter(([, state]) => this._isStateTriggered(state)),
(entry) => entry[1].getTime(), ([, state]) => state.lastTriggerTime.getTime(),
'desc',
); );
return sorted.length ? sorted[0][0] : null; return mostRecent?.[0] ?? null;
} }
public handleInitialCameraTriggers = async (): Promise<boolean> => { public handleInitialCameraTriggers = async (): Promise<boolean> => {
const hass = this._api.getHASSManager().getHASS(); const hass = this._api.getHASSManager().getHASS();
let triggered = false; let triggered = false;
let startupActionEvent: CameraEvent | null = null;
for (const [cameraID, camera] of this._api for (const [cameraID, camera] of this._api
.getCameraManager() .getCameraManager()
.getStore() .getStore()
.getCameras()) { .getCameras()) {
if ( for (const entityID of camera.getConfig().triggers.entities) {
camera if (isTriggeredState(hass?.states[entityID]?.state)) {
.getConfig()
.triggers.entities.some((entityID) =>
isTriggeredState(hass?.states[entityID]?.state),
)
) {
triggered = true; triggered = true;
await this.handleCameraEvent({ const event: CameraEvent = {
cameraID, cameraID,
id: entityID,
type: 'new', type: 'new',
}); };
if (
await this.handleCameraEvent(event, {
skipAction: true,
})
) {
startupActionEvent ??= event;
} }
} }
}
}
if (startupActionEvent) {
await this._throttledTriggerAction(startupActionEvent);
}
return triggered; return triggered;
}; };
public async handleCameraEvent(ev: CameraEvent): Promise<void> { // Returns true if the event was accepted into trigger state processing.
const triggersConfig = this._api.getConfigManager().getConfig()?.view.triggers; // 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; const selectedCameraID = this._api.getViewManager().getView()?.camera;
if (!triggersConfig || !selectedCameraID) { if (!triggersConfig || !selectedCameraID) {
return; return false;
} }
const dependentCameraIDs = this._api const dependentCameraIDs = this._api
@@ -74,18 +119,29 @@ export class TriggersManager {
.getAllDependentCameras(selectedCameraID); .getAllDependentCameras(selectedCameraID);
if (triggersConfig.filter_selected_camera && !dependentCameraIDs.has(ev.cameraID)) { if (triggersConfig.filter_selected_camera && !dependentCameraIDs.has(ev.cameraID)) {
return; return false;
} }
if (ev.type === 'end') { let state = this._states.get(ev.cameraID);
this._startUntriggerTimer(ev.cameraID); if (!state) {
return; 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(); this._setConditionStateIfNecessary();
if (!skipAction) {
await this._throttledTriggerAction(ev); await this._throttledTriggerAction(ev);
} }
return true;
}
protected _hasAllowableInteractionStateForAction(): boolean { protected _hasAllowableInteractionStateForAction(): boolean {
const triggersConfig = this._api.getConfigManager().getConfig()?.view.triggers; const triggersConfig = this._api.getConfigManager().getConfig()?.view.triggers;
@@ -100,9 +156,9 @@ export class TriggersManager {
} }
protected async _triggerAction(ev: CameraEvent): Promise<void> { protected async _triggerAction(ev: CameraEvent): Promise<void> {
const triggerAction = this._api.getConfigManager().getConfig()?.view.triggers const config = this._api.getConfigManager().getConfig();
.actions.trigger; const triggerAction = config?.view?.triggers.actions.trigger;
const defaultView = this._api.getConfigManager().getConfig()?.view.default; const defaultView = config?.view?.default;
// Early exit guard: If this is a high-fidelity event where we are certain // Early exit guard: If this is a high-fidelity event where we are certain
// about new media, don't take action unless it's to change to live (Frigate // about new media, don't take action unless it's to change to live (Frigate
@@ -169,47 +225,68 @@ export class TriggersManager {
} }
protected _setConditionStateIfNecessary(): void { protected _setConditionStateIfNecessary(): void {
const triggeredCameraIDs = new Set(this._triggeredCameras.keys()); const triggeredCameraIDs = this.getTriggeredCameraIDs();
const triggeredState = triggeredCameraIDs.size ? triggeredCameraIDs : undefined;
this._api.getConditionStateManager().setState({ this._api.getConditionStateManager().setState({
triggered: triggeredState, triggered: triggeredCameraIDs.size ? triggeredCameraIDs : undefined,
}); });
} }
protected async _untriggerAction(cameraID: string): Promise<void> { protected async _executeUntriggerAction(): Promise<boolean> {
const action = this._api.getConfigManager().getConfig()?.view.triggers const action = this._api.getConfigManager().getConfig()?.view?.triggers
.actions.untrigger; .actions.untrigger;
if (action === 'default' && this._hasAllowableInteractionStateForAction()) { if (!action || action === 'none') {
return true;
}
if (this._hasAllowableInteractionStateForAction()) {
await this._api.getViewManager().setViewDefaultWithNewQuery(); await this._api.getViewManager().setViewDefaultWithNewQuery();
} }
this._triggeredCameras.delete(cameraID); return true;
this._deleteTimer(cameraID); }
protected async _untriggerAction(cameraID: string): Promise<void> {
this._deleteUntriggerDelayTimer(cameraID);
await this._executeUntriggerAction();
this._states.delete(cameraID);
this._setConditionStateIfNecessary(); this._setConditionStateIfNecessary();
// Must update master element to remove border pulsing from live view. // Must update master element to remove border pulsing from live view.
this._api.getCardElementManager().update(); this._api.getCardElementManager().update();
} }
protected _startUntriggerTimer(cameraID: string): void { protected async _startUntrigger(cameraID: string): Promise<void> {
this._deleteTimer(cameraID); this._deleteUntriggerDelayTimer(cameraID);
const timer = new Timer(); const state = this._states.get(cameraID);
this._triggeredCameraTimers.set(cameraID, timer); if (!state) {
timer.start( return;
/* 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 () => {
await this._untriggerAction(cameraID);
},
);
} }
protected _deleteTimer(cameraID: string): void { const config = this._api.getConfigManager().getConfig();
this._triggeredCameraTimers.get(cameraID)?.stop(); const untriggerSeconds = config?.view?.triggers.untrigger_seconds ?? 0;
this._triggeredCameraTimers.delete(cameraID);
if (untriggerSeconds > 0) {
state.untriggerDelayTimer = new Timer();
state.untriggerDelayTimer.start(untriggerSeconds, async () => {
await this._untriggerAction(cameraID);
});
} else {
await this._untriggerAction(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({ expect(eventCallback).toBeCalledWith({
cameraID: 'camera_1', cameraID: 'camera_1',
id: 'sensor.force_update',
type: eventType, type: eventType,
}); });
}, },
@@ -920,6 +920,7 @@ describe('FrigateCamera', () => {
callEventWatcherCallback(eventWatcher, { callEventWatcherCallback(eventWatcher, {
type: 'new', type: 'new',
before: { before: {
id: 'event-1',
camera: 'camera.front_door', camera: 'camera.front_door',
snapshot: null, snapshot: null,
has_clip: false, has_clip: false,
@@ -928,6 +929,7 @@ describe('FrigateCamera', () => {
current_zones: [], current_zones: [],
}, },
after: { after: {
id: 'event-1',
camera: 'camera.front_door', camera: 'camera.front_door',
snapshot: null, snapshot: null,
has_clip: hasClip, has_clip: hasClip,
@@ -941,6 +943,7 @@ describe('FrigateCamera', () => {
expect(eventCallback).toBeCalledWith({ expect(eventCallback).toBeCalledWith({
type: 'new', type: 'new',
cameraID: 'CAMERA_1', cameraID: 'CAMERA_1',
id: 'event-1',
clip: hasClip && events.includes('clips'), clip: hasClip && events.includes('clips'),
snapshot: hasSnapshot && events.includes('snapshots'), snapshot: hasSnapshot && events.includes('snapshots'),
fidelity: 'high', fidelity: 'high',
@@ -989,6 +992,7 @@ describe('FrigateCamera', () => {
callEventWatcherCallback(eventWatcher, { callEventWatcherCallback(eventWatcher, {
type: 'new', type: 'new',
before: { before: {
id: 'event-1',
camera: 'camera.front_door', camera: 'camera.front_door',
snapshot: null, snapshot: null,
has_clip: false, has_clip: false,
@@ -997,6 +1001,7 @@ describe('FrigateCamera', () => {
current_zones: [], current_zones: [],
}, },
after: { after: {
id: 'event-1',
camera: 'camera.front_door', camera: 'camera.front_door',
snapshot: null, snapshot: null,
has_clip: false, has_clip: false,
@@ -1046,6 +1051,7 @@ describe('FrigateCamera', () => {
callEventWatcherCallback(eventWatcher, { callEventWatcherCallback(eventWatcher, {
type: 'new', type: 'new',
before: { before: {
id: 'event-1',
camera: 'camera.front_door', camera: 'camera.front_door',
snapshot: null, snapshot: null,
has_clip: false, has_clip: false,
@@ -1056,6 +1062,7 @@ describe('FrigateCamera', () => {
current_zones: [], current_zones: [],
}, },
after: { after: {
id: 'event-1',
camera: 'camera.front_door', camera: 'camera.front_door',
snapshot: null, snapshot: null,
has_clip: false, has_clip: false,
@@ -1100,6 +1107,7 @@ describe('FrigateCamera', () => {
callEventWatcherCallback(eventWatcher, { callEventWatcherCallback(eventWatcher, {
type: 'new', type: 'new',
before: { before: {
id: 'event-1',
camera: 'camera.front_door', camera: 'camera.front_door',
snapshot: null, snapshot: null,
has_clip: false, has_clip: false,
@@ -1108,6 +1116,7 @@ describe('FrigateCamera', () => {
current_zones: [], current_zones: [],
}, },
after: { after: {
id: 'event-1',
camera: 'camera.front_door', camera: 'camera.front_door',
snapshot: null, snapshot: null,
has_clip: false, has_clip: false,
@@ -14,6 +14,7 @@ const createEventChange = (): FrigateEventChange => {
return { return {
type: 'new', type: 'new',
before: { before: {
id: '1234.5678',
camera: 'front_door', camera: 'front_door',
snapshot: null, snapshot: null,
has_clip: false, has_clip: false,
@@ -22,6 +23,7 @@ const createEventChange = (): FrigateEventChange => {
current_zones: [], current_zones: [],
}, },
after: { after: {
id: '1234.5678',
camera: 'front_door', camera: 'front_door',
snapshot: null, snapshot: null,
has_clip: true, has_clip: true,
+1
View File
@@ -439,6 +439,7 @@ describe('CameraManager', () => {
const cameraEvent: CameraEvent = { const cameraEvent: CameraEvent = {
cameraID: 'camera', cameraID: 'camera',
id: 'event-1',
type: 'new', type: 'new',
}; };
eventCallback?.(cameraEvent); eventCallback?.(cameraEvent);
+543 -43
View File
@@ -1,4 +1,5 @@
import { add } from 'date-fns'; import { add } from 'date-fns';
import { PartialDeep } from 'type-fest';
import { beforeEach, describe, expect, it, vi } from 'vitest'; import { beforeEach, describe, expect, it, vi } from 'vitest';
import { CardController } from '../../src/card-controller/controller'; import { CardController } from '../../src/card-controller/controller';
import { TriggersManager } from '../../src/card-controller/triggers-manager'; 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?: { const createTriggerAPI = (options?: {
config?: Partial<TriggersOptions>; config?: PartialDeep<TriggersOptions>;
default?: AdvancedCameraCardView; default?: AdvancedCameraCardView;
interaction?: boolean; interaction?: boolean;
}): CardController => { }): CardController => {
@@ -44,7 +43,14 @@ const createTriggerAPI = (options?: {
createConfig({ createConfig({
view: { view: {
triggers: options?.config triggers: options?.config
? triggersSchema.parse(options.config) ? triggersSchema.parse({
...baseTriggersConfig,
...options.config,
actions: {
...baseTriggersConfig.actions,
...options.config.actions,
},
})
: baseTriggersConfig, : baseTriggersConfig,
...(options?.default && { default: options.default }), ...(options?.default && { default: options.default }),
}, },
@@ -90,19 +96,35 @@ describe('TriggersManager', () => {
expect(manager.isTriggered()).toBeFalsy(); 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(); const api = createTriggerAPI();
vi.mocked(api.getViewManager().getView).mockReturnValue(
createView({
camera: 'camera_1' as const,
}),
);
vi.mocked(api.getConfigManager().getConfig).mockReturnValue(null); vi.mocked(api.getConfigManager().getConfig).mockReturnValue(null);
const manager = new TriggersManager(api); const manager = new TriggersManager(api);
manager.handleCameraEvent({ await manager.handleCameraEvent({
cameraID: 'camera_1', cameraID: 'camera_1',
id: 'event-1',
type: 'new', type: 'new',
}); });
@@ -110,7 +132,7 @@ describe('TriggersManager', () => {
}); });
describe('trigger actions', () => { describe('trigger actions', () => {
it('update', async () => { it('should handle trigger action set to update', async () => {
const api = createTriggerAPI({ const api = createTriggerAPI({
config: { config: {
...baseTriggersConfig, ...baseTriggersConfig,
@@ -125,6 +147,7 @@ describe('TriggersManager', () => {
await manager.handleCameraEvent({ await manager.handleCameraEvent({
cameraID: 'camera_1', cameraID: 'camera_1',
id: 'event-1',
type: 'new', type: 'new',
}); });
@@ -134,7 +157,7 @@ describe('TriggersManager', () => {
}); });
}); });
it('default', async () => { it('should handle trigger action set to default', async () => {
const api = createTriggerAPI({ const api = createTriggerAPI({
config: { config: {
...baseTriggersConfig, ...baseTriggersConfig,
@@ -147,7 +170,11 @@ describe('TriggersManager', () => {
const manager = new TriggersManager(api); 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(manager.isTriggered()).toBeTruthy();
expect(api.getViewManager().setViewDefaultWithNewQuery).toBeCalledWith({ 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({ const api = createTriggerAPI({
config: { config: {
...baseTriggersConfig, ...baseTriggersConfig,
@@ -170,7 +197,11 @@ describe('TriggersManager', () => {
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(manager.isTriggered()).toBeTruthy(); expect(manager.isTriggered()).toBeTruthy();
expect(api.getViewManager().setViewByParametersWithNewQuery).toBeCalledWith({ expect(api.getViewManager().setViewByParametersWithNewQuery).toBeCalledWith({
@@ -209,8 +240,9 @@ describe('TriggersManager', () => {
}); });
const manager = new TriggersManager(api); const manager = new TriggersManager(api);
manager.handleCameraEvent({ await manager.handleCameraEvent({
cameraID: 'camera_1', cameraID: 'camera_1',
id: 'event-1',
type: 'new', type: 'new',
fidelity: 'high', fidelity: 'high',
review: hasReview, review: hasReview,
@@ -235,7 +267,7 @@ describe('TriggersManager', () => {
); );
}); });
it('none', () => { it('should handle trigger action set to none', async () => {
const api = createTriggerAPI({ const api = createTriggerAPI({
config: { config: {
...baseTriggersConfig, ...baseTriggersConfig,
@@ -248,7 +280,11 @@ describe('TriggersManager', () => {
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(manager.isTriggered()).toBeTruthy(); expect(manager.isTriggered()).toBeTruthy();
expect(api.getViewManager().setViewDefaultWithNewQuery).not.toBeCalled(); expect(api.getViewManager().setViewDefaultWithNewQuery).not.toBeCalled();
@@ -257,7 +293,7 @@ describe('TriggersManager', () => {
}); });
describe('untrigger actions', () => { describe('untrigger actions', () => {
it('none', () => { it('should handle untrigger action set to none with no trigger actions', async () => {
const api = createTriggerAPI({ const api = createTriggerAPI({
config: { config: {
...baseTriggersConfig, ...baseTriggersConfig,
@@ -270,11 +306,20 @@ describe('TriggersManager', () => {
}); });
const manager = new TriggersManager(api); const manager = new TriggersManager(api);
manager.handleCameraEvent({ cameraID: 'camera_1', type: 'new' }); await manager.handleCameraEvent({
manager.handleCameraEvent({ cameraID: 'camera_1', type: 'end' }); 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.setSystemTime(add(start, { seconds: 10 }));
vi.runOnlyPendingTimers(); vi.runOnlyPendingTimers();
await flushPromises();
expect(manager.isTriggered()).toBeFalsy(); expect(manager.isTriggered()).toBeFalsy();
@@ -282,7 +327,7 @@ describe('TriggersManager', () => {
expect(api.getViewManager().setViewByParametersWithNewQuery).not.toBeCalled(); expect(api.getViewManager().setViewByParametersWithNewQuery).not.toBeCalled();
}); });
it('default', async () => { it('should handle untrigger action set to default', async () => {
const api = createTriggerAPI({ const api = createTriggerAPI({
config: { config: {
...baseTriggersConfig, ...baseTriggersConfig,
@@ -295,8 +340,16 @@ describe('TriggersManager', () => {
}); });
const manager = new TriggersManager(api); const manager = new TriggersManager(api);
await manager.handleCameraEvent({ cameraID: 'camera_1', type: 'new' }); await manager.handleCameraEvent({
await manager.handleCameraEvent({ cameraID: 'camera_1', type: 'end' }); 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.setSystemTime(add(start, { seconds: 10 }));
vi.runOnlyPendingTimers(); vi.runOnlyPendingTimers();
@@ -306,9 +359,188 @@ describe('TriggersManager', () => {
expect(api.getViewManager().setViewDefaultWithNewQuery).toBeCalled(); 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',
}); });
it('should manage condition state', () => { 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();
});
});
describe('condition state management', () => {
it('should manage condition state', async () => {
const api = createTriggerAPI({ const api = createTriggerAPI({
config: { config: {
...baseTriggersConfig, ...baseTriggersConfig,
@@ -322,7 +554,11 @@ describe('TriggersManager', () => {
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({ expect(api.getConditionStateManager().setState).toHaveBeenLastCalledWith({
triggered: new Set(['camera_1']), triggered: new Set(['camera_1']),
@@ -331,18 +567,24 @@ describe('TriggersManager', () => {
triggered: new Set(['camera_1']), 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.setSystemTime(add(start, { seconds: 10 }));
vi.runOnlyPendingTimers(); vi.runOnlyPendingTimers();
await flushPromises();
expect(api.getConditionStateManager().setState).toHaveBeenLastCalledWith({ expect(api.getConditionStateManager().setState).toHaveBeenLastCalledWith({
triggered: undefined, triggered: undefined,
}); });
}); });
});
describe('should take no actions with high-fidelity event', () => { 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({ const api = createTriggerAPI({
config: { config: {
...baseTriggersConfig, ...baseTriggersConfig,
@@ -356,13 +598,18 @@ describe('TriggersManager', () => {
const manager = new TriggersManager(api); 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().setViewDefaultWithNewQuery).not.toBeCalled();
expect(api.getViewManager().setViewByParametersWithNewQuery).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({ const api = createTriggerAPI({
config: { config: {
...baseTriggersConfig, ...baseTriggersConfig,
@@ -376,22 +623,28 @@ describe('TriggersManager', () => {
const manager = new TriggersManager(api); 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().setViewDefaultWithNewQuery).not.toBeCalled();
expect(api.getViewManager().setViewByParametersWithNewQuery).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({ const api = createTriggerAPI({
// Interaction present. // Interaction present.
interaction: true, interaction: true,
}); });
const manager = new TriggersManager(api); const manager = new TriggersManager(api);
manager.handleCameraEvent({ await manager.handleCameraEvent({
cameraID: 'camera_1', cameraID: 'camera_1',
id: 'event-1',
type: 'new', type: 'new',
}); });
@@ -400,8 +653,9 @@ describe('TriggersManager', () => {
expect(api.getViewManager().setViewDefaultWithNewQuery).not.toBeCalled(); expect(api.getViewManager().setViewDefaultWithNewQuery).not.toBeCalled();
expect(api.getViewManager().setViewByParametersWithNewQuery).not.toBeCalled(); expect(api.getViewManager().setViewByParametersWithNewQuery).not.toBeCalled();
manager.handleCameraEvent({ await manager.handleCameraEvent({
cameraID: 'camera_1', cameraID: 'camera_1',
id: 'event-1',
type: 'end', type: 'end',
}); });
@@ -414,7 +668,7 @@ describe('TriggersManager', () => {
expect(api.getViewManager().setViewByParametersWithNewQuery).not.toBeCalled(); 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({ const api = createTriggerAPI({
config: { config: {
actions: { actions: {
@@ -425,16 +679,18 @@ describe('TriggersManager', () => {
}, },
}); });
const manager = new TriggersManager(api); const manager = new TriggersManager(api);
manager.handleCameraEvent({ await manager.handleCameraEvent({
cameraID: 'camera_1', cameraID: 'camera_1',
id: 'event-1',
type: 'new', type: 'new',
}); });
expect(manager.isTriggered()).toBeTruthy(); expect(manager.isTriggered()).toBeTruthy();
expect(api.getViewManager().setViewDefaultWithNewQuery).not.toBeCalled(); expect(api.getViewManager().setViewDefaultWithNewQuery).not.toBeCalled();
expect(api.getViewManager().setViewByParametersWithNewQuery).not.toBeCalled(); expect(api.getViewManager().setViewByParametersWithNewQuery).not.toBeCalled();
manager.handleCameraEvent({ await manager.handleCameraEvent({
cameraID: 'camera_1', cameraID: 'camera_1',
id: 'event-1',
type: 'end', type: 'end',
}); });
@@ -462,6 +718,7 @@ describe('TriggersManager', () => {
const manager = new TriggersManager(api); const manager = new TriggersManager(api);
await manager.handleCameraEvent({ await manager.handleCameraEvent({
cameraID: 'camera_1', cameraID: 'camera_1',
id: 'event-1',
type: 'new', type: 'new',
}); });
@@ -475,6 +732,7 @@ describe('TriggersManager', () => {
await manager.handleCameraEvent({ await manager.handleCameraEvent({
cameraID: 'camera_1', cameraID: 'camera_1',
id: 'event-1',
type: 'end', type: 'end',
}); });
@@ -518,10 +776,12 @@ describe('TriggersManager', () => {
await manager.handleCameraEvent({ await manager.handleCameraEvent({
cameraID: 'camera_1', cameraID: 'camera_1',
id: 'event-1',
type: 'new', type: 'new',
}); });
await manager.handleCameraEvent({ await manager.handleCameraEvent({
cameraID: 'camera_2', cameraID: 'camera_2',
id: 'event-2',
type: 'new', type: 'new',
}); });
@@ -535,6 +795,7 @@ describe('TriggersManager', () => {
await manager.handleCameraEvent({ await manager.handleCameraEvent({
cameraID: 'camera_1', cameraID: 'camera_1',
id: 'event-1',
type: 'end', type: 'end',
}); });
@@ -548,7 +809,7 @@ describe('TriggersManager', () => {
}); });
describe('should filter triggers by camera', () => { describe('should filter triggers by camera', () => {
it('no dependencies', () => { it('should filter triggers when there are no dependencies', async () => {
const api = createTriggerAPI({ const api = createTriggerAPI({
config: { config: {
...baseTriggersConfig, ...baseTriggersConfig,
@@ -564,8 +825,9 @@ describe('TriggersManager', () => {
}); });
vi.mocked(api.getViewManager().getView).mockReturnValue(otherCameraSelected); vi.mocked(api.getViewManager().getView).mockReturnValue(otherCameraSelected);
manager.handleCameraEvent({ await manager.handleCameraEvent({
cameraID: 'camera_1', cameraID: 'camera_1',
id: 'event-1',
type: 'new', type: 'new',
}); });
expect(manager.isTriggered()).toBeFalsy(); expect(manager.isTriggered()).toBeFalsy();
@@ -575,14 +837,15 @@ describe('TriggersManager', () => {
}); });
vi.mocked(api.getViewManager().getView).mockReturnValue(thisCameraSelected); vi.mocked(api.getViewManager().getView).mockReturnValue(thisCameraSelected);
manager.handleCameraEvent({ await manager.handleCameraEvent({
cameraID: 'camera_1', cameraID: 'camera_1',
id: 'event-1',
type: 'new', type: 'new',
}); });
expect(manager.isTriggered()).toBeTruthy(); expect(manager.isTriggered()).toBeTruthy();
}); });
it('dependencies', () => { it('should not filter triggers when there are dependencies', async () => {
const api = createTriggerAPI({ const api = createTriggerAPI({
config: { config: {
...baseTriggersConfig, ...baseTriggersConfig,
@@ -623,8 +886,9 @@ describe('TriggersManager', () => {
// Events for the secondary will still trigger when filter_selected_camera // Events for the secondary will still trigger when filter_selected_camera
// is true. // is true.
manager.handleCameraEvent({ await manager.handleCameraEvent({
cameraID: 'camera_secondary', cameraID: 'camera_secondary',
id: 'event-secondary',
type: 'new', type: 'new',
}); });
expect(manager.isTriggered()).toBeTruthy(); expect(manager.isTriggered()).toBeTruthy();
@@ -717,5 +981,241 @@ describe('TriggersManager', () => {
expect(result).toBeTruthy(); expect(result).toBeTruthy();
expect(manager.isTriggered()).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);
}); });
}); });