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:
@@ -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 |
|
||||||
| ------------------------ | ------- | ---------------------------------------------------------------------------------------------------------------------------------- |
|
| ------------------------ | ------- | ---------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
|
|||||||
@@ -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',
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -471,7 +471,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
|
||||||
|
|||||||
@@ -85,6 +85,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(),
|
||||||
|
|||||||
@@ -140,6 +140,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: 'new' | 'update' | 'end';
|
type: 'new' | 'update' | 'end';
|
||||||
|
|
||||||
// When fidelity is `high`, the engine is assumed to provide exact details of
|
// When fidelity is `high`, the engine is assumed to provide exact details of
|
||||||
|
|||||||
@@ -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()
|
triggered = true;
|
||||||
.triggers.entities.some((entityID) =>
|
const event: CameraEvent = {
|
||||||
isTriggeredState(hass?.states[entityID]?.state),
|
cameraID,
|
||||||
)
|
id: entityID,
|
||||||
) {
|
type: 'new',
|
||||||
triggered = true;
|
};
|
||||||
await this.handleCameraEvent({
|
if (
|
||||||
cameraID,
|
await this.handleCameraEvent(event, {
|
||||||
type: 'new',
|
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,17 +119,28 @@ 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();
|
||||||
await this._throttledTriggerAction(ev);
|
if (!skipAction) {
|
||||||
|
await this._throttledTriggerAction(ev);
|
||||||
|
}
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
protected _hasAllowableInteractionStateForAction(): boolean {
|
protected _hasAllowableInteractionStateForAction(): boolean {
|
||||||
@@ -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;
|
||||||
|
|
||||||
// If this is a high-fidelity event where we are certain about new media,
|
// 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
|
// 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 {
|
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 */
|
const config = this._api.getConfigManager().getConfig();
|
||||||
this._api.getConfigManager().getConfig()?.view.triggers.untrigger_seconds ?? 0,
|
const untriggerSeconds = config?.view?.triggers.untrigger_seconds ?? 0;
|
||||||
async () => {
|
|
||||||
|
if (untriggerSeconds > 0) {
|
||||||
|
state.untriggerDelayTimer = new Timer();
|
||||||
|
state.untriggerDelayTimer.start(untriggerSeconds, async () => {
|
||||||
await this._untriggerAction(cameraID);
|
await this._untriggerAction(cameraID);
|
||||||
},
|
});
|
||||||
);
|
} else {
|
||||||
|
await this._untriggerAction(cameraID);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
protected _deleteTimer(cameraID: string): void {
|
protected _deleteUntriggerDelayTimer(cameraID: string): void {
|
||||||
this._triggeredCameraTimers.get(cameraID)?.stop();
|
const state = this._states.get(cameraID);
|
||||||
this._triggeredCameraTimers.delete(cameraID);
|
if (state?.untriggerDelayTimer) {
|
||||||
|
state.untriggerDelayTimer.stop();
|
||||||
|
delete state.untriggerDelayTimer;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
protected _isStateTriggered(state: CameraTriggerState): boolean {
|
||||||
|
return !!(state.sources.size || state.untriggerDelayTimer);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -879,6 +879,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,
|
||||||
@@ -887,6 +888,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,
|
||||||
@@ -900,6 +902,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',
|
||||||
@@ -944,6 +947,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,
|
||||||
@@ -952,6 +956,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,
|
||||||
@@ -997,6 +1002,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,
|
||||||
@@ -1007,6 +1013,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,
|
||||||
@@ -1047,6 +1054,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,
|
||||||
@@ -1055,6 +1063,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,
|
||||||
|
|||||||
@@ -8,6 +8,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,
|
||||||
@@ -16,6 +17,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,
|
||||||
|
|||||||
@@ -11,8 +11,6 @@ import {
|
|||||||
QueryResultClassifier,
|
QueryResultClassifier,
|
||||||
} from '../../src/camera-manager/manager.js';
|
} from '../../src/camera-manager/manager.js';
|
||||||
import {
|
import {
|
||||||
CameraEndpoints,
|
|
||||||
CameraEndpointsContext,
|
|
||||||
CameraEvent,
|
CameraEvent,
|
||||||
CameraManagerCameraMetadata,
|
CameraManagerCameraMetadata,
|
||||||
Engine,
|
Engine,
|
||||||
@@ -32,13 +30,13 @@ import { ViewFolder, ViewItem, ViewMedia } from '../../src/view/item.js';
|
|||||||
import { ViewItemCapabilities } from '../../src/view/types.js';
|
import { ViewItemCapabilities } from '../../src/view/types.js';
|
||||||
import {
|
import {
|
||||||
TestViewMedia,
|
TestViewMedia,
|
||||||
createInitializedCamera,
|
|
||||||
createCameraConfig,
|
createCameraConfig,
|
||||||
createCapabilities,
|
createCapabilities,
|
||||||
createCardAPI,
|
createCardAPI,
|
||||||
createConfig,
|
createConfig,
|
||||||
createFolder,
|
createFolder,
|
||||||
createHASS,
|
createHASS,
|
||||||
|
createInitializedCamera,
|
||||||
generateViewMediaArray,
|
generateViewMediaArray,
|
||||||
} from '../test-utils.js';
|
} from '../test-utils.js';
|
||||||
|
|
||||||
@@ -386,6 +384,7 @@ describe('CameraManager', async () => {
|
|||||||
|
|
||||||
const cameraEvent: CameraEvent = {
|
const cameraEvent: CameraEvent = {
|
||||||
cameraID: 'camera',
|
cameraID: 'camera',
|
||||||
|
id: 'event-1',
|
||||||
type: 'new',
|
type: 'new',
|
||||||
};
|
};
|
||||||
eventCallback?.(cameraEvent);
|
eventCallback?.(cameraEvent);
|
||||||
|
|||||||
@@ -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({
|
||||||
@@ -205,8 +236,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',
|
||||||
snapshot: hasSnapshot,
|
snapshot: hasSnapshot,
|
||||||
@@ -230,7 +262,7 @@ describe('TriggersManager', () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('none', () => {
|
it('should handle trigger action set to none', async () => {
|
||||||
const api = createTriggerAPI({
|
const api = createTriggerAPI({
|
||||||
config: {
|
config: {
|
||||||
...baseTriggersConfig,
|
...baseTriggersConfig,
|
||||||
@@ -243,7 +275,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();
|
||||||
@@ -252,7 +288,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,
|
||||||
@@ -265,11 +301,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();
|
||||||
|
|
||||||
@@ -277,7 +322,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,
|
||||||
@@ -290,8 +335,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();
|
||||||
@@ -301,43 +354,232 @@ 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',
|
||||||
|
});
|
||||||
|
|
||||||
|
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', () => {
|
describe('condition state management', () => {
|
||||||
const api = createTriggerAPI({
|
it('should manage condition state', async () => {
|
||||||
config: {
|
const api = createTriggerAPI({
|
||||||
...baseTriggersConfig,
|
config: {
|
||||||
actions: {
|
...baseTriggersConfig,
|
||||||
...baseTriggersConfig.actions,
|
actions: {
|
||||||
trigger: 'none',
|
...baseTriggersConfig.actions,
|
||||||
untrigger: 'none',
|
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({
|
expect(api.getConditionStateManager().setState).toHaveBeenLastCalledWith({
|
||||||
triggered: new Set(['camera_1']),
|
triggered: new Set(['camera_1']),
|
||||||
});
|
});
|
||||||
vi.mocked(api.getConditionStateManager().getState).mockReturnValue({
|
vi.mocked(api.getConditionStateManager().getState).mockReturnValue({
|
||||||
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,
|
||||||
@@ -351,13 +593,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,
|
||||||
@@ -371,22 +618,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',
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -395,8 +648,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',
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -409,7 +663,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: {
|
||||||
@@ -420,16 +674,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',
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -457,6 +713,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',
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -470,6 +727,7 @@ describe('TriggersManager', () => {
|
|||||||
|
|
||||||
await manager.handleCameraEvent({
|
await manager.handleCameraEvent({
|
||||||
cameraID: 'camera_1',
|
cameraID: 'camera_1',
|
||||||
|
id: 'event-1',
|
||||||
type: 'end',
|
type: 'end',
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -513,10 +771,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',
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -530,6 +790,7 @@ describe('TriggersManager', () => {
|
|||||||
|
|
||||||
await manager.handleCameraEvent({
|
await manager.handleCameraEvent({
|
||||||
cameraID: 'camera_1',
|
cameraID: 'camera_1',
|
||||||
|
id: 'event-1',
|
||||||
type: 'end',
|
type: 'end',
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -543,7 +804,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,
|
||||||
@@ -559,8 +820,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();
|
||||||
@@ -570,14 +832,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,
|
||||||
@@ -618,8 +881,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();
|
||||||
@@ -712,5 +976,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);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user