diff --git a/docs/README.md b/docs/README.md index 339dc731..372f6c80 100644 --- a/docs/README.md +++ b/docs/README.md @@ -27,7 +27,7 @@ See [Advanced Installation](advanced-installation.md) for other installation res ## Initial configuration -### Minimal configuration +### Minimal camera configuration ```yaml type: custom:advanced-camera-card @@ -35,6 +35,14 @@ cameras: - camera_entity: camera.office ``` +### Minimal folder configuration + +```yaml +type: custom:advanced-camera-card +folders: + - type: ha +``` + ### Video scrubbing configuration ```yaml diff --git a/docs/configuration/README.md b/docs/configuration/README.md index 847f96dc..85c85f1f 100644 --- a/docs/configuration/README.md +++ b/docs/configuration/README.md @@ -2,7 +2,13 @@ The card supports a myriad of configuration options for simple or complex setups. -### Minimal configuration +### Absolute minimum configuration + +```yaml +type: custom:advanced-camera-card +``` + +### Minimal camera configuration ```yaml type: custom:advanced-camera-card @@ -10,20 +16,30 @@ cameras: - camera_entity: camera.office ``` +### Minimal folder configuration + +```yaml +type: custom:advanced-camera-card +folders: + - type: ha +``` + ### Configuration blocks #### Top-level configuration blocks -Only the `cameras` option is required, all other parameters are optional. +No top-level option is required. If `cameras` is omitted the card will still +render and can operate in `image` or `folders` views depending on configuration. | Option | Description | | ------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | [`automations`](automations.md) | Take action when conditions are met. | -| [`cameras`](cameras/README.md) | Configures the cameras to be used in the card. At least one camera must be specified. | +| [`cameras`](cameras/README.md) | Configures the cameras to be used in the card. Optional. If present, the first listed camera is the default. | | [`cameras_global`](cameras/README.md) | Global defaults that apply to all cameras from the `cameras` section. | | `card_id` | An optional ID to uniquely identify this card. For use when actions are being sent to card(s) via [URL actions](../usage/url-actions.md). Must exclusively consist of these characters: `[a-zA-Z0-9_]`. | | [`dimensions`](dimensions.md) | Configures the overall card dimensions. | | [`elements`](elements/README.md) | Add custom elements to the card. | +| [`folders`](folders.md) | Configures folders to be used in the card. Optional. If present, the first listed camera is the default. | | [`image`](image.md) | Configures the `image` view. | | [`live`](live.md) | Configures the `live` view. | | [`media_gallery`](media-gallery.md) | Configures the media gallery. | diff --git a/docs/configuration/cameras/README.md b/docs/configuration/cameras/README.md index 4adcb8e7..b7d1e85a 100644 --- a/docs/configuration/cameras/README.md +++ b/docs/configuration/cameras/README.md @@ -1,6 +1,6 @@ # Cameras -The `cameras` block configures a list of cameras the card should support. The first listed camera is the default. +The `cameras` block configures a list of cameras the card should support. This block is optional. When present, the first listed camera is the default. ```yaml cameras: diff --git a/docs/configuration/view.md b/docs/configuration/view.md index 080fd2aa..7d016e4c 100644 --- a/docs/configuration/view.md +++ b/docs/configuration/view.md @@ -12,7 +12,7 @@ view: | `actions` | | [Actions](actions/README.md) to use for all views, individual actions may be overriden by view-specific actions. | | `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. | | `dim` | `false` | Whether or not to 'dim' the brightness of the card (by 25%) if the card `interaction_seconds` has expired (i.e. card has been left unattended for that period of time). | -| `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` | `auto` | The view to show in the card by default. If `auto`, the card will choose `live` when cameras are configured, `folders` when folders are configured, or `image` otherwise (screensaver). 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. | | `keyboard_shortcuts` | See [usage](../usage/keyboard-shortcuts.md) for defaults. | Configure keyboard shortcuts. See below. | @@ -155,7 +155,7 @@ This card supports several different views. | `snapshots` | Shows a gallery of snapshots for this camera. | | `timeline` | Shows an event timeline. | -The default view is `live`, but can be configured by the `view.default` parameter. +The default view is `auto`. It will select `live` when cameras are configured, `folders` when folders are configured, or `image` otherwise (screensaver). You can override this with `view.default`. ## Fully expanded reference @@ -163,7 +163,7 @@ The default view is `live`, but can be configured by the `view.default` paramete ```yaml view: - default: live + default: auto camera_select: current interaction_seconds: 300 default_cycle_camera: false diff --git a/src/camera-manager/manager.ts b/src/camera-manager/manager.ts index 5eb1ed09..691dfdde 100644 --- a/src/camera-manager/manager.ts +++ b/src/camera-manager/manager.ts @@ -10,7 +10,7 @@ import { PTZActionPhase, PTZPanTiltAction, } from '../config/schema/actions/custom/ptz.js'; -import { CameraConfig, CamerasConfig, Rotation } from '../config/schema/cameras.js'; +import { CameraConfig, Rotation } from '../config/schema/cameras.js'; import { MEDIA_CHUNK_SIZE_DEFAULT } from '../const.js'; import { localize } from '../localize/localize.js'; import { Endpoint } from '../types.js'; @@ -166,7 +166,7 @@ export class CameraManager { // global config (which does have defaults). The merging must happen in this // order, to ensure that the defaults in the cameras global config do not // override the values specified in the per-camera config. - const cameras = config.cameras.map((camera) => + const cameras = (config.cameras ?? []).map((camera) => recursivelyMergeObjectsNotArrays({}, cloneDeep(config?.cameras_global), camera), ); @@ -186,7 +186,7 @@ export class CameraManager { } protected async _getEnginesForCameras( - camerasConfig: CamerasConfig, + camerasConfig: CameraConfig[], ): Promise> { const output: Map = new Map(); const engines: Map = new Map(); @@ -228,7 +228,7 @@ export class CameraManager { return output; } - protected async _initializeCameras(camerasConfig: CamerasConfig): Promise { + protected async _initializeCameras(camerasConfig: CameraConfig[]): Promise { const initializationStartTime = new Date(); const hass = this._api.getHASSManager().getHASS(); diff --git a/src/camera-manager/types.ts b/src/camera-manager/types.ts index 8a41d500..cf65cf82 100644 --- a/src/camera-manager/types.ts +++ b/src/camera-manager/types.ts @@ -111,6 +111,8 @@ interface CapabilitySearchAllAny { } export type CapabilitySearchKeys = CapabilityKey | CapabilitySearchAllAny; export interface CapabilitySearchOptions { + // If true, include a parent camera in results when any of its dependent + // (child) cameras have the capability, even if the parent itself doesn't. inclusive?: boolean; } diff --git a/src/card-controller/actions/actions/effect.ts b/src/card-controller/actions/actions/effect.ts index 3d5c4630..91d07fd4 100644 --- a/src/card-controller/actions/actions/effect.ts +++ b/src/card-controller/actions/actions/effect.ts @@ -8,13 +8,13 @@ export class EffectAction extends AdvancedCameraCardAction { switch (this._action.effect_action) { case 'start': - api.getEffectsControllerAPI()?.startEffect(this._action.effect); + api.getEffectsManager().startEffect(this._action.effect); break; case 'stop': - api.getEffectsControllerAPI()?.stopEffect(this._action.effect); + api.getEffectsManager().stopEffect(this._action.effect); break; case 'toggle': - api.getEffectsControllerAPI()?.toggleEffect(this._action.effect); + api.getEffectsManager().toggleEffect(this._action.effect); break; } } diff --git a/src/card-controller/actions/actions/media-player.ts b/src/card-controller/actions/actions/media-player.ts index 0a4fde1b..b3703cd4 100644 --- a/src/card-controller/actions/actions/media-player.ts +++ b/src/card-controller/actions/actions/media-player.ts @@ -20,7 +20,10 @@ export class MediaPlayerAction extends AdvancedCameraCardAction { // When the camera changes, update the entity to match (only if different // to avoid race conditions when multiple cards share the same entity). // See: https://github.com/dermotduffy/advanced-camera-card/issues/2244 - createInternalCallbackAction((api: CardActionsAPI) => + createInternalCallbackAction(async (api: CardActionsAPI) => selectOptionOnEntityIfDifferent( - cameraControlEntity, - api.getViewManager().getView()?.camera, api, + cameraControlEntity, + api.getViewManager().getView()?.camera ?? undefined, ), ), ], @@ -76,13 +76,10 @@ export const setRemoteControlEntityFromConfig = (api: CardConfigLoaderAPI) => { `{{ hass.states["${cameraControlEntity}"].state }}`, ) : // Set the selected option in the entity to the current camera ID. - createInternalCallbackAction((api: CardActionsAPI) => - selectOptionOnEntityIfDifferent( - cameraControlEntity, - api.getViewManager().getView()?.camera, - api, - ), - ), + createInternalCallbackAction(async (api: CardActionsAPI) => { + const camera = api.getViewManager().getView()?.camera ?? undefined; + return selectOptionOnEntityIfDifferent(api, cameraControlEntity, camera); + }), ], tag: automationTag, }, @@ -108,9 +105,9 @@ export const setRemoteControlEntityFromConfig = (api: CardConfigLoaderAPI) => { }; const selectOptionOnEntityIfDifferent = async ( - entity: string, - option: string | undefined, api: CardActionsAPI, + entity: string, + option?: string, ): Promise => { const hass = api.getHASSManager().getHASS(); const currentState = hass?.states[entity]?.state; diff --git a/src/card-controller/controller.ts b/src/card-controller/controller.ts index 15f45156..021c86b1 100644 --- a/src/card-controller/controller.ts +++ b/src/card-controller/controller.ts @@ -1,5 +1,6 @@ import { ReactiveController } from 'lit'; import { CameraManager } from '../camera-manager/manager'; +import { EffectsManager } from './effects/effects-manager'; import { ConditionStateManager } from '../conditions/state-manager'; import { AdvancedCameraCardConfig } from '../config/schema/types'; import { DeviceRegistryManager } from '../ha/registry/device'; @@ -8,7 +9,6 @@ import { EntityRegistryManagerLive } from '../ha/registry/entity'; import { EntityCache, EntityRegistryManager } from '../ha/registry/entity/types'; import { ResolvedMediaCache } from '../ha/resolved-media'; import { LovelaceCardEditor } from '../ha/types'; -import { EffectsControllerAPI } from '../types'; import { ActionsManager } from './actions/actions-manager'; import { AutomationsManager } from './automations-manager'; import { CameraURLManager } from './camera-url-manager'; @@ -28,7 +28,6 @@ import { InitializationManager } from './initialization-manager'; import { InteractionManager } from './interaction-manager'; import { KeyboardStateManager } from './keyboard-state-manager'; import { MediaLoadedInfoManager } from './media-info-manager'; - import { MediaPlayerManager } from './media-player-manager'; import { MessageManager } from './message-manager'; import { MicrophoneManager } from './microphone-manager'; @@ -67,8 +66,6 @@ import { import { ViewItemManager } from './view/item-manager'; import { ViewManager } from './view/view-manager'; -type EffectsControllerAPICallback = () => EffectsControllerAPI | null; - export class CardController implements CardActionsManagerAPI, @@ -97,9 +94,8 @@ export class CardController CardViewAPI, ReactiveController { - protected _effectsControllerAPICallback: EffectsControllerAPICallback; - protected _conditionStateManager = new ConditionStateManager(); + protected _effectsManager = new EffectsManager(); // These properties may be used in the construction of 'managers' (and should // be created first). @@ -138,7 +134,6 @@ export class CardController host: CardHTMLElement, scrollCallback: ScrollCallback, menuToggleCallback: MenuToggleCallback, - effectsControllerAPICallback: EffectsControllerAPICallback, ) { host.addController(this); @@ -148,7 +143,6 @@ export class CardController scrollCallback, menuToggleCallback, ); - this._effectsControllerAPICallback = effectsControllerAPICallback; } // ************************************************************************* @@ -199,8 +193,8 @@ export class CardController return this._deviceRegistryManager; } - public getEffectsControllerAPI(): EffectsControllerAPI | null { - return this._effectsControllerAPICallback(); + public getEffectsManager(): EffectsManager { + return this._effectsManager; } public getEntityRegistryManager(): EntityRegistryManager { diff --git a/src/components-lib/effects/effects-controller.ts b/src/card-controller/effects/effects-manager.ts similarity index 56% rename from src/components-lib/effects/effects-controller.ts rename to src/card-controller/effects/effects-manager.ts index ae22a483..01ee783a 100644 --- a/src/components-lib/effects/effects-controller.ts +++ b/src/card-controller/effects/effects-manager.ts @@ -1,4 +1,4 @@ -import { EffectName, EffectsControllerAPI } from '../../types'; +import { EffectName, EffectsContainer, EffectsManagerInterface } from '../../types'; import { Timer } from '../../utils/timer'; import { EffectComponent, EffectModule, EffectOptions } from './types'; @@ -29,38 +29,125 @@ const effectRegistry: Record Promise> = { }, }; -type EffectsContainer = HTMLElement | DocumentFragment; +type EffectModuleImporter = (name: EffectName) => Promise; -export class EffectsController implements EffectsControllerAPI { +const defaultImportEffectModule: EffectModuleImporter = async (name: EffectName) => { + const effectModule = await effectRegistry[name]?.(); + return effectModule ?? null; +}; + +export class EffectsManager implements EffectsManagerInterface { + private _importer: EffectModuleImporter; private _importedModules: Map = new Map(); - private _activeInstances: Map = new Map(); private _durationTimers: Map = new Map(); - private _container: EffectsContainer | null = null; - public setContainer(container: EffectsContainer | null): void { + // Effects that have been requested to start but are still loading or waiting + // for a container to be registered (test case: a card without cameras or + // folders will initialize very quickly and the card will be loaded before + // effects can be started). + private _pendingEffects: Map = new Map(); + private _activeEffects: Map = new Map(); + protected _container: EffectsContainer | null = null; + + constructor(importer: EffectModuleImporter = defaultImportEffectModule) { + this._importer = importer; + } + + public setContainer(container: EffectsContainer): void { this._container = container; + this._startPendingEffects(); + } + + public removeContainer(): void { + this._container = null; + this._pendingEffects.clear(); + + for (const timer of this._durationTimers.values()) { + timer.stop(); + } + this._durationTimers.clear(); + + for (const instance of this._activeEffects.values()) { + instance?.remove(); + } + this._activeEffects.clear(); } public async startEffect(name: EffectName, options?: EffectOptions): Promise { - if (!this._container || this._activeInstances.has(name)) { + if (this._activeEffects.has(name)) { return; } // Reserve the slot immediately with null to prevent concurrent starts. - this._activeInstances.set(name, null); + this._activeEffects.set(name, null); + if (!this._container) { + this._pendingEffects.set(name, options); + return; + } + await this._startEffect(name, options); + } + + public async stopEffect(effect: EffectName): Promise { + const timer = this._durationTimers.get(effect); + if (timer) { + timer.stop(); + this._durationTimers.delete(effect); + } + this._pendingEffects.delete(effect); + + if (!this._activeEffects.has(effect)) { + return; + } + + const instance = this._activeEffects.get(effect); + this._activeEffects.delete(effect); + + // If instance is null, it's still loading - just clearing the reservation + // will prevent it from appearing (startEffect checks this after import). + if (instance) { + await instance.startFadeOut(); + instance.remove(); + } + } + + public async toggleEffect(name: EffectName, options?: EffectOptions): Promise { + if (this._activeEffects.has(name)) { + await this.stopEffect(name); + } else { + await this.startEffect(name, options); + } + } + + private async _importEffectModule(name: EffectName): Promise { + const existingModule = this._importedModules.get(name); + if (existingModule) { + return existingModule; + } + + const effectModule = await this._importer(name); + if (!effectModule) { + return null; + } + + this._importedModules.set(name, effectModule); + return effectModule; + } + + private async _startEffect(name: EffectName, options?: EffectOptions): Promise { const effectModule = await this._importEffectModule(name); - // Check if the effect was cancelled during loading. - if (!effectModule || !this._activeInstances.has(name)) { - this._activeInstances.delete(name); + this._pendingEffects.delete(name); + + if (!effectModule || !this._activeEffects.has(name) || !this._container) { + this._activeEffects.delete(name); return; } const effectComponent = new effectModule.default(); effectComponent.fadeIn = options?.fadeIn ?? true; this._container.appendChild(effectComponent); - this._activeInstances.set(name, effectComponent); + this._activeEffects.set(name, effectComponent); const duration = options?.duration; if (duration !== undefined) { @@ -76,48 +163,10 @@ export class EffectsController implements EffectsControllerAPI { } } - public async stopEffect(effect: EffectName): Promise { - const timer = this._durationTimers.get(effect); - if (timer) { - timer.stop(); - this._durationTimers.delete(effect); + private _startPendingEffects(): void { + for (const [name, options] of this._pendingEffects.entries()) { + this._pendingEffects.delete(name); + this._startEffect(name, options); } - - if (!this._activeInstances.has(effect)) { - return; - } - - const instance = this._activeInstances.get(effect); - this._activeInstances.delete(effect); - - // If instance is null, it's still loading - just clearing the reservation - // will prevent it from appearing (startEffect checks this after import). - if (instance) { - await instance.startFadeOut(); - instance.remove(); - } - } - - public async toggleEffect(name: EffectName, options?: EffectOptions): Promise { - if (this._activeInstances.has(name)) { - await this.stopEffect(name); - } else { - await this.startEffect(name, options); - } - } - - private async _importEffectModule(name: EffectName): Promise { - const existingModule = this._importedModules.get(name); - if (existingModule) { - return existingModule; - } - - const effectModule = await effectRegistry[name]?.(); - if (!effectModule) { - return null; - } - - this._importedModules.set(name, effectModule); - return effectModule; } } diff --git a/src/components-lib/effects/types.ts b/src/card-controller/effects/types.ts similarity index 100% rename from src/components-lib/effects/types.ts rename to src/card-controller/effects/types.ts diff --git a/src/card-controller/query-string-manager.ts b/src/card-controller/query-string-manager.ts index 4665f070..703e0f06 100644 --- a/src/card-controller/query-string-manager.ts +++ b/src/card-controller/query-string-manager.ts @@ -5,12 +5,12 @@ import { createGeneralAction, createViewAction, } from '../utils/action.js'; -import { ViewParameters } from '../view/view'; import { CardQueryStringAPI } from './types'; import { SubstreamSelectViewModifier } from './view/modifiers/substream-select'; +import { ViewParametersUserSpecified } from './view/types.js'; interface QueryStringViewIntent { - view?: Partial & { + view?: ViewParametersUserSpecified & { default?: boolean; substream?: string; }; diff --git a/src/card-controller/status-bar-item-manager.ts b/src/card-controller/status-bar-item-manager.ts index d27aa4ca..c36069fc 100644 --- a/src/card-controller/status-bar-item-manager.ts +++ b/src/card-controller/status-bar-item-manager.ts @@ -43,8 +43,8 @@ export class StatusBarItemManager { view?: View | null; mediaLoadedInfo?: MediaLoadedInfo | null; }): StatusBarItem[] { - const cameraMetadata = options?.view - ? options?.cameraManager?.getCameraMetadata(options?.view?.camera) + const cameraMetadata = options?.view?.camera + ? options?.cameraManager?.getCameraMetadata(options.view.camera) : null; const engineIcon = cameraMetadata?.engineIcon ?? null; const selectedResult = options?.view?.queryResults?.getSelectedResult(); diff --git a/src/card-controller/types.ts b/src/card-controller/types.ts index 08eb9452..9ff8d38b 100644 --- a/src/card-controller/types.ts +++ b/src/card-controller/types.ts @@ -4,7 +4,7 @@ import type { Automation } from '../config/schema/automations'; import type { DeviceRegistryManager } from '../ha/registry/device'; import type { EntityRegistryManager } from '../ha/registry/entity/types'; import type { ResolvedMediaCache } from '../ha/resolved-media'; -import type { EffectsControllerAPI } from '../types'; +import type { EffectsManagerInterface } from '../types'; import type { ActionsManager } from './actions/actions-manager'; import type { AutomationsManager } from './automations-manager'; import type { CameraURLManager } from './camera-url-manager'; @@ -44,7 +44,7 @@ export interface CardActionsAPI { getCardElementManager(): CardElementManager; getConditionStateManager(): ConditionStateManager; getConfigManager(): ConfigManager; - getEffectsControllerAPI(): EffectsControllerAPI | null; + getEffectsManager(): EffectsManagerInterface; getExpandManager(): ExpandManager; getFoldersManager(): FoldersManager; getFullscreenManager(): FullscreenManager; diff --git a/src/card-controller/view/factory.ts b/src/card-controller/view/factory.ts index fe5c51e3..b0250d19 100644 --- a/src/card-controller/view/factory.ts +++ b/src/card-controller/view/factory.ts @@ -1,15 +1,22 @@ -import { AdvancedCameraCardView, VIEW_DEFAULT } from '../../config/schema/common/const'; +import { AdvancedCameraCardView } from '../../config/schema/common/const'; import { ViewDisplayMode } from '../../config/schema/common/display'; import { AdvancedCameraCardConfig } from '../../config/schema/types'; import { localize } from '../../localize/localize'; +import { resolveViewName } from '../../view/utils/resolve-default'; import { View, ViewParameters } from '../../view/view'; import { - getCameraIDsForViewName, + doesViewRequireCamera, + getCameraIDsWithCapabilityForView, isViewSupportedByCamera, } from '../../view/view-support'; import { CardViewAPI } from '../types'; import { applyViewModifiers } from './modifiers'; -import { ViewFactoryOptions, ViewIncompatible, ViewNoCameraError } from './types'; +import { ViewFactoryOptions, ViewIncompatible } from './types'; + +interface ResolvedViewTarget { + viewName: AdvancedCameraCardView; + cameraID: string | null; +} export class ViewFactory { protected _api: CardViewAPI; @@ -24,130 +31,72 @@ export class ViewFactory { return null; } - // Neither options.baseView.camera nor options.baseView.view are respected - // here, since this is the default view / camera. - // See: https://github.com/dermotduffy/advanced-camera-card/issues/1564 - - let cameraID: string | null = null; - const viewName = options?.params?.view ?? config.view.default; - - if (options?.params?.camera) { - cameraID = options.params.camera; - } else { - const cameraIDs = [ - ...getCameraIDsForViewName( - viewName, - this._api.getCameraManager(), - this._api.getFoldersManager(), - ), - ]; - - if ( - cameraIDs.length && - options?.baseView?.camera && - config.view.default_cycle_camera - ) { - const currentIndex = cameraIDs.indexOf(options.baseView.camera); - const targetIndex = currentIndex + 1 >= cameraIDs.length ? 0 : currentIndex + 1; - cameraID = cameraIDs[targetIndex]; - } else { - cameraID = cameraIDs[0] ?? null; - } - } + const viewName = this._getDefaultViewName(config); return this.getViewByParameters({ params: { ...options?.params, view: viewName, - camera: cameraID, + camera: this._getDefaultCameraID(config, viewName, options), }, baseView: options?.baseView, }); } + protected _getDefaultViewName = ( + config: AdvancedCameraCardConfig, + ): AdvancedCameraCardView => + resolveViewName( + config.view.default, + this._api.getCameraManager(), + this._api.getFoldersManager(), + ); + + protected _getDefaultCameraID( + config: AdvancedCameraCardConfig, + viewName: AdvancedCameraCardView, + options?: ViewFactoryOptions, + ): string | null { + if (options?.params?.camera) { + return options.params.camera; + } + + const cameraIDs = [ + ...getCameraIDsWithCapabilityForView( + viewName, + this._api.getCameraManager(), + this._api.getFoldersManager(), + ), + ]; + + if ( + cameraIDs.length && + options?.baseView?.camera && + config.view.default_cycle_camera + ) { + const currentIndex = cameraIDs.indexOf(options.baseView.camera); + const targetIndex = currentIndex + 1 >= cameraIDs.length ? 0 : currentIndex + 1; + return cameraIDs[targetIndex]; + } + + return cameraIDs[0] ?? null; + } + public getViewByParameters(options?: ViewFactoryOptions): View | null { const config = this._api.getConfigManager().getConfig(); if (!config) { return null; } - let cameraID: string | null = - options?.params?.camera ?? options?.baseView?.camera ?? null; - let viewName = - options?.params?.view ?? options?.baseView?.view ?? config.view.default; - - const allCameraIDs = this._api.getCameraManager().getStore().getCameraIDs(); - - if (!cameraID || !allCameraIDs.has(cameraID)) { - const viewCameraIDs = getCameraIDsForViewName( - viewName, - this._api.getCameraManager(), - this._api.getFoldersManager(), - ); - - // Reset to the default camera. - cameraID = viewCameraIDs?.keys().next().value ?? null; - } - - if (!cameraID) { - const camerasToCapabilities = [ - ...this._api.getCameraManager().getStore().getCameras(), - ].reduce((acc, [cameraID, camera]) => { - const capabilities = camera.getCapabilities()?.getRawCapabilities(); - if (capabilities) { - acc[cameraID] = capabilities; - } - return acc; - }, {}); - - throw new ViewNoCameraError(localize('error.no_supported_cameras'), { - view: viewName, - cameras_capabilities: camerasToCapabilities, - }); - } - - if ( - !isViewSupportedByCamera( - viewName, - this._api.getCameraManager(), - this._api.getFoldersManager(), - cameraID, - ) - ) { - if ( - options?.failSafe && - isViewSupportedByCamera( - VIEW_DEFAULT, - this._api.getCameraManager(), - this._api.getFoldersManager(), - cameraID, - ) - ) { - viewName = VIEW_DEFAULT; - } else { - const capabilities = this._api - .getCameraManager() - .getStore() - .getCamera(cameraID) - ?.getCapabilities() - ?.getRawCapabilities(); - - throw new ViewIncompatible(localize('error.no_supported_camera'), { - view: viewName, - camera: cameraID, - ...(capabilities && { camera_capabilities: capabilities }), - }); - } - } - const configuredDisplayMode = this._getDefaultDisplayModeForView(viewName, config); - const displayMode = - // Prioritize the configured display mode (if present). - // See: https://github.com/dermotduffy/advanced-camera-card/issues/1812 - (viewName !== options?.baseView?.view ? configuredDisplayMode : null) ?? - options?.params?.displayMode ?? - options?.baseView?.displayMode ?? - configuredDisplayMode ?? - 'single'; + let viewName = this._resolveViewName(config, options); + let cameraID = this._resolveCameraID(viewName, options); + ({ viewName, cameraID } = this._ensureViewCompatibility( + viewName, + cameraID, + config, + options, + )); + const displayMode = this._resolveDisplayMode(viewName, config, options); const viewParameters: ViewParameters = { ...options?.params, @@ -165,22 +114,153 @@ export class ViewFactory { return view; } - protected _getDefaultDisplayModeForView( + protected _resolveViewName( + config: AdvancedCameraCardConfig, + options?: ViewFactoryOptions, + ): AdvancedCameraCardView { + if (options?.params?.view !== undefined) { + return resolveViewName( + options.params.view, + this._api.getCameraManager(), + this._api.getFoldersManager(), + ); + } + return options?.baseView?.view ?? this._getDefaultViewName(config); + } + + protected _resolveCameraID( + viewName: AdvancedCameraCardView, + options?: ViewFactoryOptions, + ): string | null { + const cameraID = options?.params?.camera ?? options?.baseView?.camera ?? null; + const allCameraIDs = this._api.getCameraManager().getStore().getCameraIDs(); + + if (cameraID && allCameraIDs.has(cameraID)) { + return cameraID; + } + + const viewCameraIDs = getCameraIDsWithCapabilityForView( + viewName, + this._api.getCameraManager(), + this._api.getFoldersManager(), + ); + + return viewCameraIDs?.keys().next().value ?? null; + } + + protected _ensureViewCompatibility( + viewName: AdvancedCameraCardView, + cameraID: string | null, + config: AdvancedCameraCardConfig, + options?: ViewFactoryOptions, + ): ResolvedViewTarget { + if (!cameraID && doesViewRequireCamera(viewName)) { + return this._handleNoCameraForView(viewName, config, options); + } + + if ( + cameraID && + !isViewSupportedByCamera( + viewName, + this._api.getCameraManager(), + this._api.getFoldersManager(), + cameraID, + ) + ) { + return this._handleUnsupportedView(viewName, cameraID, config, options); + } + + return { viewName, cameraID }; + } + + protected _handleNoCameraForView( + viewName: AdvancedCameraCardView, + config: AdvancedCameraCardConfig, + options?: ViewFactoryOptions, + ): ResolvedViewTarget { + const defaultViewName = this._getDefaultViewName(config); + if (options?.failSafe && !doesViewRequireCamera(defaultViewName)) { + return { viewName: defaultViewName, cameraID: null }; + } + if (options?.failSafe) { + return { + viewName: defaultViewName, + cameraID: this._api.getCameraManager().getStore().getDefaultCameraID(), + }; + } + throw new ViewIncompatible(localize('error.no_supported_cameras'), { + view: viewName, + camera: null, + default_view: defaultViewName, + }); + } + + protected _handleUnsupportedView( + viewName: AdvancedCameraCardView, + cameraID: string, + config: AdvancedCameraCardConfig, + options?: ViewFactoryOptions, + ): ResolvedViewTarget { + const defaultViewName = this._getDefaultViewName(config); + if ( + options?.failSafe && + isViewSupportedByCamera( + defaultViewName, + this._api.getCameraManager(), + this._api.getFoldersManager(), + cameraID, + ) + ) { + return { viewName: defaultViewName, cameraID }; + } + + const capabilities = this._api + .getCameraManager() + .getStore() + .getCamera(cameraID) + ?.getCapabilities() + ?.getRawCapabilities(); + + throw new ViewIncompatible(localize('error.no_supported_camera'), { + view: viewName, + camera: cameraID, + default_view: defaultViewName, + ...(capabilities && { camera_capabilities: capabilities }), + }); + } + + protected _resolveDisplayMode( + viewName: AdvancedCameraCardView, + config: AdvancedCameraCardConfig, + options?: ViewFactoryOptions, + ): ViewDisplayMode { + const configuredDisplayMode = this._getConfiguredDisplayMode(viewName, config); + + // Prioritize the configured display mode (if present). + // See: https://github.com/dermotduffy/advanced-camera-card/issues/1812 + return ( + (viewName !== options?.baseView?.view ? configuredDisplayMode : null) ?? + options?.params?.displayMode ?? + options?.baseView?.displayMode ?? + configuredDisplayMode ?? + 'single' + ); + } + + protected _getConfiguredDisplayMode( viewName: AdvancedCameraCardView, config: AdvancedCameraCardConfig, ): ViewDisplayMode | null { - let mode: ViewDisplayMode | null = null; switch (viewName) { case 'media': case 'clip': case 'recording': case 'snapshot': - mode = config.media_viewer.display?.mode ?? null; - break; + return config.media_viewer.display?.mode ?? null; case 'live': - mode = config.live.display?.mode ?? null; - break; + return config.live.display?.mode ?? null; + default: + return null; } - return mode; } } diff --git a/src/card-controller/view/modifiers/substream-on.ts b/src/card-controller/view/modifiers/substream-on.ts index b3e1ac74..a9ebfa25 100644 --- a/src/card-controller/view/modifiers/substream-on.ts +++ b/src/card-controller/view/modifiers/substream-on.ts @@ -15,6 +15,10 @@ export class SubstreamOnViewModifier implements ViewModifier { } public modify(view: View): void { + if (!view.camera) { + return; + } + const dependencies = [ ...this._api .getCameraManager() @@ -27,6 +31,13 @@ export class SubstreamOnViewModifier implements ViewModifier { } const currentOverride = getStreamCameraID(view); + + /* istanbul ignore if: the if path cannot be reached, as there is a + view.camera guard at the start of this method and getStreamCameraID will + always return non-null as long as camera is present -- @preserve */ + if (!currentOverride) { + return; + } const currentIndex = dependencies.indexOf(currentOverride); const newIndex = currentIndex < 0 ? 0 : (currentIndex + 1) % dependencies.length; diff --git a/src/card-controller/view/types.ts b/src/card-controller/view/types.ts index 0f59e8fb..60973f87 100644 --- a/src/card-controller/view/types.ts +++ b/src/card-controller/view/types.ts @@ -3,6 +3,7 @@ import { AdvancedCameraCardError } from '../../types.js'; import { ViewItem } from '../../view/item.js'; import { QueryResults } from '../../view/query-results.js'; +import { AdvancedCameraCardUserSpecifiedView } from '../../config/schema/common/const.js'; import { View, ViewParameters } from '../../view/view.js'; export interface ViewModifier { @@ -26,12 +27,16 @@ export interface QueryExecutorOptions { useCache?: boolean; } +export type ViewParametersUserSpecified = Partial> & { + view?: AdvancedCameraCardUserSpecifiedView; +}; + export interface ViewFactoryOptions { // An existing view to evolve from. baseView?: View | null; // View parameters to set/evolve. - params?: Partial; + params?: ViewParametersUserSpecified; // Modifiers to the view once created. modifiers?: ViewModifier[]; @@ -70,5 +75,4 @@ export interface ViewManagerInterface { hasMajorMediaChange(oldView?: View | null, newView?: View | null): boolean; } -export class ViewNoCameraError extends AdvancedCameraCardError {} export class ViewIncompatible extends AdvancedCameraCardError {} diff --git a/src/card-controller/view/view-manager.ts b/src/card-controller/view/view-manager.ts index 73b734c9..783dd0d3 100644 --- a/src/card-controller/view/view-manager.ts +++ b/src/card-controller/view/view-manager.ts @@ -317,7 +317,7 @@ export class ViewManager implements ViewManagerInterface { this._api.getConditionStateManager()?.setState({ view: view?.view, - camera: view?.camera, + camera: view?.camera ?? undefined, displayMode: view?.displayMode ?? undefined, }); diff --git a/src/card-controller/view/view-query-executor.ts b/src/card-controller/view/view-query-executor.ts index ae790121..ec4cd2b2 100644 --- a/src/card-controller/view/view-query-executor.ts +++ b/src/card-controller/view/view-query-executor.ts @@ -6,6 +6,7 @@ import { UnifiedQuery } from '../../view/unified-query'; import { UnifiedQueryBuilder } from '../../view/unified-query-builder'; import { UnifiedQueryRunner } from '../../view/unified-query-runner'; import { View } from '../../view/view'; +import { doesViewRequireCamera, isViewSupported } from '../../view/view-support'; import { CardViewAPI } from '../types'; import { MergeContextViewModifier } from './modifiers/merge-context'; import { RemoveContextPropertyViewModifier } from './modifiers/remove-context-property'; @@ -103,7 +104,26 @@ export class ViewQueryExecutor { ]; }; - const cameraForQuery = view.isGrid() ? undefined : view.camera; + // Don't query if the view is not supported (e.g. a camera-based view with + // no cameras). + if ( + !isViewSupported( + view.view, + this._api.getCameraManager(), + this._api.getFoldersManager(), + view.camera, + ) + ) { + return null; + } + + // Don't query if the view requires a camera, if it's not in grid mode, but + // no camera is provided. + if (!view.isGrid() && !view.camera && doesViewRequireCamera(view.view)) { + return null; + } + + const cameraForQuery = view.isGrid() ? undefined : view.camera ?? undefined; const getDefaultQueryModifiers = async () => { const query = builder.buildDefaultCameraQuery(cameraForQuery, { diff --git a/src/card.ts b/src/card.ts index 51163d92..195afd84 100644 --- a/src/card.ts +++ b/src/card.ts @@ -9,7 +9,6 @@ import { actionHandler } from './action-handler-directive.js'; import { CardController } from './card-controller/controller'; import { MenuButtonController } from './components-lib/menu-button-controller'; import './components/effects/effects'; -import { AdvancedCameraCardEffects } from './components/effects/effects'; import './components/elements.js'; import { AdvancedCameraCardElements } from './components/elements.js'; import './components/loading.js'; @@ -96,12 +95,10 @@ class AdvancedCameraCard extends LitElement { // diagnostics starting at the top). () => this._refMain.value?.scroll({ top: 0 }), () => this._refMenu.value?.toggleMenu(), - () => this._refEffects.value ?? null, ); protected _menuButtonController = new MenuButtonController(); - protected _refEffects: Ref = createRef(); protected _refElements: Ref = createRef(); protected _refMain: Ref = createRef(); protected _refMenu: Ref = createRef(); @@ -350,7 +347,7 @@ class AdvancedCameraCard extends LitElement { // ensure the hover menu styling continues to work. return this._renderInDialogIfNecessary( html` ` : ''} diff --git a/src/components-lib/menu-button-controller.ts b/src/components-lib/menu-button-controller.ts index 58600fc8..153f5b9d 100644 --- a/src/components-lib/menu-button-controller.ts +++ b/src/components-lib/menu-button-controller.ts @@ -31,8 +31,12 @@ import { isBeingCasted } from '../utils/casting'; import { getPTZTarget } from '../utils/ptz'; import { getStreamCameraID, hasSubstream } from '../utils/substream'; import { ViewItemClassifier } from '../view/item-classifier'; +import { resolveViewName } from '../view/utils/resolve-default'; import { View } from '../view/view'; -import { getCameraIDsForViewName, isViewSupportedByCamera } from '../view/view-support'; +import { + getCameraIDsWithCapabilityForView, + isViewSupported, +} from '../view/view-support'; export interface MenuButtonControllerOptions { currentMediaLoadedInfo?: MediaLoadedInfo | null; @@ -112,7 +116,13 @@ export class MenuButtonController { this._getFoldersButton(config, foldersManager, options?.view), ...this._dynamicMenuButtons.map((button) => ({ - style: this._getStyleFromActions(config, button, options), + style: this._getStyleFromActions( + config, + cameraManager, + foldersManager, + button, + options, + ), ...button, })), ].filter(isTruthy); @@ -175,7 +185,7 @@ export class MenuButtonController { cameraManager: CameraManager, view?: View | null, ): MenuItem | null { - if (!view) { + if (!view?.camera) { return null; } @@ -184,7 +194,7 @@ export class MenuButtonController { .getAllDependentCameras(view.camera, 'substream'); if (substreamCameraIDs.size && view.is('live')) { - const substreams = [...substreamCameraIDs].filter( + const substreams = Array.from(substreamCameraIDs).filter( (cameraID) => cameraID !== view.camera, ); const streams = [view.camera, ...substreams]; @@ -239,14 +249,13 @@ export class MenuButtonController { foldersManager: FoldersManager, view?: View | null, ): MenuItem | null { - return view && - isViewSupportedByCamera('live', cameraManager, foldersManager, view.camera) + return isViewSupported('live', cameraManager, foldersManager, view?.camera) ? { icon: 'mdi:cctv', ...config.menu.buttons.live, type: 'custom:advanced-camera-card-menu-icon', title: localize('config.view.views.live'), - style: view.is('live') ? this._getEmphasizedStyle() : {}, + style: view?.is('live') ? this._getEmphasizedStyle() : {}, tap_action: createViewAction('live'), } : null; @@ -258,8 +267,7 @@ export class MenuButtonController { foldersManager: FoldersManager, view?: View | null, ): MenuItem | null { - return view && - isViewSupportedByCamera('clips', cameraManager, foldersManager, view.camera) + return isViewSupported('clips', cameraManager, foldersManager, view?.camera) ? { icon: 'mdi:filmstrip', ...config.menu.buttons.clips, @@ -278,8 +286,7 @@ export class MenuButtonController { foldersManager: FoldersManager, view?: View | null, ): MenuItem | null { - return view && - isViewSupportedByCamera('snapshots', cameraManager, foldersManager, view.camera) + return isViewSupported('snapshots', cameraManager, foldersManager, view?.camera) ? { icon: 'mdi:camera', ...config.menu.buttons.snapshots, @@ -298,14 +305,13 @@ export class MenuButtonController { foldersManager: FoldersManager, view?: View | null, ): MenuItem | null { - return view && - isViewSupportedByCamera('recordings', cameraManager, foldersManager, view.camera) + return isViewSupported('recordings', cameraManager, foldersManager, view?.camera) ? { icon: 'mdi:album', ...config.menu.buttons.recordings, type: 'custom:advanced-camera-card-menu-icon', title: localize('config.view.views.recordings'), - style: view.is('recordings') ? this._getEmphasizedStyle() : {}, + style: view?.is('recordings') ? this._getEmphasizedStyle() : {}, tap_action: createViewAction('recordings'), hold_action: createViewAction('recording'), } @@ -318,14 +324,13 @@ export class MenuButtonController { foldersManager: FoldersManager, view?: View | null, ): MenuItem | null { - return view && - isViewSupportedByCamera('reviews', cameraManager, foldersManager, view.camera) + return isViewSupported('reviews', cameraManager, foldersManager, view?.camera) ? { icon: 'mdi:play-box-edit-outline', ...config.menu.buttons.reviews, type: 'custom:advanced-camera-card-menu-icon', title: localize('config.view.views.reviews'), - style: view.is('reviews') ? this._getEmphasizedStyle() : {}, + style: view?.is('reviews') ? this._getEmphasizedStyle() : {}, tap_action: createViewAction('reviews'), hold_action: createViewAction('review'), } @@ -338,14 +343,13 @@ export class MenuButtonController { foldersManager: FoldersManager, view?: View | null, ): MenuItem | null { - return view && - isViewSupportedByCamera('gallery', cameraManager, foldersManager, view.camera) + return isViewSupported('gallery', cameraManager, foldersManager, view?.camera) ? { icon: 'mdi:play-box-multiple', ...config.menu.buttons.gallery, type: 'custom:advanced-camera-card-menu-icon', title: localize('config.menu.buttons.gallery'), - style: view.is('gallery') ? this._getEmphasizedStyle() : {}, + style: view?.is('gallery') ? this._getEmphasizedStyle() : {}, tap_action: createViewAction('gallery'), hold_action: createViewAction('media'), } @@ -358,8 +362,7 @@ export class MenuButtonController { foldersManager: FoldersManager, view?: View | null, ): MenuItem | null { - return view && - isViewSupportedByCamera('image', cameraManager, foldersManager, view.camera) + return isViewSupported('image', cameraManager, foldersManager, view?.camera) ? { icon: 'mdi:image', ...config.menu.buttons.image, @@ -377,14 +380,13 @@ export class MenuButtonController { foldersManager: FoldersManager, view?: View | null, ): MenuItem | null { - return view && - isViewSupportedByCamera('timeline', cameraManager, foldersManager, view.camera) + return isViewSupported('timeline', cameraManager, foldersManager, view?.camera) ? { icon: 'mdi:chart-gantt', ...config.menu.buttons.timeline, type: 'custom:advanced-camera-card-menu-icon', title: localize('config.view.views.timeline'), - style: view.is('timeline') ? this._getEmphasizedStyle() : {}, + style: view?.is('timeline') ? this._getEmphasizedStyle() : {}, tap_action: createViewAction('timeline'), } : null; @@ -479,11 +481,12 @@ export class MenuButtonController { view?: View | null, microphoneManager?: MicrophoneManager | null, ): MenuItem | null { - if (!view) { + const streamCameraID = view ? getStreamCameraID(view) : null; + if (!streamCameraID) { return null; } - const capabilities = cameraManager.getCameraCapabilities(getStreamCameraID(view)); + const capabilities = cameraManager.getCameraCapabilities(streamCameraID); if (microphoneManager && capabilities?.has('2-way-audio')) { const unavailable = @@ -557,7 +560,9 @@ export class MenuButtonController { if (!view) { return null; } - const selectedCameraConfig = cameraManager.getStore().getCameraConfig(view.camera); + const selectedCameraConfig = view.camera + ? cameraManager.getStore().getCameraConfig(view.camera) + : null; if ( mediaPlayerController?.hasMediaPlayers() && (view.isViewerView() || (view.is('live') && selectedCameraConfig?.camera_entity)) @@ -659,7 +664,7 @@ export class MenuButtonController { view?: View | null, ): MenuItem | null { const viewCameraIDs = view - ? getCameraIDsForViewName(view.view, cameraManager, foldersManager) + ? getCameraIDsWithCapabilityForView(view.view, cameraManager, foldersManager) : null; if ( view?.supportsMultipleDisplayModes() && @@ -754,8 +759,8 @@ export class MenuButtonController { foldersManager?: FoldersManager | null, view?: View | null, ): MenuItem | null { - const folders = [...(foldersManager?.getFolders() ?? [])]; - if (!folders?.length) { + const folders = Array.from(foldersManager?.getFolders() ?? []); + if (!foldersManager?.hasFolders()) { return null; } @@ -823,6 +828,8 @@ export class MenuButtonController { */ protected _getStyleFromActions( config: AdvancedCameraCardConfig, + cameraManager: CameraManager, + foldersManager: FoldersManager, button: MenuItem, options?: MenuButtonControllerOptions, ): StyleInfo { @@ -848,7 +855,9 @@ export class MenuButtonController { ), ) || (action.advanced_camera_card_action === 'default' && - options?.view?.is(config.view.default)) || + options?.view?.is( + resolveViewName(config.view.default, cameraManager, foldersManager), + )) || (action.advanced_camera_card_action === 'fullscreen' && !!options?.fullscreenManager?.isInFullscreen()) || (action.advanced_camera_card_action === 'camera_select' && diff --git a/src/components-lib/timeline/controller.ts b/src/components-lib/timeline/controller.ts index e99d3fd8..21b7a116 100644 --- a/src/components-lib/timeline/controller.ts +++ b/src/components-lib/timeline/controller.ts @@ -458,7 +458,7 @@ export class TimelineController { main: true, }, ); - } else if (panMode === 'seek-in-camera') { + } else if (panMode === 'seek-in-camera' && view.camera) { newResults = results .clone() .selectBestResult( diff --git a/src/components/effects/effects.ts b/src/components/effects/effects.ts index 5c626db9..42b71497 100644 --- a/src/components/effects/effects.ts +++ b/src/components/effects/effects.ts @@ -1,31 +1,28 @@ -import { LitElement, unsafeCSS } from 'lit'; -import { customElement } from 'lit/decorators.js'; -import { EffectsController } from '../../components-lib/effects/effects-controller'; -import { EffectOptions } from '../../components-lib/effects/types'; +import { LitElement, PropertyValues, unsafeCSS } from 'lit'; +import { customElement, property } from 'lit/decorators.js'; +import { EffectsManager } from '../../card-controller/effects/effects-manager'; import effectsStyle from '../../scss/effects.scss'; -import { EffectName, EffectsControllerAPI } from '../../types'; @customElement('advanced-camera-card-effects') -export class AdvancedCameraCardEffects - extends LitElement - implements EffectsControllerAPI -{ - protected _controller = new EffectsController(); +export class AdvancedCameraCardEffects extends LitElement { + @property({ attribute: false }) + public effectsManager?: EffectsManager; - public async startEffect(effect: EffectName, options?: EffectOptions): Promise { - await this._controller.startEffect(effect, options); - } - - public stopEffect(effect: EffectName): void { - this._controller.stopEffect(effect); - } - - public async toggleEffect(effect: EffectName, options?: EffectOptions): Promise { - await this._controller.toggleEffect(effect, options); + protected willUpdate(changedProperties: PropertyValues): void { + if (changedProperties.has('effectsManager')) { + const previousManager: EffectsManager | undefined = + changedProperties.get('effectsManager'); + previousManager?.removeContainer(); + } } protected updated(): void { - this._controller.setContainer(this.renderRoot); + this.effectsManager?.setContainer(this.renderRoot); + } + + public disconnectedCallback(): void { + this.effectsManager?.removeContainer(); + super.disconnectedCallback(); } static get styles() { diff --git a/src/components/image.ts b/src/components/image.ts index 2c2a7c79..76ee3f9c 100644 --- a/src/components/image.ts +++ b/src/components/image.ts @@ -10,11 +10,13 @@ import { CameraConfig } from '../config/schema/cameras'; import { ImageViewConfig } from '../config/schema/image'; import { IMAGE_VIEW_ZOOM_TARGET_SENTINEL } from '../const'; import { HomeAssistant } from '../ha/types'; +import { localize } from '../localize/localize.js'; import imageStyle from '../scss/image.scss'; import { MediaPlayer, MediaPlayerController, MediaPlayerElement } from '../types.js'; import './image-updating-player'; import { resolveImageMode } from './image-updating-player'; import './media-dimensions-container'; +import { renderMessage } from './message.js'; import './zoomer.js'; @customElement('advanced-camera-card-image') @@ -81,10 +83,24 @@ export class AdvancedCameraCardImage extends LitElement implements MediaPlayer { } protected render(): TemplateResult | void { - if (!this.hass || !this.cameraConfig) { + if (!this.hass) { return; } + // Determine if this image mode requires a camera + const mode = resolveImageMode({ + imageConfig: this.imageConfig, + cameraConfig: this.cameraConfig, + }); + + if (mode === 'camera' && !this.cameraConfig) { + return renderMessage({ + type: 'info', + message: localize('error.no_camera_for_image'), + icon: 'mdi:camera-off', + }); + } + return this._renderContainer(html` { @customElement('advanced-camera-card-loading') export class AdvancedCameraCardLoading extends LitElement { @property({ attribute: false }) - public effectsControllerAPI?: EffectsControllerAPI | null; + public effectsManager?: EffectsManagerInterface; @property({ type: Boolean, reflect: true }) public loaded = false; @@ -60,13 +60,13 @@ export class AdvancedCameraCardLoading extends LitElement { } private _startEffect(effect: EffectName): void { - this.effectsControllerAPI?.startEffect(effect, { fadeIn: false }); + this.effectsManager?.startEffect(effect, { fadeIn: false }); this._effectName = effect; } private _stopEffect(): void { if (this._effectName) { - this.effectsControllerAPI?.stopEffect(this._effectName); + this.effectsManager?.stopEffect(this._effectName); } this._effectName = null; } diff --git a/src/components/views.ts b/src/components/views.ts index 9385f7dc..fa26a6eb 100644 --- a/src/components/views.ts +++ b/src/components/views.ts @@ -139,7 +139,7 @@ export class AdvancedCameraCardViews extends LitElement { ? this.config.media_viewer.controls.timeline : undefined; - const cameraConfig = view + const cameraConfig = view?.camera ? this.cameraManager?.getStore().getCameraConfig(view.camera) ?? null : null; @@ -155,7 +155,7 @@ export class AdvancedCameraCardViews extends LitElement { .viewItemManager=${this.viewItemManager} .cardWideConfig=${this.cardWideConfig} > - ${!this.hide && view?.is('image') && cameraConfig + ${!this.hide && view?.is('image') ? html` ; -// Avoid using .nonempty() to avoid changing the inferred type -// (https://github.com/colinhacks/zod#minmaxlength). -export const camerasConfigSchema = cameraConfigSchema.array().min(1); -export type CamerasConfig = z.infer; +export const camerasConfigSchema = cameraConfigSchema.array().optional(); diff --git a/src/config/schema/common/const.ts b/src/config/schema/common/const.ts index aa07b1c8..16318e66 100644 --- a/src/config/schema/common/const.ts +++ b/src/config/schema/common/const.ts @@ -8,10 +8,7 @@ export const STATUS_BAR_PRIORITY_MAX = 100; export const BUTTON_SIZE_MIN = 20; -// The default view (may not be supported on all cameras). -export const VIEW_DEFAULT = 'live' as const; - -export const VIEWS_USER_SPECIFIED = [ +const VIEWS = [ 'diagnostics', 'live', 'clip', @@ -29,5 +26,7 @@ export const VIEWS_USER_SPECIFIED = [ 'image', 'timeline', ] as const; +export type AdvancedCameraCardView = (typeof VIEWS)[number]; + +export const VIEWS_USER_SPECIFIED = ['auto', ...VIEWS] as const; export type AdvancedCameraCardUserSpecifiedView = (typeof VIEWS_USER_SPECIFIED)[number]; -export type AdvancedCameraCardView = AdvancedCameraCardUserSpecifiedView | 'diagnostics'; diff --git a/src/config/schema/elements/custom/menu/base.ts b/src/config/schema/elements/custom/menu/base.ts index f9b8b929..c03ca165 100644 --- a/src/config/schema/elements/custom/menu/base.ts +++ b/src/config/schema/elements/custom/menu/base.ts @@ -13,4 +13,3 @@ export const menuBaseSchema = z.object({ icon: z.string().optional(), permanent: z.boolean().default(false).optional(), }); -export type MenuItemBase = z.infer; diff --git a/src/config/schema/view.ts b/src/config/schema/view.ts index 3f98aa6d..1a6d8df3 100644 --- a/src/config/schema/view.ts +++ b/src/config/schema/view.ts @@ -1,6 +1,6 @@ import { z } from 'zod'; import { actionsSchema } from './actions/types'; -import { VIEW_DEFAULT, VIEWS_USER_SPECIFIED } from './common/const'; +import { VIEWS_USER_SPECIFIED } from './common/const'; const keyboardShortcut = z.object({ key: z.string(), @@ -46,7 +46,7 @@ export type PTZKeyboardShortcutName = | 'ptz_zoom_out'; export const viewConfigDefault = { - default: VIEW_DEFAULT, + default: 'auto' as const, camera_select: 'current' as const, interaction_seconds: 300, default_reset: { diff --git a/src/editor.ts b/src/editor.ts index 02240d27..c9987fd2 100644 --- a/src/editor.ts +++ b/src/editor.ts @@ -428,6 +428,7 @@ export class AdvancedCameraCardEditor extends LitElement implements LovelaceCard protected _viewModes: EditorSelectOption[] = [ { value: '', label: '' }, + { value: 'auto', label: localize('config.view.views.auto') }, { value: 'clip', label: localize('config.view.views.clip') }, { value: 'clips', label: localize('config.view.views.clips') }, { value: 'folder', label: localize('config.view.views.folder') }, diff --git a/src/localize/languages/en.json b/src/localize/languages/en.json index f44511a1..8782f559 100644 --- a/src/localize/languages/en.json +++ b/src/localize/languages/en.json @@ -6,7 +6,6 @@ "common": { "advanced_camera_card": "Advanced Camera Card", "advanced_camera_card_description": "An Advanced Camera Card", - "up": "Up", "folder": "Folder", "live": "Live", "no_folder": "No folder to display", @@ -22,6 +21,7 @@ "medium": "Medium" }, "severity": "Severity", + "up": "Up", "version": "Version" }, "config": { @@ -628,6 +628,7 @@ "untrigger_seconds": "Seconds after inactive state change to untrigger" }, "views": { + "auto": "Automatic", "clip": "Most recent clip", "clips": "Clips gallery", "current": "Current view", @@ -738,6 +739,8 @@ "no_camera_engine": "Could not determine suitable engine for camera", "no_camera_entity": "Could not find camera entity", "no_camera_entity_for_triggers": "A camera entity is required in order to autodetect triggers", + "no_camera_for_image": "No camera configured for camera image mode", + "no_camera_for_live": "No cameras are configured for live view", "no_camera_id": "Could not determine camera id for the following camera, may need to set 'id' parameter manually", "no_camera_or_media_for_timeline": "No camera or media available for timeline", "no_dashboard_or_view": "Both 'dashboard_path' and 'view_path' parameters are required for the 'dashboard' cast method", diff --git a/src/types.ts b/src/types.ts index b00a9028..fd90a871 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,5 +1,5 @@ import { z } from 'zod'; -import type { EffectOptions } from './components-lib/effects/types'; +import type { EffectOptions } from './card-controller/effects/types'; import type { LovelaceCard, LovelaceCardConfig, LovelaceCardEditor } from './ha/types'; import { Severity } from './severity'; @@ -217,8 +217,12 @@ export type EffectName = | 'shamrocks' | 'snow'; -export interface EffectsControllerAPI { +export type EffectsContainer = HTMLElement | DocumentFragment; +export interface EffectsManagerInterface { startEffect(name: EffectName, options?: EffectOptions): Promise; stopEffect(effect: EffectName): void; toggleEffect(effect: EffectName, options?: EffectOptions): Promise; + + setContainer(container: EffectsContainer): void; + removeContainer(): void; } diff --git a/src/utils/find-best-media-time-index.ts b/src/utils/find-best-media-time-index.ts index 7519d1ca..a0ff942b 100644 --- a/src/utils/find-best-media-time-index.ts +++ b/src/utils/find-best-media-time-index.ts @@ -11,7 +11,7 @@ import { ViewItemClassifier } from '../view/item-classifier'; export const findBestMediaTimeIndex = ( mediaArray: ViewItem[], targetTime: Date, - favorCameraID?: string, + favorCameraID?: string | null, ): number | null => { let bestMatch: | { diff --git a/src/utils/ptz.ts b/src/utils/ptz.ts index 34931f78..92402d3d 100644 --- a/src/utils/ptz.ts +++ b/src/utils/ptz.ts @@ -28,6 +28,9 @@ export const getPTZTarget = ( }; } else if (view.is('live')) { const substreamAwareCameraID = getStreamCameraID(view); + if (!substreamAwareCameraID) { + return null; + } let type: PTZType = 'digital'; if (options?.type !== 'digital' && options?.cameraManager) { diff --git a/src/utils/screenshot.ts b/src/utils/screenshot.ts index dcd79019..d2b64805 100644 --- a/src/utils/screenshot.ts +++ b/src/utils/screenshot.ts @@ -28,14 +28,14 @@ const screenshotElement = ( export const generateScreenshotFilename = (view?: View | null): string => { if (view?.is('live') || view?.is('image')) { - return `${view.view}_${view.camera}_${format( + return `${view.view}${view.camera ? `_${view.camera}` : ''}_${format( new Date(), `yyyy-MM-dd-HH-mm-ss`, )}.jpg`; } else if (view?.isViewerView()) { const media = view.queryResults?.getSelectedResult(); const id = media?.getID() ?? null; - return `${view.view}_${view.camera}${id ? `_${id}` : ''}.jpg`; + return `${view.view}${view.camera ? `_${view.camera}` : ''}${id ? `_${id}` : ''}.jpg`; } return 'screenshot.jpg'; }; diff --git a/src/utils/substream.ts b/src/utils/substream.ts index 3c0157fe..65d3a90d 100644 --- a/src/utils/substream.ts +++ b/src/utils/substream.ts @@ -1,18 +1,31 @@ import { View } from '../view/view'; -export const getStreamCameraID = (view: View, cameraID?: string): string => { - return ( - view.context?.live?.overrides?.get(cameraID ?? view.camera) ?? - cameraID ?? - view.camera - ); +/** + * Get the effective camera ID for streaming, considering substream overrides. + * Returns null if the view has no camera. + */ +export const getStreamCameraID = ( + view: View, + cameraID?: string | null, +): string | null => { + const baseCameraID = cameraID ?? view.camera; + if (!baseCameraID) { + return null; + } + return view.context?.live?.overrides?.get(baseCameraID) ?? baseCameraID; }; export const hasSubstream = (view: View): boolean => { + if (!view.camera) { + return false; + } return getStreamCameraID(view) !== view.camera; }; export const setSubstream = (view: View, substreamID: string): void => { + if (!view.camera) { + return; + } const overrides: Map = view.context?.live?.overrides ?? new Map(); overrides.set(view.camera, substreamID); view.mergeInContext({ @@ -21,6 +34,9 @@ export const setSubstream = (view: View, substreamID: string): void => { }; export const removeSubstream = (view: View): void => { + if (!view.camera) { + return; + } const overrides: Map | undefined = view.context?.live?.overrides; if (overrides && overrides.has(view.camera)) { view.context?.live?.overrides?.delete(view.camera); diff --git a/src/view/utils/resolve-default.ts b/src/view/utils/resolve-default.ts new file mode 100644 index 00000000..f047a3b4 --- /dev/null +++ b/src/view/utils/resolve-default.ts @@ -0,0 +1,28 @@ +import { CameraManager } from '../../camera-manager/manager'; +import { FoldersManager } from '../../card-controller/folders/manager'; +import { + AdvancedCameraCardUserSpecifiedView, + AdvancedCameraCardView, +} from '../../config/schema/common/const'; + +/** + * Resolve a view name that may be 'auto'. + * @param viewName The view name. + * @param cameraManager The camera manager. + * @param foldersManager The folders manager. + * @returns A concrete view name. + */ +export const resolveViewName = ( + viewName: AdvancedCameraCardUserSpecifiedView, + cameraManager: CameraManager, + foldersManager: FoldersManager, +): AdvancedCameraCardView => { + if (viewName !== 'auto') { + return viewName; + } + return cameraManager.getStore().getCameraIDs().size + ? 'live' + : foldersManager.hasFolders() + ? 'folders' + : 'image'; +}; diff --git a/src/view/view-support.ts b/src/view/view-support.ts index e53b8375..b5f5ee64 100644 --- a/src/view/view-support.ts +++ b/src/view/view-support.ts @@ -1,64 +1,120 @@ import { CameraManager } from '../camera-manager/manager'; -import { CapabilitySearchOptions } from '../camera-manager/types'; +import { CapabilitySearchKeys, CapabilitySearchOptions } from '../camera-manager/types'; import { FoldersManager } from '../card-controller/folders/manager'; import { AdvancedCameraCardView } from '../config/schema/common/const'; -/** - * Get cameraIDs that are relevant for a given view name based on camera - * capability (if camera specified). - */ -export const getCameraIDsForViewName = ( +type ViewSource = 'camera' | 'folder' | 'any'; + +interface ViewCapabilityRequirements { + src?: ViewSource; + mediaCapabilities?: CapabilitySearchKeys; + mediaCapabilitiesInclusive?: boolean; +} + +const anyMedia: ViewCapabilityRequirements = { + src: 'any', + mediaCapabilities: { + anyCapabilities: ['clips', 'snapshots', 'recordings', 'reviews'], + }, + mediaCapabilitiesInclusive: false, +}; +const anyFolder = { src: 'folder' as const }; +const noRequirements = {}; + +const generateCameraRequirements = ( + mediaCapabilities: CapabilitySearchKeys, + mediaCapabilitiesInclusive = true, +): ViewCapabilityRequirements => { + return { + src: 'camera', + mediaCapabilities, + mediaCapabilitiesInclusive, + }; +}; + +const VIEW_REQUIREMENTS: Record = { + live: generateCameraRequirements('live', false), + + clip: generateCameraRequirements('clips'), + clips: generateCameraRequirements('clips'), + snapshot: generateCameraRequirements('snapshots'), + snapshots: generateCameraRequirements('snapshots'), + recording: generateCameraRequirements('recordings'), + recordings: generateCameraRequirements('recordings'), + review: generateCameraRequirements('reviews'), + reviews: generateCameraRequirements('reviews'), + + gallery: anyMedia, + media: anyMedia, + timeline: anyMedia, + + folder: anyFolder, + folders: anyFolder, + + image: noRequirements, + diagnostics: noRequirements, +}; + +export const isViewAvailable = ( + view: AdvancedCameraCardView, + cameraManager: CameraManager, + foldersManager: FoldersManager, +): boolean => { + const req = VIEW_REQUIREMENTS[view]; + const hasCameras = cameraManager.getStore().getCameraIDs().size > 0; + const hasFolders = foldersManager.hasFolders(); + + if (req.src === 'camera') { + return hasCameras; + } + if (req.src === 'folder') { + return hasFolders; + } + if (req.src === 'any') { + return hasCameras || hasFolders; + } + return true; +}; + +export const doesViewRequireCamera = (view: AdvancedCameraCardView): boolean => { + return VIEW_REQUIREMENTS[view].src === 'camera'; +}; + +export const getCameraIDsWithCapabilityForView = ( viewName: AdvancedCameraCardView, cameraManager: CameraManager, foldersManager: FoldersManager, cameraID?: string, ): Set => { - const folder = foldersManager.getFolder(); + const requirements = VIEW_REQUIREMENTS[viewName]; + const allCameras = cameraManager.getStore().getCameraIDs(); - switch (viewName) { - case 'diagnostics': - case 'image': - return cameraManager.getStore().getCameraIDs(); - - case 'folder': - case 'folders': - return folder ? cameraManager.getStore().getCameraIDs() : new Set(); - - case 'live': - case 'clip': - case 'clips': - case 'snapshot': - case 'snapshots': - case 'recording': - case 'recordings': - case 'review': - case 'reviews': - const options: CapabilitySearchOptions = { - inclusive: viewName !== 'live', - }; - const capability = - viewName === 'clip' - ? 'clips' - : viewName === 'snapshot' - ? 'snapshots' - : viewName === 'recording' - ? 'recordings' - : viewName === 'review' - ? 'reviews' - : viewName; - return cameraID - ? cameraManager.getStore().getAllDependentCameras(cameraID, capability, options) - : cameraManager.getStore().getCameraIDsWithCapability(capability, options); - - case 'gallery': - case 'media': - case 'timeline': - return folder - ? cameraManager.getStore().getCameraIDs() - : cameraManager.getStore().getCameraIDsWithCapability({ - anyCapabilities: ['clips', 'snapshots', 'recordings', 'reviews'], - }); + if (requirements.src !== 'camera' && requirements.src !== 'any') { + if (requirements.src === 'folder' && !foldersManager.hasFolders()) { + return new Set(); + } + return allCameras; } + + if (requirements.src === 'any' && foldersManager.hasFolders()) { + return allCameras; + } + + const options: CapabilitySearchOptions = { + inclusive: !!requirements.mediaCapabilitiesInclusive, + }; + const capability = requirements.mediaCapabilities; + + /* istanbul ignore next: this path is currently unreachable given the mapping + in VIEW_REQUIREMENTS includes mediaCapabilities for all camera or 'any' + related views -- @preserve */ + if (!capability) { + return allCameras; + } + + return cameraID + ? cameraManager.getStore().getAllDependentCameras(cameraID, capability, options) + : cameraManager.getStore().getCameraIDsWithCapability(capability, options); }; export const isViewSupportedByCamera = ( @@ -67,5 +123,23 @@ export const isViewSupportedByCamera = ( foldersManager: FoldersManager, cameraID: string, ): boolean => { - return !!getCameraIDsForViewName(view, cameraManager, foldersManager, cameraID)?.size; + return !!getCameraIDsWithCapabilityForView( + view, + cameraManager, + foldersManager, + cameraID, + )?.size; +}; + +export const isViewSupported = ( + viewName: AdvancedCameraCardView, + cameraManager: CameraManager, + foldersManager: FoldersManager, + cameraID?: string | null, +): boolean => { + return ( + isViewAvailable(viewName, cameraManager, foldersManager) && + (!cameraID || + isViewSupportedByCamera(viewName, cameraManager, foldersManager, cameraID)) + ); }; diff --git a/src/view/view.ts b/src/view/view.ts index 53bfe0d8..890483b3 100644 --- a/src/view/view.ts +++ b/src/view/view.ts @@ -16,7 +16,7 @@ declare module 'view' { interface ViewEvolveParameters { view?: AdvancedCameraCardView; - camera?: string; + camera?: string | null; query?: UnifiedQuery | null; queryResults?: QueryResults | null; context?: ViewContext | null; @@ -25,7 +25,6 @@ interface ViewEvolveParameters { export interface ViewParameters extends ViewEvolveParameters { view: AdvancedCameraCardView; - camera: string; } export const mergeViewContext = ( @@ -37,7 +36,7 @@ export const mergeViewContext = ( export class View { public view: AdvancedCameraCardView; - public camera: string; + public camera: string | null; public query: UnifiedQuery | null; public queryResults: QueryResults | null; public context: ViewContext | null; @@ -45,7 +44,7 @@ export class View { constructor(params: ViewParameters) { this.view = params.view; - this.camera = params.camera; + this.camera = params.camera ?? null; this.query = params.query ?? null; this.queryResults = params.queryResults ?? null; this.context = params.context ?? null; diff --git a/tests/camera-manager/manager.test.ts b/tests/camera-manager/manager.test.ts index 9acf808b..b6e9b631 100644 --- a/tests/camera-manager/manager.test.ts +++ b/tests/camera-manager/manager.test.ts @@ -24,6 +24,7 @@ import { import { CardController } from '../../src/card-controller/controller.js'; import { sortItems } from '../../src/card-controller/view/sort.js'; import { CameraConfig } from '../../src/config/schema/cameras.js'; +import { advancedCameraCardConfigSchema } from '../../src/config/schema/types.js'; import { HomeAssistant } from '../../src/ha/types.js'; import { QuerySource } from '../../src/query-source.js'; import { Endpoint, PTZMovementType } from '../../src/types.js'; @@ -340,6 +341,21 @@ describe('CameraManager', () => { expect(manager.getStore().getCameraCount()).toBe(0); }); + it('without cameras in config', async () => { + const api = createCardAPI(); + vi.mocked(api.getHASSManager().getHASS).mockReturnValue(createHASS()); + vi.mocked(api.getConfigManager().getConfig).mockReturnValue( + advancedCameraCardConfigSchema.parse({ + type: 'advanced-camera-card' as const, + }), + ); + + const manager = new CameraManager(api); + + await manager.initializeCamerasFromConfig(); + expect(manager.getStore().getCameraCount()).toBe(0); + }); + it('without an id', async () => { const api = createCardAPI(); vi.mocked(api.getHASSManager().getHASS).mockReturnValue(createHASS()); diff --git a/tests/card-controller/actions/actions/effect.test.ts b/tests/card-controller/actions/actions/effect.test.ts index 479b477a..1c44d4b4 100644 --- a/tests/card-controller/actions/actions/effect.test.ts +++ b/tests/card-controller/actions/actions/effect.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it, vi } from 'vitest'; +import { describe, expect, it } from 'vitest'; import { EffectAction } from '../../../../src/card-controller/actions/actions/effect'; import { createEffectAction } from '../../../../src/utils/action'; import { createCardAPI } from '../../../test-utils'; @@ -11,7 +11,7 @@ describe('EffectAction', () => { await action.execute(api); - expect(api.getEffectsControllerAPI()?.startEffect).toHaveBeenCalledWith('snow'); + expect(api.getEffectsManager().startEffect).toHaveBeenCalledWith('snow'); }); it('should call stopEffect when action is stop', async () => { @@ -21,7 +21,7 @@ describe('EffectAction', () => { await action.execute(api); - expect(api.getEffectsControllerAPI()?.stopEffect).toHaveBeenCalledWith('snow'); + expect(api.getEffectsManager().stopEffect).toHaveBeenCalledWith('snow'); }); it('should call toggleEffect when action is toggle', async () => { @@ -31,16 +31,7 @@ describe('EffectAction', () => { await action.execute(api); - expect(api.getEffectsControllerAPI()?.toggleEffect).toHaveBeenCalledWith('snow'); - }); - - it('should not throw when effectsControllerAPI is null', async () => { - const api = createCardAPI(); - vi.mocked(api.getEffectsControllerAPI).mockReturnValue(null); - const actionConfig = createEffectAction('snow', 'start'); - const action = new EffectAction({}, actionConfig); - - await expect(action.execute(api)).resolves.toBeUndefined(); + expect(api.getEffectsManager().toggleEffect).toHaveBeenCalledWith('snow'); }); it('should have a no-op stop method', async () => { diff --git a/tests/card-controller/actions/actions/media-player.test.ts b/tests/card-controller/actions/actions/media-player.test.ts index ca0cd3d8..38d04012 100644 --- a/tests/card-controller/actions/actions/media-player.test.ts +++ b/tests/card-controller/actions/actions/media-player.test.ts @@ -101,4 +101,28 @@ describe('should handle media_player action', () => { expect(api.getMediaPlayerManager().playMedia).not.toBeCalled(); }); + + it('to not play live without camera', async () => { + const api = createCardAPI(); + vi.mocked(api.getViewManager().getView).mockReturnValue( + createView({ + camera: null, + view: 'live', + }), + ); + + const action = new MediaPlayerAction( + {}, + { + action: 'fire-dom-event', + advanced_camera_card_action: 'media_player', + media_player_action: 'play', + media_player: 'this_is_a_media_player', + }, + ); + + await action.execute(api); + + expect(api.getMediaPlayerManager().playLive).not.toBeCalled(); + }); }); diff --git a/tests/card-controller/config/config-manager.test.ts b/tests/card-controller/config/config-manager.test.ts index d70327e5..9afb9e0c 100644 --- a/tests/card-controller/config/config-manager.test.ts +++ b/tests/card-controller/config/config-manager.test.ts @@ -1,12 +1,14 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; import { ZodError } from 'zod'; +import { AutomationsManager } from '../../../src/card-controller/automations-manager'; import { ConfigManager } from '../../../src/card-controller/config/config-manager'; import { InitializationAspect } from '../../../src/card-controller/initialization-manager'; import { ConditionStateManager } from '../../../src/conditions/state-manager'; -import { AutomationsManager } from '../../../src/card-controller/automations-manager'; +import { Automation } from '../../../src/config/schema/automations'; +import { AdvancedCameraCardCondition } from '../../../src/config/schema/conditions/types'; import { advancedCameraCardConfigSchema } from '../../../src/config/schema/types'; -import { createCardAPI, createConfig, flushPromises } from '../../test-utils'; import { createGeneralAction } from '../../../src/utils/action'; +import { createCardAPI, createConfig, flushPromises } from '../../test-utils'; /** * Create a ConfigManager test setup with real AutomationsManager and ConditionStateManager. @@ -92,7 +94,7 @@ describe('ConfigManager', () => { it('invalid configuration with hint', () => { const manager = new ConfigManager(createCardAPI()); expect(() => manager.setConfig({})).toThrowError( - 'Invalid configuration: [\n "cameras",\n "type"\n]', + 'Invalid configuration: [\n "type"\n]', ); }); @@ -290,7 +292,7 @@ describe('ConfigManager', () => { overrides: [ { conditions: [TEST_CONDITIONS.FULLSCREEN_ON], - delete: ['cameras'], + delete: ['type'], }, ], }; @@ -472,7 +474,7 @@ describe('ConfigManager', () => { ], }); - manager.setConfig(config as any); + manager.setConfig(config); await flushPromises(); // Verify keyboard shortcuts automations were added initially with ptz_home @@ -501,9 +503,10 @@ describe('ConfigManager', () => { // This confirms the override removed them (directly verified through add calls) const addCalls = addAutomationsSpy.mock.calls; const hasKeyboardShortcut = addCalls.some((call) => - call[0].some((automation: any) => + call[0].some((automation: Automation) => automation.conditions?.some( - (cond: any) => cond.condition === 'key' && cond.key === 'h', + (cond: AdvancedCameraCardCondition) => + cond.condition === 'key' && cond.key === 'h', ), ), ); @@ -524,7 +527,7 @@ describe('ConfigManager', () => { ], }); - manager.setConfig(config as any); + manager.setConfig(config); await flushPromises(); // Verify initial payload (folders may be populated with defaults, so @@ -594,7 +597,7 @@ describe('ConfigManager', () => { ], }); - manager.setConfig(config as any); + manager.setConfig(config); await flushPromises(); // Verify automations were added initially @@ -652,7 +655,7 @@ describe('ConfigManager', () => { }); // Initial set should register remote control automations - manager.setConfig(config as any); + manager.setConfig(config); await flushPromises(); // Verify remote-control automations were added initially with config condition @@ -682,9 +685,9 @@ describe('ConfigManager', () => { // Verify new automations don't contain remote-control config conditions const addCalls = addAutomationsSpy.mock.calls; const hasRemoteControl = addCalls.some((call) => - call[0].some((automation: any) => + call[0].some((automation: Automation) => automation.conditions?.some( - (cond: any) => + (cond: AdvancedCameraCardCondition) => cond.condition === 'config' && cond.paths?.includes('remote_control.entities.camera'), ), diff --git a/tests/card-controller/config/load-control-entities.test.ts b/tests/card-controller/config/load-control-entities.test.ts index 9459a5d1..ee10a09e 100644 --- a/tests/card-controller/config/load-control-entities.test.ts +++ b/tests/card-controller/config/load-control-entities.test.ts @@ -313,13 +313,16 @@ describe('setRemoteControlEntityFromConfig', () => { }); describe('should select option on entity', () => { - it('should select option when camera differs from entity state', () => { + it('should select option when camera differs from entity state', async () => { const hass = createHASS({ 'input_select.camera': createStateEntity({ state: 'camera.one', }), }); const api = createCardAPI(); + vi.mocked(api.getCameraManager().getStore).mockReturnValue( + createStore([{ cameraID: 'camera.two' }]), + ); vi.mocked(api.getHASSManager().getHASS).mockReturnValue(hass); vi.mocked(api.getConfigManager().getConfig).mockReturnValue( createConfig({ @@ -349,7 +352,7 @@ describe('setRemoteControlEntityFromConfig', () => { INTERNAL_CALLBACK_ACTION, ); - cameraSyncAction.callback(api); + await cameraSyncAction.callback(api); expect(hass.callService).toBeCalledWith( 'input_select', 'select_option', @@ -369,6 +372,9 @@ describe('setRemoteControlEntityFromConfig', () => { }), }); const api = createCardAPI(); + vi.mocked(api.getCameraManager().getStore).mockReturnValue( + createStore([{ cameraID: 'camera.one' }]), + ); vi.mocked(api.getHASSManager().getHASS).mockReturnValue(hass); vi.mocked(api.getConfigManager().getConfig).mockReturnValue( createConfig({ @@ -441,7 +447,47 @@ describe('setRemoteControlEntityFromConfig', () => { expect(hass.callService).not.toBeCalled(); }); - it('should not throw when hass is undefined', () => { + it('should not select option when view exists but has no camera', () => { + const hass = createHASS({ + 'input_select.camera': createStateEntity({ + state: 'camera.one', + }), + }); + const api = createCardAPI(); + vi.mocked(api.getHASSManager().getHASS).mockReturnValue(hass); + vi.mocked(api.getConfigManager().getConfig).mockReturnValue( + createConfig({ + remote_control: { + entities: { + camera: 'input_select.camera', + camera_priority: 'card', + }, + }, + }), + ); + vi.mocked(api.getViewManager().getView).mockReturnValue( + createView({ + camera: null, + view: 'live', + }), + ); + + setRemoteControlEntityFromConfig(api); + + // Test the 'camera' condition callback (automation index 1) + const cameraSyncAction = vi.mocked(api.getAutomationsManager().addAutomations).mock + .calls[0][0][1].actions?.[0] as InternalCallbackActionConfig; + cameraSyncAction.callback(api); + expect(hass.callService).not.toBeCalled(); + + // Also test the 'initialized' condition callback (automation index 2) + const initializedSyncAction = vi.mocked(api.getAutomationsManager().addAutomations) + .mock.calls[0][0][2].actions?.[0] as InternalCallbackActionConfig; + initializedSyncAction.callback(api); + expect(hass.callService).not.toBeCalled(); + }); + + it('should not throw when hass is undefined', async () => { const api = createCardAPI(); vi.mocked(api.getHASSManager().getHASS).mockReturnValue(null); vi.mocked(api.getConfigManager().getConfig).mockReturnValue( @@ -472,12 +518,15 @@ describe('setRemoteControlEntityFromConfig', () => { ); // Should not throw and obviously not call service (as hass is null) - cameraSyncAction.callback(api); + await cameraSyncAction.callback(api); }); - it('should select option when entity state is undefined', () => { + it('should select option when entity state is undefined', async () => { const hass = createHASS({}); const api = createCardAPI(); + vi.mocked(api.getCameraManager().getStore).mockReturnValue( + createStore([{ cameraID: 'camera.two' }]), + ); vi.mocked(api.getHASSManager().getHASS).mockReturnValue(hass); vi.mocked(api.getConfigManager().getConfig).mockReturnValue( createConfig({ @@ -506,7 +555,7 @@ describe('setRemoteControlEntityFromConfig', () => { INTERNAL_CALLBACK_ACTION, ); - cameraSyncAction.callback(api); + await cameraSyncAction.callback(api); expect(hass.callService).toBeCalledWith( 'input_select', 'select_option', @@ -519,13 +568,16 @@ describe('setRemoteControlEntityFromConfig', () => { ); }); - it('should select option on initialization with card priority', () => { + it('should select option on initialization with card priority', async () => { const hass = createHASS({ 'input_select.camera': createStateEntity({ state: 'camera.one', }), }); const api = createCardAPI(); + vi.mocked(api.getCameraManager().getStore).mockReturnValue( + createStore([{ cameraID: 'camera.two' }]), + ); vi.mocked(api.getHASSManager().getHASS).mockReturnValue(hass); vi.mocked(api.getConfigManager().getConfig).mockReturnValue( createConfig({ @@ -553,7 +605,7 @@ describe('setRemoteControlEntityFromConfig', () => { expect(isAdvancedCameraCardCustomAction(initAction)).toBeTruthy(); expect(initAction.advanced_camera_card_action).toBe(INTERNAL_CALLBACK_ACTION); - initAction.callback(api); + await initAction.callback(api); expect(hass.callService).toBeCalledWith( 'input_select', 'select_option', diff --git a/tests/card-controller/controller.test.ts b/tests/card-controller/controller.test.ts index 5f32fa48..1cd264a6 100644 --- a/tests/card-controller/controller.test.ts +++ b/tests/card-controller/controller.test.ts @@ -1,5 +1,4 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; -import { mock } from 'vitest-mock-extended'; import { CameraManager } from '../../src/camera-manager/manager'; import { ActionsManager } from '../../src/card-controller/actions/actions-manager'; import { AutomationsManager } from '../../src/card-controller/automations-manager'; @@ -34,7 +33,6 @@ import { AdvancedCameraCardEditor } from '../../src/editor'; import { DeviceRegistryManager } from '../../src/ha/registry/device'; import { EntityRegistryManagerLive } from '../../src/ha/registry/entity'; import { ResolvedMediaCache } from '../../src/ha/resolved-media'; -import { EffectsControllerAPI } from '../../src/types'; vi.mock('../../src/camera-manager/manager'); vi.mock('../../src/card-controller/actions/actions-manager'); @@ -74,7 +72,7 @@ const createCardElement = (): CardHTMLElement => { }; const createController = (): CardController => { - return new CardController(createCardElement(), vi.fn(), vi.fn(), vi.fn()); + return new CardController(createCardElement(), vi.fn(), vi.fn()); }; // @vitest-environment jsdom @@ -87,15 +85,8 @@ describe('CardController', () => { const element = createCardElement(); const scrollCallback = vi.fn(); const menuToggleCallback = vi.fn(); - const effectsControllerAPI = mock(); - const effectsControllerAPICallback = vi.fn().mockReturnValue(effectsControllerAPI); - const controller = new CardController( - element, - scrollCallback, - menuToggleCallback, - effectsControllerAPICallback, - ); + const controller = new CardController(element, scrollCallback, menuToggleCallback); expect(CardElementManager).toBeCalledWith( controller, @@ -103,7 +94,7 @@ describe('CardController', () => { scrollCallback, menuToggleCallback, ); - expect(controller.getEffectsControllerAPI()).toBe(effectsControllerAPI); + expect(controller.getEffectsManager()).toBeTruthy(); }); describe('accessors', () => { diff --git a/tests/components-lib/effects/effects-controller.test.ts b/tests/card-controller/effects/effects-manager.test.ts similarity index 70% rename from tests/components-lib/effects/effects-controller.test.ts rename to tests/card-controller/effects/effects-manager.test.ts index 4562564b..2dfb3c59 100644 --- a/tests/components-lib/effects/effects-controller.test.ts +++ b/tests/card-controller/effects/effects-manager.test.ts @@ -1,6 +1,6 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { EffectsController } from '../../../src/components-lib/effects/effects-controller'; -import { EffectComponent } from '../../../src/components-lib/effects/types'; +import { EffectsManager } from '../../../src/card-controller/effects/effects-manager'; +import { EffectComponent } from '../../../src/card-controller/effects/types'; import { EffectName } from '../../../src/types'; import { flushPromises } from '../../test-utils'; @@ -36,14 +36,14 @@ const createMockEffectComponent = (): EffectComponent => { }; // @vitest-environment jsdom -describe('EffectsController', () => { +describe('EffectsManager', () => { let container: HTMLElement; - let controller: EffectsController; + let manager: EffectsManager; beforeEach(() => { container = document.createElement('div'); - controller = new EffectsController(); - controller.setContainer(container); + manager = new EffectsManager(); + manager.setContainer(container); vi.useFakeTimers(); }); @@ -56,7 +56,7 @@ describe('EffectsController', () => { it('should load and start snow effect', async () => { expect(container.children.length).toBe(0); - await controller.startEffect('snow'); + await manager.startEffect('snow'); expect(container.children.length).toBe(1); }); @@ -64,7 +64,7 @@ describe('EffectsController', () => { it('should load and start hearts effect', async () => { expect(container.children.length).toBe(0); - await controller.startEffect('hearts'); + await manager.startEffect('hearts'); expect(container.children.length).toBe(1); }); @@ -72,7 +72,7 @@ describe('EffectsController', () => { it('should load and start shamrocks effect', async () => { expect(container.children.length).toBe(0); - await controller.startEffect('shamrocks'); + await manager.startEffect('shamrocks'); expect(container.children.length).toBe(1); }); @@ -80,7 +80,7 @@ describe('EffectsController', () => { it('should load and start fireworks effect', async () => { expect(container.children.length).toBe(0); - await controller.startEffect('fireworks'); + await manager.startEffect('fireworks'); expect(container.children.length).toBe(1); }); @@ -88,7 +88,7 @@ describe('EffectsController', () => { it('should load and start ghost effect', async () => { expect(container.children.length).toBe(0); - await controller.startEffect('ghost'); + await manager.startEffect('ghost'); expect(container.children.length).toBe(1); }); @@ -96,13 +96,13 @@ describe('EffectsController', () => { it('should load and start check effect', async () => { expect(container.children.length).toBe(0); - await controller.startEffect('check'); + await manager.startEffect('check'); expect(container.children.length).toBe(1); }); it('should stop effect automatically after duration', async () => { - const effectPromise = controller.startEffect('snow', { duration: 1 }); + const effectPromise = manager.startEffect('snow', { duration: 1 }); // Wait for the effect to be added to the DOM. await flushPromises(); @@ -117,56 +117,61 @@ describe('EffectsController', () => { it('should clean up timer when stopped manually', async () => { // Don't await startEffect as it waits for the duration - controller.startEffect('snow', { duration: 10 }); + manager.startEffect('snow', { duration: 10 }); // Wait for the effect to be added to the DOM. await flushPromises(); - await controller.stopEffect('snow'); + await manager.stopEffect('snow'); expect(container.children.length).toBe(0); }); it('should set fadeIn to true by default', async () => { - await controller.startEffect('snow'); + await manager.startEffect('snow'); const effectElement = container.children[0] as EffectComponent; expect(effectElement.fadeIn).toBe(true); }); it('should set fadeIn from options', async () => { - await controller.startEffect('snow', { fadeIn: false }); + await manager.startEffect('snow', { fadeIn: false }); const effectElement = container.children[0] as EffectComponent; expect(effectElement.fadeIn).toBe(false); }); - it('should not start effect if container is null', async () => { - controller.setContainer(null); + it('should queue effects until a container is available', async () => { + manager.removeContainer(); - await controller.startEffect('snow'); + await manager.startEffect('snow'); expect(container.children.length).toBe(0); + + manager.setContainer(container); + await flushPromises(); + + expect(container.children.length).toBe(1); }); it('should not start effect if already active', async () => { - await controller.startEffect('snow'); - await controller.startEffect('snow'); + await manager.startEffect('snow'); + await manager.startEffect('snow'); // Should only have one child since the second call is ignored. expect(container.children.length).toBe(1); }); it('should allow starting different effects simultaneously', async () => { - await controller.startEffect('snow'); - await controller.startEffect('hearts'); + await manager.startEffect('snow'); + await manager.startEffect('hearts'); expect(container.children.length).toBe(2); }); it('should return early if effect module cannot be loaded', async () => { // Use an effect name that doesn't exist in the registry - await controller.startEffect('unknown' as EffectName); + await manager.startEffect('unknown' as EffectName); expect(container.children.length).toBe(0); }); @@ -174,10 +179,10 @@ describe('EffectsController', () => { describe('stopEffect', () => { it('should stop an active effect', async () => { - await controller.startEffect('snow'); + await manager.startEffect('snow'); const effectElement = container.children[0] as EffectComponent; - await controller.stopEffect('snow'); + await manager.stopEffect('snow'); expect(effectElement.startFadeOut).toHaveBeenCalled(); expect(container.children.length).toBe(0); @@ -186,25 +191,25 @@ describe('EffectsController', () => { it('should do nothing if effect is not active', async () => { expect(container.children.length).toBe(0); - await controller.stopEffect('snow'); + await manager.stopEffect('snow'); expect(container.children.length).toBe(0); }); it('should allow starting effect again after stopping', async () => { - await controller.startEffect('snow'); - await controller.stopEffect('snow'); - await controller.startEffect('snow'); + await manager.startEffect('snow'); + await manager.stopEffect('snow'); + await manager.startEffect('snow'); expect(container.children.length).toBe(1); }); it('should cancel effect when stopped during loading', async () => { // Start the effect but don't await it - simulates the loading phase. - const startPromise = controller.startEffect('snow'); + const startPromise = manager.startEffect('snow'); // Stop the effect immediately while it's still loading. - await controller.stopEffect('snow'); + await manager.stopEffect('snow'); // Wait for start to complete. await startPromise; @@ -212,26 +217,48 @@ describe('EffectsController', () => { // The effect should not appear since it was stopped during loading. expect(container.children.length).toBe(0); }); + + it('should cancel queued effects', async () => { + manager.removeContainer(); + await manager.startEffect('snow'); + await manager.stopEffect('snow'); + + manager.setContainer(container); + await flushPromises(); + + expect(container.children.length).toBe(0); + }); + + it('should clear active effects and timers when container is removed', async () => { + manager.startEffect('snow', { duration: 10 }); + await flushPromises(); + + expect(container.children.length).toBe(1); + + manager.removeContainer(); + + expect(container.children.length).toBe(0); + }); }); describe('toggleEffect', () => { it('should start effect when not active', async () => { - await controller.toggleEffect('snow'); + await manager.toggleEffect('snow'); expect(container.children.length).toBe(1); }); it('should stop effect when active', async () => { - await controller.startEffect('snow'); + await manager.startEffect('snow'); const effectElement = container.children[0] as EffectComponent; - await controller.toggleEffect('snow'); + await manager.toggleEffect('snow'); expect(effectElement.startFadeOut).toHaveBeenCalled(); }); it('should pass options when starting effect', async () => { - await controller.toggleEffect('snow', { fadeIn: false }); + await manager.toggleEffect('snow', { fadeIn: false }); const effectElement = container.children[0] as EffectComponent; expect(effectElement.fadeIn).toBe(false); @@ -239,10 +266,10 @@ describe('EffectsController', () => { it('should cancel effect when toggled during loading', async () => { // Start the effect but don't await it - simulates the loading phase. - const startPromise = controller.startEffect('snow'); + const startPromise = manager.startEffect('snow'); // Toggle the effect immediately while it's still loading. - await controller.toggleEffect('snow'); + await manager.toggleEffect('snow'); // Wait for start to complete. await startPromise; diff --git a/tests/card-controller/view/factory.test.ts b/tests/card-controller/view/factory.test.ts index 9cc3a632..4afbb1cf 100644 --- a/tests/card-controller/view/factory.test.ts +++ b/tests/card-controller/view/factory.test.ts @@ -1,4 +1,6 @@ import { describe, expect, it, vi } from 'vitest'; +import { mock } from 'vitest-mock-extended'; +import { FoldersManager } from '../../../src/card-controller/folders/manager'; import { ViewFactory } from '../../../src/card-controller/view/factory'; import { ViewModifier } from '../../../src/card-controller/view/types'; import { AdvancedCameraCardView } from '../../../src/config/schema/common/const'; @@ -9,6 +11,7 @@ import { createCapabilities, createCardAPI, createConfig, + createFolder, createStore, createView, } from '../../test-utils'; @@ -20,7 +23,7 @@ describe('getViewDefault', () => { expect(factory.getViewDefault()).toBeNull(); }); - it('should throw if no cameras support view', () => { + it('should throw when no cameras support view', () => { const api = createCardAPI(); vi.mocked(api.getConfigManager().getConfig).mockReturnValue(createConfig()); vi.mocked(api.getCameraManager).mockReturnValue(createCameraManager()); @@ -47,6 +50,23 @@ describe('getViewDefault', () => { expect(() => factory.getViewDefault()).toThrowError(/No cameras support this view/); }); + it('should use folders view as default when folders exist without cameras', () => { + const api = createCardAPI(); + vi.mocked(api.getConfigManager().getConfig).mockReturnValue(createConfig()); + vi.mocked(api.getCameraManager).mockReturnValue(createCameraManager()); + vi.mocked(api.getFoldersManager).mockReturnValue(mock()); + vi.mocked(api.getFoldersManager().hasFolders).mockReturnValue(true); + vi.mocked(api.getFoldersManager().getFolder).mockReturnValue(createFolder()); + + vi.mocked(api.getCameraManager().getStore).mockReturnValue(createStore([])); + + const factory = new ViewFactory(api); + const view = factory.getViewDefault(); + + expect(view?.view).toBe('folders'); + expect(view?.camera).toBeNull(); + }); + it('should create view', () => { const factory = new ViewFactory(createPopulatedAPI()); const view = factory.getViewDefault(); @@ -217,8 +237,6 @@ describe('getViewByParameters', () => { const factory = new ViewFactory(api); expect(() => factory.getViewByParameters({ - // Since no camera is specified, and no camera supports the capabilities - // necessary for this view, the view will be null. params: { view: 'snapshots', }, @@ -226,6 +244,56 @@ describe('getViewByParameters', () => { ).toThrowError(/No cameras support this view/); }); + describe('should handle no camera for view with failsafe', () => { + it('should choose default view when it does not require a camera', () => { + const api = createCardAPI(); + vi.mocked(api.getCameraManager).mockReturnValue(createCameraManager()); + vi.mocked(api.getCameraManager().getStore).mockReturnValue(createStore([])); + vi.mocked(api.getConfigManager().getConfig).mockReturnValue( + createConfig({ + view: { + default: 'diagnostics', + }, + }), + ); + + const factory = new ViewFactory(api); + const view = factory.getViewByParameters({ + params: { + view: 'live', + }, + failSafe: true, + }); + expect(view?.is('diagnostics')).toBeTruthy(); + expect(view?.camera).toBeNull(); + }); + + it('should choose default view and camera when it requires a camera', () => { + const api = createCardAPI(); + vi.mocked(api.getCameraManager).mockReturnValue(createCameraManager()); + vi.mocked(api.getCameraManager().getStore).mockReturnValue( + createStore([{ cameraID: 'camera.one' }]), + ); + vi.mocked(api.getConfigManager().getConfig).mockReturnValue( + createConfig({ + view: { + default: 'live', + }, + }), + ); + + const factory = new ViewFactory(api); + const view = factory.getViewByParameters({ + params: { + view: 'snapshots', + }, + failSafe: true, + }); + expect(view?.is('live')).toBeTruthy(); + expect(view?.camera).toBe('camera.one'); + }); + }); + describe('should handle unsupported view', () => { it('should throw without failsafe', () => { const api = createCardAPI(); @@ -285,6 +353,24 @@ describe('getViewByParameters', () => { }); expect(view?.is('live')).toBeTruthy(); }); + + it('should throw and omit capabilities when camera object is missing from store', () => { + const api = createCardAPI(); + const store = createStore([{ cameraID: 'camera.one' }]); + vi.mocked(api.getCameraManager).mockReturnValue(createCameraManager(store)); + vi.spyOn(store, 'getCamera').mockReturnValue(null); + vi.mocked(api.getConfigManager().getConfig).mockReturnValue(createConfig()); + + const factory = new ViewFactory(api); + expect(() => + factory.getViewByParameters({ + params: { + camera: 'camera.one', + view: 'snapshots', + }, + }), + ).toThrowError(/The selected camera or media does not support this view/); + }); }); it('should call modifiers', () => { diff --git a/tests/card-controller/view/modifiers/substream-on.test.ts b/tests/card-controller/view/modifiers/substream-on.test.ts index 52d95240..c7b19f68 100644 --- a/tests/card-controller/view/modifiers/substream-on.test.ts +++ b/tests/card-controller/view/modifiers/substream-on.test.ts @@ -21,29 +21,27 @@ const createAPIWithSubstreams = ( config?: RawAdvancedCameraCardConfig, ): CardController => { const api = createCardAPI(); - vi.mocked(api.getCameraManager).mockReturnValue(createCameraManager()); - vi.mocked(api.getCameraManager().getStore).mockReturnValue( - createStore([ - { - cameraID: 'camera.office', - capabilities: createCapabilities({ - live: true, - substream: true, - }), - config: createCameraConfig({ - dependencies: { - all_cameras: true, - }, - }), - }, - { - cameraID: 'camera.kitchen', - capabilities: createCapabilities({ - substream: true, - }), - }, - ]), - ); + const store = createStore([ + { + cameraID: 'camera.office', + capabilities: createCapabilities({ + live: true, + substream: true, + }), + config: createCameraConfig({ + dependencies: { + all_cameras: true, + }, + }), + }, + { + cameraID: 'camera.kitchen', + capabilities: createCapabilities({ + substream: true, + }), + }, + ]); + vi.mocked(api.getCameraManager).mockReturnValue(createCameraManager(store)); vi.mocked(api.getConfigManager().getConfig).mockReturnValue(createConfig(config)); return api; }; @@ -97,11 +95,25 @@ describe('should turn on substream', () => { expect(hasSubstream(view)).toBe(false); const api = createCardAPI(); - vi.mocked(api.getCameraManager).mockReturnValue(createCameraManager()); + const cameraManager = createCameraManager(createStore([])); + vi.mocked(api.getCameraManager).mockReturnValue(cameraManager); const modifier = new SubstreamOnViewModifier(api); modifier.modify(view); expect(hasSubstream(view)).toBe(false); }); + + it('without camera', () => { + const view = createView({ + camera: null, + view: 'live', + }); + + const api = createCardAPI(); + const modifier = new SubstreamOnViewModifier(api); + modifier.modify(view); + + expect(hasSubstream(view)).toBe(false); + }); }); diff --git a/tests/card-controller/view/view-query-executor.test.ts b/tests/card-controller/view/view-query-executor.test.ts index 14931557..505d0129 100644 --- a/tests/card-controller/view/view-query-executor.test.ts +++ b/tests/card-controller/view/view-query-executor.test.ts @@ -257,7 +257,7 @@ describe('ViewQueryExecutor', () => { const store = createStore([ { cameraID: 'camera.office', - capabilities: createCapabilities({ [mode]: true }), + capabilities: createCapabilities({ live: true, [mode]: true }), config: createCameraConfig({ media: { type: mode === 'reviews' ? 'reviews' : 'events', @@ -546,6 +546,34 @@ describe('ViewQueryExecutor', () => { expect(view.query).toBeNull(); expect(view.queryResults).toBeNull(); }); + + it('should handle non-grid view without a camera', async () => { + const api = createPopulatedAPI(); + const viewQueryExecutor = new ViewQueryExecutor(api); + const view = createView({ + view: 'live', + camera: null, + }); + + const modifiers = await viewQueryExecutor.getNewQueryModifiers(view); + applyViewModifiers(view, modifiers); + + expect(view.query).toBeNull(); + }); + + it('should handle diagnostics view without a camera', async () => { + const api = createPopulatedAPI(); + const viewQueryExecutor = new ViewQueryExecutor(api); + const view = createView({ + view: 'diagnostics', + camera: null, + }); + + const modifiers = await viewQueryExecutor.getNewQueryModifiers(view); + applyViewModifiers(view, modifiers); + + expect(view.query).toBeNull(); + }); }); describe('with a timeline view', () => { @@ -799,6 +827,7 @@ describe('ViewQueryExecutor', () => { 'should execute default folder query with %s view', async (viewName: AdvancedCameraCardView) => { const api = createPopulatedAPI(); + vi.mocked(api.getFoldersManager().hasFolders).mockReturnValue(true); vi.mocked(api.getFoldersManager().getFolder).mockReturnValue({ type: 'ha', id: 'office', @@ -827,6 +856,7 @@ describe('ViewQueryExecutor', () => { it('should execute default folder query with folder view and handle null results', async () => { const api = createPopulatedAPI(); + vi.mocked(api.getFoldersManager().hasFolders).mockReturnValue(true); vi.mocked(api.getFoldersManager().getFolder).mockReturnValue(null); vi.mocked(api.getFoldersManager().getDefaultQueryParameters).mockReturnValue( null, diff --git a/tests/components-lib/menu-button-controller.test.ts b/tests/components-lib/menu-button-controller.test.ts index d0b5417d..6a725244 100644 --- a/tests/components-lib/menu-button-controller.test.ts +++ b/tests/components-lib/menu-button-controller.test.ts @@ -26,8 +26,8 @@ import { ViewMedia, ViewMediaType } from '../../src/view/item.js'; import { QueryResults } from '../../src/view/query-results.js'; import { UnifiedQuery } from '../../src/view/unified-query.js'; import { - getCameraIDsForViewName, - isViewSupportedByCamera, + getCameraIDsWithCapabilityForView, + isViewSupported, } from '../../src/view/view-support.js'; import { View } from '../../src/view/view.js'; import { @@ -89,6 +89,7 @@ describe('MenuButtonController', () => { beforeEach(() => { vi.resetAllMocks(); + vi.mocked(isViewSupported).mockReturnValue(true); controller = new MenuButtonController(); }); @@ -507,7 +508,7 @@ describe('MenuButtonController', () => { describe('should have live menu button', () => { it('when in live view', () => { const viewManager = mock(); - vi.mocked(isViewSupportedByCamera).mockReturnValue(true); + vi.mocked(isViewSupported).mockReturnValue(true); const buttons = calculateButtons(controller, { view: createView({ view: 'live' }), viewManager: viewManager, @@ -526,7 +527,7 @@ describe('MenuButtonController', () => { it('when not in live view', () => { const viewManager = mock(); - vi.mocked(isViewSupportedByCamera).mockReturnValue(true); + vi.mocked(isViewSupported).mockReturnValue(true); const buttons = calculateButtons(controller, { view: createView({ view: 'clips' }), viewManager: viewManager, @@ -545,7 +546,7 @@ describe('MenuButtonController', () => { it('when not supported', () => { const viewManager = mock(); - vi.mocked(isViewSupportedByCamera).mockReturnValue(false); + vi.mocked(isViewSupported).mockReturnValue(false); const buttons = calculateButtons(controller, { viewManager: viewManager, }); @@ -561,7 +562,7 @@ describe('MenuButtonController', () => { describe('should have clips menu button', () => { it('when in clips view', () => { const viewManager = mock(); - vi.mocked(isViewSupportedByCamera).mockReturnValue(true); + vi.mocked(isViewSupported).mockReturnValue(true); const buttons = calculateButtons(controller, { view: createView({ view: 'clips' }), viewManager: viewManager, @@ -581,7 +582,7 @@ describe('MenuButtonController', () => { it('when not in clips view', () => { const viewManager = mock(); - vi.mocked(isViewSupportedByCamera).mockReturnValue(true); + vi.mocked(isViewSupported).mockReturnValue(true); const buttons = calculateButtons(controller, { viewManager: viewManager, }); @@ -600,7 +601,7 @@ describe('MenuButtonController', () => { it('when not supported', () => { const viewManager = mock(); - vi.mocked(isViewSupportedByCamera).mockReturnValue(false); + vi.mocked(isViewSupported).mockReturnValue(false); const buttons = calculateButtons(controller, { viewManager: viewManager, }); @@ -614,9 +615,7 @@ describe('MenuButtonController', () => { describe('should have snapshots menu button', () => { it('when in snapshots view', () => { const viewManager = mock(); - vi.mocked(isViewSupportedByCamera).mockImplementation( - (view) => view !== 'reviews', - ); + vi.mocked(isViewSupported).mockImplementation((view) => view !== 'reviews'); const buttons = calculateButtons(controller, { view: createView({ view: 'snapshots' }), viewManager: viewManager, @@ -642,7 +641,7 @@ describe('MenuButtonController', () => { it('when not in snapshots view', () => { const viewManager = mock(); - vi.mocked(isViewSupportedByCamera).mockReturnValue(true); + vi.mocked(isViewSupported).mockReturnValue(true); const buttons = calculateButtons(controller, { viewManager: viewManager, }); @@ -667,7 +666,7 @@ describe('MenuButtonController', () => { it('when not supported', () => { const viewManager = mock(); - vi.mocked(isViewSupportedByCamera).mockReturnValue(false); + vi.mocked(isViewSupported).mockReturnValue(false); const buttons = calculateButtons(controller, { viewManager: viewManager, }); @@ -683,7 +682,7 @@ describe('MenuButtonController', () => { describe('should have reviews menu button', () => { it('when in reviews view', () => { const viewManager = mock(); - vi.mocked(isViewSupportedByCamera).mockReturnValue(true); + vi.mocked(isViewSupported).mockReturnValue(true); const buttons = calculateButtons(controller, { view: createView({ view: 'reviews' }), viewManager: viewManager, @@ -709,7 +708,7 @@ describe('MenuButtonController', () => { it('when not in reviews view', () => { const viewManager = mock(); - vi.mocked(isViewSupportedByCamera).mockReturnValue(true); + vi.mocked(isViewSupported).mockReturnValue(true); const buttons = calculateButtons(controller, { viewManager: viewManager, }); @@ -734,7 +733,7 @@ describe('MenuButtonController', () => { it('when not supported', () => { const viewManager = mock(); - vi.mocked(isViewSupportedByCamera).mockReturnValue(false); + vi.mocked(isViewSupported).mockReturnValue(false); const buttons = calculateButtons(controller, { viewManager: viewManager, }); @@ -748,7 +747,7 @@ describe('MenuButtonController', () => { describe('should have gallery menu button', () => { it('when in gallery view', () => { const viewManager = mock(); - vi.mocked(isViewSupportedByCamera).mockReturnValue(true); + vi.mocked(isViewSupported).mockReturnValue(true); const buttons = calculateButtons(controller, { view: createView({ view: 'gallery' }), viewManager: viewManager, @@ -774,7 +773,7 @@ describe('MenuButtonController', () => { it('when not in gallery view', () => { const viewManager = mock(); - vi.mocked(isViewSupportedByCamera).mockReturnValue(true); + vi.mocked(isViewSupported).mockReturnValue(true); const buttons = calculateButtons(controller, { viewManager: viewManager, }); @@ -799,7 +798,7 @@ describe('MenuButtonController', () => { it('when not supported', () => { const viewManager = mock(); - vi.mocked(isViewSupportedByCamera).mockReturnValue(false); + vi.mocked(isViewSupported).mockReturnValue(false); const buttons = calculateButtons(controller, { viewManager: viewManager, }); @@ -813,7 +812,7 @@ describe('MenuButtonController', () => { describe('should have recordings menu button', () => { it('when in recordings view', () => { const viewManager = mock(); - vi.mocked(isViewSupportedByCamera).mockReturnValue(true); + vi.mocked(isViewSupported).mockReturnValue(true); const buttons = calculateButtons(controller, { view: createView({ view: 'recordings' }), viewManager: viewManager, @@ -839,7 +838,7 @@ describe('MenuButtonController', () => { it('when not in recordings view', () => { const viewManager = mock(); - vi.mocked(isViewSupportedByCamera).mockReturnValue(true); + vi.mocked(isViewSupported).mockReturnValue(true); const buttons = calculateButtons(controller, { viewManager: viewManager, }); @@ -864,7 +863,7 @@ describe('MenuButtonController', () => { it('when not supported', () => { const viewManager = mock(); - vi.mocked(isViewSupportedByCamera).mockReturnValue(false); + vi.mocked(isViewSupported).mockReturnValue(false); const buttons = calculateButtons(controller, { viewManager: viewManager, }); @@ -880,7 +879,7 @@ describe('MenuButtonController', () => { describe('should have image menu button', () => { it('when in image view', () => { const viewManager = mock(); - vi.mocked(isViewSupportedByCamera).mockReturnValue(true); + vi.mocked(isViewSupported).mockReturnValue(true); const buttons = calculateButtons(controller, { view: createView({ view: 'image' }), @@ -900,7 +899,7 @@ describe('MenuButtonController', () => { it('when not in image view', () => { const viewManager = mock(); - vi.mocked(isViewSupportedByCamera).mockReturnValue(true); + vi.mocked(isViewSupported).mockReturnValue(true); const buttons = calculateButtons(controller, { view: createView({ view: 'live' }), @@ -920,7 +919,7 @@ describe('MenuButtonController', () => { it('when not supported', () => { const viewManager = mock(); - vi.mocked(isViewSupportedByCamera).mockReturnValue(false); + vi.mocked(isViewSupported).mockReturnValue(false); const buttons = calculateButtons(controller, { viewManager: viewManager, }); @@ -936,7 +935,7 @@ describe('MenuButtonController', () => { describe('should have timeline button', () => { it('when in timeline view', () => { const viewManager = mock(); - vi.mocked(isViewSupportedByCamera).mockReturnValue(true); + vi.mocked(isViewSupported).mockReturnValue(true); const buttons = calculateButtons(controller, { view: createView({ view: 'timeline' }), viewManager: viewManager, @@ -958,7 +957,7 @@ describe('MenuButtonController', () => { it('when not in timeline view', () => { const viewManager = mock(); - vi.mocked(isViewSupportedByCamera).mockReturnValue(true); + vi.mocked(isViewSupported).mockReturnValue(true); const buttons = calculateButtons(controller, { view: createView({ view: 'live' }), viewManager: viewManager, @@ -980,7 +979,7 @@ describe('MenuButtonController', () => { it('when not supported', () => { const viewManager = mock(); - vi.mocked(isViewSupportedByCamera).mockReturnValue(false); + vi.mocked(isViewSupported).mockReturnValue(false); const buttons = calculateButtons(controller, { view: createView({ view: 'live' }), viewManager: viewManager, @@ -1640,7 +1639,7 @@ describe('MenuButtonController', () => { ]), ); - vi.mocked(getCameraIDsForViewName).mockReturnValue( + vi.mocked(getCameraIDsWithCapabilityForView).mockReturnValue( new Set(['camera-1', 'camera-2']), ); @@ -1928,6 +1927,7 @@ describe('MenuButtonController', () => { describe('should have folders button', () => { it('should have no folders button without folders', () => { const foldersManager = mock(); + foldersManager.hasFolders.mockReturnValue(true); foldersManager.getFolders.mockReturnValue(new Map().entries()); const buttons = calculateButtons(controller, { @@ -1945,6 +1945,7 @@ describe('MenuButtonController', () => { it('should have a folders button for a single folder outside the folder view', () => { const foldersManager = mock(); + foldersManager.hasFolders.mockReturnValue(true); foldersManager.getFolders.mockReturnValue( new Map([['folder-0', createFolder({ id: 'folder-0' })]]).entries(), ); @@ -1968,6 +1969,7 @@ describe('MenuButtonController', () => { it('should have a folders button for a single folder inside the folder view', () => { const folder = createFolder({ id: 'folder-0' }); const foldersManager = mock(); + foldersManager.hasFolders.mockReturnValue(true); foldersManager.getFolders.mockReturnValue( new Map([['folder-0', folder]]).entries(), ); @@ -2006,6 +2008,7 @@ describe('MenuButtonController', () => { ['folder-0', createFolder({ id: 'folder-0' })], ['folder-selected', selectedFolder], ]); + foldersManager.hasFolders.mockReturnValue(true); foldersManager.getFolders.mockReturnValue(folders.entries()); const selectedFolderNode: FolderQuery = { @@ -2079,6 +2082,7 @@ describe('MenuButtonController', () => { ['folder-0', createFolder({ id: 'folder-0' })], ['folder-1', createFolder({ id: 'folder-1' })], ]); + foldersManager.hasFolders.mockReturnValue(true); foldersManager.getFolders.mockReturnValue(folders.entries()); const buttons = calculateButtons(controller, { @@ -2202,9 +2206,10 @@ describe('MenuButtonController', () => { ...dynamicButton, tap_action: { action: 'fire-dom-event', advanced_camera_card_action: 'default' }, }; + const cameraManager = createCameraManager(createStore([{ cameraID: 'camera-1' }])); controller.addDynamicMenuButton(button); - expect(calculateButtons(controller)).toContainEqual({ + expect(calculateButtons(controller, { cameraManager })).toContainEqual({ ...button, style: { color: 'var(--advanced-camera-card-menu-button-active-color)' }, }); @@ -2346,4 +2351,12 @@ describe('MenuButtonController', () => { ); }); }); + + it('should handle view without camera', () => { + const buttons = calculateButtons(controller, { + view: createView({ camera: null }), + }); + + expect(buttons).toBeDefined(); + }); }); diff --git a/tests/config/types.test.ts b/tests/config/types.test.ts index 91c28b22..cb23c97b 100644 --- a/tests/config/types.test.ts +++ b/tests/config/types.test.ts @@ -378,7 +378,7 @@ describe('config defaults', () => { type: 'advanced-camera-card', view: { camera_select: 'current', - default: 'live', + default: 'auto', keyboard_shortcuts: { enabled: true, ptz_down: { diff --git a/tests/test-utils.ts b/tests/test-utils.ts index 80a2f76a..ddffa092 100644 --- a/tests/test-utils.ts +++ b/tests/test-utils.ts @@ -28,6 +28,7 @@ import { CardElementManager } from '../src/card-controller/card-element-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 { EffectsManager } from '../src/card-controller/effects/effects-manager'; import { ExpandManager } from '../src/card-controller/expand-manager'; import { FoldersManager } from '../src/card-controller/folders/manager'; import { FolderQuery } from '../src/card-controller/folders/types'; @@ -71,12 +72,7 @@ import { Entity, EntityRegistryManager } from '../src/ha/registry/entity/types'; import { CurrentUser, HassStateDifference, HomeAssistant } from '../src/ha/types'; import { QuerySource } from '../src/query-source'; import { Severity } from '../src/severity'; -import { - CapabilitiesRaw, - EffectsControllerAPI, - Interaction, - MediaLoadedInfo, -} from '../src/types'; +import { CapabilitiesRaw, Interaction, MediaLoadedInfo } from '../src/types'; import { EventViewMedia, ReviewViewMedia, @@ -658,7 +654,7 @@ export const createCardAPI = (): CardController => { api.getCardElementManager.mockReturnValue(mock()); api.getConditionStateManager.mockReturnValue(mock()); api.getConfigManager.mockReturnValue(mock()); - api.getEffectsControllerAPI.mockReturnValue(mock()); + api.getEffectsManager.mockReturnValue(mock()); api.getEntityRegistryManager.mockReturnValue(mock()); api.getExpandManager.mockReturnValue(mock()); api.getFoldersManager.mockReturnValue(mock()); diff --git a/tests/utils/ptz.test.ts b/tests/utils/ptz.test.ts index 19048588..3f3e3cf6 100644 --- a/tests/utils/ptz.test.ts +++ b/tests/utils/ptz.test.ts @@ -120,6 +120,14 @@ describe('getPTZTarget', () => { type: 'digital', }); }); + + it('without cameraID', () => { + const view = createView({ + view: 'live', + camera: null, + }); + expect(getPTZTarget(view, { cameraManager: createCameraManager() })).toBeNull(); + }); }); describe('in non-media views', () => { diff --git a/tests/utils/screenshot.test.ts b/tests/utils/screenshot.test.ts index 5b3c7250..e14cecf0 100644 --- a/tests/utils/screenshot.test.ts +++ b/tests/utils/screenshot.test.ts @@ -123,4 +123,16 @@ describe('generateScreenshotTitle', () => { expect(generateScreenshotFilename(view)).toBe('media_camera-1.jpg'); }); + + it('should get title for live view without camera', () => { + expect(generateScreenshotFilename(createView({ view: 'live', camera: null }))).toBe( + 'live_2023-06-13-21-54-01.jpg', + ); + }); + + it('should get title for viewer view without camera', () => { + expect(generateScreenshotFilename(createView({ view: 'media', camera: null }))).toBe( + 'media.jpg', + ); + }); }); diff --git a/tests/utils/substream.test.ts b/tests/utils/substream.test.ts index 7d600ba7..6fbf6eb1 100644 --- a/tests/utils/substream.test.ts +++ b/tests/utils/substream.test.ts @@ -3,6 +3,7 @@ import { getStreamCameraID, hasSubstream, removeSubstream, + setSubstream, } from '../../src/utils/substream'; import { View } from '../../src/view/view'; import { createView } from '../test-utils'; @@ -62,6 +63,25 @@ describe('hasSubstream/getStreamCameraID', () => { expect(getStreamCameraID(view, 'camera3')).toBe('camera3'); }); }); + + it('should correctly handle null cameras', () => { + expect(getStreamCameraID(createView({ camera: null }))).toBeNull(); + expect(hasSubstream(createView({ camera: null }))).toBeFalsy(); + }); +}); + +describe('setSubstream', () => { + it('should set substream', () => { + const view = createView({ camera: 'camera1' }); + setSubstream(view, 'substream1'); + expect(view.context?.live?.overrides?.get('camera1')).toBe('substream1'); + }); + + it('should return null without a camera', () => { + const view = createView({ camera: null }); + setSubstream(view, 'foo'); + expect(view.context).toBeNull(); + }); }); describe('removeSubstream', () => { @@ -100,4 +120,12 @@ describe('removeSubstream', () => { }, }); }); + + it('should not remove substream without camera', () => { + const view = createView({ + camera: null, + }); + removeSubstream(view); + expect(view.context).toBeNull(); + }); }); diff --git a/tests/view/utils/resolve-default.test.ts b/tests/view/utils/resolve-default.test.ts new file mode 100644 index 00000000..8df4ee31 --- /dev/null +++ b/tests/view/utils/resolve-default.test.ts @@ -0,0 +1,42 @@ +import { describe, expect, it, vi } from 'vitest'; +import { mock } from 'vitest-mock-extended'; +import { CameraManager } from '../../../src/camera-manager/manager'; +import { FoldersManager } from '../../../src/card-controller/folders/manager'; +import { resolveViewName } from '../../../src/view/utils/resolve-default'; +import { createStore } from '../../test-utils'; + +describe('resolveViewName', () => { + it('should return the view name directly if not auto', () => { + expect(resolveViewName('live', mock(), mock())).toBe( + 'live', + ); + }); + + it('should return live if auto and cameras are present', () => { + const cameraManager = mock(); + vi.mocked(cameraManager.getStore).mockReturnValue( + createStore([{ cameraID: 'camera-1' }]), + ); + const foldersManager = mock(); + + expect(resolveViewName('auto', cameraManager, foldersManager)).toBe('live'); + }); + + it('should return folders if auto, no cameras, but folders are present', () => { + const cameraManager = mock(); + vi.mocked(cameraManager.getStore).mockReturnValue(createStore([])); + const foldersManager = mock(); + vi.mocked(foldersManager.hasFolders).mockReturnValue(true); + + expect(resolveViewName('auto', cameraManager, foldersManager)).toBe('folders'); + }); + + it('should return image if auto and nothing is present', () => { + const cameraManager = mock(); + vi.mocked(cameraManager.getStore).mockReturnValue(createStore([])); + const foldersManager = mock(); + vi.mocked(foldersManager.hasFolders).mockReturnValue(false); + + expect(resolveViewName('auto', cameraManager, foldersManager)).toBe('image'); + }); +}); diff --git a/tests/view/view-support.test.ts b/tests/view/view-support.test.ts index e9b62c33..1a558153 100644 --- a/tests/view/view-support.test.ts +++ b/tests/view/view-support.test.ts @@ -4,18 +4,19 @@ import { FoldersManager } from '../../src/card-controller/folders/manager'; import { AdvancedCameraCardView } from '../../src/config/schema/common/const'; import { CapabilityKey } from '../../src/types'; import { - getCameraIDsForViewName, + getCameraIDsWithCapabilityForView, + isViewAvailable, + isViewSupported, isViewSupportedByCamera, } from '../../src/view/view-support'; import { createCameraConfig, createCameraManager, createCapabilities, - createFolder, createStore, } from '../test-utils'; -describe('getCameraIDsForViewName', () => { +describe('getCameraIDsWithCapabilityForView', () => { describe('views that are always supported', () => { it.each([['diagnostics' as const], ['image' as const]])( '%s', @@ -32,14 +33,24 @@ describe('getCameraIDsForViewName', () => { ); const foldersManager = mock(); - expect(getCameraIDsForViewName(viewName, cameraManager, foldersManager)).toEqual( - new Set(['camera-1', 'camera-2']), - ); expect( - getCameraIDsForViewName(viewName, cameraManager, foldersManager, 'camera-1'), + getCameraIDsWithCapabilityForView(viewName, cameraManager, foldersManager), ).toEqual(new Set(['camera-1', 'camera-2'])); expect( - getCameraIDsForViewName(viewName, cameraManager, foldersManager, 'camera-2'), + getCameraIDsWithCapabilityForView( + viewName, + cameraManager, + foldersManager, + 'camera-1', + ), + ).toEqual(new Set(['camera-1', 'camera-2'])); + expect( + getCameraIDsWithCapabilityForView( + viewName, + cameraManager, + foldersManager, + 'camera-2', + ), ).toEqual(new Set(['camera-1', 'camera-2'])); }, ); @@ -76,9 +87,9 @@ describe('getCameraIDsForViewName', () => { ); const foldersManager = mock(); - expect(getCameraIDsForViewName(viewName, cameraManager, foldersManager)).toEqual( - new Set(['camera-2']), - ); + expect( + getCameraIDsWithCapabilityForView(viewName, cameraManager, foldersManager), + ).toEqual(new Set(['camera-2'])); }); it.each([ @@ -106,20 +117,14 @@ describe('getCameraIDsForViewName', () => { ); const foldersManager = mock(); - expect(getCameraIDsForViewName(viewName, cameraManager, foldersManager)).toEqual( - new Set(['camera-1', 'camera-2']), - ); expect( - getCameraIDsForViewName(viewName, cameraManager, foldersManager, 'camera-1'), - ).toEqual(new Set(['camera-1', 'camera-2'])); - expect( - getCameraIDsForViewName(viewName, cameraManager, foldersManager, 'camera-2'), - ).toEqual(new Set(['camera-2'])); + getCameraIDsWithCapabilityForView(viewName, cameraManager, foldersManager), + ).toEqual(new Set(['camera-2', 'camera-1'])); }); }); - describe('views that respect a folder', () => { - describe('should return cameras when a folder is present', () => { + describe('folder views', () => { + describe('should return all cameras when a folder is present', () => { it.each([ ['folder' as const], ['folders' as const], @@ -140,16 +145,10 @@ describe('getCameraIDsForViewName', () => { ]), ); const foldersManager = mock(); - vi.mocked(foldersManager.getFolder).mockReturnValue(createFolder()); + vi.mocked(foldersManager.hasFolders).mockReturnValue(true); - expect(getCameraIDsForViewName(viewName, cameraManager, foldersManager)).toEqual( - new Set(['camera-1', 'camera-2']), - ); expect( - getCameraIDsForViewName(viewName, cameraManager, foldersManager, 'camera-1'), - ).toEqual(new Set(['camera-1', 'camera-2'])); - expect( - getCameraIDsForViewName(viewName, cameraManager, foldersManager, 'camera-2'), + getCameraIDsWithCapabilityForView(viewName, cameraManager, foldersManager), ).toEqual(new Set(['camera-1', 'camera-2'])); }); }); @@ -175,22 +174,63 @@ describe('getCameraIDsForViewName', () => { ]), ); const foldersManager = mock(); - vi.mocked(foldersManager.getFolder).mockReturnValue(null); + vi.mocked(foldersManager.hasFolders).mockReturnValue(false); - expect(getCameraIDsForViewName(viewName, cameraManager, foldersManager)).toEqual( - new Set(), - ); expect( - getCameraIDsForViewName(viewName, cameraManager, foldersManager, 'camera-1'), - ).toEqual(new Set()); - expect( - getCameraIDsForViewName(viewName, cameraManager, foldersManager, 'camera-2'), + getCameraIDsWithCapabilityForView(viewName, cameraManager, foldersManager), ).toEqual(new Set()); }); }); }); }); +describe('isViewAvailable', () => { + it('should return true when cameras are present for camera view', () => { + const cameraManager = createCameraManager(); + vi.mocked(cameraManager.getStore).mockReturnValue( + createStore([{ cameraID: 'camera-1' }]), + ); + const foldersManager = mock(); + expect(isViewAvailable('live', cameraManager, foldersManager)).toBe(true); + }); + + it('should return false when cameras are missing for camera view', () => { + const cameraManager = createCameraManager(); + vi.mocked(cameraManager.getStore).mockReturnValue(createStore([])); + const foldersManager = mock(); + expect(isViewAvailable('live', cameraManager, foldersManager)).toBe(false); + }); + + it('should return true when folders are present for folder view', () => { + const cameraManager = createCameraManager(); + const foldersManager = mock(); + vi.mocked(foldersManager.hasFolders).mockReturnValue(true); + expect(isViewAvailable('folder', cameraManager, foldersManager)).toBe(true); + }); + + it('should return false when folders are missing for folder view', () => { + const cameraManager = createCameraManager(); + const foldersManager = mock(); + vi.mocked(foldersManager.hasFolders).mockReturnValue(false); + expect(isViewAvailable('folder', cameraManager, foldersManager)).toBe(false); + }); + + it('should return true when either covers it', () => { + const cameraManager = createCameraManager(); + vi.mocked(cameraManager.getStore).mockReturnValue(createStore([])); + const foldersManager = mock(); + vi.mocked(foldersManager.hasFolders).mockReturnValue(true); + expect(isViewAvailable('gallery', cameraManager, foldersManager)).toBe(true); + }); + + it('should return true for diagnostics and image view', () => { + const cameraManager = createCameraManager(); + const foldersManager = mock(); + expect(isViewAvailable('diagnostics', cameraManager, foldersManager)).toBe(true); + expect(isViewAvailable('image', cameraManager, foldersManager)).toBe(true); + }); +}); + describe('isViewSupportedByCamera', () => { it('should return true for supported view', () => { const cameraManager = createCameraManager(); @@ -226,3 +266,57 @@ describe('isViewSupportedByCamera', () => { ).toBe(false); }); }); + +describe('isViewSupported', () => { + it('should return true when view is available and camera is null', () => { + const cameraManager = createCameraManager(); + vi.mocked(cameraManager.getStore).mockReturnValue( + createStore([{ cameraID: 'camera-1' }]), + ); + const foldersManager = mock(); + + expect(isViewSupported('live', cameraManager, foldersManager, null)).toBe(true); + }); + + it('should return true when view is available and camera is supported', () => { + const cameraManager = createCameraManager(); + vi.mocked(cameraManager.getStore).mockReturnValue( + createStore([ + { + cameraID: 'camera-1', + capabilities: createCapabilities({ live: true }), + }, + ]), + ); + const foldersManager = mock(); + + expect(isViewSupported('live', cameraManager, foldersManager, 'camera-1')).toBe( + true, + ); + }); + + it('should return false when view is available but camera is not supported', () => { + const cameraManager = createCameraManager(); + vi.mocked(cameraManager.getStore).mockReturnValue( + createStore([ + { + cameraID: 'camera-1', + capabilities: createCapabilities({ live: false }), + }, + ]), + ); + const foldersManager = mock(); + + expect(isViewSupported('live', cameraManager, foldersManager, 'camera-1')).toBe( + false, + ); + }); + + it('should return false when view is not available', () => { + const cameraManager = createCameraManager(); + vi.mocked(cameraManager.getStore).mockReturnValue(createStore([])); + const foldersManager = mock(); + + expect(isViewSupported('live', cameraManager, foldersManager, null)).toBe(false); + }); +});