From 5e72070c96491159d459657cbdef57fe92fe0ea4 Mon Sep 17 00:00:00 2001 From: Dermot Duffy Date: Thu, 25 Jan 2024 22:06:21 -0800 Subject: [PATCH] Add interaction and triggered conditions. --- README.md | 90 +++++++++++++++++-- src/card-controller/card-element-manager.ts | 7 ++ src/card-controller/conditions-manager.ts | 15 +++- src/card-controller/expand-manager.ts | 14 ++- src/card-controller/fullscreen-manager.ts | 14 ++- src/card-controller/interaction-manager.ts | 23 +++-- src/card-controller/media-info-manager.ts | 4 + src/card-controller/triggers-manager.ts | 10 +++ src/card-controller/types.ts | 3 + src/config-mgmt.ts | 2 + src/config/types.ts | 11 ++- src/const.ts | 3 +- src/editor.ts | 13 ++- src/localize/languages/en.json | 3 +- src/localize/languages/it.json | 33 +++---- src/localize/languages/pt-BR.json | 31 +++---- src/localize/languages/pt-PT.json | 31 +++---- .../card-element-manager.test.ts | 5 ++ .../conditions-manager.test.ts | 22 +++++ tests/card-controller/expand-manager.test.ts | 8 ++ .../fullscreen-manager.test.ts | 11 +++ .../interaction-manager.test.ts | 72 ++++++++++++++- .../media-info-manager.test.ts | 8 ++ .../card-controller/triggers-manager.test.ts | 7 ++ tests/config-mgmt.test.ts | 18 ++++ tests/config/types.test.ts | 3 +- vite.config.ts | 8 +- 27 files changed, 379 insertions(+), 90 deletions(-) diff --git a/README.md b/README.md index f5d2a47d..3cec8618 100644 --- a/README.md +++ b/README.md @@ -400,8 +400,9 @@ See the [fully expanded view configuration example](#config-expanded-view) for h | - | - | - | - | | `default` | `live` | :white_check_mark: | The view to show in the card by default. The default camera is the first one listed. See [views](#views) below.| | `camera_select` | `current` | :white_check_mark: | The view to show when a new camera is selected (e.g. in the camera menu). If `current` the view is unchanged when a new camera is selected. Other acceptable values may be seen at [views](#views) below.| -| `dark_mode` | `off` | :white_check_mark: | Whether or not to turn dark mode `on`, `off` or `auto` to automatically turn on if the card `timeout_seconds` has expired (i.e. card has been left unattended for that period of time) or if dark mode is enabled in the HA profile theme setting. Dark mode dims the brightness by `25%`.| -| `timeout_seconds` | `300` | :white_check_mark: | A numbers of seconds of inactivity after user interaction, after which the card will reset to the default configured view (i.e. 'screensaver' functionality). Inactivity is defined as lack of mouse/touch interaction with the Frigate card. If the default view occurs sooner (e.g. via `update_seconds` or manually) the timer will be stopped. `0` means disable this functionality. | +| `dark_mode` | `off` | :white_check_mark: | Whether or not to turn dark mode `on`, `off` or `auto` to automatically turn on if the card `interaction_seconds` has expired (i.e. card has been left unattended for that period of time) or if dark mode is enabled in the HA profile theme setting. Dark mode dims the brightness by `25%`.| +| `interaction_seconds` | `300` | :white_check_mark: | After a mouse/touch interaction with the Frigate card, it will be considered "interacted with" until this number of seconds elapses without further interaction. May be used in conditions with the `interaction` parameter of a [Frigate card condition](#frigate-card-condition) or with `reset_after_interaction` (below). `0` means no interactions are reported / acted upon. | +| `reset_after_interaction` | `true` | :white_check_mark: | If `true` the card will reset to the default configured view (i.e. 'screensaver' functionality) after `interaction_seconds` has elapsed after user interaction. | | `update_seconds` | `0` | :white_check_mark: | A number of seconds after which to automatically update/refresh the default view. See [card updates](#card-updates) below for behavior and usecases. If the default view occurs sooner (e.g. manually) the timer will start over. `0` disables this functionality.| | `update_force` | `false` | :white_check_mark: | Whether automated card updates/refreshes should ignore user interaction. See [card updates](#card-updates) below for behavior and usecases.| | `update_entities` | | :white_check_mark: | **YAML only**: A card-wide list of entities that should cause the view to reset to the default (if the entity only pertains to a particular camera use `triggers` for the selected camera instead, see [Trigger Configuration](#camera-triggers-configuration)). See [card updates](#card-updates) below for behavior and usecases.| @@ -441,7 +442,7 @@ Scan mode tracks Home Assistant state *changes* -- when the card is first starte | `trigger` | `live` | :white_check_mark | When `live` the trigger will select the triggered camera in `live` view, when `none` will take no action. | | `untrigger` | `default` | :white_check_mark | When `default` the untrigger will return to the default view and camera, when `none` will take no action. | -| `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 `show_trigger_status` is true) but rather just prevents the actions being performed.| +| `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.interaction_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 `show_trigger_status` is true) but rather just prevents the actions being performed.| ### Menu Options @@ -1283,12 +1284,14 @@ All variables listed are under a `conditions:` section. | Condition | Description | | ------------- | --------------------------------------------- | | `view` | A list of [views](#views) in which this condition is satified (e.g. `clips`) | -| `camera` | A list of camera ids in which this condition is satisfied. See [camera IDs](#camera-ids).| +| `camera` | A list of camera IDs in which this condition is satisfied. See [camera IDs](#camera-ids).| | `fullscreen` | If `true` the condition is satisfied if the card is in fullscreen mode. If `false` the condition is satisfied if the card is **NOT** in fullscreen mode.| | `expand` | If `true` the condition is satisfied if the card is in expanded mode (in a dialog/popup). If `false` the condition is satisfied if the card is **NOT** in expanded mode (in a dialog/popup).| | `state` | A list of state conditions to compare with Home Assistant state. See below. | | `media_loaded` | If `true` the condition is satisfied if there is media load**ED** (not load**ING**) in the card (e.g. a clip, snapshot or live view). This may be used to hide controls during media loading or when a message (not media) is being displayed. Note that if `true` this condition will never be satisfied for views that do not themselves load media directly (e.g. gallery).| | `media_query` | Any valid [media query](https://developer.mozilla.org/en-US/docs/Web/CSS/Media_Queries/Using_media_queries) string. Media queries must start and end with parentheses. This may be used to alter card configuration based on device/media properties (e.g. viewport width, orientation). Please note that `width` and `height` refer to the entire viewport not just the card. See the [media query example](#media-query-example).| +| `interacted` | If `true` the condition is satisfied if the card has had human interaction within `view.interaction_seconds` elapsed seconds. If `false` the condition is satisfied if the card has **NOT** had human interaction in that time. | +| `triggered` | A list of camera IDs which, if triggered in [scan mode](#scan-mode), satisfy the condition.| See the [example below](#frigate-card-conditional-example) for a real-world example of how these conditions can be used. @@ -1456,7 +1459,7 @@ This card supports several different views: |`clip`|Shows a viewer for the most recent clip for this camera. Can also be accessed by holding down the `clips` menu icon.| |`recordings`|Shows a gallery of recent (last day) recordings for this camera and its dependents.| |`recording`|Shows a viewer for the most recent recording for this camera. Can also be accessed by holding down the `recordings` menu icon.| -|`image`|Shows a static image specified by the `image` parameter, can be used as a discrete default view or a screensaver (via `view.timeout_seconds`).| +|`image`|Shows a static image specified by the `image` parameter, can be used as a discrete default view or a screensaver (via `view.interaction_seconds`).| |`timeline`|Shows an event timeline.| ### Navigating From A Snapshot To A Clip @@ -1908,7 +1911,7 @@ Reference: [View Options](#view-options). view: default: live camera_select: current - timeout_seconds: 300 + interaction_seconds: 300 update_seconds: 0 update_force: false update_cycle_camera: false @@ -2860,7 +2863,7 @@ overrides: view: default: live camera_select: current - timeout_seconds: 300 + interaction_seconds: 300 update_seconds: 0 update_force: false update_cycle_camera: false @@ -3663,6 +3666,37 @@ view: ``` +
+ Expand: Showing an alarm menu button when a camera is triggered + +This example adds a menu button to optionally activate a siren when the camera is triggered. + +```yaml +type: custom:frigate-card +cameras: + - camera_entity: camera.office +elements: + - type: custom:frigate-card-conditional + elements: + - type: custom:frigate-card-menu-icon + icon: mdi:alarm-bell + title: Activate alarm + style: + color: red + tap_action: + action: call-service + service: homeassistant.toggle + data: + entity_id: siren.siren + conditions: + triggered: + - camera.office +view: + scan: + enabled: true +``` +
+ ### Changing the Media Layout @@ -4051,6 +4085,44 @@ views: +### Interaction conditions + +The card can automatically execute actions when the card is interacted with (mouse or touch). + +
+ Expand: Automatically show a high-bandwidth stream on interaction + +This example will automatically use a HD live substream when the mouse cursor interacts with the card. + +```yaml +type: custom:frigate-card +cameras: + - live_provider: go2rtc + camera_entity: camera.office + triggers: + entities: + - switch.office_detect + dependencies: + cameras: + - camera.office_hd + - camera_entity: camera.office_hd + live_provider: go2rtc + hide: true +automations: + - actions: + - action: custom:frigate-card-action + frigate_card_action: live_substream_on + actions_not: + - action: custom:frigate-card-action + frigate_card_action: live_substream_off + conditions: + interaction: true +view: + scan: + enabled: true +``` +
+ ## Card Refreshes @@ -4066,7 +4138,7 @@ Note that no (other) automated updates are permitted when [scan mode](#scan-mode In the below "Trigger Entities" refers to the combination of `view.update_entities` and the `triggers.entities` for the currently selected camera (which in turn will also include the occupancy and motion sensor entities for Frigate cameras if `triggers.occupancy` and `triggers.motion` options are enabled, see [Trigger Configuration](#camera-triggers-configuration)). -| `view . update_seconds` | `view . timeout_seconds` | `view . update_force` | Trigger Entities | Behavior | +| `view . update_seconds` | `view . interaction_seconds` | `view . update_force` | Trigger Entities | Behavior | | :-: | :-: | :-: | :-: | - | | `0` | `0` | *(Any value)* | Unset | Card will not automatically refresh. | | `0` | `0` | *(Any value)* | *(Any entity)* | Card will reload default view & camera when entity state changes. | @@ -4108,7 +4180,7 @@ view: ```yaml view: default: clip - timeout_seconds: 30 + interaction_seconds: 30 ``` diff --git a/src/card-controller/card-element-manager.ts b/src/card-controller/card-element-manager.ts index 1df6edef..e984cb48 100644 --- a/src/card-controller/card-element-manager.ts +++ b/src/card-controller/card-element-manager.ts @@ -54,6 +54,13 @@ export class CardElementManager { } public elementConnected(): void { + // Set initial condition state. Must be done after the element is connected to + // allow callbacks to interact with the card. + this._api.getInteractionManager().initialize(); + this._api.getFullscreenManager().initialize(); + this._api.getExpandManager().initialize(); + this._api.getMediaLoadedInfoManager().initialize(); + // Whether or not the card is in panel mode on the dashboard. setOrRemoveAttribute(this._element, isCardInPanel(this._element), 'panel'); diff --git a/src/card-controller/conditions-manager.ts b/src/card-controller/conditions-manager.ts index deba399f..7a726f71 100644 --- a/src/card-controller/conditions-manager.ts +++ b/src/card-controller/conditions-manager.ts @@ -6,7 +6,7 @@ import { frigateConditionalSchema, OverrideConfigurationKey, RawFrigateCardConfig, - ViewDisplayMode + ViewDisplayMode, } from '../config/types'; import { CardConditionAPI } from './types'; @@ -18,6 +18,8 @@ interface ConditionState { state?: HassEntities; media_loaded?: boolean; displayMode?: ViewDisplayMode; + triggered?: Set; + interaction?: boolean; } export class ConditionEvaluateRequestEvent extends Event { @@ -133,7 +135,7 @@ export class ConditionsManager { this._listeners = [ () => this._api.getConfigManager().computeOverrideConfig(), () => this._api.getAutomationsManager().execute(), - ...(listener ? [listener] : []) + ...(listener ? [listener] : []), ]; } @@ -243,6 +245,15 @@ export class ConditionsManager { if (condition.display_mode) { result &&= !!state.displayMode && condition.display_mode === state.displayMode; } + if (condition.triggered?.length) { + result &&= condition.triggered.some((triggeredCameraID) => + state.triggered?.has(triggeredCameraID), + ); + } + if (condition.interaction !== undefined) { + result &&= + state.interaction !== undefined && condition.interaction === state.interaction; + } return result; } diff --git a/src/card-controller/expand-manager.ts b/src/card-controller/expand-manager.ts index 9516a9f0..2a112136 100644 --- a/src/card-controller/expand-manager.ts +++ b/src/card-controller/expand-manager.ts @@ -8,6 +8,10 @@ export class ExpandManager { this._api = api; } + public initialize(): void { + this._setConditionState(); + } + public isExpanded(): boolean { return this._expanded; } @@ -23,9 +27,13 @@ export class ExpandManager { } this._expanded = expanded; - this._api.getConditionsManager()?.setState({ - expand: expanded, - }); + this._setConditionState(); this._api.getCardElementManager().update(); } + + protected _setConditionState(): void { + this._api.getConditionsManager()?.setState({ + expand: this._expanded, + }); + } } diff --git a/src/card-controller/fullscreen-manager.ts b/src/card-controller/fullscreen-manager.ts index 418bf72f..a60d5dfe 100644 --- a/src/card-controller/fullscreen-manager.ts +++ b/src/card-controller/fullscreen-manager.ts @@ -8,6 +8,10 @@ export class FullscreenManager { this._api = api; } + public initialize(): void { + this._setConditionState(); + } + public connect(): void { if (screenfull.isEnabled) { screenfull.on('change', this._fullscreenHandler); @@ -32,12 +36,16 @@ export class FullscreenManager { screenfull.exit(); } - protected _fullscreenHandler = (): void => { - this._api.getExpandManager().setExpanded(false); - + protected _setConditionState(): void { this._api.getConditionsManager()?.setState({ fullscreen: this.isInFullscreen(), }); + } + + protected _fullscreenHandler = (): void => { + this._api.getExpandManager().setExpanded(false); + + this._setConditionState(); // Re-render after a change to fullscreen mode to take advantage of // the expanded screen real-estate (vs staying in aspect-ratio locked diff --git a/src/card-controller/interaction-manager.ts b/src/card-controller/interaction-manager.ts index af04cc81..64a43912 100644 --- a/src/card-controller/interaction-manager.ts +++ b/src/card-controller/interaction-manager.ts @@ -16,32 +16,37 @@ export class InteractionManager { this._reportInteraction(); }, 1 * 1000); + public initialize(): void { + this._api.getConditionsManager().setState({ interaction: false }); + } + public hasInteraction(): boolean { return this._timer.isRunning(); } /** * Start the user interaction ('screensaver') timer to reset the view to - * default `view.timeout_seconds` after user interaction. + * default `view.interaction_seconds` after user interaction. */ protected _reportInteraction(): void { this._timer.stop(); const timeoutSeconds = this._api.getConfigManager().getConfig() - ?.view.timeout_seconds; + ?.view.interaction_seconds; if (timeoutSeconds) { + this._api.getConditionsManager().setState({ interaction: true }); + this._timer.start(timeoutSeconds, () => { - if (this._isAutomatedUpdateAllowed()) { - this._api.getViewManager().setViewDefault(); + this._api.getConditionsManager().setState({ interaction: false }); + + if (!this._api.getTriggersManager().isTriggered()) { + if (this._api.getConfigManager().getConfig()?.view.reset_after_interaction) { + this._api.getViewManager().setViewDefault(); + } this._api.getStyleManager().setLightOrDarkMode(); } }); } - this._api.getStyleManager().setLightOrDarkMode(); - } - - protected _isAutomatedUpdateAllowed(): boolean { - return !this._api.getTriggersManager().isTriggered(); } } diff --git a/src/card-controller/media-info-manager.ts b/src/card-controller/media-info-manager.ts index 88dde87a..78878996 100644 --- a/src/card-controller/media-info-manager.ts +++ b/src/card-controller/media-info-manager.ts @@ -12,6 +12,10 @@ export class MediaLoadedInfoManager { this._api = api; } + public initialize(): void { + this.clear(); + } + public set(mediaInfo: MediaLoadedInfo): void { if (!isValidMediaLoadedInfo(mediaInfo)) { return; diff --git a/src/card-controller/triggers-manager.ts b/src/card-controller/triggers-manager.ts index dbd17869..ff4362ff 100644 --- a/src/card-controller/triggers-manager.ts +++ b/src/card-controller/triggers-manager.ts @@ -95,6 +95,7 @@ export class TriggersManager { cameraID === this._api.getViewManager().getView()?.camera) ) { this._triggeredCameras.set(cameraID, now); + this._setConditionState(); this._triggerAction(cameraID); } } @@ -132,6 +133,14 @@ export class TriggersManager { this._api.getCardElementManager().update(); } + protected _setConditionState(): void { + this._api.getConditionsManager().setState({ + triggered: this._triggeredCameras.size + ? new Set(this._triggeredCameras.keys()) + : undefined, + }); + } + protected _untriggerAction(cameraID: string): void { const action = this._api.getConfigManager().getConfig()?.view.scan.actions.untrigger; @@ -140,6 +149,7 @@ export class TriggersManager { } this._triggeredCameras.delete(cameraID); this._deleteTimer(cameraID); + this._setConditionState(); // Must update master element to remove border pulsing. this._api.getCardElementManager().update(); diff --git a/src/card-controller/types.ts b/src/card-controller/types.ts index 068439ee..7cc92bba 100644 --- a/src/card-controller/types.ts +++ b/src/card-controller/types.ts @@ -100,6 +100,7 @@ export interface CardDownloadAPI { export interface CardElementAPI { getActionsManager(): ActionsManager; + getExpandManager(): ExpandManager; getFullscreenManager(): FullscreenManager; getInteractionManager(): InteractionManager; getMediaLoadedInfoManager(): MediaLoadedInfoManager; @@ -147,6 +148,7 @@ export interface CardInitializerAPI { } export interface CardInteractionAPI { + getConditionsManager(): ConditionsManager; getConfigManager(): ConfigManager; getStyleManager(): StyleManager; getTriggersManager(): TriggersManager; @@ -198,6 +200,7 @@ export interface CardStyleAPI { export interface CardTriggersAPI { getCameraManager(): CameraManager; + getConditionsManager(): ConditionsManager; getCardElementManager(): CardElementManager; getConfigManager(): ConfigManager; getHASSManager(): HASSManager; diff --git a/src/config-mgmt.ts b/src/config-mgmt.ts index 8a9a8234..d6f5eb61 100644 --- a/src/config-mgmt.ts +++ b/src/config-mgmt.ts @@ -13,6 +13,7 @@ import { CONF_MEDIA_GALLERY, CONF_MENU_BUTTONS_CAMERA_UI, CONF_OVERRIDES, + CONF_VIEW_INTERACTION_SECONDS, CONF_VIEW_SCAN_ACTIONS_UNTRIGGER, } from './const'; import { arrayify } from './utils/basic'; @@ -483,4 +484,5 @@ const UPGRADES = [ transform: (val) => (val ? 'default' : null), }, ), + upgradeMoveToWithOverrides('view.timeout_seconds', CONF_VIEW_INTERACTION_SECONDS), ]; diff --git a/src/config/types.ts b/src/config/types.ts index 3c70cf38..2da6551a 100644 --- a/src/config/types.ts +++ b/src/config/types.ts @@ -1,4 +1,3 @@ - import { CallServiceActionConfig, ConfirmationRestrictionConfig, @@ -490,6 +489,8 @@ export const frigateCardConditionSchema = z.object({ state: stateConditions.optional(), media_query: z.string().optional(), display_mode: viewDisplayModeSchema.optional(), + triggered: z.string().array().optional(), + interaction: z.boolean().optional(), }); export type FrigateCardCondition = z.infer; @@ -1114,7 +1115,8 @@ export type CamerasConfig = z.infer; const viewConfigDefault = { default: FRIGATE_CARD_VIEW_DEFAULT, camera_select: 'current' as const, - timeout_seconds: 300, + interaction_seconds: 300, + reset_after_interaction: true, update_seconds: 0, update_force: false, update_cycle_camera: false, @@ -1163,7 +1165,10 @@ const viewConfigSchema = z camera_select: z .enum([...FRIGATE_CARD_VIEWS_USER_SPECIFIED, 'current']) .default(viewConfigDefault.camera_select), - timeout_seconds: z.number().default(viewConfigDefault.timeout_seconds), + interaction_seconds: z.number().default(viewConfigDefault.interaction_seconds), + reset_after_interaction: z + .boolean() + .default(viewConfigDefault.reset_after_interaction), update_seconds: z.number().default(viewConfigDefault.update_seconds), update_force: z.boolean().default(viewConfigDefault.update_force), update_cycle_camera: z.boolean().default(viewConfigDefault.update_cycle_camera), diff --git a/src/const.ts b/src/const.ts index 16f16147..d0fddf23 100644 --- a/src/const.ts +++ b/src/const.ts @@ -71,10 +71,11 @@ const CONF_VIEW = 'view' as const; export const CONF_VIEW_CAMERA_SELECT = `${CONF_VIEW}.camera_select` as const; export const CONF_VIEW_DARK_MODE = `${CONF_VIEW}.dark_mode` as const; export const CONF_VIEW_DEFAULT = `${CONF_VIEW}.default` as const; -export const CONF_VIEW_TIMEOUT_SECONDS = `${CONF_VIEW}.timeout_seconds` as const; +export const CONF_VIEW_INTERACTION_SECONDS = `${CONF_VIEW}.interaction_seconds` as const; export const CONF_VIEW_UPDATE_CYCLE_CAMERA = `${CONF_VIEW}.update_cycle_camera` as const; 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_RESET_AFTER_INTERACTION = `${CONF_VIEW}.reset_after_interaction` 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 = diff --git a/src/editor.ts b/src/editor.ts index d3be3939..00ddc989 100644 --- a/src/editor.ts +++ b/src/editor.ts @@ -22,9 +22,9 @@ import { } from './config-mgmt.js'; import { BUTTON_SIZE_MIN, - FRIGATE_MENU_PRIORITY_MAX, FrigateCardConfig, frigateCardConfigDefaults, + FRIGATE_MENU_PRIORITY_MAX, RawFrigateCardConfig, RawFrigateCardConfigArray, THUMBNAIL_WIDTH_MAX, @@ -157,8 +157,8 @@ import { CONF_MEDIA_VIEWER_TRANSITION_EFFECT, CONF_MEDIA_VIEWER_ZOOMABLE, CONF_MENU_ALIGNMENT, - CONF_MENU_BUTTON_SIZE, CONF_MENU_BUTTONS, + CONF_MENU_BUTTON_SIZE, CONF_MENU_POSITION, CONF_MENU_STYLE, CONF_PERFORMANCE_FEATURES_ANIMATED_PROGRESS_INDICATOR, @@ -180,6 +180,8 @@ import { CONF_VIEW_CAMERA_SELECT, CONF_VIEW_DARK_MODE, CONF_VIEW_DEFAULT, + CONF_VIEW_INTERACTION_SECONDS, + CONF_VIEW_RESET_AFTER_INTERACTION, CONF_VIEW_SCAN, CONF_VIEW_SCAN_ACTIONS, CONF_VIEW_SCAN_ACTIONS_INTERACTION_MODE, @@ -189,7 +191,6 @@ import { CONF_VIEW_SCAN_FILTER_SELECTED_CAMERA, CONF_VIEW_SCAN_SHOW_TRIGGER_STATUS, CONF_VIEW_SCAN_UNTRIGGER_SECONDS, - CONF_VIEW_TIMEOUT_SECONDS, CONF_VIEW_UPDATE_CYCLE_CAMERA, CONF_VIEW_UPDATE_FORCE, CONF_VIEW_UPDATE_SECONDS, @@ -1948,7 +1949,11 @@ export class FrigateCardEditor extends LitElement implements LovelaceCardEditor this._cameraSelectViewModes, )} ${this._renderOptionSelector(CONF_VIEW_DARK_MODE, this._darkModes)} - ${this._renderNumberInput(CONF_VIEW_TIMEOUT_SECONDS)} + ${this._renderNumberInput(CONF_VIEW_INTERACTION_SECONDS)} + ${this._renderSwitch( + CONF_VIEW_RESET_AFTER_INTERACTION, + this._defaults.view.reset_after_interaction, + )} ${this._renderNumberInput(CONF_VIEW_UPDATE_SECONDS)} ${this._renderSwitch( CONF_VIEW_UPDATE_FORCE, diff --git a/src/localize/languages/en.json b/src/localize/languages/en.json index d4f7fd10..1c61c77a 100644 --- a/src/localize/languages/en.json +++ b/src/localize/languages/en.json @@ -410,7 +410,8 @@ "show_trigger_status": "Show pulsing border when triggered", "untrigger_seconds": "Seconds after inactive state change to untrigger" }, - "timeout_seconds": "Reset to default view X seconds after user action (0=never)", + "interaction_seconds": "Seconds after user action to remain interacted with (0=never)", + "reset_after_interaction": "Reset to the default view after user interaction", "update_cycle_camera": "Cycle through cameras when default view updates", "update_force": "Force card updates (ignore user interaction)", "update_seconds": "Refresh default view every X seconds (0=never)", diff --git a/src/localize/languages/it.json b/src/localize/languages/it.json index b5940789..0d174950 100644 --- a/src/localize/languages/it.json +++ b/src/localize/languages/it.json @@ -18,8 +18,8 @@ "editor_label": "", "method": "", "methods": { - "standard": "", - "dashboard": "" + "dashboard": "", + "standard": "" } }, "dependencies": { @@ -236,27 +236,27 @@ "editor_label": "Controlli dal vivo", "ptz": { "editor_label": "", + "hide_home": "", + "hide_pan_tilt": "", + "hide_zoom": "", "mode": "", "modes": { - "on": "", + "auto": "", "off": "", - "auto": "" + "on": "" }, "orientation": "", "orientations": { - "vertical": "", - "horizontal": "" + "horizontal": "", + "vertical": "" }, "position": "", "positions": { - "top-left": "", - "top-right": "", "bottom-left": "", - "bottom-right": "" - }, - "hide_zoom": "", - "hide_pan_tilt": "", - "hide_home": "" + "bottom-right": "", + "top-left": "", + "top-right": "" + } } }, "draggable": "Il Visualizzatore eventi può essere trascinato oppure puoi scorrere", @@ -380,6 +380,8 @@ "on": "On" }, "default": "Visualizzazione predefinita", + "interaction_seconds": "", + "reset_after_interaction": "", "scan": { "actions": { "editor_label": "", @@ -406,7 +408,6 @@ "show_trigger_status": "Mostra bordo pulsante quando attivato", "untrigger_seconds": "Reimposta la vista ai valori predefiniti dopo aver annullato l'attivazione" }, - "timeout_seconds": "Ripristina la vista predefinita x secondi dopo l'azione dell'utente (0 = mai)", "update_cycle_camera": "Scorri le telecamere quando si aggiorna la visualizzazione predefinita", "update_force": "Aggiornamenti della scheda forza (ignora l'interazione dell'utente)", "update_seconds": "Aggiorna la visualizzazione predefinita ogni x secondi (0 = mai)", @@ -497,8 +498,8 @@ "no_visible_cameras": "Nessuna telecamera visibile trovata, è necessario configurare almeno una telecamera non nascosta", "reconnecting": "Riconnessione", "timeline_no_cameras": "Nessuna telecamera damostrare in Frigate nella timeline", - "troubleshooting": "Controllare la risoluzione dei problemi", "too_many_automations": "", + "troubleshooting": "Controllare la risoluzione dei problemi", "unknown": "Errore sconosciuto", "upgrade_available": "È disponibile un aggiornamento di configurazione della scheda automatizzato, visitare l'editor di schede visive", "webrtc_card_reported_error": "La scheda WebRTC ha riportato un errore", @@ -567,4 +568,4 @@ }, "select_date": "Scegli la data" } -} +} \ No newline at end of file diff --git a/src/localize/languages/pt-BR.json b/src/localize/languages/pt-BR.json index 8c6db881..eb91e704 100644 --- a/src/localize/languages/pt-BR.json +++ b/src/localize/languages/pt-BR.json @@ -18,8 +18,8 @@ "editor_label": "", "method": "", "methods": { - "standard": "", - "dashboard": "" + "dashboard": "", + "standard": "" } }, "dependencies": { @@ -236,27 +236,27 @@ "editor_label": "Controles da visualização ao vivo", "ptz": { "editor_label": "", + "hide_home": "", + "hide_pan_tilt": "", + "hide_zoom": "", "mode": "", "modes": { - "on": "", + "auto": "", "off": "", - "auto": "" + "on": "" }, "orientation": "", "orientations": { - "vertical": "", - "horizontal": "" + "horizontal": "", + "vertical": "" }, "position": "", "positions": { - "top-left": "", - "top-right": "", "bottom-left": "", - "bottom-right": "" - }, - "hide_zoom": "", - "hide_pan_tilt": "", - "hide_home": "" + "bottom-right": "", + "top-left": "", + "top-right": "" + } } }, "draggable": "A visualização ao vivo das câmeras pode ser arrastada/deslizada", @@ -383,6 +383,8 @@ "on": "Ligado" }, "default": "Visualização padrão", + "interaction_seconds": "", + "reset_after_interaction": "", "scan": { "actions": { "editor_label": "", @@ -409,7 +411,6 @@ "show_trigger_status": "Pulsar borda quando acionado", "untrigger_seconds": "Segundos após a mudar para o estado inativo para desacionar" }, - "timeout_seconds": "Redefinir para a visualização padrão X segundos após a ação do usuário (0 = nunca)", "update_cycle_camera": "Percorrer as câmeras quando a visualização padrão for atualizada", "update_force": "Forçar atualizações do cartão (ignore a interação do usuário)", "update_seconds": "Atualize a visualização padrão a cada X segundos (0 = nunca)", @@ -577,4 +578,4 @@ }, "select_date": "Escolha a data" } -} +} \ No newline at end of file diff --git a/src/localize/languages/pt-PT.json b/src/localize/languages/pt-PT.json index b164abc4..1e744d30 100644 --- a/src/localize/languages/pt-PT.json +++ b/src/localize/languages/pt-PT.json @@ -18,8 +18,8 @@ "editor_label": "", "method": "", "methods": { - "standard": "", - "dashboard": "" + "dashboard": "", + "standard": "" } }, "dependencies": { @@ -229,27 +229,27 @@ "editor_label": "Controles da visualização ao vivo", "ptz": { "editor_label": "", + "hide_home": "", + "hide_pan_tilt": "", + "hide_zoom": "", "mode": "", "modes": { - "on": "", + "auto": "", "off": "", - "auto": "" + "on": "" }, "orientation": "", "orientations": { - "vertical": "", - "horizontal": "" + "horizontal": "", + "vertical": "" }, "position": "", "positions": { - "top-left": "", - "top-right": "", "bottom-left": "", - "bottom-right": "" - }, - "hide_zoom": "", - "hide_pan_tilt": "", - "hide_home": "" + "bottom-right": "", + "top-left": "", + "top-right": "" + } } }, "draggable": "A visualização ao vivo das câmeras pode ser arrastada/deslizada", @@ -373,6 +373,8 @@ "on": "Ligado" }, "default": "Visualização padrão", + "interaction_seconds": "", + "reset_after_interaction": "", "scan": { "actions": { "editor_label": "", @@ -399,7 +401,6 @@ "show_trigger_status": "Exibir estado do gatilho", "untrigger_seconds": "Segundos após a mudar para o estado inativo para desacionar" }, - "timeout_seconds": "Redefinir para a visualização padrão X segundos após a ação do usuário (0 = nunca)", "update_cycle_camera": "Percorrer as câmeras quando a visualização padrão for atualizada", "update_force": "Forçar atualizações do cartão (ignore a interação do Utilizador)", "update_seconds": "Atualize a visualização padrão a cada X segundos (0 = nunca)", @@ -490,8 +491,8 @@ "no_visible_cameras": "Sem camaras visiveis", "reconnecting": "A voltar a ligar", "timeline_no_cameras": "Nenhuma câmera do Frigate para mostrar na linha do tempo", - "troubleshooting": "Verifique a solução de problemas", "too_many_automations": "", + "troubleshooting": "Verifique a solução de problemas", "unknown": "Erro desconhecido", "upgrade_available": "Uma atualização automatizada da configuração do cartão está disponível, visite o editor visual do cartão", "webrtc_card_reported_error": "O cartão WebRTC relatou um erro", diff --git a/tests/card-controller/card-element-manager.test.ts b/tests/card-controller/card-element-manager.test.ts index a9eb5f40..1f9877a1 100644 --- a/tests/card-controller/card-element-manager.test.ts +++ b/tests/card-controller/card-element-manager.test.ts @@ -136,6 +136,11 @@ describe('CardElementManager', () => { ); expect(windowAddEventListener).toBeCalledWith('location-changed', expect.anything()); expect(windowAddEventListener).toBeCalledWith('popstate', expect.anything()); + + expect(api.getInteractionManager().initialize).toBeCalled(); + expect(api.getFullscreenManager().initialize).toBeCalled(); + expect(api.getExpandManager().initialize).toBeCalled(); + expect(api.getMediaLoadedInfoManager().initialize).toBeCalled(); }); it('should disconnect', () => { diff --git a/tests/card-controller/conditions-manager.test.ts b/tests/card-controller/conditions-manager.test.ts index 148c0dda..f1c321ec 100644 --- a/tests/card-controller/conditions-manager.test.ts +++ b/tests/card-controller/conditions-manager.test.ts @@ -360,4 +360,26 @@ describe('ConditionsManager', () => { manager.setState({ displayMode: 'single' }); expect(manager.evaluateCondition(condition)).toBeFalsy(); }); + + it('should evaluate conditions with triggers', () => { + const manager = new ConditionsManager(createCardAPI()); + const condition: FrigateCardCondition = { triggered: ['camera_1', 'camera_2'] }; + expect(manager.evaluateCondition(condition)).toBeFalsy(); + manager.setState({ triggered: new Set(['camera_1']) }); + expect(manager.evaluateCondition(condition)).toBeTruthy(); + manager.setState({ triggered: new Set(['camera_2', 'camera_1', 'camera_3']) }); + expect(manager.evaluateCondition(condition)).toBeTruthy(); + manager.setState({ triggered: new Set(['camera_3']) }); + expect(manager.evaluateCondition(condition)).toBeFalsy(); + }); + + it('should evaluate conditions with interaction', () => { + const manager = new ConditionsManager(createCardAPI()); + const condition: FrigateCardCondition = { interaction: true }; + expect(manager.evaluateCondition(condition)).toBeFalsy(); + manager.setState({ interaction: true }) + expect(manager.evaluateCondition(condition)).toBeTruthy(); + manager.setState({ interaction: false }) + expect(manager.evaluateCondition(condition)).toBeFalsy(); + }); }); diff --git a/tests/card-controller/expand-manager.test.ts b/tests/card-controller/expand-manager.test.ts index cfbc514e..1417c064 100644 --- a/tests/card-controller/expand-manager.test.ts +++ b/tests/card-controller/expand-manager.test.ts @@ -9,6 +9,14 @@ describe('ExpandManager', () => { expect(manager.isExpanded()).toBeFalsy(); }); + it('should initialize', () => { + const api = createCardAPI(); + const manager = new ExpandManager(api); + + manager.initialize(); + expect(api.getConditionsManager().setState).toBeCalledWith({ expand: false }); + }); + it('should set expanded', () => { const api = createCardAPI(); vi.mocked(api.getFullscreenManager().isInFullscreen).mockReturnValue(true); diff --git a/tests/card-controller/fullscreen-manager.test.ts b/tests/card-controller/fullscreen-manager.test.ts index 7599ae62..ba0d3f6c 100644 --- a/tests/card-controller/fullscreen-manager.test.ts +++ b/tests/card-controller/fullscreen-manager.test.ts @@ -29,6 +29,17 @@ describe('FullscreenManager', () => { vi.clearAllMocks(); }); + it('should initialize', () => { + const api = createCardAPI(); + const manager = new FullscreenManager(api); + + setScreenfulEnabled(true); + setScreenfulFullscreen(false); + + manager.initialize(); + expect(api.getConditionsManager().setState).toBeCalledWith({ fullscreen: false }); + }); + it('should correctly determine whether in fullscreen', () => { const manager = new FullscreenManager(createCardAPI()); diff --git a/tests/card-controller/interaction-manager.test.ts b/tests/card-controller/interaction-manager.test.ts index be6534d5..8e363948 100644 --- a/tests/card-controller/interaction-manager.test.ts +++ b/tests/card-controller/interaction-manager.test.ts @@ -15,12 +15,20 @@ describe('InteractionManager', () => { vi.useRealTimers(); }); + it('should initialize', () => { + const api = createCardAPI(); + const manager = new InteractionManager(api); + + manager.initialize(); + expect(api.getConditionsManager().setState).toBeCalledWith({ interaction: false }); + }); + it('should take action when interaction is reported', () => { const api = createCardAPI(); vi.mocked(api.getConfigManager().getConfig).mockReturnValue( createConfig({ view: { - timeout_seconds: 10, + interaction_seconds: 10, }, }), ); @@ -45,7 +53,7 @@ describe('InteractionManager', () => { vi.mocked(api.getConfigManager().getConfig).mockReturnValue( createConfig({ view: { - timeout_seconds: 10, + interaction_seconds: 10, }, }), ); @@ -64,12 +72,12 @@ describe('InteractionManager', () => { expect(api.getViewManager().setViewDefault).not.toBeCalled(); }); - it('should not take action when not configured', () => { + it('should not take action without an interaction timeout', () => { const api = createCardAPI(); vi.mocked(api.getConfigManager().getConfig).mockReturnValue( createConfig({ view: { - timeout_seconds: 0, + interaction_seconds: 0, }, }), ); @@ -81,4 +89,60 @@ describe('InteractionManager', () => { // true but the default view will not have been set. expect(api.getViewManager().setViewDefault).not.toBeCalled(); }); + + it('should not take action without reset_after_interaction', () => { + const api = createCardAPI(); + vi.mocked(api.getConfigManager().getConfig).mockReturnValue( + createConfig({ + view: { + reset_after_interaction: false, + interaction_seconds: 10, + }, + }), + ); + const manager = new InteractionManager(api); + vi.useFakeTimers(); + vi.setSystemTime(start); + + manager.reportInteraction(); + + vi.setSystemTime(add(start, { seconds: 10 })); + vi.runOnlyPendingTimers(); + + // First call is blocked by triggers (above), so interaction will report + // true but the default view will not have been set. + expect(api.getViewManager().setViewDefault).not.toBeCalled(); + }); + + it('should set condition state', () => { + const api = createCardAPI(); + vi.mocked(api.getConfigManager().getConfig).mockReturnValue( + createConfig({ + view: { + interaction_seconds: 10, + }, + }), + ); + const manager = new InteractionManager(api); + vi.useFakeTimers(); + vi.setSystemTime(start); + + expect(api.getConditionsManager().setState).not.toBeCalled(); + + manager.reportInteraction(); + expect(api.getConditionsManager().setState).toHaveBeenLastCalledWith( + expect.objectContaining({ + interaction: true, + }), + ); + + vi.setSystemTime(add(start, { seconds: 10 })); + vi.runOnlyPendingTimers(); + + expect(api.getConditionsManager().setState).toHaveBeenLastCalledWith( + expect.objectContaining({ + interaction: false, + }), + ); + }); }); diff --git a/tests/card-controller/media-info-manager.test.ts b/tests/card-controller/media-info-manager.test.ts index fc0e1c9f..3f2969d3 100644 --- a/tests/card-controller/media-info-manager.test.ts +++ b/tests/card-controller/media-info-manager.test.ts @@ -3,6 +3,14 @@ import { MediaLoadedInfoManager } from '../../src/card-controller/media-info-man import { createCardAPI, createMediaLoadedInfo } from '../test-utils.js'; describe('MediaLoadedInfoManager', () => { + it('should initialize', () => { + const api = createCardAPI(); + const manager = new MediaLoadedInfoManager(api); + + manager.initialize(); + expect(api.getConditionsManager().setState).toBeCalledWith({ media_loaded: false }); + }); + it('should set', () => { const api = createCardAPI(); const manager = new MediaLoadedInfoManager(api); diff --git a/tests/card-controller/triggers-manager.test.ts b/tests/card-controller/triggers-manager.test.ts index d6ae6b4d..90bcedd7 100644 --- a/tests/card-controller/triggers-manager.test.ts +++ b/tests/card-controller/triggers-manager.test.ts @@ -123,6 +123,10 @@ describe('TriggersManager', () => { manager.updateTriggerHAState(createHASS(hassInactiveState)); expect(manager.isTriggered()).toBeTruthy(); + expect(api.getConditionsManager().setState).toHaveBeenLastCalledWith({ + triggered: new Set(['camera_1']), + }); + expect(api.getViewManager().setViewByParameters).toBeCalledWith({ viewName: 'live' as const, cameraID: 'camera_1' as const, @@ -144,6 +148,9 @@ describe('TriggersManager', () => { vi.runOnlyPendingTimers(); expect(manager.isTriggered()).toBeFalsy(); + expect(api.getConditionsManager().setState).toHaveBeenLastCalledWith({ + triggered: undefined, + }); expect(api.getViewManager().setViewDefault).toBeCalled(); }); diff --git a/tests/config-mgmt.test.ts b/tests/config-mgmt.test.ts index d90c099f..834928de 100644 --- a/tests/config-mgmt.test.ts +++ b/tests/config-mgmt.test.ts @@ -1213,4 +1213,22 @@ describe('should handle version specific upgrades', () => { }); }); }); + + it('should move view.timeout_seconds', () => { + const config = { + type: 'custom:frigate-card', + cameras: [{ camera_entity: 'camera.office' }], + view: { + timeout_seconds: 200, + }, + }; + expect(upgradeConfig(config)).toBeTruthy(); + expect(config).toEqual({ + type: 'custom:frigate-card', + cameras: [{ camera_entity: 'camera.office' }], + view: { + interaction_seconds: 200, + }, + }); + }); }); diff --git a/tests/config/types.test.ts b/tests/config/types.test.ts index 9bd1fe41..95de1a00 100644 --- a/tests/config/types.test.ts +++ b/tests/config/types.test.ts @@ -288,7 +288,8 @@ describe('config defaults', () => { }, filter_selected_camera: false, }, - timeout_seconds: 300, + interaction_seconds: 300, + reset_after_interaction: true, update_cycle_camera: false, update_force: false, update_seconds: 0, diff --git a/vite.config.ts b/vite.config.ts index 699b52b7..8647cf09 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.82, - branches: 60.67, - functions: 72.86, - lines: 71.71, + statements: 71.95, + branches: 60.8, + functions: 73.04, + lines: 71.84, }, }, });