From 278f4f372d8ebc1014cd2c2c2794a86528726733 Mon Sep 17 00:00:00 2001 From: Dermot Duffy Date: Wed, 17 Jan 2024 22:07:30 -0800 Subject: [PATCH] Initial version of more advanced scan mode control. --- README.md | 35 ++- src/card-controller/actions-manager.ts | 34 +- src/card-controller/hass-manager.ts | 2 +- src/card-controller/interaction-manager.ts | 3 - src/card-controller/query-string-manager.ts | 44 +-- src/card-controller/triggers-manager.ts | 162 ++++++---- src/card-controller/types.ts | 30 +- src/card-controller/view-manager.ts | 2 + src/card.ts | 2 +- src/config/types.ts | 82 ++++- src/const.ts | 6 +- src/editor.ts | 13 +- src/localize/languages/en.json | 2 +- src/localize/languages/it.json | 2 +- src/localize/languages/pt-BR.json | 2 +- src/localize/languages/pt-PT.json | 2 +- tests/card-controller/actions-manager.test.ts | 138 ++++++-- tests/card-controller/hass-manager.test.ts | 4 +- .../interaction-manager.test.ts | 1 - .../media-player-manager.test.ts | 1 - .../query-string-manager.test.ts | 88 +----- .../card-controller/triggers-manager.test.ts | 294 ++++++++++++++---- tests/config/types.test.ts | 20 +- vite.config.ts | 6 +- 24 files changed, 648 insertions(+), 327 deletions(-) diff --git a/README.md b/README.md index ec370777..218f38ec 100644 --- a/README.md +++ b/README.md @@ -421,20 +421,43 @@ view: scan: ``` -Scan mode allows the card to automatically "follow the action". In this mode the card will automatically select a camera in the `live` view when an entity changes to an active state (specifically `on` or `open`). The entities considered are defined by your camera configuration (see `triggers` parameters). An untrigger is defined as the state for all the configured entities returning to inactive (i.e. not `on` or `open`), with an optional number of seconds to wait prior to the untriggering (see `untrigger_seconds`). +Scan mode allows the card to automatically "follow the action". In this mode the card will automatically execute an action (defined by `trigger_action`) when a camera is 'triggered', by default selecting that camera in the `live` view . The trigger entities considered are defined by your camera configuration (see `triggers` parameters). An untrigger is defined as the state for all the configured entities returning to inactive (i.e. not `on` or `open`), with an optional number of seconds to wait prior to the untriggering (see `untrigger_seconds`). -When the camera untriggers, the view will either remain as-is (if `untrigger_reset` is `false`) and the card return to normal operation, or reset to the default view (if `untrigger_reset` is `true` -- the default). +When the camera untriggers, a different action (defined by `untrigger_action`) is automatically executed, by default returning the card to the default view and camera. -Triggering is only allowed when there is no ongoing human interaction with the card -- interaction will automatically untrigger and further triggering will not occur until after the card has been unattended for `view.timeout_seconds`. +By default, triggering is only allowed when there is no ongoing human interaction with the card. This behavior can be controlled by the `interaction_mode` parameter. Scan mode tracks Home Assistant state *changes* -- when the card is first started, it takes an active change in state to trigger (i.e. an already occupied room will not trigger it, but a newly occupied room will). | Option | Default | Overridable | Description | | - | - | - | - | | `enabled` | `false` | :white_check_mark: | Whether to enable scan mode. | -| `show_trigger_status` | `true` | :white_check_mark: | Whether or not the card should show a visual indication that it is triggered (a pulsing border around the card edge). | +| `interaction_mode` | `inactive` | :white_check_mark: | Whether actions should be taken when the card is being interacted with. If `all`, actions will always be taken regardless. If `inactive` actions will only be taken if the card has *not* had human interaction recently (as defined by `view.timeout_seconds`). If `active` actions will only be taken if the card *has* had human interaction recently. This does not stop triggering itself (i.e. border will still pulse if `trigger_show_status` is true) but rather just prevents the actions being performed.| +| `trigger_filter_camera` | `all` | :white_check_mark: | If set to `all` the camera will be triggered regardless of which camera is currently selected, if set to `selected` the camera will only trigger if that camera is already selected.| +| `trigger_show_status` | `true` | :white_check_mark: | Whether or not the card should show a visual indication that it is triggered (a pulsing border around the card edge). | +| `trigger_action` | Selects triggered camera in `live` view (see below) | :white_check_mark: | An action or list of actions that are executed when a camera is triggered. May be set to `null` for no action.| | `untrigger_reset` | `true` | :white_check_mark: | Whether or not to reset the view to the default after untriggering. | | `untrigger_seconds` | `0` | :white_check_mark: | The number of seconds to wait after all entities are inactive before untriggering. | +| `untrigger_action` | Selects default view and camera (see below) | :white_check_mark: | An action or list of actions that are executed when a camera is triggered. May bet to `null` for no action.| + +##### View: Scan Mode default actions + +The default `trigger_action` is: + +```yaml + - action: custom:frigate-card-action + frigate_card_action: camera_select + triggered: true + - action: custom:frigate-card-action + frigate_card_action: live +``` + +The default `untrigger_action` is: + +```yaml + - action: custom:frigate-card-action + frigate_card_action: default +``` ### Menu Options @@ -1422,7 +1445,7 @@ Parameters for the `custom:frigate-card-conditional` element: |`download`|Download the displayed media.| |`camera_ui`|Open the Frigate UI at the configured URL.| |`fullscreen`|Toggle fullscreen.| -|`camera_select`|Select a given camera. Takes a single additional `camera` parameter with the [camera ID](#camera-ids) of the camera to select. Respects the value of `view.camera_select` to choose the appropriate view on the new camera.| +|`camera_select`|Select a given camera. Takes an additional `camera` parameter with the [camera ID](#camera-ids) of the camera to select. Respects the value of `view.camera_select` to choose the appropriate view on the new camera. If a `triggered` parameter is set to `true` instead of `camera` being specified then a triggered camera (if any) is selected instead. | |`menu_toggle` | Show/hide the menu (for the `hidden` mode style). | |`media_player`| Perform a media player action. Takes a `media_player` parameter with the entity ID of the media_player on which to perform the action, and a `media_player_action` parameter which should be either `play` or `stop` to play or stop the media in question. | |`live_substream_select`| Perform a media player action. Takes a `camera` parameter with the [camera ID](#camera-ids) of the substream camera. | @@ -1912,7 +1935,7 @@ view: dark_mode: 'off' scan: enabled: false - show_trigger_status: true + trigger_show_status: true untrigger_reset: true untrigger_seconds: 0 actions: diff --git a/src/card-controller/actions-manager.ts b/src/card-controller/actions-manager.ts index 68a0572f..a40ab535 100644 --- a/src/card-controller/actions-manager.ts +++ b/src/card-controller/actions-manager.ts @@ -1,11 +1,13 @@ import { Actions, ActionsConfig, + ActionType, FrigateCardCustomAction, FRIGATE_CARD_VIEW_DEFAULT, } from '../config/types.js'; import { convertActionToFrigateCardCustomAction, + frigateCardHandleAction, frigateCardHandleActionConfig, getActionConfigGivenAction, } from '../utils/action.js'; @@ -81,16 +83,36 @@ export class ActionsManager { const frigateCardAction = convertActionToFrigateCardCustomAction(ev.detail); if (frigateCardAction) { - this.executeAction(frigateCardAction); + this.executeFrigateAction(frigateCardAction); } }; + /** + * Small convenience method to call frigateCardHandleAction without the caller + * needing hass or the element. + */ + public executeActions(actions: ActionType | ActionType[]): void { + const hass = this._api.getHASSManager().getHASS(); + if (!hass) { + return; + } + + frigateCardHandleAction( + this._api.getCardElementManager().getElement(), + hass, + {}, + actions, + ); + } + /** * Execute a card action. * @param frigateCardAction * @returns `true` if an action is executed. */ - public async executeAction(frigateCardAction: FrigateCardCustomAction): Promise { + public async executeFrigateAction( + frigateCardAction: FrigateCardCustomAction, + ): Promise { const config = this._api.getConfigManager().getConfig(); const mediaLoadedInfoManager = this._api.getMediaLoadedInfoManager(); @@ -146,8 +168,12 @@ export class ActionsManager { this._api.getCardElementManager().toggleMenu(); break; case 'camera_select': - const selectCameraID = frigateCardAction.camera; - if (view) { + const selectCameraID = + frigateCardAction.camera ?? + (frigateCardAction.triggered + ? this._api.getTriggersManager().getMostRecentlyTriggeredCameraID() + : null); + if (selectCameraID && view) { const viewOnCameraSelect = config?.view.camera_select ?? 'current'; const targetViewName = viewOnCameraSelect === 'current' ? view.view : viewOnCameraSelect; diff --git a/src/card-controller/hass-manager.ts b/src/card-controller/hass-manager.ts index 35134171..4c697165 100644 --- a/src/card-controller/hass-manager.ts +++ b/src/card-controller/hass-manager.ts @@ -74,7 +74,7 @@ export class HASSManager { this._api.getCardElementManager().update(); } - this._api.getTriggersManager().updateTriggeredCameras(oldHass); + this._api.getTriggersManager().updateTriggerHAState(oldHass); if (this._api.getConditionsManager().hasHAStateConditions()) { this._api.getConditionsManager().setState({ state: this._hass.states }); diff --git a/src/card-controller/interaction-manager.ts b/src/card-controller/interaction-manager.ts index 24a473b8..af04cc81 100644 --- a/src/card-controller/interaction-manager.ts +++ b/src/card-controller/interaction-manager.ts @@ -27,9 +27,6 @@ export class InteractionManager { protected _reportInteraction(): void { this._timer.stop(); - // Interactions reset the trigger state. - this._api.getTriggersManager().untrigger(); - const timeoutSeconds = this._api.getConfigManager().getConfig() ?.view.timeout_seconds; diff --git a/src/card-controller/query-string-manager.ts b/src/card-controller/query-string-manager.ts index 41840f84..f118f3be 100644 --- a/src/card-controller/query-string-manager.ts +++ b/src/card-controller/query-string-manager.ts @@ -1,5 +1,8 @@ import { FrigateCardCustomAction, FrigateCardViewAction } from '../config/types'; -import { createFrigateCardCameraAction, createFrigateCardSimpleAction } from '../utils/action.js'; +import { + createFrigateCardCameraAction, + createFrigateCardSimpleAction +} from '../utils/action.js'; import { CardQueryStringAPI } from './types'; import { ViewManagerSetViewParameters } from './view-manager'; @@ -35,43 +38,6 @@ export class QueryStringManager { this._executeNonViewRelated(intent); }; - public generateQueryString(action: FrigateCardCustomAction): string | null { - const baseKey = - 'frigate-card-action.' + (action.card_id ? `${action.card_id}.` : ''); - - switch (action.frigate_card_action) { - case 'camera_select': - case 'live_substream_select': - return new URLSearchParams([ - [baseKey + action.frigate_card_action, action.camera], - ]).toString(); - case 'camera_ui': - case 'clip': - case 'clips': - case 'default': - case 'diagnostics': - case 'download': - case 'expand': - case 'image': - case 'live': - case 'menu_toggle': - case 'recording': - case 'recordings': - case 'snapshot': - case 'snapshots': - case 'timeline': - return new URLSearchParams([ - [baseKey + action.frigate_card_action, ''], - ]).toString(); - default: - console.warn( - `Frigate card cannot convert unsupported action to query string:`, - action, - ); - } - return null; - } - protected _executeViewRelated(intent: QueryStringViewIntent): void { if (intent.view) { if (intent.view.default) { @@ -96,7 +62,7 @@ export class QueryStringManager { } intent.other?.forEach((action) => - this._api.getActionsManager().executeAction(action), + this._api.getActionsManager().executeFrigateAction(action), ); } diff --git a/src/card-controller/triggers-manager.ts b/src/card-controller/triggers-manager.ts index 9e9ab222..78ff1295 100644 --- a/src/card-controller/triggers-manager.ts +++ b/src/card-controller/triggers-manager.ts @@ -2,30 +2,44 @@ import { HomeAssistant } from '@dermotduffy/custom-card-helpers'; import orderBy from 'lodash-es/orderBy'; import { getHassDifferences, isTriggeredState } from '../utils/ha'; import { Timer } from '../utils/timer'; +import { View } from '../view/view'; import { CardTriggersAPI } from './types'; export class TriggersManager { protected _api: CardTriggersAPI; - protected _triggers: Map = new Map(); - protected _untriggerTimer = new Timer(); + protected _triggeredCameras: Map = new Map(); + protected _triggeredCameraTimers: Map = new Map(); + protected _triggeredState: Set = new Set(); constructor(api: CardTriggersAPI) { this._api = api; } - public isTriggered(): boolean { - return !!this._triggers.size || this._untriggerTimer.isRunning(); + public getTriggeredCameraIDs(): Set { + return new Set(this._triggeredCameras.keys()); } - public updateTriggeredCameras(oldHass?: HomeAssistant | null): boolean { - if (!this._shouldTrackTriggers()) { - return false; + public isTriggered(): boolean { + return !!this._triggeredCameras.size; + } + + public getMostRecentlyTriggeredCameraID(): string | null { + const sorted = orderBy( + [...this._triggeredCameras.entries()], + (entry) => entry[1].getTime(), + 'desc', + ); + return sorted.length ? sorted[0][0] : null; + } + + public updateTriggerHAState(oldHass?: HomeAssistant | null): void { + const scanConfig = this._api.getConfigManager().getConfig()?.view.scan; + if (!scanConfig || !scanConfig.enabled) { + return; } const hass = this._api.getHASSManager().getHASS(); - - const now = new Date(); let triggerChanges = false; const visibleCameraIDs = this._api @@ -45,88 +59,108 @@ export class TriggersManager { (entity) => !isTriggeredState(hass?.states[entity]), ); if (shouldTrigger) { - this._triggers.set(cameraID, now); + this._triggeredState.add(cameraID); triggerChanges = true; - } else if (shouldUntrigger && this._triggers.has(cameraID)) { - this._triggers.delete(cameraID); + } else if (shouldUntrigger && this._triggeredState.has(cameraID)) { + this._triggeredState.delete(cameraID); triggerChanges = true; } } if (triggerChanges) { - const targetCameraID = this._getMostRecentTrigger(); - if (targetCameraID) { - this._triggerAction(targetCameraID); - return true; - } else { - this._startUntriggerTimer(); + this._evaluateTriggers(); + } + } + + public updateView(oldView?: View | null): void { + if (oldView?.camera !== this._api.getViewManager().getView()?.camera) { + // If the view changes, a new camera may have been selected, which may + // mean a trigger is required (in the case that `trigger_filter_camera` + // has been set to `selected`). + this._evaluateTriggers(); + } + } + + protected _evaluateTriggers(): void { + const scanConfig = this._api.getConfigManager().getConfig()?.view.scan; + if (!scanConfig) { + return; + } + + const now = new Date(); + for (const cameraID of this._triggeredState.keys()) { + if ( + !this._triggeredCameras.has(cameraID) && + (scanConfig.trigger_filter_camera === 'all' || + (scanConfig.trigger_filter_camera === 'selected' && + cameraID === this._api.getViewManager().getView()?.camera)) + ) { + this._triggeredCameras.set(cameraID, now); + this._triggerAction(); } } - return false; - } - public untrigger(): void { - const wasTriggered = this.isTriggered(); - this._triggers.clear(); - this._untriggerTimer.stop(); - - if (wasTriggered) { - this._untriggerAction(); + for (const cameraID of this._triggeredCameras.keys()) { + if (!this._triggeredState.has(cameraID)) { + this._startUntriggerTimer(cameraID); + } } } - protected _triggerAction(cameraID: string): void { - const view = this._api.getViewManager().getView(); - if ( - this._isAutomatedViewUpdateAllowed() && - (view?.camera !== cameraID || !view?.is('live')) - ) { - this._api.getViewManager().setViewByParameters({ - viewName: 'live', - cameraID: cameraID, - }); - } - } + protected _hasAllowableInteractionState(): boolean { + const scanConfig = this._api.getConfigManager().getConfig()?.view.scan; + const hasInteraction = this._api.getInteractionManager().hasInteraction(); - protected _untriggerAction(): void { - if ( - !this.isTriggered() && - this._isAutomatedViewUpdateAllowed() && - this._api.getConfigManager().getConfig()?.view.scan.untrigger_reset - ) { - this._api.getViewManager().setViewDefault(); - } - } - - protected _isAutomatedViewUpdateAllowed(): boolean { return ( - this._api.getConfigManager().getConfig()?.view.update_force || - !this._api.getInteractionManager().hasInteraction() + !!scanConfig && + (scanConfig.interaction_mode === 'all' || + (scanConfig.interaction_mode === 'active' && hasInteraction) || + (scanConfig.interaction_mode === 'inactive' && !hasInteraction)) ); } - protected _shouldTrackTriggers(): boolean { - return !!this._api.getConfigManager().getConfig()?.view.scan.enabled; + protected _triggerAction(): void { + const action = this._api.getConfigManager().getConfig()?.view.scan.trigger_action; + + if (action && this._hasAllowableInteractionState()) { + this._api.getActionsManager().executeActions(action); + } + + // Must update master element to add border pulsing. + this._api.getCardElementManager().update(); } - protected _startUntriggerTimer(): void { - this._untriggerTimer.start( + protected _untriggerAction(cameraID: string): void { + const action = this._api.getConfigManager().getConfig()?.view.scan.untrigger_action; + + if (action && this._hasAllowableInteractionState()) { + this._api.getActionsManager().executeActions(action); + } + this._triggeredCameras.delete(cameraID); + this._deleteTimer(cameraID); + + // Must update master element to remove border pulsing. + this._api.getCardElementManager().update(); + } + + protected _startUntriggerTimer(cameraID: string): void { + this._deleteTimer(cameraID); + + const timer = new Timer(); + this._triggeredCameraTimers.set(cameraID, timer); + timer.start( /* istanbul ignore next: the case of config being null here cannot be reached, as there's no way to have the untrigger call happen without a config. -- @preserve */ this._api.getConfigManager().getConfig()?.view.scan.untrigger_seconds ?? 0, () => { - this._untriggerAction(); + this._untriggerAction(cameraID); }, ); } - protected _getMostRecentTrigger(): string | null { - const sorted = orderBy( - [...this._triggers.entries()], - (entry) => entry[1].getTime(), - 'desc', - ); - return sorted.length ? sorted[0][0] : null; + protected _deleteTimer(cameraID: string): void { + this._triggeredCameraTimers.get(cameraID)?.stop(); + this._triggeredCameraTimers.delete(cameraID); } } diff --git a/src/card-controller/types.ts b/src/card-controller/types.ts index 95776b50..a962d08a 100644 --- a/src/card-controller/types.ts +++ b/src/card-controller/types.ts @@ -41,8 +41,9 @@ export interface CardActionsManagerAPI { getMediaLoadedInfoManager(): MediaLoadedInfoManager; getMediaPlayerManager(): MediaPlayerManager; getMessageManager(): MessageManager; - getViewManager(): ViewManager; getMicrophoneManager(): MicrophoneManager; + getTriggersManager(): TriggersManager; + getViewManager(): ViewManager; } export interface CardAutomationsAPI { @@ -55,17 +56,17 @@ export interface CardAutomationsAPI { export interface CardAutoRefreshAPI { getConfigManager(): ConfigManager; - getViewManager(): ViewManager; - getTriggersManager(): TriggersManager; getInteractionManager(): InteractionManager; + getTriggersManager(): TriggersManager; + getViewManager(): ViewManager; } export interface CardCameraAPI { getConfigManager(): ConfigManager; getEntityRegistryManager(): EntityRegistryManager; - getResolvedMediaCache(): ResolvedMediaCache; getHASSManager(): HASSManager; getMessageManager(): MessageManager; + getResolvedMediaCache(): ResolvedMediaCache; } export interface CardCameraURLAPI { @@ -106,15 +107,15 @@ export interface CardElementAPI { } export interface CardExpandAPI { - getFullscreenManager(): FullscreenManager; - getConditionsManager(): ConditionsManager; getCardElementManager(): CardElementManager; + getConditionsManager(): ConditionsManager; + getFullscreenManager(): FullscreenManager; } export interface CardFullscreenAPI { getCardElementManager(): CardElementManager; - getExpandManager(): ExpandManager; getConditionsManager(): ConditionsManager; + getExpandManager(): ExpandManager; getMediaPlayerManager(): MediaPlayerManager; } @@ -153,23 +154,23 @@ export interface CardInteractionAPI { } export interface CardMediaLoadedAPI { + getCardElementManager(): CardElementManager; getConditionsManager(): ConditionsManager; getConfigManager(): ConfigManager; - getCardElementManager(): CardElementManager; getStyleManager(): StyleManager; } export interface CardMediaPlayerAPI { - getHASSManager(): HASSManager; getCameraManager(): CameraManager; getEntityRegistryManager(): EntityRegistryManager; + getHASSManager(): HASSManager; getMessageManager(): MessageManager; getQueryStringManager(): QueryStringManager; } export interface CardMessageAPI { - getConditionsManager(): ConditionsManager; getCardElementManager(): CardElementManager; + getConditionsManager(): ConditionsManager; getMediaLoadedInfoManager(): MediaLoadedInfoManager; } @@ -179,9 +180,9 @@ export interface CardMicrophoneAPI { } export interface CardQueryStringAPI { + getActionsManager(): ActionsManager; getCardElementManager(): CardElementManager; getViewManager(): ViewManager; - getActionsManager(): ActionsManager; } export interface CardStyleAPI { @@ -196,7 +197,9 @@ export interface CardStyleAPI { } export interface CardTriggersAPI { + getActionsManager(): ActionsManager; getCameraManager(): CameraManager; + getCardElementManager(): CardElementManager; getConfigManager(): ConfigManager; getHASSManager(): HASSManager; getInteractionManager(): InteractionManager; @@ -206,11 +209,12 @@ export interface CardTriggersAPI { export interface CardViewAPI { getAutoUpdateManager(): AutoUpdateManager; getCameraManager(): CameraManager; + getCardElementManager(): CardElementManager; + getConditionsManager(): ConditionsManager; getConfigManager(): ConfigManager; getHASSManager(): HASSManager; getMediaLoadedInfoManager(): MediaLoadedInfoManager; getMessageManager(): MessageManager; getStyleManager(): StyleManager; - getConditionsManager(): ConditionsManager; - getCardElementManager(): CardElementManager; + getTriggersManager(): TriggersManager; } diff --git a/src/card-controller/view-manager.ts b/src/card-controller/view-manager.ts index 569cb323..71940719 100644 --- a/src/card-controller/view-manager.ts +++ b/src/card-controller/view-manager.ts @@ -244,6 +244,8 @@ export class ViewManager { camera: view.camera, displayMode: view.displayMode ?? undefined, }); + + this._api.getTriggersManager().updateView(oldView); this._api.getCardElementManager().update(); } diff --git a/src/card.ts b/src/card.ts index 527b710a..1f50e2b4 100644 --- a/src/card.ts +++ b/src/card.ts @@ -241,7 +241,7 @@ class FrigateCard extends LitElement { const cardClasses = { triggered: !!this._controller.getTriggersManager().isTriggered() && - !!this._config?.view.scan.show_trigger_status, + !!this._config?.view.scan.trigger_show_status, }; const mainClasses = { main: true, diff --git a/src/config/types.ts b/src/config/types.ts index 3b7d12e6..f618ea74 100644 --- a/src/config/types.ts +++ b/src/config/types.ts @@ -1,3 +1,43 @@ +// TODO: Config migration +// TODO: Editor support for scan mode changes +// TODO: Consider this: + +/* +// NOW ===> +view: + scan: + enabled: true + trigger_show_status: true, + trigger_action: + - action: custom:frigate-card-action + frigate_card_action: camera_select + triggered: true + - action: custom:frigate-card-action + frigate_card_action: live + untrigger_action: + - action: custom:frigate-card-action + interaction_mode: inactive + trigger_filter_camera: all + untrigger_seconds: 0 + +view: + scan: + enabled: true + +// TO + +view: + scan: + enabled: true + trigger_show_status: true + actions: + live | live-reset | none + interaction: inactive | active | all + trigger_filter_camera: all + untrigger_seconds: 0 +automations: +*/ + import { CallServiceActionConfig, ConfirmationRestrictionConfig, @@ -241,7 +281,8 @@ const frigateCardGeneralActionSchema = frigateCardCustomActionsBaseSchema.extend const frigateCardCameraSelectActionSchema = frigateCardCustomActionsBaseSchema.extend({ frigate_card_action: z.literal('camera_select'), - camera: z.string(), + camera: z.string().optional(), + triggered: z.boolean().optional(), }); const frigateCardLiveDependencySelectActionSchema = @@ -1119,17 +1160,48 @@ const viewConfigDefault = { dark_mode: 'off' as const, scan: { enabled: false, - show_trigger_status: true, + trigger_show_status: true, + trigger_action: [ + { + action: 'custom:frigate-card-action' as const, + frigate_card_action: 'camera_select' as const, + triggered: true, + }, + { + action: 'custom:frigate-card-action' as const, + frigate_card_action: 'live' as const, + }, + ], + untrigger_action: { + action: 'custom:frigate-card-action' as const, + frigate_card_action: 'default' as const, + }, + interaction_mode: 'inactive' as const, + trigger_filter_camera: 'all' as const, untrigger_seconds: 0, - untrigger_reset: true, }, }; const scanSchema = z.object({ enabled: z.boolean().default(viewConfigDefault.scan.enabled), - show_trigger_status: z.boolean().default(viewConfigDefault.scan.show_trigger_status), + + interaction_mode: z + .enum(['all', 'inactive', 'active']) + .default(viewConfigDefault.scan.interaction_mode), + trigger_filter_camera: z + .enum(['all', 'selected']) + .default(viewConfigDefault.scan.trigger_filter_camera), + trigger_show_status: z.boolean().default(viewConfigDefault.scan.trigger_show_status), + trigger_action: actionSchema + .or(actionSchema.array()) + .nullable() + .default(viewConfigDefault.scan.trigger_action), + + untrigger_action: actionSchema + .or(actionSchema.array()) + .nullable() + .default(viewConfigDefault.scan.untrigger_action), untrigger_seconds: z.number().default(viewConfigDefault.scan.untrigger_seconds), - untrigger_reset: z.boolean().default(viewConfigDefault.scan.untrigger_reset), }); export type ScanOptions = z.infer; diff --git a/src/const.ts b/src/const.ts index d2a2a41b..1d6c4c62 100644 --- a/src/const.ts +++ b/src/const.ts @@ -77,10 +77,8 @@ export const CONF_VIEW_UPDATE_FORCE = `${CONF_VIEW}.update_force` as const; export const CONF_VIEW_UPDATE_SECONDS = `${CONF_VIEW}.update_seconds` as const; export const CONF_VIEW_SCAN = `${CONF_VIEW}.scan` as const; export const CONF_VIEW_SCAN_ENABLED = `${CONF_VIEW_SCAN}.enabled` as const; -export const CONF_VIEW_SCAN_SHOW_TRIGGER_STATUS = - `${CONF_VIEW_SCAN}.show_trigger_status` as const; -export const CONF_VIEW_SCAN_UNTRIGGER_RESET = - `${CONF_VIEW_SCAN}.untrigger_reset` as const; +export const CONF_VIEW_SCAN_TRIGGER_SHOW_STATUS = + `${CONF_VIEW_SCAN}.trigger_show_status` as const; export const CONF_VIEW_SCAN_UNTRIGGER_SECONDS = `${CONF_VIEW_SCAN}.untrigger_seconds` as const; diff --git a/src/editor.ts b/src/editor.ts index d981a550..64ab9401 100644 --- a/src/editor.ts +++ b/src/editor.ts @@ -182,8 +182,7 @@ import { CONF_VIEW_DEFAULT, CONF_VIEW_SCAN, CONF_VIEW_SCAN_ENABLED, - CONF_VIEW_SCAN_SHOW_TRIGGER_STATUS, - CONF_VIEW_SCAN_UNTRIGGER_RESET, + CONF_VIEW_SCAN_TRIGGER_SHOW_STATUS, CONF_VIEW_SCAN_UNTRIGGER_SECONDS, CONF_VIEW_TIMEOUT_SECONDS, CONF_VIEW_UPDATE_CYCLE_CAMERA, @@ -902,16 +901,12 @@ export class FrigateCardEditor extends LitElement implements LovelaceCardEditor }, )} ${this._renderSwitch( - CONF_VIEW_SCAN_SHOW_TRIGGER_STATUS, - this._defaults.view.scan.show_trigger_status, + CONF_VIEW_SCAN_TRIGGER_SHOW_STATUS, + this._defaults.view.scan.trigger_show_status, { - label: localize(`config.${CONF_VIEW_SCAN_SHOW_TRIGGER_STATUS}`), + label: localize(`config.${CONF_VIEW_SCAN_TRIGGER_SHOW_STATUS}`), }, )} - ${this._renderSwitch( - CONF_VIEW_SCAN_UNTRIGGER_RESET, - this._defaults.view.scan.untrigger_reset, - )} ${this._renderNumberInput(CONF_VIEW_SCAN_UNTRIGGER_SECONDS, { default: this._defaults.view.scan.untrigger_seconds, })} diff --git a/src/localize/languages/en.json b/src/localize/languages/en.json index 64b2c503..6d93d174 100644 --- a/src/localize/languages/en.json +++ b/src/localize/languages/en.json @@ -387,7 +387,7 @@ "scan": { "enabled": "Scan mode enabled", "scan_mode": "Scan mode", - "show_trigger_status": "Show pulsing border when triggered", + "trigger_show_status": "Show pulsing border when triggered", "untrigger_reset": "Reset the view to default after untrigger", "untrigger_seconds": "Seconds after inactive state change to untrigger" }, diff --git a/src/localize/languages/it.json b/src/localize/languages/it.json index e4af3d0f..363c00c4 100644 --- a/src/localize/languages/it.json +++ b/src/localize/languages/it.json @@ -383,7 +383,7 @@ "scan": { "enabled": "Modalità di scansione abilitata", "scan_mode": "Modalità di scansione", - "show_trigger_status": "Mostra bordo pulsante quando attivato", + "trigger_show_status": "Mostra bordo pulsante quando attivato", "untrigger_reset": "Reset the view to default after untrigger", "untrigger_seconds": "Reimposta la vista ai valori predefiniti dopo aver annullato l'attivazione" }, diff --git a/src/localize/languages/pt-BR.json b/src/localize/languages/pt-BR.json index 4028931f..58c5c7ad 100644 --- a/src/localize/languages/pt-BR.json +++ b/src/localize/languages/pt-BR.json @@ -386,7 +386,7 @@ "scan": { "enabled": "Modo scan ativado", "scan_mode": "Modo scan", - "show_trigger_status": "Pulsar borda quando acionado", + "trigger_show_status": "Pulsar borda quando acionado", "untrigger_reset": "Redefinir a visualização para o padrão após desacionar", "untrigger_seconds": "Segundos após a mudar para o estado inativo para desacionar" }, diff --git a/src/localize/languages/pt-PT.json b/src/localize/languages/pt-PT.json index daebd3ed..702913ce 100644 --- a/src/localize/languages/pt-PT.json +++ b/src/localize/languages/pt-PT.json @@ -376,7 +376,7 @@ "scan": { "enabled": "Modo scan ativado", "scan_mode": "Modo scan", - "show_trigger_status": "Exibir estado do gatilho", + "trigger_show_status": "Exibir estado do gatilho", "untrigger_reset": "Redefinir a visualização para o padrão após desacionar", "untrigger_seconds": "Segundos após a mudar para o estado inativo para desacionar" }, diff --git a/tests/card-controller/actions-manager.test.ts b/tests/card-controller/actions-manager.test.ts index 52c794f1..f864302c 100644 --- a/tests/card-controller/actions-manager.test.ts +++ b/tests/card-controller/actions-manager.test.ts @@ -9,6 +9,7 @@ import { import { FrigateCardMediaPlayer } from '../../src/types'; import { convertActionToFrigateCardCustomAction, + frigateCardHandleAction, frigateCardHandleActionConfig, getActionConfigGivenAction, } from '../../src/utils/action.js'; @@ -245,7 +246,7 @@ describe('ActionsManager.executeAction', () => { const manager = new ActionsManager(api); - await manager.executeAction( + await manager.executeFrigateAction( createAction({ card_id: 'NOT_foo', frigate_card_action: 'default', @@ -259,7 +260,7 @@ describe('ActionsManager.executeAction', () => { const api = createCardAPI(); const manager = new ActionsManager(api); - await manager.executeAction( + await manager.executeFrigateAction( createAction({ frigate_card_action: 'default', })!, @@ -283,7 +284,7 @@ describe('ActionsManager.executeAction', () => { const api = createCardAPI(); const manager = new ActionsManager(api); - await manager.executeAction( + await manager.executeFrigateAction( createAction({ frigate_card_action: viewName, })!, @@ -301,7 +302,7 @@ describe('ActionsManager.executeAction', () => { const api = createCardAPI(); const manager = new ActionsManager(api); - await manager.executeAction( + await manager.executeFrigateAction( createAction({ frigate_card_action: 'download', })!, @@ -314,7 +315,7 @@ describe('ActionsManager.executeAction', () => { const api = createCardAPI(); const manager = new ActionsManager(api); - await manager.executeAction( + await manager.executeFrigateAction( createAction({ frigate_card_action: 'camera_ui', })!, @@ -327,7 +328,7 @@ describe('ActionsManager.executeAction', () => { const api = createCardAPI(); const manager = new ActionsManager(api); - await manager.executeAction( + await manager.executeFrigateAction( createAction({ frigate_card_action: 'expand', })!, @@ -340,7 +341,7 @@ describe('ActionsManager.executeAction', () => { const api = createCardAPI(); const manager = new ActionsManager(api); - await manager.executeAction( + await manager.executeFrigateAction( createAction({ frigate_card_action: 'fullscreen', })!, @@ -353,7 +354,7 @@ describe('ActionsManager.executeAction', () => { const api = createCardAPI(); const manager = new ActionsManager(api); - await manager.executeAction( + await manager.executeFrigateAction( createAction({ frigate_card_action: 'menu_toggle', })!, @@ -370,7 +371,7 @@ describe('ActionsManager.executeAction', () => { vi.mocked(api.getViewManager().getView).mockReturnValue(createView()); vi.mocked(api.getViewManager().isViewSupportedByCamera).mockReturnValue(true); - await manager.executeAction( + await manager.executeFrigateAction( createAction({ frigate_card_action: 'camera_select', camera: 'camera', @@ -397,7 +398,7 @@ describe('ActionsManager.executeAction', () => { ); vi.mocked(api.getViewManager().isViewSupportedByCamera).mockReturnValue(true); - await manager.executeAction( + await manager.executeFrigateAction( createAction({ frigate_card_action: 'camera_select', camera: 'camera', @@ -430,7 +431,7 @@ describe('ActionsManager.executeAction', () => { vi.mocked(api.getViewManager().isViewSupportedByCamera).mockReturnValue(true); const manager = new ActionsManager(api); - await manager.executeAction( + await manager.executeFrigateAction( createAction({ frigate_card_action: 'camera_select', camera: 'camera', @@ -445,11 +446,55 @@ describe('ActionsManager.executeAction', () => { ); }); + it('with triggered camera', async () => { + const api = createCardAPI(); + const manager = new ActionsManager(api); + + vi.mocked(api.getViewManager().getView).mockReturnValue(createView()); + vi.mocked(api.getViewManager().isViewSupportedByCamera).mockReturnValue(true); + vi.mocked( + api.getTriggersManager().getMostRecentlyTriggeredCameraID, + ).mockReturnValue('camera'); + + await manager.executeFrigateAction( + createAction({ + frigate_card_action: 'camera_select', + triggered: true, + })!, + ); + + expect(api.getViewManager().setViewByParameters).toBeCalledWith( + expect.objectContaining({ + viewName: 'live', + cameraID: 'camera', + }), + ); + }); + + it('without camera or triggered camera', async () => { + const api = createCardAPI(); + const manager = new ActionsManager(api); + + vi.mocked(api.getViewManager().getView).mockReturnValue(createView()); + vi.mocked(api.getViewManager().isViewSupportedByCamera).mockReturnValue(true); + vi.mocked( + api.getTriggersManager().getMostRecentlyTriggeredCameraID, + ).mockReturnValue('camera'); + + await manager.executeFrigateAction( + createAction({ + frigate_card_action: 'camera_select', + })!, + ); + + expect(api.getViewManager().setViewByParameters).not.toBeCalled(); + }); + it('without a current view', async () => { const api = createCardAPI(); const manager = new ActionsManager(api); - await manager.executeAction( + await manager.executeFrigateAction( createAction({ frigate_card_action: 'camera_select', camera: 'camera', @@ -469,7 +514,7 @@ describe('ActionsManager.executeAction', () => { }), ); - await manager.executeAction( + await manager.executeFrigateAction( createAction({ frigate_card_action: 'camera_select', camera: 'camera', @@ -490,7 +535,7 @@ describe('ActionsManager.executeAction', () => { const api = createCardAPI(); const manager = new ActionsManager(api); - await manager.executeAction( + await manager.executeFrigateAction( createAction({ frigate_card_action: 'live_substream_select', camera: 'substream', @@ -504,7 +549,7 @@ describe('ActionsManager.executeAction', () => { const api = createCardAPI(); const manager = new ActionsManager(api); - await manager.executeAction( + await manager.executeFrigateAction( createAction({ frigate_card_action: 'live_substream_off', })!, @@ -517,7 +562,7 @@ describe('ActionsManager.executeAction', () => { const api = createCardAPI(); const manager = new ActionsManager(api); - await manager.executeAction( + await manager.executeFrigateAction( createAction({ frigate_card_action: 'live_substream_on', })!, @@ -531,7 +576,7 @@ describe('ActionsManager.executeAction', () => { const api = createCardAPI(); const manager = new ActionsManager(api); - await manager.executeAction( + await manager.executeFrigateAction( createAction({ frigate_card_action: 'media_player', media_player_action: 'stop', @@ -552,7 +597,7 @@ describe('ActionsManager.executeAction', () => { ); const manager = new ActionsManager(api); - await manager.executeAction( + await manager.executeFrigateAction( createAction({ frigate_card_action: 'media_player', media_player_action: 'play', @@ -576,7 +621,7 @@ describe('ActionsManager.executeAction', () => { vi.mocked(api.getViewManager().getView).mockReturnValue(view); const manager = new ActionsManager(api); - await manager.executeAction( + await manager.executeFrigateAction( createAction({ frigate_card_action: 'media_player', media_player_action: 'play', @@ -599,7 +644,7 @@ describe('ActionsManager.executeAction', () => { ); const manager = new ActionsManager(api); - await manager.executeAction( + await manager.executeFrigateAction( createAction({ frigate_card_action: 'media_player', media_player_action: 'play', @@ -615,7 +660,7 @@ describe('ActionsManager.executeAction', () => { const api = createCardAPI(); const manager = new ActionsManager(api); - await manager.executeAction( + await manager.executeFrigateAction( createAction({ frigate_card_action: 'diagnostics', })!, @@ -632,7 +677,7 @@ describe('ActionsManager.executeAction', () => { const api = createCardAPI(); const manager = new ActionsManager(api); - await manager.executeAction( + await manager.executeFrigateAction( createAction({ frigate_card_action: 'microphone_mute', })!, @@ -645,7 +690,7 @@ describe('ActionsManager.executeAction', () => { const api = createCardAPI(); const manager = new ActionsManager(api); - await manager.executeAction( + await manager.executeFrigateAction( createAction({ frigate_card_action: 'microphone_unmute', })!, @@ -670,7 +715,7 @@ describe('ActionsManager.executeAction', () => { ); const manager = new ActionsManager(api); - await manager.executeAction( + await manager.executeFrigateAction( createAction({ frigate_card_action: action, })!, @@ -684,7 +729,7 @@ describe('ActionsManager.executeAction', () => { const api = createCardAPI(); const manager = new ActionsManager(api); - await manager.executeAction( + await manager.executeFrigateAction( createAction({ frigate_card_action: 'screenshot', })!, @@ -697,7 +742,7 @@ describe('ActionsManager.executeAction', () => { const api = createCardAPI(); const manager = new ActionsManager(api); - await manager.executeAction( + await manager.executeFrigateAction( createAction({ frigate_card_action: 'display_mode_select', display_mode: 'grid', @@ -715,7 +760,7 @@ describe('ActionsManager.executeAction', () => { createView({ camera: 'camera.office' }), ); - await manager.executeAction( + await manager.executeFrigateAction( createAction({ frigate_card_action: 'ptz', ptz_action: 'left', @@ -736,7 +781,7 @@ describe('ActionsManager.executeAction', () => { const api = createCardAPI(); const manager = new ActionsManager(api); - await manager.executeAction( + await manager.executeFrigateAction( createAction({ frigate_card_action: 'ptz', ptz_action: 'left', @@ -751,7 +796,7 @@ describe('ActionsManager.executeAction', () => { const api = createCardAPI(); const manager = new ActionsManager(api); - await manager.executeAction( + await manager.executeFrigateAction( createAction({ frigate_card_action: 'show_ptz', show_ptz: true, @@ -768,7 +813,7 @@ describe('ActionsManager.executeAction', () => { const spy = vi.spyOn(global.console, 'warn').mockImplementation(() => true); - await manager.executeAction( + await manager.executeFrigateAction( // Have to manually create the action (vs using `createAction()`) since // it's malformed. { @@ -781,3 +826,36 @@ describe('ActionsManager.executeAction', () => { ); }); }); + +describe('ActionsManager.executeActions', () => { + it('should execute actions', async () => { + const api = createCardAPI(); + const hass = createHASS(); + const element = document.createElement('div'); + + vi.mocked(api.getCardElementManager().getElement).mockReturnValue(element); + vi.mocked(api.getHASSManager().getHASS).mockReturnValue(hass); + + const manager = new ActionsManager(api); + const action = createAction({ + frigate_card_action: 'default', + })!; + manager.executeActions(action); + + expect(frigateCardHandleAction).toBeCalledWith(element, hass, {}, action); + }); + + it('should not execute actions without hass', async () => { + const api = createCardAPI(); + vi.mocked(api.getHASSManager().getHASS).mockReturnValue(null); + + const manager = new ActionsManager(api); + manager.executeActions( + createAction({ + frigate_card_action: 'default', + })!, + ); + + expect(api.getViewManager().setViewDefault).not.toBeCalled(); + }); +}); diff --git a/tests/card-controller/hass-manager.test.ts b/tests/card-controller/hass-manager.test.ts index 0fbe2b97..51f64728 100644 --- a/tests/card-controller/hass-manager.test.ts +++ b/tests/card-controller/hass-manager.test.ts @@ -72,10 +72,10 @@ describe('HASSManager', () => { const originalHASS = createHASS(); manager.setHASS(originalHASS); - expect(api.getTriggersManager().updateTriggeredCameras).toBeCalledWith(null); + expect(api.getTriggersManager().updateTriggerHAState).toBeCalledWith(null); manager.setHASS(createHASS()); - expect(api.getTriggersManager().updateTriggeredCameras).toBeCalledWith(originalHASS); + expect(api.getTriggersManager().updateTriggerHAState).toBeCalledWith(originalHASS); }); describe('should handle connection state change when', () => { diff --git a/tests/card-controller/interaction-manager.test.ts b/tests/card-controller/interaction-manager.test.ts index 1f024dbf..be6534d5 100644 --- a/tests/card-controller/interaction-manager.test.ts +++ b/tests/card-controller/interaction-manager.test.ts @@ -30,7 +30,6 @@ describe('InteractionManager', () => { manager.reportInteraction(); - expect(api.getTriggersManager().untrigger).toBeCalled(); expect(manager.hasInteraction()).toBeTruthy(); expect(api.getViewManager().setViewDefault).not.toBeCalled(); diff --git a/tests/card-controller/media-player-manager.test.ts b/tests/card-controller/media-player-manager.test.ts index 836f544e..9e4cb610 100644 --- a/tests/card-controller/media-player-manager.test.ts +++ b/tests/card-controller/media-player-manager.test.ts @@ -271,7 +271,6 @@ describe('MediaPlayerManager', () => { }, ]), ); - vi.mocked(api.getQueryStringManager().generateQueryString).mockReturnValue(''); vi.mocked(api.getHASSManager().getHASS).mockReturnValue(createHASS()); const manager = new MediaPlayerManager(api); diff --git a/tests/card-controller/query-string-manager.test.ts b/tests/card-controller/query-string-manager.test.ts index d453b518..eb9ee80c 100644 --- a/tests/card-controller/query-string-manager.test.ts +++ b/tests/card-controller/query-string-manager.test.ts @@ -28,7 +28,7 @@ describe('QueryStringManager', () => { manager.executeAll(); expect(manager.hasViewRelatedActions()).toBeFalsy(); - expect(api.getActionsManager().executeAction).not.toBeCalled(); + expect(api.getActionsManager().executeFrigateAction).not.toBeCalled(); expect(api.getViewManager().setViewByParameters).not.toBeCalled(); }); @@ -76,7 +76,7 @@ describe('QueryStringManager', () => { manager.executeAll(); expect(manager.hasViewRelatedActions()).toBeFalsy(); - expect(api.getActionsManager().executeAction).toBeCalledWith({ + expect(api.getActionsManager().executeFrigateAction).toBeCalledWith({ action: 'fire-dom-event', card_id: 'id', frigate_card_action: action, @@ -97,7 +97,7 @@ describe('QueryStringManager', () => { expect(api.getViewManager().setViewDefault).toBeCalled(); expect(manager.hasViewRelatedActions()).toBeTruthy(); - expect(api.getActionsManager().executeAction).not.toBeCalled(); + expect(api.getActionsManager().executeFrigateAction).not.toBeCalled(); expect(api.getViewManager().setViewByParameters).not.toBeCalled(); }); @@ -114,7 +114,7 @@ describe('QueryStringManager', () => { }); expect(manager.hasViewRelatedActions()).toBeTruthy(); - expect(api.getActionsManager().executeAction).not.toBeCalled(); + expect(api.getActionsManager().executeFrigateAction).not.toBeCalled(); expect(api.getViewManager().setViewDefault).not.toBeCalled(); }); @@ -131,7 +131,7 @@ describe('QueryStringManager', () => { }); expect(manager.hasViewRelatedActions()).toBeTruthy(); - expect(api.getActionsManager().executeAction).not.toBeCalled(); + expect(api.getActionsManager().executeFrigateAction).not.toBeCalled(); expect(api.getViewManager().setViewDefault).not.toBeCalled(); }); @@ -147,7 +147,7 @@ describe('QueryStringManager', () => { manager.executeAll(); expect(manager.hasViewRelatedActions()).toBeFalsy(); - expect(api.getActionsManager().executeAction).not.toBeCalled(); + expect(api.getActionsManager().executeFrigateAction).not.toBeCalled(); expect(api.getViewManager().setViewDefault).not.toBeCalled(); expect(api.getViewManager().setViewByParameters).not.toBeCalled(); }, @@ -165,7 +165,7 @@ describe('QueryStringManager', () => { manager.executeAll(); expect(manager.hasViewRelatedActions()).toBeFalsy(); - expect(api.getActionsManager().executeAction).not.toBeCalled(); + expect(api.getActionsManager().executeFrigateAction).not.toBeCalled(); expect(api.getViewManager().setViewDefault).not.toBeCalled(); expect(api.getViewManager().setViewByParameters).not.toBeCalled(); expect(consoleSpy).toBeCalled(); @@ -212,7 +212,7 @@ describe('QueryStringManager', () => { manager.executeAll(); - expect(api.getActionsManager().executeAction).not.toBeCalled(); + expect(api.getActionsManager().executeFrigateAction).not.toBeCalled(); expect(api.getViewManager().setViewDefault).not.toBeCalled(); expect(api.getViewManager().setViewByParameters).not.toBeCalled(); }); @@ -296,77 +296,7 @@ describe('QueryStringManager', () => { manager.executeViewRelated(); - expect(api.getActionsManager().executeAction).not.toBeCalled(); - }); - }); - - describe('should generate query string', () => { - describe('that require no arguments', () => { - it.each([ - ['camera_ui' as const], - ['clip' as const], - ['clips' as const], - ['default' as const], - ['diagnostics' as const], - ['download' as const], - ['expand' as const], - ['image' as const], - ['live' as const], - ['menu_toggle' as const], - ['recording' as const], - ['recordings' as const], - ['snapshot' as const], - ['snapshots' as const], - ['timeline' as const], - ])('%s', (actionName: FrigateCardGeneralAction | FrigateCardUserSpecifiedView) => { - const manager = new QueryStringManager(createCardAPI()); - expect( - manager.generateQueryString({ - action: 'fire-dom-event', - frigate_card_action: actionName, - }), - ).toBe(`frigate-card-action.${actionName}=`); - }); - }); - - describe('that require camera argument', () => { - it.each([['camera_select' as const], ['live_substream_select' as const]])( - '%s', - (actionName: 'camera_select' | 'live_substream_select') => { - const manager = new QueryStringManager(createCardAPI()); - expect( - manager.generateQueryString({ - action: 'fire-dom-event', - frigate_card_action: actionName, - camera: 'camera', - }), - ).toBe(`frigate-card-action.${actionName}=camera`); - }, - ); - }); - - it('that include a card_id', () => { - const manager = new QueryStringManager(createCardAPI()); - expect( - manager.generateQueryString({ - action: 'fire-dom-event', - frigate_card_action: 'clips', - card_id: 'card-id', - }), - ).toBe(`frigate-card-action.card-id.clips=`); - }); - - it('that include an unsupported action', () => { - const spy = vi.spyOn(global.console, 'warn').mockReturnValue(undefined); - const manager = new QueryStringManager(createCardAPI()); - - expect( - manager.generateQueryString({ - action: 'fire-dom-event', - frigate_card_action: 'microphone_unmute', - }), - ).toBeNull(); - expect(spy).toBeCalled(); + expect(api.getActionsManager().executeFrigateAction).not.toBeCalled(); }); }); }); diff --git a/tests/card-controller/triggers-manager.test.ts b/tests/card-controller/triggers-manager.test.ts index d14d2f7a..7fc1688a 100644 --- a/tests/card-controller/triggers-manager.test.ts +++ b/tests/card-controller/triggers-manager.test.ts @@ -15,6 +15,28 @@ import { createView, } from '../test-utils'; +const baseScanConfig: Partial = { + enabled: true, + untrigger_seconds: 10, + interaction_mode: 'inactive' as const, + trigger_filter_camera: 'all' as const, + trigger_action: [ + { + action: 'fire-dom-event' as const, + frigate_card_action: 'camera_select' as const, + triggered: true, + }, + { + action: 'fire-dom-event' as const, + frigate_card_action: 'live' as const, + }, + ], + untrigger_action: { + action: 'fire-dom-event' as const, + frigate_card_action: 'default' as const, + }, +}; + // Creating and mocking a trigger API is a lot of boilerplate, this convenience // function reduces it. const createTriggerAPI = (options?: { @@ -26,11 +48,7 @@ const createTriggerAPI = (options?: { vi.mocked(api.getConfigManager().getConfig).mockReturnValue( createConfig({ view: { - scan: options?.config ?? { - enabled: true, - untrigger_reset: true, - untrigger_seconds: 10, - }, + scan: options?.config ?? baseScanConfig, }, }), ); @@ -83,11 +101,28 @@ describe('TriggersManager', () => { }); const manager = new TriggersManager(api); - manager.updateTriggeredCameras(null); + manager.updateTriggerHAState(null); expect(manager.isTriggered()).toBeFalsy(); }); + it('should not trigger without a scan config', () => { + const api = createTriggerAPI({ + hassStates: hassActiveState, + }); + + vi.mocked(api.getViewManager().getView).mockReturnValue( + createView({ + camera: 'camera_1' as const, + }), + ); + vi.mocked(api.getConfigManager().getConfig).mockReturnValue(null); + + const manager = new TriggersManager(api); + manager.updateView(null); + expect(manager.isTriggered()).toBeFalsy(); + }); + it('should trigger and untrigger based on entity state', () => { const start = new Date('2023-10-01T17:14'); vi.setSystemTime(start); @@ -96,22 +131,29 @@ describe('TriggersManager', () => { }); const manager = new TriggersManager(api); - manager.updateTriggeredCameras(createHASS(hassInactiveState)); + manager.updateTriggerHAState(createHASS(hassInactiveState)); expect(manager.isTriggered()).toBeTruthy(); - expect(api.getViewManager().setViewByParameters).toBeCalledWith({ - viewName: 'live', - cameraID: 'camera_1', - }); + expect(api.getActionsManager().executeActions).toBeCalledWith([ + { + action: 'fire-dom-event' as const, + frigate_card_action: 'camera_select' as const, + triggered: true, + }, + { + action: 'fire-dom-event' as const, + frigate_card_action: 'live' as const, + }, + ]); vi.mocked(api.getHASSManager().getHASS).mockReturnValue( createHASS(hassInactiveState), ); - manager.updateTriggeredCameras(createHASS(hassActiveState)); + manager.updateTriggerHAState(createHASS(hassActiveState)); // Intentional state update with no change. - manager.updateTriggeredCameras(createHASS(hassActiveState)); + manager.updateTriggerHAState(createHASS(hassActiveState)); // Will still be triggered, but untrigger timer will be running. expect(manager.isTriggered()).toBeTruthy(); @@ -120,26 +162,10 @@ describe('TriggersManager', () => { vi.runOnlyPendingTimers(); expect(manager.isTriggered()).toBeFalsy(); - expect(api.getViewManager().setViewDefault).toBeCalled(); - }); - it('should trigger and set view if current view is wrong', () => { - const api = createTriggerAPI({ - hassStates: hassActiveState, - }); - vi.mocked(api.getViewManager().getView).mockReturnValue( - createView({ - // Correct camera, but wrong view. - view: 'clips', - camera: 'camera_1', - }), - ); - const manager = new TriggersManager(api); - manager.updateTriggeredCameras(null); - - expect(api.getViewManager().setViewByParameters).toBeCalledWith({ - viewName: 'live', - cameraID: 'camera_1', + expect(api.getActionsManager().executeActions).toBeCalledWith({ + action: 'fire-dom-event' as const, + frigate_card_action: 'default' as const, }); }); @@ -150,40 +176,196 @@ describe('TriggersManager', () => { const manager = new TriggersManager(api); expect(manager.isTriggered()).toBeFalsy(); - manager.updateTriggeredCameras(null); + manager.updateTriggerHAState(null); expect(manager.isTriggered()).toBeTruthy(); }); - it('should untrigger manually', () => { - const api = createTriggerAPI({ - hassStates: hassActiveState, - }); - const manager = new TriggersManager(api); - - // Untriggering when not triggered. - manager.untrigger(); - expect(api.getViewManager().setViewDefault).not.toBeCalled(); - - manager.updateTriggeredCameras(null); - expect(manager.isTriggered()).toBeTruthy(); - - manager.untrigger(); - expect(manager.isTriggered()).toBeFalsy(); - expect(api.getViewManager().setViewDefault).toBeCalled(); - }); - - it('should take no actions when automated actions are not allowed', () => { + it('should take no actions with human interactions', () => { + const start = new Date('2023-10-01T17:14'); const api = createTriggerAPI({ hassStates: hassActiveState, // Interaction present. interaction: true, }); const manager = new TriggersManager(api); - manager.updateTriggeredCameras(null); + manager.updateTriggerHAState(createHASS(hassInactiveState)); expect(manager.isTriggered()).toBeTruthy(); - expect(api.getViewManager().setViewByParameters).not.toBeCalled(); - manager.untrigger(); - expect(api.getViewManager().setViewDefault).not.toBeCalled(); + expect(api.getActionsManager().executeActions).not.toBeCalled(); + + vi.mocked(api.getHASSManager().getHASS).mockReturnValue( + createHASS(hassInactiveState), + ); + manager.updateTriggerHAState(createHASS(hassActiveState)); + + vi.setSystemTime(add(start, { seconds: 10 })); + vi.runOnlyPendingTimers(); + + expect(manager.isTriggered()).toBeFalsy(); + + expect(api.getActionsManager().executeActions).not.toBeCalled(); + }); + + it('should take actions with human interactions when interaction mode is active', () => { + const start = new Date('2023-10-01T17:14'); + const api = createTriggerAPI({ + hassStates: hassActiveState, + // Interaction present. + interaction: true, + config: { + ...baseScanConfig, + interaction_mode: 'active', + }, + }); + const manager = new TriggersManager(api); + manager.updateTriggerHAState(createHASS(hassInactiveState)); + expect(manager.isTriggered()).toBeTruthy(); + + expect(api.getActionsManager().executeActions).toBeCalledTimes(1); + + vi.mocked(api.getHASSManager().getHASS).mockReturnValue( + createHASS(hassInactiveState), + ); + manager.updateTriggerHAState(createHASS(hassActiveState)); + + vi.setSystemTime(add(start, { seconds: 10 })); + vi.runOnlyPendingTimers(); + + expect(manager.isTriggered()).toBeFalsy(); + + expect(api.getActionsManager().executeActions).toBeCalledTimes(2); + }); + + it('should report multiple triggered cameras', () => { + const start = new Date('2023-10-01T17:14'); + const bothOnState = { + 'binary_sensor.one': createStateEntity({ state: 'on' }), + 'binary_sensor.two': createStateEntity({ state: 'on' }), + }; + const api = createTriggerAPI({ + hassStates: bothOnState, + }); + vi.mocked(api.getCameraManager().getStore).mockReturnValue( + createStore([ + { + cameraID: 'camera_1', + config: createCameraConfig({ + triggers: { + entities: ['binary_sensor.one'], + }, + }), + }, + { + cameraID: 'camera_2', + config: createCameraConfig({ + triggers: { + entities: ['binary_sensor.two'], + }, + }), + }, + ]), + ); + + const manager = new TriggersManager(api); + + expect(manager.isTriggered()).toBeFalsy(); + expect(manager.getMostRecentlyTriggeredCameraID()).toBeNull(); + expect(manager.getTriggeredCameraIDs()).toEqual(new Set()); + + manager.updateTriggerHAState(null); + expect(manager.isTriggered()).toBeTruthy(); + expect(manager.getTriggeredCameraIDs()).toEqual(new Set(['camera_1', 'camera_2'])); + + // Either is the most recently triggered. + expect(['camera_1', 'camera_2']).toContain( + manager.getMostRecentlyTriggeredCameraID(), + ); + + vi.mocked(api.getHASSManager().getHASS).mockReturnValue( + createHASS({ + 'binary_sensor.one': createStateEntity({ state: 'off' }), + 'binary_sensor.two': createStateEntity({ state: 'on' }), + }), + ); + + manager.updateTriggerHAState(createHASS(bothOnState)); + + vi.setSystemTime(add(start, { seconds: 10 })); + vi.runOnlyPendingTimers(); + + expect(manager.getTriggeredCameraIDs()).toEqual(new Set(['camera_2'])); + expect(manager.getMostRecentlyTriggeredCameraID()).toBe('camera_2'); + }); + + it('should filter triggers by camera', () => { + const start = new Date('2023-10-01T17:14'); + const api = createTriggerAPI({ + config: { + ...baseScanConfig, + // Filter triggers to selected camera only. + trigger_filter_camera: 'selected' as const, + }, + hassStates: hassActiveState, + }); + const manager = new TriggersManager(api); + manager.updateTriggerHAState(createHASS(hassInactiveState)); + expect(manager.isTriggered()).toBeFalsy(); + + const otherCameraSelected = createView({ + camera: 'camera_SOME_OTHER_CAMERA' as const, + }); + + vi.mocked(api.getViewManager().getView).mockReturnValue(otherCameraSelected); + manager.updateView(null); + expect(manager.isTriggered()).toBeFalsy(); + + const thisCameraSelected = createView({ + camera: 'camera_1' as const, + }); + + vi.mocked(api.getViewManager().getView).mockReturnValue(thisCameraSelected); + manager.updateView(otherCameraSelected); + expect(manager.isTriggered()).toBeTruthy(); + + // Ensure a view change to the same camera stays triggered. + vi.mocked(api.getViewManager().getView).mockReturnValue(thisCameraSelected); + manager.updateView(thisCameraSelected); + expect(manager.isTriggered()).toBeTruthy(); + }); + + it('should not untrigger triggers by camera', () => { + const start = new Date('2023-10-01T17:14'); + const api = createTriggerAPI({ + config: { + ...baseScanConfig, + // Filter triggers to selected camera only. + trigger_filter_camera: 'selected' as const, + }, + hassStates: hassActiveState, + }); + const manager = new TriggersManager(api); + manager.updateTriggerHAState(createHASS(hassInactiveState)); + expect(manager.isTriggered()).toBeFalsy(); + + const otherCameraSelected = createView({ + camera: 'camera_SOME_OTHER_CAMERA' as const, + }); + + vi.mocked(api.getViewManager().getView).mockReturnValue(otherCameraSelected); + manager.updateView(null); + expect(manager.isTriggered()).toBeFalsy(); + + const thisCameraSelected = createView({ + camera: 'camera_1' as const, + }); + + vi.mocked(api.getViewManager().getView).mockReturnValue(thisCameraSelected); + manager.updateView(otherCameraSelected); + expect(manager.isTriggered()).toBeTruthy(); + + // Ensure a view change to the same camera stays triggered. + vi.mocked(api.getViewManager().getView).mockReturnValue(thisCameraSelected); + manager.updateView(thisCameraSelected); + expect(manager.isTriggered()).toBeTruthy(); }); }); diff --git a/tests/config/types.test.ts b/tests/config/types.test.ts index 0dbd69c3..7758c2bf 100644 --- a/tests/config/types.test.ts +++ b/tests/config/types.test.ts @@ -279,9 +279,25 @@ describe('config defaults', () => { default: 'live', scan: { enabled: false, - show_trigger_status: true, - untrigger_reset: true, + interaction_mode: 'inactive', + trigger_show_status: true, untrigger_seconds: 0, + trigger_action: [ + { + action: 'fire-dom-event', + frigate_card_action: 'camera_select', + triggered: true, + }, + { + action: 'fire-dom-event', + frigate_card_action: 'live', + }, + ], + trigger_filter_camera: 'all', + untrigger_action: { + action: 'fire-dom-event', + frigate_card_action: 'default', + }, }, timeout_seconds: 300, update_cycle_camera: false, diff --git a/vite.config.ts b/vite.config.ts index aeacea4f..ff62e3a5 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -10,10 +10,10 @@ export default defineConfig({ // Thresholds will automatically be updated as coverage improves to avoid // back-sliding. thresholdAutoUpdate: true, - statements: 71.65, + statements: 71.74, branches: 60.62, - functions: 72.78, - lines: 71.54, + functions: 72.84, + lines: 71.63, }, }, });