diff --git a/docs/configuration/view.md b/docs/configuration/view.md index c9436084..9e2ad5dd 100644 --- a/docs/configuration/view.md +++ b/docs/configuration/view.md @@ -13,18 +13,40 @@ view: | `camera_select` | `current` | The [view](view.md?id=supported-views) 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. | | `dark_mode` | `off` | 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%`. | | `default` | `live` | The view to show in the card by default. The default camera is the first one listed. See [Supported Views](view.md?id=supported-views) below. | +| `default_reset` | | The circumstances and behavior that cause the card to reset to the default view. See below. | | `interaction_seconds` | `300` | After a mouse/touch interaction with the card, it will be considered "interacted with" until this number of seconds elapses without further interaction. May be used as part of an [interaction condition](conditions.md?id=interaction) or with `reset_after_interaction` to reset the view after the interaction is complete. `0` means no interactions are reported / acted upon. | | `keyboard_shortcuts` | See [usage](../usage/keyboard-shortcuts.md) for defaults. | Configure keyboard shortcuts. See below. | | `render_entities` | | **YAML only**: A list of entity ids that should cause the card to re-render 'in-place'. The view/camera is not changed. `update_*` flags do not pertain/relate to the behavior of this flag. This should **very** rarely be needed, but could be useful if the card is both setting and changing HA state of the same object as could be the case for some complex `card_mod` scenarios ([example](https://github.com/dermotduffy/frigate-hass-card/issues/343)). | | `reset_after_interaction` | `true` | If `true` the card will reset to the default configured view (i.e. 'screensaver' functionality) after `interaction_seconds` has elapsed after user interaction. | | `triggers` | | How to react when a camera is [triggered](cameras/README.md?id=triggers). | -| `update_cycle_camera` | `false` | When set to `true` the selected camera is cycled on each default view change. | +| `default_cycle_camera` | `false` | When set to `true` the selected camera is cycled on each default view change. | | `update_entities` | | **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`](cameras/README.md?id=triggers) for the selected camera instead. | -| `update_force` | `false` | Whether automated card updates should ignore user interaction. | -| `update_seconds` | `0` | A number of seconds after which to automatically update/refresh the default view. If the default view occurs sooner (e.g. manually) the timer will start over. `0` disables this functionality. | + +## `default_reset` + +Configure the circumstances and behavior that cause the card to reset to the default view. All configuration is under: + +```yaml +view: + default_reset: [...] +``` + +| Option | Default | Description | +| ------------------- | ---------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `after_interaction` | `true` | If `true` the card will reset to the default configured view (i.e. 'screensaver' functionality) after `interaction_seconds` has elapsed after user interaction. | +| `entities` | | A list of entities that should cause the view to reset to the default (if the entity only pertains to a particular camera use [`triggers`](cameras/README.md?id=triggers) for the selected camera instead). | +| `interaction_mode` | `inactive` | Whether the default reset should happen when the card is being interacted with. If `all`, the reset will always happen regardless. If `inactive` the reset will only be taken if the card has _not_ had human interaction recently (as defined by `view.interaction_seconds`). If `active` the reset will only be happen if the card _has_ had human interaction recently. This controls resets triggered by `entities` and `every_seconds`, but not `after_interaction` which by definition requires no interaction. | +| `every_seconds` | `0` | A number of seconds after which to automatically reset to the default view. `0` disables this functionality. | ## `keyboard_shortcuts` +All configuration is under: + +```yaml +view: + keyboard_shortcuts: [...] +``` + Configure the key-bindings for the builtin keyboard shortcuts. See [usage](../usage/keyboard-shortcuts.md) information for defaults on keyboard shortcuts. | Option | Default | Description | @@ -106,11 +128,13 @@ view: default: live camera_select: current interaction_seconds: 300 - update_seconds: 0 - update_force: false - update_cycle_camera: false - update_entities: - - binary_sensor.my_motion_sensor + default_cycle_camera: false + default_reset: + after_interaction: false + entities: + - binary_sensor.my_motion_sensor + every_seconds: 0 + interaction_mode: inactive render_entities: - switch.render_card dark_mode: 'off' diff --git a/rollup.config.js b/rollup.config.js index c3b3f421..5d40a750 100644 --- a/rollup.config.js +++ b/rollup.config.js @@ -50,6 +50,7 @@ const plugins = [ }), typescript({ sourceMap: dev, + inlineSources: dev, }), json({ exclude: 'package.json' }), replace({ diff --git a/src/camera-manager/camera.ts b/src/camera-manager/camera.ts index c987b9f3..9e83e5d9 100644 --- a/src/camera-manager/camera.ts +++ b/src/camera-manager/camera.ts @@ -3,6 +3,7 @@ import { CameraConfig } from '../config/types'; import { localize } from '../localize/localize'; import { allPromises } from '../utils/basic'; import { + DestroyCallback, isTriggeredState, parseStateChangeTrigger, subscribeToTrigger, @@ -13,8 +14,6 @@ import { CameraManagerEngine } from './engine'; import { CameraNoIDError } from './error'; import { CameraEventCallback } from './types'; -type DestroyCallback = () => Promise; - export class Camera { protected _config: CameraConfig; protected _engine: CameraManagerEngine; diff --git a/src/card-controller/auto-update-manager.ts b/src/card-controller/auto-update-manager.ts deleted file mode 100644 index bad2f5a7..00000000 --- a/src/card-controller/auto-update-manager.ts +++ /dev/null @@ -1,42 +0,0 @@ -import { Timer } from '../utils/timer'; -import { CardAutoRefreshAPI } from './types'; - -export class AutoUpdateManager { - protected _timer = new Timer(); - protected _api: CardAutoRefreshAPI; - - constructor(api: CardAutoRefreshAPI) { - this._api = api; - } - - /** - * Set the update timer to trigger an update refresh every - * `view.update_seconds`. - */ - public startDefaultViewTimer(): void { - this._timer.stop(); - const updateSeconds = this._api.getConfigManager().getConfig()?.view.update_seconds; - if (updateSeconds) { - this._timer.start(updateSeconds, () => { - if (this._isAutomatedUpdateAllowed()) { - this._api.getViewManager().setViewDefault(); - } else { - // Not allowed to update this time around, but try again at the next - // interval. - this.startDefaultViewTimer(); - } - }); - } - } - - protected _isAutomatedUpdateAllowed(): boolean { - const triggers = this._api.getTriggersManager(); - const config = this._api.getConfigManager().getConfig(); - const interactionManager = this._api.getInteractionManager(); - - return ( - !triggers.isTriggered() && - (config?.view.update_force || !interactionManager.hasInteraction()) - ); - } -} diff --git a/src/card-controller/card-element-manager.ts b/src/card-controller/card-element-manager.ts index 5c8b80db..14aa2c48 100644 --- a/src/card-controller/card-element-manager.ts +++ b/src/card-controller/card-element-manager.ts @@ -67,6 +67,7 @@ export class CardElementManager { this._api.getMediaLoadedInfoManager().initialize(); this._api.getMicrophoneManager().initialize(); this._api.getKeyboardStateManager().initialize(); + this._api.getDefaultManager().initialize(); // Whether or not the card is in panel mode on the dashboard. setOrRemoveAttribute(this._element, isCardInPanel(this._element), 'panel'); @@ -123,6 +124,7 @@ export class CardElementManager { this._api.getFullscreenManager().disconnect(); this._api.getKeyboardStateManager().uninitialize(); this._api.getActionsManager().uninitialize(); + this._api.getDefaultManager().uninitialize(); // Uninitialize cameras to cause them to reinitialize on // reconnection, to ensure the state subscription/unsubscription works diff --git a/src/card-controller/config/config-manager.ts b/src/card-controller/config/config-manager.ts index a2b3fa5e..7271ac99 100644 --- a/src/card-controller/config/config-manager.ts +++ b/src/card-controller/config/config-manager.ts @@ -142,6 +142,18 @@ export class ConfigManager { .uninitialize(InitializationAspect.MICROPHONE_CONNECT); } + if ( + previousConfig && + !isEqual( + previousConfig?.view.default_reset, + this._overriddenConfig?.view.default_reset, + ) + ) { + this._api + .getInitializationManager() + .uninitialize(InitializationAspect.DEFAULT_RESET); + } + this._api.getCardElementManager().update(); } } diff --git a/src/card-controller/controller.ts b/src/card-controller/controller.ts index ca69fff8..683611cd 100644 --- a/src/card-controller/controller.ts +++ b/src/card-controller/controller.ts @@ -6,7 +6,7 @@ import { EntityRegistryManager } from '../utils/ha/entity-registry'; import { EntityCache } from '../utils/ha/entity-registry/cache'; import { ResolvedMediaCache } from '../utils/ha/resolved-media'; import { ActionsManager } from './actions/actions-manager'; -import { AutoUpdateManager } from './auto-update-manager'; +import { DefaultManager } from './default-manager'; import { AutomationsManager } from './automations-manager'; import { CameraURLManager } from './camera-url-manager'; import { @@ -32,12 +32,12 @@ import { StyleManager } from './style-manager'; import { TriggersManager } from './triggers-manager'; import { CardActionsManagerAPI, - CardAutoRefreshAPI, CardAutomationsAPI, CardCameraAPI, CardCameraURLAPI, CardConditionAPI, CardConfigAPI, + CardDefaultManagerAPI, CardDownloadAPI, CardElementAPI, CardExpandAPI, @@ -62,11 +62,11 @@ export class CardController implements CardActionsManagerAPI, CardAutomationsAPI, - CardAutoRefreshAPI, CardCameraAPI, CardCameraURLAPI, CardConditionAPI, CardConfigAPI, + CardDefaultManagerAPI, CardDownloadAPI, CardElementAPI, CardExpandAPI, @@ -92,12 +92,12 @@ export class CardController protected _actionsManager = new ActionsManager(this); protected _automationsManager = new AutomationsManager(this); - protected _autoUpdateManager = new AutoUpdateManager(this); protected _cameraManager = new CameraManager(this); protected _cameraURLManager = new CameraURLManager(this); protected _cardElementManager: CardElementManager; protected _conditionsManager: ConditionsManager; protected _configManager = new ConfigManager(this); + protected _defaultManager = new DefaultManager(this); protected _downloadManager = new DownloadManager(this); protected _expandManager = new ExpandManager(this); protected _fullscreenManager = new FullscreenManager(this); @@ -143,10 +143,6 @@ export class CardController return this._automationsManager; } - public getAutoUpdateManager(): AutoUpdateManager { - return this._autoUpdateManager; - } - public getCameraManager(): CameraManager { return this._cameraManager; } @@ -171,6 +167,11 @@ export class CardController public getConfigManager(): ConfigManager { return this._configManager; } + + public getDefaultManager(): DefaultManager { + return this._defaultManager; + } + public getDownloadManager(): DownloadManager { return this._downloadManager; } diff --git a/src/card-controller/default-manager.ts b/src/card-controller/default-manager.ts new file mode 100644 index 00000000..6d782b0e --- /dev/null +++ b/src/card-controller/default-manager.ts @@ -0,0 +1,92 @@ +import PQueue from 'p-queue'; +import { DestroyCallback, subscribeToTrigger } from '../utils/ha'; +import { isActionAllowedBasedOnInteractionState } from '../utils/interaction-mode'; +import { Timer } from '../utils/timer'; +import { CardDefaultManagerAPI } from './types'; + +/** + * Manages automated resetting to the default view. + */ +export class DefaultManager { + protected _timer = new Timer(); + protected _api: CardDefaultManagerAPI; + protected _unsubscribeCallback: DestroyCallback | null = null; + protected _initializationLimit = new PQueue({ concurrency: 1 }); + + constructor(api: CardDefaultManagerAPI) { + this._api = api; + } + + /** + * Initialize the default manager. Requires both hass and configuration to be + * effective (so cannot be called from just the configuration manager, as hass + * will not be available yet) + */ + public async initialize(): Promise { + const result = await this._initializationLimit.add(() => this._reconfigure()); + this._startTimer(); + return !!result; + } + + public uninitialize(): void { + this._timer.stop(); + this._unsubscribeCallback?.(); + this._unsubscribeCallback = null; + } + + protected async _reconfigure(): Promise { + const hass = this._api.getHASSManager().getHASS(); + const config = this._api.getConfigManager().getConfig()?.view.default_reset; + if (!hass || !config) { + return false; + } + + if (this._unsubscribeCallback) { + await this._unsubscribeCallback(); + } + + this._unsubscribeCallback = await subscribeToTrigger( + hass, + () => this._setToDefaultIfAllowed(), + { + entityID: config.entities, + platform: 'state', + stateOnly: true, + }, + ); + + // If the timer is running, restart it with the newly configured timer. + if (this._timer.isRunning()) { + this._timer.stop(); + this._startTimer(); + } + + return true; + } + + protected _setToDefaultIfAllowed(): void { + if (this._isAutomatedUpdateAllowed()) { + this._api.getViewManager().setViewDefault(); + } + } + + protected _isAutomatedUpdateAllowed(): boolean { + const interactionMode = this._api.getConfigManager().getConfig()?.view + .default_reset.interaction_mode; + return ( + !!interactionMode && + isActionAllowedBasedOnInteractionState( + interactionMode, + this._api.getInteractionManager().hasInteraction(), + ) + ); + } + + protected _startTimer(): void { + const timerSeconds = this._api.getConfigManager().getConfig()?.view + .default_reset.every_seconds; + if (timerSeconds) { + this._timer.startRepeated(timerSeconds, () => this._setToDefaultIfAllowed()); + } + } +} diff --git a/src/card-controller/hass-manager.ts b/src/card-controller/hass-manager.ts index 2ada031f..34c35e7d 100644 --- a/src/card-controller/hass-manager.ts +++ b/src/card-controller/hass-manager.ts @@ -37,23 +37,6 @@ export class HASSManager { this._hass = hass; if ( - // Home Assistant pumps a lot of updates through. Re-rendering the card is - // necessary at times (e.g. to update the 'clip' view as new clips - // arrive), but also is a jarring experience for the user (e.g. if they - // are browsing the mini-gallery). Do not allow re-rendering from a Home - // Assistant update if there's been recent interaction (e.g. clicks on the - // card) or if there is media active playing. - this._isAutomatedViewUpdateAllowed() && - isHassDifferent( - this._hass, - oldHass, - this._api.getConfigManager().getConfig()?.view.update_entities ?? [], - ) - ) { - // If entities being monitored have changed then reset the view to the - // default. - this._api.getViewManager().setViewDefault(); - } else if ( isHassDifferent(this._hass, oldHass, [ ...(this._api.getConfigManager().getConfig()?.view.render_entities ?? []), @@ -75,11 +58,4 @@ export class HASSManager { // Dark mode may depend on HASS. this._api.getStyleManager().setLightOrDarkMode(); } - - protected _isAutomatedViewUpdateAllowed(): boolean { - return ( - this._api.getConfigManager().getConfig()?.view.update_force || - !this._api.getInteractionManager().hasInteraction() - ); - } } diff --git a/src/card-controller/initialization-manager.ts b/src/card-controller/initialization-manager.ts index cbc23190..a8de6c75 100644 --- a/src/card-controller/initialization-manager.ts +++ b/src/card-controller/initialization-manager.ts @@ -9,6 +9,7 @@ export enum InitializationAspect { MEDIA_PLAYERS = 'media-players', CAMERAS = 'cameras', MICROPHONE_CONNECT = 'microphone-connect', + DEFAULT_RESET = 'default-reset', } export class InitializationManager { @@ -125,6 +126,7 @@ export class InitializationManager { if ( this._initializer.isInitializedMultiple([ + InitializationAspect.DEFAULT_RESET, ...(config.menu.buttons.media_player.enabled ? [InitializationAspect.MEDIA_PLAYERS] : []), @@ -135,6 +137,8 @@ export class InitializationManager { if ( !(await this._initializer.initializeMultipleIfNecessary({ + [InitializationAspect.DEFAULT_RESET]: async () => + await this._api.getDefaultManager().initialize(), ...(config.menu.buttons.media_player.enabled && { [InitializationAspect.MEDIA_PLAYERS]: async () => await this._api.getMediaPlayerManager().initialize(), diff --git a/src/card-controller/interaction-manager.ts b/src/card-controller/interaction-manager.ts index 64a43912..350a6fcb 100644 --- a/src/card-controller/interaction-manager.ts +++ b/src/card-controller/interaction-manager.ts @@ -41,7 +41,10 @@ export class InteractionManager { this._api.getConditionsManager().setState({ interaction: false }); if (!this._api.getTriggersManager().isTriggered()) { - if (this._api.getConfigManager().getConfig()?.view.reset_after_interaction) { + if ( + this._api.getConfigManager().getConfig()?.view.default_reset + .after_interaction + ) { this._api.getViewManager().setViewDefault(); } this._api.getStyleManager().setLightOrDarkMode(); diff --git a/src/card-controller/keyboard-state-manager.ts b/src/card-controller/keyboard-state-manager.ts index 53a24a6c..b2ad3626 100644 --- a/src/card-controller/keyboard-state-manager.ts +++ b/src/card-controller/keyboard-state-manager.ts @@ -1,5 +1,5 @@ import { CardKeyboardStateAPI, KeysState } from './types'; -import isEqual from 'lodash/isEqual'; +import isEqual from 'lodash-es/isEqual'; export class KeyboardStateManager { protected _api: CardKeyboardStateAPI; diff --git a/src/card-controller/types.ts b/src/card-controller/types.ts index 7eb9ebf7..a77d89af 100644 --- a/src/card-controller/types.ts +++ b/src/card-controller/types.ts @@ -3,7 +3,7 @@ import type { ConditionsManager } from './conditions-manager'; import type { EntityRegistryManager } from '../utils/ha/entity-registry'; import type { ResolvedMediaCache } from '../utils/ha/resolved-media'; import type { ActionsManager } from './actions/actions-manager'; -import type { AutoUpdateManager } from './auto-update-manager'; +import type { DefaultManager } from './default-manager'; import type { AutomationsManager } from './automations-manager'; import type { CameraURLManager } from './camera-url-manager'; import type { CardElementManager } from './card-element-manager'; @@ -60,13 +60,6 @@ export interface CardAutomationsAPI { getMessageManager(): MessageManager; } -export interface CardAutoRefreshAPI { - getConfigManager(): ConfigManager; - getInteractionManager(): InteractionManager; - getTriggersManager(): TriggersManager; - getViewManager(): ViewManager; -} - export interface CardCameraAPI { getActionsManager(): ActionsManager; getConfigManager(): ConfigManager; @@ -92,6 +85,7 @@ export interface CardConfigAPI { getCardElementManager(): CardElementManager; getConditionsManager(): ConditionsManager; getConfigManager(): ConfigManager; + getDefaultManager(): DefaultManager; getInitializationManager(): InitializationManager; getMediaLoadedInfoManager(): MediaLoadedInfoManager; getMessageManager(): MessageManager; @@ -104,6 +98,14 @@ export interface CardConfigLoaderAPI { getAutomationsManager(): AutomationsManager; } +export interface CardDefaultManagerAPI { + getConfigManager(): ConfigManager; + getHASSManager(): HASSManager; + getInteractionManager(): InteractionManager; + getTriggersManager(): TriggersManager; + getViewManager(): ViewManager; +} + export interface CardDownloadAPI { getCameraManager(): CameraManager; getHASSManager(): HASSManager; @@ -115,6 +117,7 @@ export interface CardDownloadAPI { export interface CardElementAPI { getActionsManager(): ActionsManager; getCameraManager(): CameraManager; + getDefaultManager(): DefaultManager; getExpandManager(): ExpandManager; getFullscreenManager(): FullscreenManager; getInitializationManager(): InitializationManager; @@ -143,6 +146,7 @@ export interface CardHASSAPI { getCardElementManager(): CardElementManager; getConditionsManager(): ConditionsManager; getConfigManager(): ConfigManager; + getDefaultManager(): DefaultManager; getInteractionManager(): InteractionManager; getMediaPlayerManager(): MediaPlayerManager; getMessageManager(): MessageManager; @@ -155,6 +159,7 @@ export interface CardInitializerAPI { getCameraManager(): CameraManager; getCardElementManager(): CardElementManager; getConfigManager(): ConfigManager; + getDefaultManager(): DefaultManager; getEntityRegistryManager(): EntityRegistryManager; getHASSManager(): HASSManager; getMediaPlayerManager(): MediaPlayerManager; @@ -233,7 +238,6 @@ export interface CardTriggersAPI { } export interface CardViewAPI { - getAutoUpdateManager(): AutoUpdateManager; getCameraManager(): CameraManager; getCardElementManager(): CardElementManager; getConditionsManager(): ConditionsManager; diff --git a/src/card-controller/view-manager.ts b/src/card-controller/view-manager.ts index 5a369700..655d7ab3 100644 --- a/src/card-controller/view-manager.ts +++ b/src/card-controller/view-manager.ts @@ -55,7 +55,7 @@ export class ViewManager { let forceCameraID: string | null = params?.cameraID ?? null; const viewName = config.view.default; - if (!forceCameraID && this._view?.camera && config.view.update_cycle_camera) { + if (!forceCameraID && this._view?.camera && config.view.default_cycle_camera) { const cameraIDs = [ ...getCameraIDsForViewName(this._api.getCameraManager(), viewName), ]; @@ -69,10 +69,6 @@ export class ViewManager { viewName: viewName, ...(forceCameraID && { cameraID: forceCameraID }), }); - - // Restart the refresh timer, so the default view is refreshed at a fixed - // interval from now (if so configured). - this._api.getAutoUpdateManager().startDefaultViewTimer(); } } diff --git a/src/config/management.ts b/src/config/management.ts index 031abd2a..9f7d73ae 100644 --- a/src/config/management.ts +++ b/src/config/management.ts @@ -25,7 +25,11 @@ import { CONF_OVERRIDES, CONF_PROFILES, CONF_TIMELINE_EVENTS_MEDIA_TYPE, + CONF_VIEW_DEFAULT_CYCLE_CAMERA, CONF_VIEW_INTERACTION_SECONDS, + CONF_VIEW_DEFAULT_RESET_EVERY_SECONDS, + CONF_VIEW_DEFAULT_RESET_ENTITIES, + CONF_VIEW_DEFAULT_RESET_INTERACTION_MODE, CONF_VIEW_TRIGGERS, CONF_VIEW_TRIGGERS_ACTIONS_TRIGGER, CONF_VIEW_TRIGGERS_ACTIONS_UNTRIGGER, @@ -805,4 +809,17 @@ const UPGRADES = [ keepOriginal: true, }), upgradeWithOverrides('live.controls.ptz', ptzControlSettingsTransform), + upgradeMoveToWithOverrides('view.update_cycle_camera', CONF_VIEW_DEFAULT_CYCLE_CAMERA), + upgradeMoveToWithOverrides( + 'view.update_force', + CONF_VIEW_DEFAULT_RESET_INTERACTION_MODE, + { + transform: (val) => (val === true ? 'all' : null), + }, + ), + upgradeMoveToWithOverrides( + 'view.update_seconds', + CONF_VIEW_DEFAULT_RESET_EVERY_SECONDS, + ), + upgradeMoveToWithOverrides('view.update_entities', CONF_VIEW_DEFAULT_RESET_ENTITIES), ]; diff --git a/src/config/profiles/low-performance.ts b/src/config/profiles/low-performance.ts index 92fcea8f..e80e07cc 100644 --- a/src/config/profiles/low-performance.ts +++ b/src/config/profiles/low-performance.ts @@ -137,5 +137,5 @@ export const LOW_PERFORMANCE_PROFILE = { [CONF_CAMERAS_GLOBAL_IMAGE_REFRESH_SECONDS]: 10, // No trigger actions. - [CONF_VIEW_TRIGGERS_ACTIONS_TRIGGER]: 'none' + [CONF_VIEW_TRIGGERS_ACTIONS_TRIGGER]: 'none', }; diff --git a/src/config/types.ts b/src/config/types.ts index 8289ecc3..e62089ec 100644 --- a/src/config/types.ts +++ b/src/config/types.ts @@ -1396,16 +1396,18 @@ const viewConfigDefault = { default: FRIGATE_CARD_VIEW_DEFAULT, camera_select: 'current' as const, interaction_seconds: 300, - reset_after_interaction: true, - update_seconds: 0, - update_force: false, - update_cycle_camera: false, + default_reset: { + every_seconds: 0, + after_interaction: false, + entities: [], + interaction_mode: 'inactive' as const, + }, + default_cycle_camera: false, dark_mode: 'off' as const, triggers: { show_trigger_status: false, filter_selected_camera: true, actions: { - interaction_mode: 'inactive' as const, trigger: 'update' as const, untrigger: 'none' as const, }, @@ -1414,12 +1416,13 @@ const viewConfigDefault = { keyboard_shortcuts: keyboardShortcutsDefault, }; +const interactionModeSchema = z.enum(['all', 'inactive', 'active']).default('inactive'); +export type InteractionMode = z.infer; + export const triggersSchema = z.object({ actions: z .object({ - interaction_mode: z - .enum(['all', 'inactive', 'active']) - .default(viewConfigDefault.triggers.actions.interaction_mode), + interaction_mode: interactionModeSchema, trigger: z .enum(['default', 'live', 'media', 'none', 'update']) .default(viewConfigDefault.triggers.actions.trigger), @@ -1447,13 +1450,21 @@ const viewConfigSchema = z .enum([...FRIGATE_CARD_VIEWS_USER_SPECIFIED, 'current']) .default(viewConfigDefault.camera_select), 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), - update_entities: z.string().array().optional(), + default_cycle_camera: z.boolean().default(viewConfigDefault.default_cycle_camera), + + default_reset: z + .object({ + after_interaction: z + .boolean() + .default(viewConfigDefault.default_reset.after_interaction), + every_seconds: z.number().default(viewConfigDefault.default_reset.every_seconds), + entities: z.string().array().default(viewConfigDefault.default_reset.entities), + interaction_mode: interactionModeSchema.default( + viewConfigDefault.default_reset.interaction_mode, + ), + }) + .default(viewConfigDefault.default_reset), + render_entities: z.string().array().optional(), dark_mode: z.enum(['on', 'off', 'auto']).optional(), triggers: triggersSchema.default(viewConfigDefault.triggers), diff --git a/src/const.ts b/src/const.ts index 0593f1e5..0b13b4a4 100644 --- a/src/const.ts +++ b/src/const.ts @@ -123,11 +123,17 @@ export const CONF_VIEW_KEYBOARD_SHORTCUTS_PTZ_ZOOM_OUT = `${CONF_VIEW_KEYBOARD_SHORTCUTS}.ptz_zoom_out` as const; export const CONF_VIEW_KEYBOARD_SHORTCUTS_PTZ_HOME = `${CONF_VIEW_KEYBOARD_SHORTCUTS}.ptz_home` 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_DEFAULT_CYCLE_CAMERA = + `${CONF_VIEW}.default_cycle_camera` as const; +export const CONF_VIEW_DEFAULT_RESET = `${CONF_VIEW}.default_reset` as const; +export const CONF_VIEW_DEFAULT_RESET_INTERACTION_MODE = + `${CONF_VIEW_DEFAULT_RESET}.interaction_mode` as const; +export const CONF_VIEW_DEFAULT_RESET_EVERY_SECONDS = + `${CONF_VIEW_DEFAULT_RESET}.every_seconds` as const; +export const CONF_VIEW_DEFAULT_RESET_ENTITIES = + `${CONF_VIEW_DEFAULT_RESET}.entities` as const; +export const CONF_VIEW_DEFAULT_RESET_AFTER_INTERACTION = + `${CONF_VIEW_DEFAULT_RESET}.after_interaction` as const; export const CONF_VIEW_TRIGGERS = `${CONF_VIEW}.triggers` as const; export const CONF_VIEW_TRIGGERS_SHOW_TRIGGER_STATUS = `${CONF_VIEW_TRIGGERS}.show_trigger_status` as const; diff --git a/src/editor.ts b/src/editor.ts index bbc7ed33..9839194b 100644 --- a/src/editor.ts +++ b/src/editor.ts @@ -201,7 +201,7 @@ import { CONF_VIEW_KEYBOARD_SHORTCUTS_PTZ_UP, CONF_VIEW_KEYBOARD_SHORTCUTS_PTZ_ZOOM_IN, CONF_VIEW_KEYBOARD_SHORTCUTS_PTZ_ZOOM_OUT, - CONF_VIEW_RESET_AFTER_INTERACTION, + CONF_VIEW_DEFAULT_RESET_AFTER_INTERACTION, CONF_VIEW_TRIGGERS, CONF_VIEW_TRIGGERS_ACTIONS, CONF_VIEW_TRIGGERS_ACTIONS_INTERACTION_MODE, @@ -210,10 +210,12 @@ import { CONF_VIEW_TRIGGERS_FILTER_SELECTED_CAMERA, CONF_VIEW_TRIGGERS_SHOW_TRIGGER_STATUS, CONF_VIEW_TRIGGERS_UNTRIGGER_SECONDS, - CONF_VIEW_UPDATE_CYCLE_CAMERA, - CONF_VIEW_UPDATE_FORCE, - CONF_VIEW_UPDATE_SECONDS, + CONF_VIEW_DEFAULT_CYCLE_CAMERA, MEDIA_CHUNK_SIZE_MAX, + CONF_VIEW_DEFAULT_RESET, + CONF_VIEW_DEFAULT_RESET_EVERY_SECONDS, + CONF_VIEW_DEFAULT_RESET_INTERACTION_MODE, + CONF_VIEW_DEFAULT_RESET_ENTITIES, } from './const.js'; import { localize } from './localize/localize.js'; import frigate_card_editor_style from './scss/editor.scss'; @@ -263,6 +265,7 @@ const MENU_PERFORMANCE_FEATURES = 'performance.features'; const MENU_PERFORMANCE_STYLE = 'performance.style'; const MENU_TIMELINE_CONTROLS_THUMBNAILS = 'timeline.controls.thumbnails'; const MENU_VIEW_KEYBOARD_SHORTCUTS = 'view.keyboard_shortcuts'; +const MENU_VIEW_DEFAULT_RESET = 'view.default_reset'; const MENU_VIEW_TRIGGERS = 'view.triggers'; const MENU_VIEW_TRIGGERS_ACTIONS = 'view.triggers.actions'; @@ -813,6 +816,22 @@ export class FrigateCardEditor extends LitElement implements LovelaceCardEditor }, ]; + protected _defaultResetInteractionModes: EditorSelectOption[] = [ + { value: '', label: '' }, + { + value: 'all', + label: localize('config.view.default_reset.interaction_modes.all'), + }, + { + value: 'inactive', + label: localize('config.view.default_reset.interaction_modes.inactive'), + }, + { + value: 'active', + label: localize('config.view.default_reset.interaction_modes.active'), + }, + ]; + public setConfig(config: RawFrigateCardConfig): void { // Note: This does not use Zod to parse the full configuration, so it may be // partially or completely invalid. It's more useful to have a partially @@ -1068,6 +1087,36 @@ export class FrigateCardEditor extends LitElement implements LovelaceCardEditor ); } + protected _renderViewDefaultResetMenu(): TemplateResult { + return this._putInSubmenu( + MENU_VIEW_DEFAULT_RESET, + true, + `config.${CONF_VIEW_DEFAULT_RESET}.editor_label`, + { name: 'mdi:restart' }, + html` + ${this._renderSwitch( + CONF_VIEW_DEFAULT_RESET_AFTER_INTERACTION, + this._defaults.view.default_reset.after_interaction, + )} + ${this._renderNumberInput(CONF_VIEW_DEFAULT_RESET_EVERY_SECONDS)} + ${this._renderOptionSelector( + CONF_VIEW_DEFAULT_RESET_INTERACTION_MODE, + this._defaultResetInteractionModes, + { + label: localize('config.view.default_reset.interaction_mode'), + }, + )}, + ${this._renderOptionSelector( + CONF_VIEW_DEFAULT_RESET_ENTITIES, + this.hass ? getEntitiesFromHASS(this.hass) : [], + { + multiple: true, + }, + )} + `, + ); + } + protected _renderViewTriggersMenu(): TemplateResult { return this._putInSubmenu( MENU_VIEW_TRIGGERS, @@ -2330,19 +2379,10 @@ export class FrigateCardEditor extends LitElement implements LovelaceCardEditor ${this._renderOptionSelector(CONF_VIEW_DARK_MODE, this._darkModes)} ${this._renderNumberInput(CONF_VIEW_INTERACTION_SECONDS)} ${this._renderSwitch( - CONF_VIEW_RESET_AFTER_INTERACTION, - this._defaults.view.reset_after_interaction, + CONF_VIEW_DEFAULT_CYCLE_CAMERA, + this._defaults.view.default_cycle_camera, )} - ${this._renderNumberInput(CONF_VIEW_UPDATE_SECONDS)} - ${this._renderSwitch( - CONF_VIEW_UPDATE_FORCE, - this._defaults.view.update_force, - )} - ${this._renderSwitch( - CONF_VIEW_UPDATE_CYCLE_CAMERA, - this._defaults.view.update_cycle_camera, - )} - ${this._renderViewTriggersMenu()} + ${this._renderViewDefaultResetMenu()} ${this._renderViewTriggersMenu()} ${this._renderViewKeyboardShortcutMenu()} ` diff --git a/src/localize/languages/ca.json b/src/localize/languages/ca.json index 01d3b62c..9492db6f 100644 --- a/src/localize/languages/ca.json +++ b/src/localize/languages/ca.json @@ -436,12 +436,31 @@ "on": "Activat" }, "default": "Vista per defecte", + "default_cycle_camera": "Passeu per les càmeres quan s'actualitzi la vista predeterminada", + "default_reset": { + "after_interaction": "Restableix la vista predeterminada després de la interacció de l'usuari", + "editor_label": "", + "entities": "", + "every_seconds": "Actualitza la vista predeterminada cada X segons (0=mai)", + "interaction_mode": "", + "interaction_modes": { + "active": "", + "all": "", + "inactive": "" + } + }, "interaction_seconds": "Segons després de l'acció de l'usuari per continuar interactuant (0=mai)", "keyboard_shortcuts": { "editor_label": "", - "enabled": "" + "enabled": "", + "ptz_down": "", + "ptz_home": "", + "ptz_left": "", + "ptz_right": "", + "ptz_up": "", + "ptz_zoom_in": "", + "ptz_zoom_out": "" }, - "reset_after_interaction": "Restableix la vista predeterminada després de la interacció de l'usuari", "triggers": { "actions": { "editor_label": "Activar accions", @@ -469,9 +488,6 @@ "show_trigger_status": "Mostra la vora intermitent quan s'activa", "untrigger_seconds": "Segons després del canvi d'estat inactiu a desactivat" }, - "update_cycle_camera": "Passeu per les càmeres quan s'actualitzi la vista predeterminada", - "update_force": "Força les actualitzacions de la targeta (ignora la interacció de l'usuari)", - "update_seconds": "Actualitza la vista predeterminada cada X segons (0=mai)", "views": { "clip": "Clip més recent", "clips": "Galeria de clips", diff --git a/src/localize/languages/en.json b/src/localize/languages/en.json index e7d8d321..72f3ff79 100644 --- a/src/localize/languages/en.json +++ b/src/localize/languages/en.json @@ -362,7 +362,6 @@ "camera_ui": "Camera user interface", "cameras": "Cameras", "clips": "Clips", - "ptz_home": "PTZ Home", "display_mode": "Display mode", "download": "Download", "enabled": "Button enabled", @@ -378,6 +377,7 @@ "play": "Play / Pause", "priority": "Priority", "ptz_controls": "Show PTZ controls", + "ptz_home": "PTZ Home", "recordings": "Recordings", "screenshot": "Screenshot", "snapshots": "Snapshots", @@ -436,6 +436,19 @@ "on": "On" }, "default": "Default view", + "default_cycle_camera": "Cycle through cameras when default view updates", + "default_reset": { + "after_interaction": "Reset to the default view after user interaction ends", + "editor_label": "Default view reset behavior", + "entities": "Reset to the default view on entity state change", + "every_seconds": "Reset to default view every X seconds (0=never)", + "interaction_mode": "How default reset behaves when the card has human interaction", + "interaction_modes": { + "active": "Only allow reset when card has active human interaction", + "all": "Reset regardless of human interaction", + "inactive": "Only reset when card has no human interaction" + } + }, "interaction_seconds": "Seconds after user action to remain interacted with (0=never)", "keyboard_shortcuts": { "editor_label": "Keyboard shortcuts", @@ -448,7 +461,6 @@ "ptz_zoom_in": "PTZ Zoom In", "ptz_zoom_out": "PTZ Zoom Out" }, - "reset_after_interaction": "Reset to the default view after user interaction", "triggers": { "actions": { "editor_label": "Trigger actions", @@ -476,9 +488,6 @@ "show_trigger_status": "Show pulsing border when triggered", "untrigger_seconds": "Seconds after inactive state change to untrigger" }, - "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)", "views": { "clip": "Most recent clip", "clips": "Clips gallery", diff --git a/src/localize/languages/fr.json b/src/localize/languages/fr.json index 5d435d66..43acdfe4 100644 --- a/src/localize/languages/fr.json +++ b/src/localize/languages/fr.json @@ -436,12 +436,31 @@ "on": "Activé" }, "default": "Vue par défaut", + "default_cycle_camera": "Parcourez les caméras lorsque la vue par défaut est mise à jour", + "default_reset": { + "after_interaction": "", + "editor_label": "", + "entities": "", + "every_seconds": "Actualiser la vue par défaut toutes les X secondes (0=jamais)", + "interaction_mode": "", + "interaction_modes": { + "active": "", + "all": "", + "inactive": "" + } + }, "interaction_seconds": "", "keyboard_shortcuts": { "editor_label": "", - "enabled": "" + "enabled": "", + "ptz_down": "", + "ptz_home": "", + "ptz_left": "", + "ptz_right": "", + "ptz_up": "", + "ptz_zoom_in": "", + "ptz_zoom_out": "" }, - "reset_after_interaction": "", "triggers": { "actions": { "editor_label": "", @@ -469,9 +488,6 @@ "show_trigger_status": "Afficher la bordure clignotante lors du déclenchement", "untrigger_seconds": "Quelques secondes après le changement d'état inactif pour débloquer" }, - "update_cycle_camera": "Parcourez les caméras lorsque la vue par défaut est mise à jour", - "update_force": "Forcer les mises à jour de la carte (ignorer l'interaction de l'utilisateur)", - "update_seconds": "Actualiser la vue par défaut toutes les X secondes (0=jamais)", "views": { "clip": "Clip le plus récent", "clips": "Galerie de clips", diff --git a/src/localize/languages/it.json b/src/localize/languages/it.json index 08276c1a..70527221 100644 --- a/src/localize/languages/it.json +++ b/src/localize/languages/it.json @@ -436,12 +436,31 @@ "on": "On" }, "default": "Visualizzazione predefinita", + "default_cycle_camera": "Scorri le telecamere quando si aggiorna la visualizzazione predefinita", + "default_reset": { + "after_interaction": "", + "editor_label": "", + "entities": "", + "every_seconds": "Aggiorna la visualizzazione predefinita ogni x secondi (0 = mai)", + "interaction_mode": "", + "interaction_modes": { + "active": "", + "all": "", + "inactive": "" + } + }, "interaction_seconds": "", "keyboard_shortcuts": { "editor_label": "", - "enabled": "" + "enabled": "", + "ptz_down": "", + "ptz_home": "", + "ptz_left": "", + "ptz_right": "", + "ptz_up": "", + "ptz_zoom_in": "", + "ptz_zoom_out": "" }, - "reset_after_interaction": "", "triggers": { "actions": { "editor_label": "", @@ -469,9 +488,6 @@ "show_trigger_status": "Mostra bordo pulsante quando attivato", "untrigger_seconds": "Reimposta la vista ai valori predefiniti dopo aver annullato l'attivazione" }, - "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)", "views": { "clip": "Clip più recente", "clips": "Galleria delle clip", diff --git a/src/localize/languages/pt-BR.json b/src/localize/languages/pt-BR.json index df882da2..865334e1 100644 --- a/src/localize/languages/pt-BR.json +++ b/src/localize/languages/pt-BR.json @@ -436,12 +436,31 @@ "on": "Ligado" }, "default": "Visualização padrão", + "default_cycle_camera": "Percorrer as câmeras quando a visualização padrão for atualizada", + "default_reset": { + "after_interaction": "", + "editor_label": "", + "entities": "", + "every_seconds": "Atualize a visualização padrão a cada X segundos (0 = nunca)", + "interaction_mode": "", + "interaction_modes": { + "active": "", + "all": "", + "inactive": "" + } + }, "interaction_seconds": "", "keyboard_shortcuts": { "editor_label": "", - "enabled": "" + "enabled": "", + "ptz_down": "", + "ptz_home": "", + "ptz_left": "", + "ptz_right": "", + "ptz_up": "", + "ptz_zoom_in": "", + "ptz_zoom_out": "" }, - "reset_after_interaction": "", "triggers": { "actions": { "editor_label": "", @@ -469,9 +488,6 @@ "show_trigger_status": "Pulsar borda quando acionado", "untrigger_seconds": "Segundos após a mudar para o estado inativo para desacionar" }, - "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)", "views": { "clip": "Clipe mais recente", "clips": "Galeria de clipes", diff --git a/src/localize/languages/pt-PT.json b/src/localize/languages/pt-PT.json index ad966a0b..119c7ada 100644 --- a/src/localize/languages/pt-PT.json +++ b/src/localize/languages/pt-PT.json @@ -436,12 +436,31 @@ "on": "Ligado" }, "default": "Visualização padrão", + "default_cycle_camera": "Percorrer as câmeras quando a visualização padrão for atualizada", + "default_reset": { + "after_interaction": "", + "editor_label": "", + "entities": "", + "every_seconds": "Atualize a visualização padrão a cada X segundos (0 = nunca)", + "interaction_mode": "", + "interaction_modes": { + "active": "", + "all": "", + "inactive": "" + } + }, "interaction_seconds": "", "keyboard_shortcuts": { "editor_label": "", - "enabled": "" + "enabled": "", + "ptz_down": "", + "ptz_home": "", + "ptz_left": "", + "ptz_right": "", + "ptz_up": "", + "ptz_zoom_in": "", + "ptz_zoom_out": "" }, - "reset_after_interaction": "", "triggers": { "actions": { "editor_label": "", @@ -469,9 +488,6 @@ "show_trigger_status": "Exibir estado do gatilho", "untrigger_seconds": "Segundos após a mudar para o estado inativo para desacionar" }, - "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)", "views": { "clip": "Clipe mais recente", "clips": "Galeria de clipes", diff --git a/src/utils/ha/index.ts b/src/utils/ha/index.ts index 1d52a860..1a85e69a 100644 --- a/src/utils/ha/index.ts +++ b/src/utils/ha/index.ts @@ -24,6 +24,8 @@ import { SubscriptionUnsubscribe, } from './types.js'; +export type DestroyCallback = () => Promise; + /** * Make a HomeAssistant websocket request. May throw. * @param hass The HomeAssistant object to send the request with. diff --git a/src/utils/interaction-mode.ts b/src/utils/interaction-mode.ts new file mode 100644 index 00000000..a500f7b5 --- /dev/null +++ b/src/utils/interaction-mode.ts @@ -0,0 +1,15 @@ +import { InteractionMode } from '../config/types'; + +export const isActionAllowedBasedOnInteractionState = ( + interactionMode: InteractionMode, + interactionState: boolean, +): boolean => { + switch (interactionMode) { + case 'all': + return true; + case 'active': + return interactionState; + case 'inactive': + return !interactionState; + } +}; diff --git a/tests/card-controller/auto-update-manager.test.ts b/tests/card-controller/auto-update-manager.test.ts deleted file mode 100644 index 34382e1b..00000000 --- a/tests/card-controller/auto-update-manager.test.ts +++ /dev/null @@ -1,73 +0,0 @@ -import { add } from 'date-fns'; -import { afterEach, describe, expect, it, vi } from 'vitest'; -import { AutoUpdateManager } from '../../src/card-controller/auto-update-manager'; -import { createCardAPI, createConfig } from '../test-utils'; - -// @vitest-environment jsdom -describe('AutoUpdateManager', () => { - const start = new Date('2023-09-23T19:12:00'); - - afterEach(() => { - vi.useRealTimers(); - }); - - it('should set default view when allowed', () => { - const api = createCardAPI(); - vi.mocked(api.getConfigManager().getConfig).mockReturnValue( - createConfig({ - view: { - update_seconds: 10, - }, - }), - ); - // Card is triggered. - vi.mocked(api.getTriggersManager().isTriggered).mockReturnValue(true); - vi.mocked(api.getInteractionManager().hasInteraction).mockReturnValue(false); - - vi.useFakeTimers(); - vi.setSystemTime(start); - - const manager = new AutoUpdateManager(api); - manager.startDefaultViewTimer(); - - expect(api.getViewManager().setViewDefault).not.toBeCalled(); - - vi.setSystemTime(add(start, { seconds: 10 })); - vi.runOnlyPendingTimers(); - - expect(api.getViewManager().setViewDefault).not.toBeCalled(); - - vi.mocked(api.getTriggersManager().isTriggered).mockReturnValue(false); - - vi.setSystemTime(add(start, { seconds: 20 })); - vi.runOnlyPendingTimers(); - - expect(api.getViewManager().setViewDefault).toBeCalled(); - }); - - it('should not set default view when not configured', () => { - const api = createCardAPI(); - vi.mocked(api.getConfigManager().getConfig).mockReturnValue( - createConfig({ - view: { - update_seconds: 0, - }, - }), - ); - vi.mocked(api.getTriggersManager().isTriggered).mockReturnValue(false); - vi.mocked(api.getInteractionManager().hasInteraction).mockReturnValue(false); - - vi.useFakeTimers(); - vi.setSystemTime(start); - - const manager = new AutoUpdateManager(api); - manager.startDefaultViewTimer(); - - expect(api.getViewManager().setViewDefault).not.toBeCalled(); - - vi.setSystemTime(add(start, { seconds: 10 })); - vi.runOnlyPendingTimers(); - - expect(api.getViewManager().setViewDefault).not.toBeCalled(); - }); -}); diff --git a/tests/card-controller/config/config-manager.test.ts b/tests/card-controller/config/config-manager.test.ts index de06f683..2272b86e 100644 --- a/tests/card-controller/config/config-manager.test.ts +++ b/tests/card-controller/config/config-manager.test.ts @@ -301,5 +301,38 @@ describe('ConfigManager', () => { InitializationAspect.MICROPHONE_CONNECT, ); }); + + it('view.default_reset', () => { + const api = createCardAPI(); + const manager = new ConfigManager(api); + const config_1 = { + type: 'custom:frigate-card', + cameras: [{ camera_entity: 'camera.office' }], + view: { + default_reset: { + every_seconds: 1, + }, + }, + }; + vi.mocked(getOverriddenConfig).mockReturnValue(createConfig(config_1)); + + manager.setConfig(config_1); + expect(api.getInitializationManager().uninitialize).not.toBeCalled(); + + const config_2 = { + ...config_1, + view: { + default_reset: { + every_seconds: 2, + }, + }, + }; + vi.mocked(getOverriddenConfig).mockReturnValue(createConfig(config_2)); + manager.computeOverrideConfig(); + + expect(api.getInitializationManager().uninitialize).toBeCalledWith( + InitializationAspect.DEFAULT_RESET, + ); + }); }); }); diff --git a/tests/card-controller/controller.test.ts b/tests/card-controller/controller.test.ts index ca4f6d7b..f2ae1f97 100644 --- a/tests/card-controller/controller.test.ts +++ b/tests/card-controller/controller.test.ts @@ -1,7 +1,6 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; import { CameraManager } from '../../src/camera-manager/manager'; import { ActionsManager } from '../../src/card-controller/actions/actions-manager'; -import { AutoUpdateManager } from '../../src/card-controller/auto-update-manager'; import { AutomationsManager } from '../../src/card-controller/automations-manager'; import { CameraURLManager } from '../../src/card-controller/camera-url-manager'; import { @@ -11,6 +10,7 @@ import { import { ConditionsManager } from '../../src/card-controller/conditions-manager'; import { ConfigManager } from '../../src/card-controller/config/config-manager'; import { CardController } from '../../src/card-controller/controller'; +import { DefaultManager } from '../../src/card-controller/default-manager'; import { DownloadManager } from '../../src/card-controller/download-manager'; import { ExpandManager } from '../../src/card-controller/expand-manager'; import { FullscreenManager } from '../../src/card-controller/fullscreen-manager'; @@ -32,12 +32,12 @@ import { ResolvedMediaCache } from '../../src/utils/ha/resolved-media'; vi.mock('../../src/camera-manager/manager'); vi.mock('../../src/card-controller/actions/actions-manager'); -vi.mock('../../src/card-controller/auto-update-manager'); vi.mock('../../src/card-controller/automations-manager'); vi.mock('../../src/card-controller/camera-url-manager'); vi.mock('../../src/card-controller/card-element-manager'); vi.mock('../../src/card-controller/conditions-manager'); vi.mock('../../src/card-controller/config/config-manager'); +vi.mock('../../src/card-controller/default-manager'); vi.mock('../../src/card-controller/download-manager'); vi.mock('../../src/card-controller/expand-manager'); vi.mock('../../src/card-controller/fullscreen-manager'); @@ -107,9 +107,9 @@ describe('CardController', () => { ); }); - it('getAutoUpdateManager', () => { - expect(createController().getAutoUpdateManager()).toBe( - vi.mocked(AutoUpdateManager).mock.instances[0], + it('getDefaultManager', () => { + expect(createController().getDefaultManager()).toBe( + vi.mocked(DefaultManager).mock.instances[0], ); }); diff --git a/tests/card-controller/default-manager.test.ts b/tests/card-controller/default-manager.test.ts new file mode 100644 index 00000000..fedb496e --- /dev/null +++ b/tests/card-controller/default-manager.test.ts @@ -0,0 +1,163 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { DefaultManager } from '../../src/card-controller/default-manager'; +import { + callHASubscribeMessageHandler, + createCardAPI, + createConfig, + createHASS, +} from '../test-utils'; + +// @vitest-environment jsdom +describe('DefaultManager', () => { + afterEach(() => { + vi.useRealTimers(); + }); + + describe('time based', () => { + it('should set default view when allowed', async () => { + const api = createCardAPI(); + vi.mocked(api.getConfigManager().getConfig).mockReturnValue( + createConfig({ + view: { + default_reset: { + every_seconds: 10, + }, + }, + }), + ); + vi.mocked(api.getInteractionManager().hasInteraction).mockReturnValue(true); + + vi.useFakeTimers(); + + const manager = new DefaultManager(api); + await manager.initialize(); + + expect(api.getViewManager().setViewDefault).not.toBeCalled(); + + vi.runOnlyPendingTimers(); + + expect(api.getViewManager().setViewDefault).not.toBeCalled(); + + vi.mocked(api.getInteractionManager().hasInteraction).mockReturnValue(false); + vi.runOnlyPendingTimers(); + + expect(api.getViewManager().setViewDefault).toBeCalledTimes(1); + + manager.uninitialize(); + vi.runOnlyPendingTimers(); + + expect(api.getViewManager().setViewDefault).toBeCalledTimes(1); + }); + + it('should not set default view when not configured', () => { + const api = createCardAPI(); + vi.mocked(api.getConfigManager().getConfig).mockReturnValue( + createConfig({ + view: { + default_reset: { + every_seconds: 0, + }, + }, + }), + ); + vi.mocked(api.getInteractionManager().hasInteraction).mockReturnValue(false); + + vi.useFakeTimers(); + + const manager = new DefaultManager(api); + manager.initialize(); + + expect(api.getViewManager().setViewDefault).not.toBeCalled(); + + vi.runOnlyPendingTimers(); + + expect(api.getViewManager().setViewDefault).not.toBeCalled(); + }); + + it('should restart timer when reconfigured', async () => { + const api = createCardAPI(); + vi.mocked(api.getConfigManager().getConfig).mockReturnValue( + createConfig({ + view: { + default_reset: { + every_seconds: 10, + }, + }, + }), + ); + vi.mocked(api.getInteractionManager().hasInteraction).mockReturnValue(false); + + const hass = createHASS(); + const unsubcribeCallback = vi.fn(); + vi.mocked(hass.connection.subscribeMessage).mockResolvedValue(unsubcribeCallback); + vi.mocked(api.getHASSManager().getHASS).mockReturnValue(hass); + + vi.useFakeTimers(); + + const manager = new DefaultManager(api); + + await manager.initialize(); + expect(api.getViewManager().setViewDefault).not.toBeCalled(); + + vi.runOnlyPendingTimers(); + + expect(api.getViewManager().setViewDefault).toBeCalled(); + }); + }); + + describe('state based', () => { + it('should set default view when state changed', async () => { + const api = createCardAPI(); + vi.mocked(api.getConfigManager().getConfig).mockReturnValue( + createConfig({ + view: { + default_reset: { + every_seconds: 10, + }, + }, + }), + ); + vi.mocked(api.getInteractionManager().hasInteraction).mockReturnValue(false); + + const hass = createHASS(); + const unsubcribeCallback = vi.fn(); + vi.mocked(hass.connection.subscribeMessage).mockResolvedValue(unsubcribeCallback); + vi.mocked(api.getHASSManager().getHASS).mockReturnValue(hass); + + const manager = new DefaultManager(api); + await manager.initialize(); + + callHASubscribeMessageHandler(hass, { + variables: { + trigger: { + from_state: { + entity_id: 'binary_sensor.foo', + state: 'off', + }, + to_state: { + entity_id: 'binary_sensor.foo', + state: 'on', + }, + }, + }, + }); + + await manager.initialize(); + expect(unsubcribeCallback).toBeCalledTimes(1); + + expect(api.getViewManager().setViewDefault).toBeCalledTimes(1); + }); + + it('should not monitor state without config', async () => { + const api = createCardAPI(); + const hass = createHASS(); + vi.mocked(api.getHASSManager().getHASS).mockReturnValue(hass); + + const manager = new DefaultManager(api); + await manager.initialize(); + + const mock = vi.mocked(hass.connection.subscribeMessage).mock; + expect(mock.calls.length).toBe(0); + }); + }); +}); diff --git a/tests/card-controller/hass-manager.test.ts b/tests/card-controller/hass-manager.test.ts index 3d0a1fa3..6bf3bf5a 100644 --- a/tests/card-controller/hass-manager.test.ts +++ b/tests/card-controller/hass-manager.test.ts @@ -146,7 +146,7 @@ describe('HASSManager', () => { }); }); - describe('should set default view when', () => { + describe('should not set default view when', () => { it('selected camera is unknown', () => { const api = createAPIWithoutMediaPlayers(); vi.mocked(api.getCameraManager).mockReturnValue(createCameraManager()); @@ -178,15 +178,18 @@ describe('HASSManager', () => { expect(api.getViewManager().setViewDefault).not.toBeCalled(); }); - it('view.update_entities changes', () => { + it('when there is card interaction', () => { const api = createAPIWithoutMediaPlayers(); vi.mocked(api.getConfigManager().getConfig).mockReturnValue( createConfig({ view: { - update_entities: ['sensor.force_default_view'], + default_reset: { + entities: ['sensor.force_default_view'], + }, }, }), ); + vi.mocked(api.getInteractionManager().hasInteraction).mockReturnValue(true); const manager = new HASSManager(api); const hass = createHASS({ @@ -195,7 +198,7 @@ describe('HASSManager', () => { manager.setHASS(hass); - expect(api.getViewManager().setViewDefault).toBeCalled(); + expect(api.getViewManager().setViewDefault).not.toBeCalled(); }); }); @@ -236,25 +239,4 @@ describe('HASSManager', () => { expect(api.getCardElementManager().update).toBeCalled(); }); }); - - it('set view default is not called when there is card interaction', () => { - const api = createAPIWithoutMediaPlayers(); - vi.mocked(api.getConfigManager().getConfig).mockReturnValue( - createConfig({ - view: { - update_entities: ['sensor.force_default_view'], - }, - }), - ); - vi.mocked(api.getInteractionManager().hasInteraction).mockReturnValue(true); - - const manager = new HASSManager(api); - const hass = createHASS({ - 'sensor.force_default_view': createStateEntity(), - }); - - manager.setHASS(hass); - - expect(api.getViewManager().setViewDefault).not.toBeCalled(); - }); }); diff --git a/tests/card-controller/initialization-manager.test.ts b/tests/card-controller/initialization-manager.test.ts index 6ba18553..fb8ba160 100644 --- a/tests/card-controller/initialization-manager.test.ts +++ b/tests/card-controller/initialization-manager.test.ts @@ -208,9 +208,13 @@ describe('InitializationManager', () => { expect(await manager.initializeBackgroundIfNecessary()).toBeFalsy(); }); - it('successfully with minimal initializers', async () => { + it('successfully when already initialized', async () => { const api = createCardAPI(); - const manager = new InitializationManager(api); + + const initializer = mock(); + initializer.isInitializedMultiple.mockReturnValue(true); + + const manager = new InitializationManager(api, initializer); vi.mocked(api.getHASSManager().getHASS).mockReturnValue(createHASS()); vi.mocked(api.getConfigManager().getConfig).mockReturnValue( createConfig({ @@ -226,6 +230,8 @@ describe('InitializationManager', () => { expect(await manager.initializeBackgroundIfNecessary()).toBeTruthy(); expect(api.getMediaPlayerManager().initialize).not.toBeCalled(); + expect(api.getDefaultManager().initialize).not.toBeCalled(); + expect(api.getCardElementManager().update).not.toBeCalled(); }); it('successfully with all inititalizers', async () => { @@ -246,10 +252,11 @@ describe('InitializationManager', () => { expect(await manager.initializeBackgroundIfNecessary()).toBeTruthy(); expect(api.getMediaPlayerManager().initialize).toBeCalled(); + expect(api.getDefaultManager().initialize).toBeCalled(); expect(api.getCardElementManager().update).toBeCalled(); }); - it('with media player in progress', async () => { + it('with initializers in progress', async () => { const api = createCardAPI(); vi.mocked(api.getHASSManager().getHASS).mockReturnValue(createHASS()); vi.mocked(api.getConfigManager().getConfig).mockReturnValue( diff --git a/tests/card-controller/interaction-manager.test.ts b/tests/card-controller/interaction-manager.test.ts index 8c17ae03..b0e2546a 100644 --- a/tests/card-controller/interaction-manager.test.ts +++ b/tests/card-controller/interaction-manager.test.ts @@ -23,12 +23,15 @@ describe('InteractionManager', () => { expect(api.getConditionsManager().setState).toBeCalledWith({ interaction: false }); }); - it('should take action when interaction is reported', () => { + it('should take action after interaction ends', () => { const api = createCardAPI(); vi.mocked(api.getConfigManager().getConfig).mockReturnValue( createConfig({ view: { interaction_seconds: 10, + default_reset: { + after_interaction: true, + }, }, }), ); @@ -90,12 +93,14 @@ describe('InteractionManager', () => { expect(api.getViewManager().setViewDefault).not.toBeCalled(); }); - it('should not take action without reset_after_interaction', () => { + it('should not take action without default_reset.after_interaction', () => { const api = createCardAPI(); vi.mocked(api.getConfigManager().getConfig).mockReturnValue( createConfig({ view: { - reset_after_interaction: false, + default_reset: { + after_interaction: false, + }, interaction_seconds: 10, }, }), diff --git a/tests/card-controller/view-manager.test.ts b/tests/card-controller/view-manager.test.ts index c0bed650..ae911bb4 100644 --- a/tests/card-controller/view-manager.test.ts +++ b/tests/card-controller/view-manager.test.ts @@ -129,7 +129,6 @@ describe('ViewManager.setViewDefault', () => { expect(manager.getView()?.view).toBe('live'); expect(manager.getView()?.camera).toBe('camera'); - expect(api.getAutoUpdateManager().startDefaultViewTimer).toBeCalled(); }); it('should not set default view without config', () => { @@ -140,7 +139,6 @@ describe('ViewManager.setViewDefault', () => { manager.setViewDefault(); expect(manager.getView()).toBeNull(); - expect(api.getAutoUpdateManager().startDefaultViewTimer).not.toBeCalled(); }); it('should cycle camera when configured', () => { @@ -161,7 +159,7 @@ describe('ViewManager.setViewDefault', () => { vi.mocked(api.getConfigManager().getConfig).mockReturnValue( createConfig({ view: { - update_cycle_camera: true, + default_cycle_camera: true, }, }), ); diff --git a/tests/config/management.test.ts b/tests/config/management.test.ts index bd9cfbcb..1ba51ed9 100644 --- a/tests/config/management.test.ts +++ b/tests/config/management.test.ts @@ -25,6 +25,7 @@ import { frigateCardConfigSchema, } from '../../src/config/types'; import { getParseErrorPaths } from '../../src/utils/zod'; +import { update } from 'lodash-es'; describe('general functions', () => { it('should set value', () => { @@ -134,6 +135,7 @@ describe('upgrade functions', () => { }); }); it('with non-number', () => { + // eslint-disable-next-line @typescript-eslint/no-unused-vars expect(createRangedTransform((_val) => 'foo')(1)).toBe('foo'); }); }); @@ -220,6 +222,7 @@ describe('upgrade functions', () => { c: 10, }; expect( + // eslint-disable-next-line @typescript-eslint/no-unused-vars moveConfigValue(config, 'c', 'd', { transform: (_val) => null }), ).toBeTruthy(); expect(config).toEqual({}); @@ -231,6 +234,7 @@ describe('upgrade functions', () => { }; expect( moveConfigValue(config, 'c', 'd', { + // eslint-disable-next-line @typescript-eslint/no-unused-vars transform: (_val) => null, keepOriginal: true, }), @@ -244,6 +248,7 @@ describe('upgrade functions', () => { c: 10, }; expect( + // eslint-disable-next-line @typescript-eslint/no-unused-vars moveConfigValue(config, 'c', 'd', { transform: (_val) => undefined }), ).toBeFalsy(); expect(config).toEqual({ c: 10 }); @@ -294,11 +299,13 @@ describe('upgrade functions', () => { describe('should upgrade array', () => { it('in case of non-array', () => { const config = { c: 10 }; + // eslint-disable-next-line @typescript-eslint/no-unused-vars expect(upgradeArrayOfObjects('c', (_val) => false)(config)).toBeFalsy(); }); it('in case of non-object items', () => { const config = { c: [10, 11] }; + // eslint-disable-next-line @typescript-eslint/no-unused-vars expect(upgradeArrayOfObjects('c', (_val) => false)(config)).toBeFalsy(); }); @@ -328,6 +335,7 @@ describe('upgrade functions', () => { describe('should recursively upgrade', () => { it('ignoring simple objects', () => { const config = { c: 10, d: 10 }; + // eslint-disable-next-line @typescript-eslint/no-unused-vars expect(upgradeObjectRecursively((_val) => false)(config)).toBeFalsy(); expect(config).toEqual({ c: 10, d: 10 }); }); @@ -3099,5 +3107,91 @@ describe('should handle version specific upgrades', () => { postUpgradeChecks(config); }); }); + + it('view.update_cycle_camera -> view.default_cycle_camera', () => { + const config = { + type: 'custom:frigate-card', + cameras: [{}], + view: { + update_cycle_camera: true, + }, + }; + + expect(upgradeConfig(config)).toBeTruthy(); + expect(config.view).toEqual({ + default_cycle_camera: true, + }); + postUpgradeChecks(config); + }); + + describe('view.update_force -> view.default_reset.interaction_mode', () => { + it('should convert to all when true', () => { + const config = { + type: 'custom:frigate-card', + cameras: [{}], + view: { + update_force: true, + }, + }; + + expect(upgradeConfig(config)).toBeTruthy(); + expect(config.view).toEqual({ + default_reset: { + interaction_mode: 'all', + }, + }); + postUpgradeChecks(config); + }); + + it('should remove when false', () => { + const config = { + type: 'custom:frigate-card', + cameras: [{}], + view: { + update_force: false, + }, + }; + + expect(upgradeConfig(config)).toBeTruthy(); + expect(config.view).toEqual({}); + postUpgradeChecks(config); + }); + }); + + it('view.update_seconds', () => { + const config = { + type: 'custom:frigate-card', + cameras: [{}], + view: { + update_seconds: 42, + }, + }; + + expect(upgradeConfig(config)).toBeTruthy(); + expect(config.view).toEqual({ + default_reset: { + every_seconds: 42, + }, + }); + postUpgradeChecks(config); + }); + + it('view.update_entities', () => { + const config = { + type: 'custom:frigate-card', + cameras: [{}], + view: { + update_entities: ['binary_sensor.foo', 'camera.bar'], + }, + }; + + expect(upgradeConfig(config)).toBeTruthy(); + expect(config.view).toEqual({ + default_reset: { + entities: ['binary_sensor.foo', 'camera.bar'], + }, + }); + postUpgradeChecks(config); + }); }); }); diff --git a/tests/config/types.test.ts b/tests/config/types.test.ts index 62ee482e..5b926078 100644 --- a/tests/config/types.test.ts +++ b/tests/config/types.test.ts @@ -331,10 +331,13 @@ describe('config defaults', () => { filter_selected_camera: true, }, interaction_seconds: 300, - reset_after_interaction: true, - update_cycle_camera: false, - update_force: false, - update_seconds: 0, + default_cycle_camera: false, + default_reset: { + after_interaction: false, + every_seconds: 0, + entities: [], + interaction_mode: 'inactive', + }, }, }); }); diff --git a/tests/test-utils.ts b/tests/test-utils.ts index 6b97f48e..2e5defb3 100644 --- a/tests/test-utils.ts +++ b/tests/test-utils.ts @@ -15,7 +15,7 @@ import { CameraManagerMediaCapabilities, } from '../src/camera-manager/types'; import { ActionsManager } from '../src/card-controller/actions/actions-manager'; -import { AutoUpdateManager } from '../src/card-controller/auto-update-manager'; +import { DefaultManager } from '../src/card-controller/default-manager'; import { AutomationsManager } from '../src/card-controller/automations-manager'; import { CameraURLManager } from '../src/card-controller/camera-url-manager'; import { CardElementManager } from '../src/card-controller/card-element-manager'; @@ -434,7 +434,7 @@ export const createCardAPI = (): CardController => { api.getActionsManager.mockReturnValue(mock()); api.getAutomationsManager.mockReturnValue(mock()); - api.getAutoUpdateManager.mockReturnValue(mock()); + api.getDefaultManager.mockReturnValue(mock()); api.getCameraManager.mockReturnValue(mock()); api.getCameraURLManager.mockReturnValue(mock()); api.getCardElementManager.mockReturnValue(mock()); diff --git a/tests/utils/interaction-mode.test.ts b/tests/utils/interaction-mode.test.ts new file mode 100644 index 00000000..41e68b9e --- /dev/null +++ b/tests/utils/interaction-mode.test.ts @@ -0,0 +1,19 @@ +import { describe, expect, it } from 'vitest'; +import { isActionAllowedBasedOnInteractionState } from '../../src/utils/interaction-mode'; + +describe('isActionAllowedBasedOnInteractionState', () => { + it('should handle interactionMode: all', () => { + expect(isActionAllowedBasedOnInteractionState('all', true)).toBeTruthy(); + expect(isActionAllowedBasedOnInteractionState('all', false)).toBeTruthy(); + }); + + it('should handle interactionMode: active', () => { + expect(isActionAllowedBasedOnInteractionState('active', true)).toBeTruthy(); + expect(isActionAllowedBasedOnInteractionState('active', false)).toBeFalsy(); + }); + + it('should handle interactionMode: inactive', () => { + expect(isActionAllowedBasedOnInteractionState('inactive', true)).toBeFalsy(); + expect(isActionAllowedBasedOnInteractionState('inactive', false)).toBeTruthy(); + }); +}); diff --git a/vite.config.ts b/vite.config.ts index 74dd7e43..a35a2154 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -34,6 +34,7 @@ const FULL_COVERAGE_FILES_RELATIVE = [ 'utils/endpoint.ts', 'utils/ha/entity-registry/types.ts', 'utils/ha/types.ts', + 'utils/interaction-mode.ts', 'utils/media-info.ts', 'utils/media-layout.ts', 'utils/media-to-view.ts',