import { maxBy, throttle } from 'lodash-es'; import { CameraEvent } from '../camera-manager/types'; import { isTriggeredState } from '../ha/is-triggered-state'; import { Timer } from '../utils/timer'; import { CardTriggersAPI } from './types'; interface CameraTriggerState { // The time of the most recent trigger event. Used to determine the most // recently triggered camera. lastTriggerTime: Date; // The set of active trigger source IDs (e.g. entity IDs or Frigate event // IDs). sources: Set; // The set of ignored event IDs (e.g. events that have been forcibly // untriggered). ignoredSources: Set; // A timer used to delay the untrigger action. untriggerDelayTimer?: Timer; // A one-shot timer used to force untriggering a camera if no end event is // seen within a configured duration. This timer starts when the camera first // triggers and is not reset by subsequent trigger events. untriggerForceTimer?: Timer; } export class TriggersManager { private _api: CardTriggersAPI; private _states: Map = new Map(); private _throttledTriggerAction = throttle(this._triggerAction.bind(this), 1000, { trailing: true, }); constructor(api: CardTriggersAPI) { this._api = api; } public getTriggeredCameraIDs(): Set { const ids = new Set(); this._states.forEach((state, cameraID) => { if (this._isStateTriggered(state)) { ids.add(cameraID); } }); return ids; } public isTriggered(): boolean { return [...this._states.values()].some((state) => this._isStateTriggered(state)); } public getMostRecentlyTriggeredCameraID(): string | null { const mostRecent = maxBy( [...this._states.entries()].filter(([, state]) => this._isStateTriggered(state)), ([, state]) => state.lastTriggerTime.getTime(), ); return mostRecent?.[0] ?? null; } public handleInitialCameraTriggers = async (): Promise => { const hass = this._api.getHASSManager().getHASS(); let triggered = false; let startupActionEvent: CameraEvent | null = null; this.reset(); for (const [cameraID, camera] of this._api .getCameraManager() .getStore() .getCameras()) { for (const entityID of camera.getConfig().triggers.entities) { if (isTriggeredState(hass?.states[entityID]?.state)) { triggered = true; const event: CameraEvent = { cameraID, id: entityID, type: 'new', }; if ( await this.handleCameraEvent(event, { skipAction: true, }) ) { startupActionEvent ??= event; } } } } if (startupActionEvent) { await this._throttledTriggerAction(startupActionEvent); } return triggered; }; // Returns true if the event was accepted into trigger state processing. // Returns false if it was ignored (e.g. missing config/view or camera filter // mismatch). public async handleCameraEvent( ev: CameraEvent, options?: { skipAction?: boolean; }, ): Promise { const skipAction = options?.skipAction ?? false; if (ev.type === 'end') { return this._handleEndEvent(ev); } // Ignore stale updates for force-untriggered IDs before doing any further // processing to avoid re-activating muted IDs. if (this._isIgnoredUpdateEvent(ev)) { return false; } const config = this._api.getConfigManager().getConfig(); const triggersConfig = config?.view?.triggers; const selectedCameraID = this._api.getViewManager().getView()?.camera; if (!triggersConfig || !selectedCameraID) { return false; } const dependentCameraIDs = this._api .getCameraManager() .getStore() .getAllDependentCameras(selectedCameraID); if (triggersConfig.filter_selected_camera && !dependentCameraIDs.has(ev.cameraID)) { return false; } const state = this._getOrCreateState(ev.cameraID); state.lastTriggerTime = new Date(); state.sources.add(ev.id); this._deleteUntriggerDelayTimer(ev.cameraID); this._startForceUntriggerTimerIfNecessary( ev.cameraID, triggersConfig.untrigger_force_seconds, ); this._setConditionStateIfNecessary(); if (!skipAction) { await this._throttledTriggerAction(ev); } return true; } private async _handleEndEvent(ev: CameraEvent): Promise { this._deleteIgnoredEventID(ev.cameraID, ev.id); const state = this._states.get(ev.cameraID); state?.sources.delete(ev.id); if (!state?.sources.size) { await this._startUntrigger(ev.cameraID); } return true; } private _isIgnoredUpdateEvent(ev: CameraEvent): boolean { return ( (ev.type === 'update' || ev.type === 'genai') && this._hasIgnoredEventID(ev.cameraID, ev.id) ); } private _hasAllowableInteractionStateForAction(): boolean { const triggersConfig = this._api.getConfigManager().getConfig()?.view.triggers; const hasInteraction = this._api.getInteractionManager().hasInteraction(); return ( !!triggersConfig && (triggersConfig.actions.interaction_mode === 'all' || (triggersConfig.actions.interaction_mode === 'active' && hasInteraction) || (triggersConfig.actions.interaction_mode === 'inactive' && !hasInteraction)) ); } private async _triggerAction(ev: CameraEvent): Promise { const config = this._api.getConfigManager().getConfig(); const triggersConfig = config?.view?.triggers; const triggerAction = triggersConfig?.actions.trigger; const defaultView = config?.view?.default; // Skip the trigger action for a high-fidelity "no new media" event when // the configured action would change to a non-live view (Frigate may pump // out such events). `live`, `call`, and default-with-live remain valid // since they don't depend on media being available. const skipViewAction = ev.fidelity === 'high' && !ev.snapshot && !ev.clip && !ev.review && !( triggerAction === 'call' || triggerAction === 'live' || (triggerAction === 'default' && defaultView === 'live') ); if (this._hasAllowableInteractionStateForAction() && !skipViewAction) { if (triggerAction === 'update') { await this._api.getViewManager().setViewByParametersWithNewQuery({ queryExecutorOptions: { useCache: false }, }); } else if (triggerAction === 'live') { await this._api.getViewManager().setViewByParametersWithNewQuery({ params: { view: 'live', camera: ev.cameraID, }, }); } else if (triggerAction === 'default') { await this._api.getViewManager().setViewDefaultWithNewQuery({ params: { camera: ev.cameraID, }, }); } else if (triggerAction === 'call') { // Auto-call the triggered camera. `start()` itself handles the // navigation to live -- it is idempotent if the view already matches. await this._api.getCallManager().start({ cameraID: ev.cameraID, inbound: true }); } else if (ev.fidelity === 'high' && triggerAction === 'media') { // Choose the most appropriate media view based on what's available. // Priority: review > clip > snapshot const view = ev.review ? 'review' : ev.clip ? 'clip' : ev.snapshot ? 'snapshot' : /* istanbul ignore next: unreachable due to `skipViewAction` above -- @preserve */ null; /* istanbul ignore next: unreachable due to `skipViewAction` above -- @preserve */ if (view) { await this._api.getViewManager().setViewByParametersWithNewQuery({ params: { view, camera: ev.cameraID, }, }); } } } // Must update master element to add border pulsing to live view. this._api.getCardElementManager().update(); } private _setConditionStateIfNecessary(): void { const triggeredCameraIDs = this.getTriggeredCameraIDs(); this._api.getConditionStateManager().setState({ triggered: triggeredCameraIDs.size ? triggeredCameraIDs : undefined, }); } private async _executeUntriggerAction(cameraID: string): Promise { const action = this._api.getConfigManager().getConfig()?.view?.triggers .actions.untrigger; if (!action || action === 'none') { return true; } if (!this._hasAllowableInteractionStateForAction()) { return true; } switch (action) { case 'default': await this._api.getViewManager().setViewDefaultWithNewQuery(); break; case 'call': // Triggers only end a call if the call is owned by this cameraID, if it // was an inbound call and was not yet answered. this._api.getCallManager().endIf({ cameraID, inbound: true, answered: false }); break; } return true; } private async _untriggerAction(cameraID: string): Promise { this._deleteUntriggerDelayTimer(cameraID); this._deleteForceUntriggerTimer(cameraID); await this._executeUntriggerAction(cameraID); this._deleteStateIfIdle(cameraID); this._setConditionStateIfNecessary(); // Must update master element to remove border pulsing from live view. this._api.getCardElementManager().update(); } private async _startUntrigger(cameraID: string): Promise { this._deleteUntriggerDelayTimer(cameraID); this._deleteForceUntriggerTimer(cameraID); const state = this._states.get(cameraID); if (!state) { return; } const config = this._api.getConfigManager().getConfig(); const untriggerDelaySeconds = config?.view?.triggers.untrigger_delay_seconds ?? 0; if (untriggerDelaySeconds > 0) { state.untriggerDelayTimer = new Timer(); state.untriggerDelayTimer.start(untriggerDelaySeconds, async () => { await this._untriggerAction(cameraID); }); } else { await this._untriggerAction(cameraID); } } private _startForceUntriggerTimerIfNecessary( cameraID: string, forceUntriggerSeconds: number, ): void { if (forceUntriggerSeconds <= 0) { return; } const state = this._states.get(cameraID); if (!state || state.untriggerForceTimer) { return; } const timer = new Timer(); state.untriggerForceTimer = timer; timer.start(forceUntriggerSeconds, async () => { await this._forceUntrigger(state, cameraID); }); } private async _forceUntrigger( state: CameraTriggerState, cameraID: string, ): Promise { state.sources.forEach((id) => this._addIgnoredEventID(cameraID, id)); state.sources.clear(); this._deleteForceUntriggerTimer(cameraID); await this._startUntrigger(cameraID); } private _addIgnoredEventID(cameraID: string, eventID: string): void { const state = this._getOrCreateState(cameraID); state.ignoredSources.add(eventID); } private _deleteIgnoredEventID(cameraID: string, eventID: string): void { const state = this._states.get(cameraID); if (!state) { return; } state.ignoredSources.delete(eventID); this._deleteStateIfIdle(cameraID); } private _hasIgnoredEventID(cameraID: string, eventID: string): boolean { return !!this._states.get(cameraID)?.ignoredSources.has(eventID); } private _getOrCreateState(cameraID: string): CameraTriggerState { let state = this._states.get(cameraID); if (!state) { state = { lastTriggerTime: new Date(), sources: new Set(), ignoredSources: new Set(), }; this._states.set(cameraID, state); } return state; } private _deleteStateIfIdle(cameraID: string): void { const state = this._states.get(cameraID); if ( state && !state.sources.size && !state.ignoredSources.size && !state.untriggerDelayTimer && !state.untriggerForceTimer ) { this._states.delete(cameraID); } } private _deleteUntriggerDelayTimer(cameraID: string): void { const state = this._states.get(cameraID); if (state?.untriggerDelayTimer) { state.untriggerDelayTimer.stop(); delete state.untriggerDelayTimer; } } private _deleteForceUntriggerTimer(cameraID: string): void { const state = this._states.get(cameraID); if (state?.untriggerForceTimer) { state.untriggerForceTimer.stop(); delete state.untriggerForceTimer; } } private _stopAllTimers(): void { for (const [cameraID] of this._states) { this._deleteUntriggerDelayTimer(cameraID); this._deleteForceUntriggerTimer(cameraID); } } public reset(): void { this._throttledTriggerAction.cancel(); this._stopAllTimers(); this._states.clear(); this._setConditionStateIfNecessary(); } private _isStateTriggered(state: CameraTriggerState): boolean { return !!(state.sources.size || state.untriggerDelayTimer); } }