diff --git a/README.md b/README.md index eed4af83..b38369f2 100644 --- a/README.md +++ b/README.md @@ -138,7 +138,6 @@ See the [fully expanded cameras configuration example](#config-expanded-cameras) | `live_provider` | `auto` | :white_check_mark: | The choice of live stream provider. See [Live Providers](#live-providers) below.| | `title` | Autodetected from `camera_entity` if that is specified. | :white_check_mark: | A friendly name for this camera to use in the card. | | `icon` | Autodetected from `camera_entity` if that is specified. | :white_check_mark: | The icon to use for this camera in the camera menu and in the next & previous controls when using the `icon` style. | -| `hide` | `false` | :white_check_mark: | Whether or not to hide this as an independent camera (e.g. hidden on the live carousel, media filter, camera menu, and triggers cannot trigger this camera). This may be useful if this camera is exclusively used as a dependency of another camera. | | `id` | `camera_entity`, `webrtc_card.entity` or `frigate.camera_name` if set (in that preference order). | :white_check_mark: | An optional identifier to use throughout the card configuration to refer unambiguously to this camera. See [camera IDs](#camera-ids). | | `engine` | `auto` | :white_check_mark: | Which camera engine to use for this camera. If `auto` the card will attempt to choose the correct engine from the specified options. See [engines](#engines) below for valid options.| | `frigate` | | :white_check_mark: | Options for a Frigate camera. See [Frigate configuration](#camera-frigate-configuration) below. | @@ -147,6 +146,7 @@ See the [fully expanded cameras configuration example](#config-expanded-cameras) | `webrtc_card` | | :white_check_mark: | The WebRTC entity/URL to use for this camera with the `webrtc-card` live provider. See below. | | `cast` | | :white_check_mark: | Configuration that controls how this camera is "casted" / sent to media players. See below. | | `dimensions` | | :white_check_mark: | Controls the dimensions and layout for media from this camera. See below. | +| `capabilities` | | :white_check_mark: | Allows selective disabling of camera capabilities. See below. | @@ -397,6 +397,35 @@ This block is used to control the fit and position of the media _within_ the cam See [media layout examples](#media-layout-examples). +### Camera Capabilities + +The `capabilities` block allows selected disabling of auto-detected camera capabilities. This is rarely used, with substreams being a notable exception. + +```yaml +cameras: + - capabilities: +``` + +| Option | Default | Overridable | Description | +| - | - | - | - | +| `disable` | | :white_check_mark: | A list of camera capabilities to disable. By default all capabilities supported by the camera are enabled. | +| `disable_except` | | :white_check_mark: | A list of camera capabilities to leave enabled if supported. Everything else will be disabled. | + +#### Capabilities + +| Capability | Purpose | +| - | - | +| `clips` | Clips can be fetched from the camera. | +| `favorite-events` | Events can be favorited. | +| `favorite-recordings` | Recordings can be favorited. | +| `live` | Live video can be received from the camera. | +| `menu` | The camera should show up in the card camera menu. | +| `ptz` | The camera can be PTZ controlled. | +| `recordings` | Recordings can be fetched from the camera. | +| `seek` | Clips can be seeked / scrubbed by the timeline. | +| `snapshots` | Snapshots can be fetched from the camera. | +| `substream` | The camera can be used as a substream on another camera. | + #### Camera IDs: Referring to cameras in card configuration @@ -1789,7 +1818,6 @@ cameras: - camera_entity: camera.front_Door live_provider: ha engine: auto - hide: false frigate: url: http://my.frigate.local client_id: frigate @@ -1832,8 +1860,6 @@ cameras: title: 'Front entrance' # Custom identifier for the camera to refer to it above. id: 'camera-2' - # Don't show this camera on the UI (will only be available as a dependent substream). - hide: true webrtc_card: entity: camera.entrance_rtsp url: 'rtsp://username:password@camera:554/av_stream/ch0' @@ -1905,7 +1931,6 @@ Reference: [Cameras Global Options](#camera-global-options). cameras_global: live_provider: ha engine: auto - hide: false frigate: url: http://my.frigate.local client_id: frigate @@ -2763,7 +2788,6 @@ overrides: - {} # No overrides for camera index 0. - live_provider: 'ha' # Overrides for camera index 1. engine: auto - hide: false frigate: url: http://my.frigate.local client_id: frigate @@ -3598,8 +3622,10 @@ cameras: title: Sitting Room HD live_provider: go2rtc id: sitting_room_hd - # Do not show the HD camera independently on the UI. - hide: true + # This camera serves only as a substream. + capabilities: + disable_except: + - substream menu: buttons: substreams: @@ -3625,13 +3651,17 @@ cameras: - camera_entity: camera.sitting_room live_provider: go2rtc id: sitting_room_go2rtc - hide: true + capabilities: + disable_except: + - substream title: Sitting Room go2rtc icon: mdi:alpha-g - camera_entity: camera.sitting_room live_provider: ha id: sitting_room_ha - hide: true + capabilities: + disable_except: + - substream title: Sitting Room HA icon: mdi:home ``` @@ -4151,7 +4181,9 @@ cameras: - camera.office_hd - camera_entity: camera.office_hd live_provider: go2rtc - hide: true + capabilities: + disable_except: + - substream automations: - actions: - action: custom:frigate-card-action diff --git a/src/camera-manager/browse-media/engine-browse-media.ts b/src/camera-manager/browse-media/engine-browse-media.ts index 74a4d9e1..d12d94f0 100644 --- a/src/camera-manager/browse-media/engine-browse-media.ts +++ b/src/camera-manager/browse-media/engine-browse-media.ts @@ -14,6 +14,7 @@ import { ResolvedMediaCache, resolveMedia } from '../../utils/ha/resolved-media' import { ViewMedia } from '../../view/media'; import { RequestCache } from '../cache'; import { Camera } from '../camera'; +import { Capabilities } from '../capabilities'; import { CameraManagerEngine } from '../engine'; import { GenericCameraManagerEngine } from '../generic/engine-generic'; import { rangesOverlap } from '../range'; @@ -142,15 +143,23 @@ export class BrowseMediaCameraManagerEngine cameraConfig: CameraConfig, ): Promise { const camera = new BrowseMediaCamera(cameraConfig, this, { - capabilities: { - canFavoriteEvents: false, - canFavoriteRecordings: false, - canSeek: false, - supportsClips: true, - supportsRecordings: false, - supportsSnapshots: true, - supportsTimeline: true, - }, + capabilities: new Capabilities( + { + 'favorite-events': false, + 'favorite-recordings': false, + clips: true, + live: true, + menu: true, + recordings: false, + seek: false, + snapshots: true, + substream: true, + }, + { + disable: cameraConfig.capabilities?.disable, + disableExcept: cameraConfig.capabilities?.disable_except, + }, + ), eventCallback: this._eventCallback, }); return await camera.initialize(hass, entityRegistryManager); diff --git a/src/camera-manager/camera.ts b/src/camera-manager/camera.ts index fd59026d..c1bf6b14 100644 --- a/src/camera-manager/camera.ts +++ b/src/camera-manager/camera.ts @@ -10,14 +10,15 @@ import { import { EntityRegistryManager } from '../utils/ha/entity-registry'; import { CameraManagerEngine } from './engine'; import { CameraNoIDError } from './error'; -import { CameraEventCallback, CameraManagerCameraCapabilities } from './types'; +import { CameraEventCallback } from './types'; +import { Capabilities } from './capabilities'; type DestroyCallback = () => Promise; export class Camera { protected _config: CameraConfig; protected _engine: CameraManagerEngine; - protected _capabilities?: CameraManagerCameraCapabilities; + protected _capabilities?: Capabilities; protected _eventCallback?: CameraEventCallback; protected _destroyCallbacks: DestroyCallback[] = []; @@ -25,7 +26,7 @@ export class Camera { config: CameraConfig, engine: CameraManagerEngine, options?: { - capabilities?: CameraManagerCameraCapabilities; + capabilities?: Capabilities; eventCallback?: CameraEventCallback; }, ) { @@ -97,7 +98,7 @@ export class Camera { return this._engine; } - public getCapabilities(): CameraManagerCameraCapabilities | null { + public getCapabilities(): Capabilities | null { return this._capabilities ?? null; } } diff --git a/src/camera-manager/capabilities.ts b/src/camera-manager/capabilities.ts new file mode 100644 index 00000000..29c79456 --- /dev/null +++ b/src/camera-manager/capabilities.ts @@ -0,0 +1,60 @@ +import { + CapabilitiesRaw, + CapabilityKey, + capabilityKeys, + PTZCapabilities, +} from '../types'; +import { CapabilitySearchOptions } from './types'; + +export class Capabilities { + private _capabilities: CapabilitiesRaw; + + constructor( + capabilities: CapabilitiesRaw, + options?: { + disable?: CapabilityKey[]; + disableExcept?: CapabilityKey[]; + }, + ) { + this._capabilities = capabilities; + + for (const key of options?.disable ?? []) { + this._disable(key); + } + for (const key of capabilityKeys) { + if (options?.disableExcept?.length && !options.disableExcept.includes(key)) { + this._disable(key); + } + } + } + + protected _disable(capability: CapabilityKey): void { + delete this._capabilities[capability]; + } + + public matches(capability: CapabilitySearchOptions): boolean { + let result = true; + if (typeof capability === 'string') { + result &&= this.has(capability); + } + if (typeof capability === 'object' && capability.allCapabilities) { + result &&= capability.allCapabilities.every((capability) => this.has(capability)); + } + if (typeof capability === 'object' && capability.anyCapabilities) { + result &&= capability.anyCapabilities.some((capability) => this.has(capability)); + } + return result; + } + + public has(capability: CapabilityKey): boolean { + return !!this._capabilities[capability]; + } + + public getPTZCapabilities(): PTZCapabilities | null { + return this._capabilities.ptz ?? null; + } + + public getRawCapabilities(): CapabilitiesRaw { + return this._capabilities; + } +} diff --git a/src/camera-manager/engine-factory.ts b/src/camera-manager/engine-factory.ts index 397f390f..39b7384c 100644 --- a/src/camera-manager/engine-factory.ts +++ b/src/camera-manager/engine-factory.ts @@ -10,7 +10,7 @@ import { MemoryRequestCache, RecordingSegmentsCache, RequestCache } from './cach import { CameraManagerEngine } from './engine'; import { CameraInitializationError } from './error'; import { CameraEventCallback, Engine } from './types'; -import { getCameraEntityFromConfig } from './utils'; +import { getCameraEntityFromConfig } from './utils/camera-entity-from-config'; export class CameraManagerEngineFactory { protected _entityRegistryManager: EntityRegistryManager; @@ -24,7 +24,10 @@ export class CameraManagerEngineFactory { this._resolvedMediaCache = resolvedMediaCache; } - public async createEngine(engine: Engine, eventCallback?: CameraEventCallback): Promise { + public async createEngine( + engine: Engine, + eventCallback?: CameraEventCallback, + ): Promise { let cameraManagerEngine: CameraManagerEngine; switch (engine) { case Engine.Generic: diff --git a/src/camera-manager/frigate/camera.ts b/src/camera-manager/frigate/camera.ts index 50a62cca..fdef3229 100644 --- a/src/camera-manager/frigate/camera.ts +++ b/src/camera-manager/frigate/camera.ts @@ -2,14 +2,15 @@ import { HomeAssistant } from '@dermotduffy/custom-card-helpers'; import uniq from 'lodash-es/uniq'; import { CameraConfig } from '../../config/types'; import { localize } from '../../localize/localize'; +import { PTZCapabilities, PTZMovementType } from '../../types'; import { errorToConsole } from '../../utils/basic'; import { subscribeToTrigger } from '../../utils/ha'; import { EntityRegistryManager } from '../../utils/ha/entity-registry'; import { Entity } from '../../utils/ha/entity-registry/types'; import { Camera } from '../camera'; +import { Capabilities } from '../capabilities'; import { CameraInitializationError } from '../error'; -import { PTZCapabilities, PTZMovementType } from '../types'; -import { getCameraEntityFromConfig } from '../utils.js'; +import { getCameraEntityFromConfig } from '../utils/camera-entity-from-config'; import { getPTZInfo } from './requests'; import { PTZInfo, frigateEventChangeTriggerResponseSchema } from './types'; @@ -98,16 +99,24 @@ export class FrigateCamera extends Camera { const config = this.getConfig(); const ptz = await this._getPTZCapabilities(hass, config); const birdseye = isBirdseye(config); - this._capabilities = { - canFavoriteEvents: !birdseye, - canFavoriteRecordings: !birdseye, - canSeek: true, - supportsClips: !birdseye, - supportsSnapshots: !birdseye, - supportsRecordings: !birdseye, - supportsTimeline: !birdseye, - ...(ptz && { ptz: ptz }), - }; + this._capabilities = new Capabilities( + { + 'favorite-events': !birdseye, + 'favorite-recordings': false, + seek: !birdseye, + clips: !birdseye, + snapshots: !birdseye, + recordings: !birdseye, + live: true, + menu: true, + substream: true, + ...(ptz && { ptz: ptz }), + }, + { + disable: config.capabilities?.disable, + disableExcept: config.capabilities?.disable_except, + }, + ); } protected _getFrigateCameraNameFromEntity(entity: Entity): string | null { diff --git a/src/camera-manager/frigate/engine-frigate.ts b/src/camera-manager/frigate/engine-frigate.ts index ea44df50..0a9da710 100644 --- a/src/camera-manager/frigate/engine-frigate.ts +++ b/src/camera-manager/frigate/engine-frigate.ts @@ -23,8 +23,8 @@ import { ViewMediaClassifier } from '../../view/media-classifier'; import { RecordingSegmentsCache, RequestCache } from '../cache'; import { Camera } from '../camera'; import { - CameraManagerEngine, CAMERA_MANAGER_ENGINE_EVENT_LIMIT_DEFAULT, + CameraManagerEngine, } from '../engine'; import { GenericCameraManagerEngine } from '../generic/engine-generic'; import { DateRange } from '../range'; @@ -59,18 +59,18 @@ import { RecordingSegmentsQuery, RecordingSegmentsQueryResultsMap, } from '../types'; -import { getDefaultGo2RTCEndpoint } from '../utils.js'; +import { getDefaultGo2RTCEndpoint } from '../utils/go2rtc-endpoint'; import frigateLogo from './assets/frigate-logo-dark.svg'; import { FrigateCamera, isBirdseye } from './camera'; import { FrigateViewMediaFactory } from './media'; import { FrigateViewMediaClassifier } from './media-classifier'; import { - getEvents, - getEventSummary, - getRecordingSegments, - getRecordingsSummary, NativeFrigateEventQuery, NativeFrigateRecordingSegmentsQuery, + getEventSummary, + getEvents, + getRecordingSegments, + getRecordingsSummary, retainEvent, } from './requests'; import { diff --git a/src/camera-manager/generic/engine-generic.ts b/src/camera-manager/generic/engine-generic.ts index 195ebedf..971aec8b 100644 --- a/src/camera-manager/generic/engine-generic.ts +++ b/src/camera-manager/generic/engine-generic.ts @@ -7,6 +7,7 @@ import { getEntityIcon, getEntityTitle } from '../../utils/ha'; import { EntityRegistryManager } from '../../utils/ha/entity-registry'; import { ViewMedia } from '../../view/media'; import { Camera } from '../camera'; +import { Capabilities } from '../capabilities'; import { CameraManagerEngine } from '../engine'; import { CameraManagerReadOnlyConfigStore } from '../store'; import { @@ -32,7 +33,8 @@ import { RecordingSegmentsQuery, RecordingSegmentsQueryResultsMap, } from '../types'; -import { getCameraEntityFromConfig, getDefaultGo2RTCEndpoint } from '../utils.js'; +import { getCameraEntityFromConfig } from '../utils/camera-entity-from-config'; +import { getDefaultGo2RTCEndpoint } from '../utils/go2rtc-endpoint'; export class GenericCameraManagerEngine implements CameraManagerEngine { protected _eventCallback?: CameraEventCallback; @@ -51,15 +53,23 @@ export class GenericCameraManagerEngine implements CameraManagerEngine { cameraConfig: CameraConfig, ): Promise { return await new Camera(cameraConfig, this, { - capabilities: { - canFavoriteEvents: false, - canFavoriteRecordings: false, - canSeek: false, - supportsClips: false, - supportsRecordings: false, - supportsSnapshots: false, - supportsTimeline: false, - }, + capabilities: new Capabilities( + { + 'favorite-events': false, + 'favorite-recordings': false, + clips: false, + live: true, + menu: true, + recordings: false, + seek: false, + snapshots: false, + substream: true, + }, + { + disable: cameraConfig.capabilities?.disable, + disableExcept: cameraConfig.capabilities?.disable_except, + }, + ), eventCallback: this._eventCallback, }).initialize(hass, entityRegistryManager); } diff --git a/src/camera-manager/manager.ts b/src/camera-manager/manager.ts index 82960b0f..7f3968a6 100644 --- a/src/camera-manager/manager.ts +++ b/src/camera-manager/manager.ts @@ -8,12 +8,14 @@ import { localize } from '../localize/localize.js'; import { allPromises, arrayify, + isTruthy, recursivelyMergeObjectsNotArrays, setify, } from '../utils/basic.js'; import { getCameraID } from '../utils/camera.js'; import { log } from '../utils/debug.js'; import { ViewMedia } from '../view/media.js'; +import { Capabilities } from './capabilities.js'; import { CameraManagerEngineFactory } from './engine-factory.js'; import { CameraManagerEngine } from './engine.js'; import { CameraInitializationError } from './error.js'; @@ -22,9 +24,7 @@ import { CameraEndpoint, CameraEndpoints, CameraEndpointsContext, - CameraManagerCameraCapabilities, CameraManagerCameraMetadata, - CameraManagerCapabilities, CameraManagerMediaCapabilities, DataQuery, Engine, @@ -53,7 +53,7 @@ import { RecordingSegmentsQueryResultsMap, ResultsMap, } from './types.js'; -import { sortMedia } from './utils.js'; +import { sortMedia } from './utils/sort-media.js'; export class QueryClassifier { public static isEventQuery(query: DataQuery | PartialDataQuery): query is EventQuery { @@ -260,10 +260,6 @@ export class CameraManager { this._store.addCamera(camera); }); - if (!this._store.getVisibleCameraCount()) { - throw new CameraInitializationError(localize('error.no_visible_cameras')); - } - log( this._api.getConfigManager().getCardWideConfig(), 'Frigate Card CameraManager initialized (Cameras: ', @@ -739,33 +735,29 @@ export class CameraManager { return engine.getCameraMetadata(hass, cameraConfig); } - public getCameraCapabilities( - cameraID: string, - ): CameraManagerCameraCapabilities | null { + public getCameraCapabilities(cameraID: string): Capabilities | null { return this._store.getCamera(cameraID)?.getCapabilities() ?? null; } - public getAggregateCameraCapabilities( - cameraIDs?: Set, - ): CameraManagerCapabilities | null { - const perCameraCapabilities = [...(cameraIDs ?? this._store.getCameraIDs())].map( - (cameraID) => this.getCameraCapabilities(cameraID), - ); + public getAggregateCameraCapabilities(cameraIDs?: Set): Capabilities { + const cameras = [...(cameraIDs ?? this._store.getCameraIDs())] + .map((cameraID) => this._store.getCamera(cameraID)) + .filter(isTruthy); - return { - canFavoriteEvents: perCameraCapabilities.some((cap) => cap?.canFavoriteEvents), - canFavoriteRecordings: perCameraCapabilities.some( - (cap) => cap?.canFavoriteRecordings, + return new Capabilities({ + live: cameras.some((camera) => camera.getCapabilities()?.has('live')), + clips: cameras.some((camera) => camera.getCapabilities()?.has('clips')), + recordings: cameras.some((camera) => camera.getCapabilities()?.has('recordings')), + snapshots: cameras.some((camera) => camera.getCapabilities()?.has('snapshots')), + 'favorite-events': cameras.some((camera) => + camera.getCapabilities()?.has('favorite-events'), ), - canSeek: perCameraCapabilities.some((cap) => cap?.canSeek), - - supportsClips: perCameraCapabilities.some((cap) => cap?.supportsClips), - supportsRecordings: perCameraCapabilities.some((cap) => cap?.supportsRecordings), - supportsSnapshots: perCameraCapabilities.some((cap) => cap?.supportsSnapshots), - supportsTimeline: perCameraCapabilities.some((cap) => cap?.supportsTimeline), - - supportsPTZ: perCameraCapabilities.some((cap) => !!cap?.ptz), - }; + 'favorite-recordings': cameras.some((camera) => + camera.getCapabilities()?.has('favorite-recordings'), + ), + seek: cameras.some((camera) => camera.getCapabilities()?.has('seek')), + menu: cameras.some((camera) => camera.getCapabilities()?.has('menu')), + }); } public async executePTZAction( diff --git a/src/camera-manager/store.ts b/src/camera-manager/store.ts index 1f5d2846..1cc58742 100644 --- a/src/camera-manager/store.ts +++ b/src/camera-manager/store.ts @@ -1,9 +1,10 @@ import { CameraConfig } from '../config/types'; +import { CapabilityKey } from '../types'; import { allPromises } from '../utils/basic'; import { ViewMedia } from '../view/media'; import { Camera } from './camera'; import { CameraManagerEngine } from './engine'; -import { Engine } from './types'; +import { CapabilitySearchOptions, Engine } from './types'; type CameraManagerEngineCameraIDMap = Map>; @@ -14,8 +15,8 @@ export interface CameraManagerReadOnlyConfigStore { hasCameraID(cameraID: string): boolean; getCamera(cameraID: string): Camera | null; + getCameras(): Map; getCameraCount(): number; - getVisibleCameraCount(): number; getCameraConfigs(cameraIDs?: Iterable): IterableIterator; getCameraConfigEntries( @@ -23,10 +24,15 @@ export interface CameraManagerReadOnlyConfigStore { ): IterableIterator<[string, CameraConfig]>; getCameraIDs(): Set; - getVisibleCameraIDs(): Set; getDefaultCameraID(): string | null; - getAllDependentCameras(cameraID: string): Set; + getCameraIDsWithCapability( + capability: CapabilityKey | CapabilitySearchOptions, + ): Set; + getAllDependentCameras( + cameraID: string, + capability?: CapabilityKey | CapabilitySearchOptions, + ): Set; } export class CameraManagerStore implements CameraManagerReadOnlyConfigStore { @@ -47,6 +53,9 @@ export class CameraManagerStore implements CameraManagerReadOnlyConfigStore { public getCamera(cameraID: string): Camera | null { return this._cameras.get(cameraID) ?? null; } + public getCameras(): Map { + return this._cameras; + } public getCameraConfig(cameraID: string): CameraConfig | null { return this._cameras.get(cameraID)?.getConfig() ?? null; } @@ -58,18 +67,11 @@ export class CameraManagerStore implements CameraManagerReadOnlyConfigStore { public getCameraCount(): number { return this._cameras.size; } - public getVisibleCameraCount(): number { - return this.getVisibleCameraIDs().size; - } public getDefaultCameraID(): string | null { return this._cameras.keys().next().value ?? null; } - public getCameras(): Map { - return this._cameras; - } - public *getCameraConfigs( cameraIDs?: Iterable, ): IterableIterator { @@ -93,8 +95,17 @@ export class CameraManagerStore implements CameraManagerReadOnlyConfigStore { public getCameraIDs(): Set { return new Set(this._cameras.keys()); } - public getVisibleCameraIDs(): Set { - return this._getMatchingCameraIDs((camera) => !camera.getConfig().hide); + + public getCameraIDsWithCapability( + capability: CapabilityKey | CapabilitySearchOptions, + ): Set { + const output: Set = new Set(); + for (const camera of this._cameras.values()) { + if (camera.getCapabilities()?.matches(capability)) { + output.add(camera.getID()); + } + } + return output; } public getCameraConfigForMedia(media: ViewMedia): CameraConfig | null { @@ -138,35 +149,35 @@ export class CameraManagerStore implements CameraManagerReadOnlyConfigStore { * @returns A set of dependent cameraIDs or null (since JS sets guarantee order, * the first item in the set is guaranteed to be the cameraID itself). */ - public getAllDependentCameras(cameraID: string): Set { - const cameraIDs: Set = new Set(); + public getAllDependentCameras( + cameraID: string, + capability?: CapabilitySearchOptions, + ): Set { + const visitedCameraIDs = new Set(); + const matchingCameraIDs: Set = new Set(); const getDependentCameras = (cameraID: string): void => { - const cameraConfig = this.getCameraConfig(cameraID); - if (cameraConfig) { - cameraIDs.add(cameraID); + visitedCameraIDs.add(cameraID); + + const camera = this.getCamera(cameraID); + const cameraConfig = camera?.getConfig(); + + if (camera && cameraConfig) { + if (!capability || camera.getCapabilities()?.matches(capability)) { + matchingCameraIDs.add(cameraID); + } const dependentCameras: Set = new Set(); cameraConfig.dependencies.cameras.forEach((item) => dependentCameras.add(item)); if (cameraConfig.dependencies.all_cameras) { this.getCameraIDs().forEach((cameraID) => dependentCameras.add(cameraID)); } - for (const eventCameraID of dependentCameras) { - if (!cameraIDs.has(eventCameraID)) { - getDependentCameras(eventCameraID); + for (const dependentCameraID of dependentCameras) { + if (!visitedCameraIDs.has(dependentCameraID)) { + getDependentCameras(dependentCameraID); } } } }; getDependentCameras(cameraID); - return cameraIDs; - } - - protected _getMatchingCameraIDs(func: (camera: Camera) => boolean): Set { - const output = new Set(); - for (const [cameraID, camera] of this._cameras.entries()) { - if (func(camera)) { - output.add(cameraID); - } - } - return output; + return matchingCameraIDs; } } diff --git a/src/camera-manager/types.ts b/src/camera-manager/types.ts index 76d0f7bc..8a26450c 100644 --- a/src/camera-manager/types.ts +++ b/src/camera-manager/types.ts @@ -1,3 +1,4 @@ +import { CapabilityKey } from '../types'; import { FrigateCardView } from '../config/types'; import { ViewMedia } from '../view/media'; @@ -91,32 +92,11 @@ export interface MediaMetadata { what?: Set; } -export type PTZMovementType = 'relative' | 'continuous'; - -export interface PTZCapabilities { - panTilt?: PTZMovementType[]; - zoom?: PTZMovementType[]; - presets?: string[]; -} - -interface BaseCapabilities { - canFavoriteEvents: boolean; - canFavoriteRecordings: boolean; - canSeek: boolean; - - supportsClips: boolean; - supportsRecordings: boolean; - supportsSnapshots: boolean; - supportsTimeline: boolean; -} - -export interface CameraManagerCapabilities extends BaseCapabilities { - supportsPTZ: boolean; -} - -export interface CameraManagerCameraCapabilities extends BaseCapabilities { - ptz?: PTZCapabilities; +interface CapabilitySearchAllAny { + allCapabilities?: CapabilityKey[]; + anyCapabilities?: CapabilityKey[]; } +export type CapabilitySearchOptions = CapabilityKey | CapabilitySearchAllAny; export interface CameraManagerMediaCapabilities { canFavorite: boolean; diff --git a/src/camera-manager/utils.ts b/src/camera-manager/utils.ts deleted file mode 100644 index 89c9b2e7..00000000 --- a/src/camera-manager/utils.ts +++ /dev/null @@ -1,84 +0,0 @@ -import endOfDay from 'date-fns/endOfDay'; -import endOfHour from 'date-fns/endOfHour'; -import endOfMinute from 'date-fns/endOfMinute'; -import startOfDay from 'date-fns/startOfDay'; -import startOfHour from 'date-fns/startOfHour'; -import orderBy from 'lodash-es/orderBy'; -import uniqBy from 'lodash-es/uniqBy'; -import { CameraConfig } from '../config/types'; -import { ViewMedia } from '../view/media'; -import { DateRange } from './range'; -import { CameraEndpoint } from './types'; - -export const convertRangeToCacheFriendlyTimes = ( - range: DateRange, - options?: { - endCap?: boolean; - }, -): DateRange => { - const widthSeconds = (range.end.getTime() - range.start.getTime()) / 1000; - let cacheableStart: Date; - let cacheableEnd: Date; - - if (widthSeconds <= 60 * 60) { - cacheableStart = startOfHour(range.start); - cacheableEnd = endOfHour(range.end); - } else { - cacheableStart = startOfDay(range.start); - cacheableEnd = endOfDay(range.end); - } - - if (options?.endCap) { - cacheableEnd = endOfMinute(capEndDate(cacheableEnd)); - } - - return { - start: cacheableStart, - end: cacheableEnd, - }; -}; - -export const capEndDate = (end: Date): Date => { - const now = new Date(); - return end > now ? now : end; -}; - -export const sortMedia = (mediaArray: ViewMedia[]): ViewMedia[] => { - return orderBy( - // Ensure uniqueness by the ID (if specified), otherwise all elements - // are assumed to be unique. - uniqBy(mediaArray, (media) => media.getID() ?? media), - - // Sort all items leading oldest -> youngest (so media is loaded in this - // order in the viewer which matches the left-to-right timeline order). - (media) => media.getStartTime() ?? media.getID(), - 'asc', - ); -}; - -export const getCameraEntityFromConfig = (cameraConfig: CameraConfig): string | null => { - return cameraConfig.camera_entity ?? cameraConfig.webrtc_card?.entity ?? null; -}; - -export const getDefaultGo2RTCEndpoint = ( - cameraConfig: CameraConfig, - options?: { - url?: string; - stream?: string; - }, -): CameraEndpoint | null => { - const url = options?.url ?? cameraConfig.go2rtc?.url; - const stream = options?.stream ?? cameraConfig.go2rtc?.stream; - - if (!url || !stream) { - return null; - } - const endpoint = `${url}/api/ws?src=${stream}`; - - return { - endpoint: endpoint, - - // Only sign the endpoint if it's local to HA. - sign: endpoint.startsWith('/'), - }; -}; diff --git a/src/camera-manager/utils/camera-entity-from-config.ts b/src/camera-manager/utils/camera-entity-from-config.ts new file mode 100644 index 00000000..4319b467 --- /dev/null +++ b/src/camera-manager/utils/camera-entity-from-config.ts @@ -0,0 +1,5 @@ +import { CameraConfig } from '../../config/types'; + +export const getCameraEntityFromConfig = (cameraConfig: CameraConfig): string | null => { + return cameraConfig.camera_entity ?? cameraConfig.webrtc_card?.entity ?? null; +}; diff --git a/src/camera-manager/utils/cap-end-date.ts b/src/camera-manager/utils/cap-end-date.ts new file mode 100644 index 00000000..3634b29f --- /dev/null +++ b/src/camera-manager/utils/cap-end-date.ts @@ -0,0 +1,4 @@ +export const capEndDate = (end: Date): Date => { + const now = new Date(); + return end > now ? now : end; +}; diff --git a/src/camera-manager/utils/go2rtc-endpoint.ts b/src/camera-manager/utils/go2rtc-endpoint.ts new file mode 100644 index 00000000..398211b4 --- /dev/null +++ b/src/camera-manager/utils/go2rtc-endpoint.ts @@ -0,0 +1,25 @@ +import { CameraConfig } from '../../config/types'; +import { CameraEndpoint } from '../types'; + +export const getDefaultGo2RTCEndpoint = ( + cameraConfig: CameraConfig, + options?: { + url?: string; + stream?: string; + }, +): CameraEndpoint | null => { + const url = options?.url ?? cameraConfig.go2rtc?.url; + const stream = options?.stream ?? cameraConfig.go2rtc?.stream; + + if (!url || !stream) { + return null; + } + const endpoint = `${url}/api/ws?src=${stream}`; + + return { + endpoint: endpoint, + + // Only sign the endpoint if it's local to HA. + sign: endpoint.startsWith('/'), + }; +}; diff --git a/src/camera-manager/utils/range-to-cache-friendly.ts b/src/camera-manager/utils/range-to-cache-friendly.ts new file mode 100644 index 00000000..0b8464bd --- /dev/null +++ b/src/camera-manager/utils/range-to-cache-friendly.ts @@ -0,0 +1,35 @@ +import endOfDay from 'date-fns/endOfDay'; +import endOfHour from 'date-fns/endOfHour'; +import endOfMinute from 'date-fns/endOfMinute'; +import startOfDay from 'date-fns/startOfDay'; +import startOfHour from 'date-fns/startOfHour'; +import { DateRange } from '../range'; +import { capEndDate } from './cap-end-date'; + +export const convertRangeToCacheFriendlyTimes = ( + range: DateRange, + options?: { + endCap?: boolean; + }, +): DateRange => { + const widthSeconds = (range.end.getTime() - range.start.getTime()) / 1000; + let cacheableStart: Date; + let cacheableEnd: Date; + + if (widthSeconds <= 60 * 60) { + cacheableStart = startOfHour(range.start); + cacheableEnd = endOfHour(range.end); + } else { + cacheableStart = startOfDay(range.start); + cacheableEnd = endOfDay(range.end); + } + + if (options?.endCap) { + cacheableEnd = endOfMinute(capEndDate(cacheableEnd)); + } + + return { + start: cacheableStart, + end: cacheableEnd, + }; +}; diff --git a/src/camera-manager/utils/sort-media.ts b/src/camera-manager/utils/sort-media.ts new file mode 100644 index 00000000..9f503330 --- /dev/null +++ b/src/camera-manager/utils/sort-media.ts @@ -0,0 +1,16 @@ +import orderBy from 'lodash-es/orderBy'; +import uniqBy from 'lodash-es/uniqBy'; +import { ViewMedia } from '../../view/media'; + +export const sortMedia = (mediaArray: ViewMedia[]): ViewMedia[] => { + return orderBy( + // Ensure uniqueness by the ID (if specified), otherwise all elements + // are assumed to be unique. + uniqBy(mediaArray, (media) => media.getID() ?? media), + + // Sort all items leading oldest -> youngest (so media is loaded in this + // order in the viewer which matches the left-to-right timeline order). + (media) => media.getStartTime() ?? media.getID(), + 'asc', + ); +}; diff --git a/src/card-controller/view-manager.ts b/src/card-controller/view-manager.ts index 463e626f..2570fed9 100644 --- a/src/card-controller/view-manager.ts +++ b/src/card-controller/view-manager.ts @@ -1,3 +1,4 @@ +import isEqual from 'lodash-es/isEqual'; import { ViewContext } from 'view'; import { FRIGATE_CARD_VIEW_DEFAULT, @@ -5,18 +6,21 @@ import { FrigateCardView, ViewDisplayMode, } from '../config/types'; +import { localize } from '../localize/localize'; import { log } from '../utils/debug'; import { executeMediaQueryForView } from '../utils/media-to-view'; import { View } from '../view/view'; +import { getCameraIDsForViewName } from '../view/view-to-cameras'; import { CardViewAPI } from './types'; interface ViewManagerSetViewDefaultParameters { cameraID?: string; substream?: string; - // When failSafe is true, the view will be changed to an "always-works" view - // (e.g. `live`) if the proposed view is unsupported. By default the view will - // just not be changed. + // When failSafe is true, the view will be changed to the default view, or the + // `live` view if the default view is not supported, or failing that an error + // message is shown. Without `failSafe` the view will just not be changed if + // unsupported. failSafe?: boolean; } @@ -49,9 +53,11 @@ export class ViewManager { const config = this._api.getConfigManager().getConfig(); if (config) { let forceCameraID: string | null = params?.cameraID ?? null; + const viewName = config.view.default; + if (!forceCameraID && this._view?.camera && config.view.update_cycle_camera) { const cameraIDs = [ - ...this._api.getCameraManager().getStore().getVisibleCameraIDs(), + ...getCameraIDsForViewName(this._api.getCameraManager(), viewName), ]; const currentIndex = cameraIDs.indexOf(this._view.camera); const targetIndex = currentIndex + 1 >= cameraIDs.length ? 0 : currentIndex + 1; @@ -60,7 +66,7 @@ export class ViewManager { this.setViewByParameters({ ...params, - viewName: config.view.default, + viewName: viewName, ...(forceCameraID && { cameraID: forceCameraID }), }); @@ -76,38 +82,82 @@ export class ViewManager { if (config) { let cameraID: string | null = null; - const cameras = this._api.getCameraManager().getStore().getVisibleCameraIDs(); - if (cameras.size) { - if (params?.cameraID && cameras.has(params.cameraID)) { - cameraID = params.cameraID; - } else { - // Reset to the default camera. - cameraID = cameras.keys().next().value; - } - } let viewName = params?.viewName ?? this._view?.view ?? config.view.default; - if (cameraID && viewName) { - if (!this.isViewSupportedByCamera(cameraID, viewName)) { - if (params.failSafe) { + const allCameraIDs = this._api.getCameraManager().getStore().getCameraIDs(); + if (params?.cameraID && allCameraIDs.has(params.cameraID)) { + cameraID = params.cameraID; + } else { + const viewCameraIDs = getCameraIDsForViewName( + this._api.getCameraManager(), + viewName, + ); + + // Reset to the default camera. + cameraID = viewCameraIDs.keys().next().value; + } + + if (!cameraID) { + if (params.failSafe) { + const camerasToCapabilities = [ + ...this._api.getCameraManager().getStore().getCameras(), + ].reduce((acc, [cameraID, camera]) => { + const capabilities = camera.getCapabilities()?.getRawCapabilities(); + if (capabilities) { + acc[cameraID] = capabilities; + } + return acc; + }, {}); + + this._api.getMessageManager().setMessageIfHigherPriority({ + type: 'error', + message: localize('error.no_supported_cameras'), + context: { + view: viewName, + cameras_capabilities: camerasToCapabilities, + }, + }); + } + return; + } + + if (!this.isViewSupportedByCamera(cameraID, viewName)) { + if (params.failSafe) { + if (this.isViewSupportedByCamera(cameraID, FRIGATE_CARD_VIEW_DEFAULT)) { viewName = FRIGATE_CARD_VIEW_DEFAULT; } else { + const capabilities = this._api + .getCameraManager() + .getStore() + .getCamera(cameraID) + ?.getCapabilities() + ?.getRawCapabilities(); + this._api.getMessageManager().setMessageIfHigherPriority({ + type: 'error', + message: localize('error.no_supported_camera'), + context: { + view: viewName, + camera: cameraID, + ...(capabilities && { camera_capabilities: capabilities }), + }, + }); return; } + } else { + return; } - - const displayMode = - this._view?.displayMode ?? - this._getDefaultDisplayModeForView(viewName, config); - let view: View = new View({ - view: viewName, - camera: cameraID, - displayMode: displayMode, - }); - if (params.substream) { - view = this._createViewWithSelectedSubstream(view, params.substream); - } - this._setView(view); } + + const displayMode = + this._view?.displayMode ?? this._getDefaultDisplayModeForView(viewName, config); + let view: View = new View({ + view: viewName, + camera: cameraID, + displayMode: displayMode, + }); + if (params.substream) { + view = this._createViewWithSelectedSubstream(view, params.substream); + } + this._setView(view); } } @@ -121,6 +171,12 @@ export class ViewManager { this._view = null; } + protected _getCameraIDsInvolvedInView(view: View): Set { + return view.supportsMultipleDisplayModes() && view.isGrid() + ? getCameraIDsForViewName(this._api.getCameraManager(), view.view) + : getCameraIDsForViewName(this._api.getCameraManager(), view.view, view.camera); + } + public async setViewWithNewDisplayMode(displayMode: ViewDisplayMode): Promise { const hass = this._api.getHASSManager().getHASS(); @@ -129,18 +185,10 @@ export class ViewManager { displayMode: displayMode, }); - const cameraCount = this._api - .getCameraManager() - .getStore() - .getVisibleCameraCount(); - const queryCameraCount = view.query?.getQueryCameraIDs()?.size ?? 0; - const generateNewQuery = - view?.query && - queryCameraCount && - ((view.isGrid() && queryCameraCount < cameraCount) || - (!view.isGrid() && queryCameraCount > 1)); + const expectedCameraIDs = this._getCameraIDsInvolvedInView(view); + const queryCameraIDs = view.query?.getQueryCameraIDs(); - if (generateNewQuery && view && view.query) { + if (!isEqual(expectedCameraIDs, queryCameraIDs) && view && view.query) { // If the user requests a grid but the current query does not have a // query for more than one camera, reset the query results, change the // existing query to refer to all cameras and execute it to fetch new @@ -150,13 +198,7 @@ export class ViewManager { viewWithNewQuery = await executeMediaQueryForView( this._api.getCameraManager(), view, - view.query - .clone() - .setQueryCameraIDs( - view.isGrid() - ? this._api.getCameraManager().getStore().getVisibleCameraIDs() - : view.camera, - ), + view.query.clone().setQueryCameraIDs(expectedCameraIDs), ); } catch (e: unknown) { this._api.getMessageManager().setErrorIfHigherPriority(e); @@ -190,42 +232,7 @@ export class ViewManager { } public isViewSupportedByCamera(cameraID: string, view: FrigateCardView): boolean { - const dependentCamerasCapabilities = this._api - .getCameraManager() - .getAggregateCameraCapabilities( - this._api.getCameraManager().getStore().getAllDependentCameras(cameraID), - ); - const allCamerasCapabilities = this._api - .getCameraManager() - .getAggregateCameraCapabilities( - this._api.getCameraManager().getStore().getCameraIDs(), - ); - - switch (view) { - case 'live': - case 'image': - case 'diagnostics': - return true; - case 'clip': - case 'clips': - return !!dependentCamerasCapabilities?.supportsClips; - case 'snapshot': - case 'snapshots': - return !!dependentCamerasCapabilities?.supportsSnapshots; - case 'recording': - case 'recordings': - return !!dependentCamerasCapabilities?.supportsRecordings; - case 'timeline': - // Show the timeline if any camera supports it, even cameras unrelated - // to the currently selected camera. - return !!allCamerasCapabilities?.supportsTimeline; - case 'media': - return ( - !!dependentCamerasCapabilities?.supportsClips || - !!dependentCamerasCapabilities?.supportsSnapshots || - !!dependentCamerasCapabilities?.supportsRecordings - ); - } + return !!getCameraIDsForViewName(this._api.getCameraManager(), view, cameraID).size; } protected _getDefaultDisplayModeForView( diff --git a/src/components-lib/media-filter-controller.ts b/src/components-lib/media-filter-controller.ts index ef5bfbd2..bfb68e15 100644 --- a/src/components-lib/media-filter-controller.ts +++ b/src/components-lib/media-filter-controller.ts @@ -182,11 +182,11 @@ export class MediaFilterController { return null; }; - const visibleCameraIDs = cameraManager.getStore().getVisibleCameraIDs(); - if (!visibleCameraIDs.size || !values.mediaType) { + const cameraIDs = + getArrayValueAsSet(values.camera) ?? this._getAllCameraIDs(cameraManager); + if (!cameraIDs.size || !values.mediaType) { return; } - const cameraIDs = getArrayValueAsSet(values.camera) ?? visibleCameraIDs; const when = this._getWhen(values.when); const favorite = values.favorite @@ -280,10 +280,16 @@ export class MediaFilterController { this._host.requestUpdate(); } + protected _getAllCameraIDs(cameraManager: CameraManager): Set { + return cameraManager.getStore().getCameraIDsWithCapability({ + anyCapabilities: ['clips', 'snapshots', 'recordings'], + }); + } + public computeInitialDefaultsFromView(cameraManager: CameraManager, view: View): void { const queries = view.query?.getQueries(); - const visibleCameraIDs = cameraManager.getStore().getVisibleCameraIDs(); - if (!queries || !visibleCameraIDs.size) { + const allCameraIDs = this._getAllCameraIDs(cameraManager); + if (!queries || !allCameraIDs.size) { return; } @@ -300,7 +306,7 @@ export class MediaFilterController { ); // Special note: If all visible cameras are selected, this is the same as no // selector at all. - if (cameraIDSets.length === 1 && !isEqual(queries[0].cameraIDs, visibleCameraIDs)) { + if (cameraIDSets.length === 1 && !isEqual(queries[0].cameraIDs, allCameraIDs)) { cameraIDs = [...queries[0].cameraIDs]; } @@ -375,8 +381,7 @@ export class MediaFilterController { } public computeCameraOptions(cameraManager: CameraManager): void { - const cameras = cameraManager.getStore().getVisibleCameraIDs(); - this._cameraOptions = [...cameras].map((cameraID) => ({ + this._cameraOptions = [...this._getAllCameraIDs(cameraManager)].map((cameraID) => ({ value: cameraID, label: cameraManager.getCameraMetadata(cameraID)?.title ?? cameraID, })); @@ -451,9 +456,9 @@ export class MediaFilterController { events: events, recordings: recordings, favorites: events - ? !!managerCapabilities?.canFavoriteEvents + ? managerCapabilities?.has('favorite-events') : recordings - ? !!managerCapabilities?.canFavoriteRecordings + ? managerCapabilities?.has('favorite-recordings') : false, }; } diff --git a/src/components-lib/menu-button-controller.ts b/src/components-lib/menu-button-controller.ts index cfe3c385..6d0a7f73 100644 --- a/src/components-lib/menu-button-controller.ts +++ b/src/components-lib/menu-button-controller.ts @@ -24,6 +24,7 @@ import { getEntityIcon, getEntityTitle } from '../utils/ha'; import { hasUsablePTZ } from '../utils/ptz'; import { hasSubstream } from '../utils/substream'; import { View } from '../view/view'; +import { getCameraIDsForViewName } from '../view/view-to-cameras'; export interface MenuButtonControllerOptions { currentMediaLoadedInfo?: MediaLoadedInfo | null; @@ -62,16 +63,12 @@ export class MenuButtonController { view: View, options?: MenuButtonControllerOptions, ): MenuItem[] { - const visibleCameraIDs = cameraManager.getStore().getVisibleCameraIDs(); const selectedCameraID = view.camera; const substreamAwareCameraID = view.context?.live?.overrides?.get(selectedCameraID) ?? selectedCameraID; const selectedCameraConfig = cameraManager .getStore() .getCameraConfig(selectedCameraID); - const allSelectedCameraIDs = cameraManager - .getStore() - .getAllDependentCameras(selectedCameraID); const substreamAwareCameraCapabilities = cameraManager.getCameraCapabilities(substreamAwareCameraID); @@ -98,9 +95,12 @@ export class MenuButtonController { ) as FrigateCardCustomAction, }); - if (visibleCameraIDs.size) { + // Show all cameras in the menu rather than just cameras that support the + // current view for a less surprising UX. + const menuCameraIDs = cameraManager.getStore().getCameraIDsWithCapability('menu'); + if (menuCameraIDs.size) { const menuItems = Array.from( - cameraManager.getStore().getCameraConfigEntries(visibleCameraIDs), + cameraManager.getStore().getCameraConfigEntries(menuCameraIDs), ([cameraID, config]) => { const action = createFrigateCardCameraAction('camera_select', cameraID); const metadata = cameraManager.getCameraMetadata(cameraID); @@ -126,10 +126,15 @@ export class MenuButtonController { }); } - if (selectedCameraID && allSelectedCameraIDs && view.is('live')) { - const dependencies = [...allSelectedCameraIDs]; + const substreamCameraIDs = cameraManager + .getStore() + .getAllDependentCameras(selectedCameraID, 'substream'); - if (dependencies.length === 2) { + if (selectedCameraID && substreamCameraIDs && view.is('live')) { + const substreams = [...substreamCameraIDs].filter((cameraID) => cameraID !== selectedCameraID); + const streams = [selectedCameraID, ...substreams]; + + if (streams.length === 2) { // If there are only two dependencies (the main camera, and 1 other) // then use a button not a menu to toggle. buttons.push({ @@ -145,14 +150,14 @@ export class MenuButtonController { hasSubstream(view) ? 'live_substream_off' : 'live_substream_on', ) as FrigateCardCustomAction, }); - } else if (dependencies.length > 2) { - const menuItems = Array.from(dependencies, (cameraID) => { + } else if (streams.length > 2) { + const menuItems = Array.from(streams, (streamID) => { const action = createFrigateCardCameraAction( 'live_substream_select', - cameraID, + streamID, ); - const metadata = cameraManager.getCameraMetadata(cameraID) ?? undefined; - const cameraConfig = cameraManager.getStore().getCameraConfig(cameraID); + const metadata = cameraManager.getCameraMetadata(streamID) ?? undefined; + const cameraConfig = cameraManager.getStore().getCameraConfig(streamID); return { enabled: true, icon: metadata?.icon, @@ -161,7 +166,7 @@ export class MenuButtonController { title: metadata?.title, selected: (view.context?.live?.overrides?.get(selectedCameraID) ?? - selectedCameraID) === cameraID, + selectedCameraID) === streamID, ...(action && { tap_action: action }), }; }); @@ -409,7 +414,8 @@ export class MenuButtonController { }); } - if (view.supportsMultipleDisplayModes() && visibleCameraIDs.size > 1) { + const viewCameraIDs = getCameraIDsForViewName(cameraManager, view.view); + if (view.supportsMultipleDisplayModes() && viewCameraIDs.size > 1) { const isGrid = view.isGrid(); buttons.push({ icon: isGrid ? 'mdi:grid-off' : 'mdi:grid', diff --git a/src/components-lib/ptz-controller.ts b/src/components-lib/ptz-controller.ts index 1db58905..5399574a 100644 --- a/src/components-lib/ptz-controller.ts +++ b/src/components-lib/ptz-controller.ts @@ -137,7 +137,7 @@ export class PTZController { const ptzCapabilities = this._cameraManager.getCameraCapabilities( this._cameraID, - )?.ptz; + )?.getPTZCapabilities(); const defaultActions: FrigateCardPTZActions = {}; const panTilt = ptzCapabilities?.panTilt; diff --git a/src/components-lib/timeline-source.ts b/src/components-lib/timeline-source.ts index 823cce5c..6b4f9f62 100644 --- a/src/components-lib/timeline-source.ts +++ b/src/components-lib/timeline-source.ts @@ -4,14 +4,15 @@ import { DataSet } from 'vis-data'; import { IdType, TimelineItem, TimelineWindow } from 'vis-timeline/esnext'; import { CameraManager } from '../camera-manager/manager'; import { + compressRanges, ExpiringMemoryRangeSet, MemoryRangeSet, - compressRanges, } from '../camera-manager/range'; import { EventQuery, RecordingQuery, RecordingSegment } from '../camera-manager/types'; -import { capEndDate, convertRangeToCacheFriendlyTimes } from '../camera-manager/utils'; +import { capEndDate } from '../camera-manager/utils/cap-end-date'; +import { convertRangeToCacheFriendlyTimes } from '../camera-manager/utils/range-to-cache-friendly'; import { ClipsOrSnapshotsOrAll } from '../types'; -import { ModifyInterface, errorToConsole } from '../utils/basic.js'; +import { errorToConsole, ModifyInterface } from '../utils/basic.js'; import { ViewMedia } from '../view/media'; // Allow timeline freshness to be at least this number of seconds out of date diff --git a/src/components/live/live.ts b/src/components/live/live.ts index 38ab5c0b..a5f79ad0 100644 --- a/src/components/live/live.ts +++ b/src/components/live/live.ts @@ -324,7 +324,7 @@ export class FrigateCardLiveGrid extends LitElement { } protected _needsGrid(): boolean { - const cameraIDs = this.cameraManager?.getStore().getVisibleCameraIDs(); + const cameraIDs = this.cameraManager?.getStore().getCameraIDsWithCapability('live'); return ( !!this.view?.isGrid() && !!this.view?.supportsMultipleDisplayModes() && @@ -343,8 +343,8 @@ export class FrigateCardLiveGrid extends LitElement { if (!this.conditionsManagerEpoch || !this.nonOverriddenLiveConfig) { return; } - const cameraIDs = this.cameraManager?.getStore().getVisibleCameraIDs(); - if (!cameraIDs || !this._needsGrid()) { + const cameraIDs = this.cameraManager?.getStore().getCameraIDsWithCapability('live'); + if (!cameraIDs?.size || !this._needsGrid()) { return this._renderCarousel(); } return html` @@ -416,8 +416,8 @@ export class FrigateCardLiveCarousel extends LitElement { } protected _getSelectedCameraIndex(): number { - const cameraIDs = this.cameraManager?.getStore().getVisibleCameraIDs(); - if (!cameraIDs || !this.view || this.viewFilterCameraID) { + const cameraIDs = this.cameraManager?.getStore().getCameraIDsWithCapability('live'); + if (!cameraIDs?.size || !this.view || this.viewFilterCameraID) { // If the carousel is limited to a single cameraID, the first (only) // element is always the selected one. return 0; @@ -481,7 +481,7 @@ export class FrigateCardLiveCarousel extends LitElement { const cameraIDs = this.viewFilterCameraID ? new Set([this.viewFilterCameraID]) - : this.cameraManager.getStore().getVisibleCameraIDs(); + : this.cameraManager?.getStore().getCameraIDsWithCapability('live'); const slides: TemplateResult[] = []; const cameraToSlide: Record = {}; @@ -508,8 +508,8 @@ export class FrigateCardLiveCarousel extends LitElement { } protected _setViewHandler(ev: CustomEvent): void { - const cameraIDs = this.cameraManager?.getStore().getVisibleCameraIDs(); - if (cameraIDs && ev.detail.index !== this._getSelectedCameraIndex()) { + const cameraIDs = this.cameraManager?.getStore().getCameraIDsWithCapability('live'); + if (cameraIDs?.size && ev.detail.index !== this._getSelectedCameraIndex()) { this._setViewCameraID([...cameraIDs][ev.detail.index]); } } @@ -597,7 +597,7 @@ export class FrigateCardLiveCarousel extends LitElement { protected _getCameraIDsOfNeighbors(): [string | null, string | null] { const cameraIDs = this.cameraManager - ? [...this.cameraManager.getStore().getVisibleCameraIDs()] + ? [...this.cameraManager?.getStore().getCameraIDsWithCapability('live')] : []; if (this.viewFilterCameraID || cameraIDs.length <= 1 || !this.view || !this.hass) { return [null, null]; diff --git a/src/components/surround.ts b/src/components/surround.ts index c7cc0d5b..cab592b3 100644 --- a/src/components/surround.ts +++ b/src/components/surround.ts @@ -159,14 +159,22 @@ export class FrigateCardSurround extends LitElement { } protected _getCameraIDsForTimeline(): Set | null { - if (!this.view) { + if (!this.view || !this.cameraManager) { return null; } - if (this.view?.is('live')) { - return this.view.isGrid() - ? this.cameraManager?.getStore().getVisibleCameraIDs() ?? null - : this.cameraManager?.getStore().getAllDependentCameras(this.view.camera) ?? - null; + if (this.view.is('live')) { + const capabilitySearch = { + anyCapabilities: ['clips' as const, 'snapshots' as const, 'recordings' as const], + }; + if (this.view.supportsMultipleDisplayModes() && this.view.isGrid()) { + return this.cameraManager + .getStore() + .getCameraIDsWithCapability(capabilitySearch); + } else { + return this.cameraManager + .getStore() + .getAllDependentCameras(this.view.camera, capabilitySearch); + } } if (this.view.isViewerView()) { return this.view.query?.getQueryCameraIDs() ?? null; diff --git a/src/components/timeline-core.ts b/src/components/timeline-core.ts index ba20b89b..c46bc8f3 100644 --- a/src/components/timeline-core.ts +++ b/src/components/timeline-core.ts @@ -27,7 +27,7 @@ import { import { CameraManager } from '../camera-manager/manager'; import { rangesOverlap } from '../camera-manager/range'; import { MediaQuery } from '../camera-manager/types'; -import { convertRangeToCacheFriendlyTimes } from '../camera-manager/utils'; +import { convertRangeToCacheFriendlyTimes } from '../camera-manager/utils/range-to-cache-friendly'; import { FrigateCardTimelineItem, TimelineDataSource, @@ -72,7 +72,6 @@ import { MediaQueriesResults } from '../view/media-queries-results'; import { View } from '../view/view'; import './date-picker.js'; import { DatePickerEvent, FrigateCardDatePicker } from './date-picker.js'; -import { dispatchMessageEvent } from './message.js'; import './thumbnail.js'; interface FrigateCardGroupData { @@ -275,18 +274,11 @@ export class FrigateCardTimelineCore extends LitElement { request.detail.view = this.view; } - /** - * Master render method. - * @returns A rendered template. - */ protected render(): TemplateResult | void { - const cameraIDs = this._getTimelineCameraIDs(); - - if (!this.hass || !this.view || !this.timelineConfig || !cameraIDs) { + if (!this.hass || !this.view || !this.timelineConfig || !this.cameraIDs?.size) { return; } - const capabilities = this.cameraManager?.getAggregateCameraCapabilities(cameraIDs); const panMode = this._getEffectivePanMode(); const panTitle = @@ -306,55 +298,43 @@ export class FrigateCardTimelineCore extends LitElement { ? 'mdi:play-box-lock' : 'mdi:camera-lock'; - return html` ${capabilities?.supportsTimeline - ? html`
-
- ${this._shouldSupportSeeking() - ? html` { - this._panMode = - panMode === 'pan' - ? 'seek' - : panMode === 'seek' - ? 'seek-in-media' - : panMode === 'seek-in-media' - ? 'seek-in-camera' - : 'pan'; - }} - aria-label="${panTitle}" - title="${panTitle}" - > - ` - : ''} - ) => { - if (ev.detail.date) { - this._timeline?.moveTo(ev.detail.date); - } + return html`
+
+ ${this._shouldSupportSeeking() + ? html` { + this._panMode = + panMode === 'pan' + ? 'seek' + : panMode === 'seek' + ? 'seek-in-media' + : panMode === 'seek-in-media' + ? 'seek-in-camera' + : 'pan'; }} + aria-label="${panTitle}" + title="${panTitle}" > - -
-
` - : ''}`; - } - - /** - * Get all the keys of the cameras in scope for this timeline. - * @returns A set of camera ids (may be empty). - */ - protected _getTimelineCameraIDs(): Set | null { - return ( - this.cameraIDs ?? this.cameraManager?.getStore().getVisibleCameraIDs() ?? null - ); + ` + : ''} + ) => { + if (ev.detail.date) { + this._timeline?.moveTo(ev.detail.date); + } + }} + > + +
+
`; } /** @@ -555,7 +535,6 @@ export class FrigateCardTimelineCore extends LitElement { stopEventFromActivatingCardWideActions(properties.event); } - const timelineCameraIDs = this._getTimelineCameraIDs(); if ( this._ignoreClick || !this.hass || @@ -563,7 +542,8 @@ export class FrigateCardTimelineCore extends LitElement { !this.view || !this.cameraManager || !this.cardWideConfig || - !timelineCameraIDs || + !this.cameraIDs || + !this.cameraIDs.size || !this._timelineSource || !properties.what ) { @@ -767,14 +747,13 @@ export class FrigateCardTimelineCore extends LitElement { */ protected _getGroups(): DataGroupCollectionType { const groups: FrigateCardGroupData[] = []; - (this._getTimelineCameraIDs() ?? []).forEach((cameraID) => { + (this.cameraIDs ?? []).forEach((cameraID: string) => { if (!this.hass || !this.cameraManager) { return; } const cameraMetadata = this.cameraManager.getCameraMetadata(cameraID); - const cameraCapabilities = this.cameraManager.getCameraCapabilities(cameraID); - if (cameraMetadata && cameraCapabilities?.supportsTimeline) { + if (cameraMetadata) { groups.push({ id: cameraID, content: cameraMetadata.title, @@ -1142,11 +1121,10 @@ export class FrigateCardTimelineCore extends LitElement { changedProps.has('timelineConfig') || changedProps.has('cameraIDs') ) { - const cameraIDs = this._getTimelineCameraIDs(); - if (cameraIDs && this.cameraManager && this.timelineConfig) { + if (this.cameraIDs?.size && this.cameraManager && this.timelineConfig) { this._timelineSource = new TimelineDataSource( this.cameraManager, - cameraIDs, + this.cameraIDs, this.timelineConfig.events_media_type, this.timelineConfig.show_recordings, ); @@ -1191,12 +1169,6 @@ export class FrigateCardTimelineCore extends LitElement { const groups = this._getGroups(); if (!groups.length) { - if (!this.mini) { - // Don't show an empty timeline, show a message instead. - dispatchMessageEvent(this, localize('error.timeline_no_cameras'), 'info', { - icon: 'mdi:chart-gantt', - }); - } return; } diff --git a/src/components/timeline.ts b/src/components/timeline.ts index 01a324f5..08f737e6 100644 --- a/src/components/timeline.ts +++ b/src/components/timeline.ts @@ -37,6 +37,9 @@ export class FrigateCardTimeline extends LitElement { .timelineConfig=${this.timelineConfig} .thumbnailConfig=${this.timelineConfig.controls.thumbnails} .cameraManager=${this.cameraManager} + .cameraIDs=${this.cameraManager?.getStore().getCameraIDsWithCapability({ + anyCapabilities: ['clips', 'snapshots', 'recordings'], + })} .cardWideConfig=${this.cardWideConfig} .itemClickAction=${this.timelineConfig.controls.thumbnails.mode === 'none' ? 'play' diff --git a/src/config-mgmt.ts b/src/config-mgmt.ts index 0c8d6f28..28f379ab 100644 --- a/src/config-mgmt.ts +++ b/src/config-mgmt.ts @@ -678,4 +678,10 @@ const UPGRADES = [ typeof data === 'object' && data ? data[CONF_AUTOMATIONS] : {}, ); }, + upgradeArrayOfObjects( + CONF_CAMERAS, + upgradeMoveToWithOverrides('hide', 'capabilities', { + transform: (val) => (val === true ? { disable_except: 'substream' } : null), + }), + ), ]; diff --git a/src/config/types.ts b/src/config/types.ts index d241c793..67508e46 100644 --- a/src/config/types.ts +++ b/src/config/types.ts @@ -9,6 +9,7 @@ import { } from '@dermotduffy/custom-card-helpers'; import { z } from 'zod'; import { MEDIA_CHUNK_SIZE_DEFAULT, MEDIA_CHUNK_SIZE_MAX } from '../const.js'; +import { capabilityKeys } from '../types.js'; import { deepRemoveDefaults } from '../utils/zod.js'; // ************************************************************************* @@ -44,6 +45,8 @@ const FRIGATE_CARD_VIEWS = [ ] as const; export type FrigateCardView = (typeof FRIGATE_CARD_VIEWS)[number]; + +// The default view (may not be supported on all cameras). export const FRIGATE_CARD_VIEW_DEFAULT = 'live' as const; export const MEDIA_ACTION_NEGATIVE_CONDITIONS = ['unselected', 'hidden'] as const; @@ -730,12 +733,7 @@ const timelineCoreConfigDefault = { pan_mode: 'pan' as const, }; -export const timelinePanModeSchema = z.enum([ - 'pan', - 'seek', - 'seek-in-media', - 'seek-in-camera', -]); +const timelinePanModeSchema = z.enum(['pan', 'seek', 'seek-in-media', 'seek-in-camera']); export type TimelinePanMode = z.infer; const timelineCoreConfigSchema = z.object({ @@ -757,9 +755,7 @@ const timelineCoreConfigSchema = z.object({ .optional() .default(timelineCoreConfigDefault.show_recordings), style: z.enum(['stack', 'ribbon']).optional().default(timelineCoreConfigDefault.style), - pan_mode: timelinePanModeSchema - .optional() - .default(timelineCoreConfigDefault.pan_mode), + pan_mode: timelinePanModeSchema.optional().default(timelineCoreConfigDefault.pan_mode), }); export type TimelineCoreConfig = z.infer; @@ -1119,7 +1115,6 @@ const cameraConfigDefault = { frigate: { client_id: 'frigate' as const, }, - hide: false, image: { refresh_seconds: 1, }, @@ -1151,8 +1146,12 @@ export const cameraConfigSchema = z icon: z.string().optional(), title: z.string().optional(), - // Used to hide the camera (e.g. when used only as a dependency). - hide: z.boolean().optional(), + capabilities: z + .object({ + disable: z.enum(capabilityKeys).array().optional(), + disable_except: z.enum(capabilityKeys).array().optional(), + }) + .optional(), // Optional identifier to separate different camera configurations used in // this card. diff --git a/src/const.ts b/src/const.ts index fdb6237c..7471da29 100644 --- a/src/const.ts +++ b/src/const.ts @@ -8,6 +8,10 @@ export const CONF_CAMERAS_ARRAY_CAMERA_ENTITY = `${CONF_CAMERAS}.#.camera_entity` as const; export const CONF_CAMERAS_ARRAY_FRIGATE_CAMERA_NAME = `${CONF_CAMERAS}.#.frigate.camera_name` as const; +export const CONF_CAMERAS_ARRAY_CAPABILITIES_DISABLE = + `${CONF_CAMERAS}.#.capabilities.disable` as const; +export const CONF_CAMERAS_ARRAY_CAPABILITIES_DISABLE_EXCEPT = + `${CONF_CAMERAS}.#.capabilities.disable_except` as const; export const CONF_CAMERAS_ARRAY_CAST_METHOD = `${CONF_CAMERAS}.#.cast.method` as const; export const CONF_CAMERAS_ARRAY_CAST_DASHBOARD_DASHBOARD_PATH = `${CONF_CAMERAS}.#.cast.dashboard.dashboard_path` as const; @@ -25,7 +29,6 @@ export const CONF_CAMERAS_ARRAY_FRIGATE_ZONES = export const CONF_CAMERAS_ARRAY_GO2RTC_MODES = `${CONF_CAMERAS}.#.go2rtc.modes` as const; export const CONF_CAMERAS_ARRAY_GO2RTC_STREAM = `${CONF_CAMERAS}.#.go2rtc.stream` as const; -export const CONF_CAMERAS_ARRAY_HIDE = `${CONF_CAMERAS}.#.hide` as const; export const CONF_CAMERAS_ARRAY_ICON = `${CONF_CAMERAS}.#.icon` as const; export const CONF_CAMERAS_ARRAY_ID = `${CONF_CAMERAS}.#.id` as const; export const CONF_CAMERAS_ARRAY_IMAGE_REFRESH_SECONDS = diff --git a/src/editor.ts b/src/editor.ts index 415d5e56..b9865192 100644 --- a/src/editor.ts +++ b/src/editor.ts @@ -31,8 +31,9 @@ import { THUMBNAIL_WIDTH_MIN, } from './config/types.js'; import { - CONF_CAMERAS, CONF_CAMERAS_ARRAY_CAMERA_ENTITY, + CONF_CAMERAS_ARRAY_CAPABILITIES_DISABLE_EXCEPT, + CONF_CAMERAS_ARRAY_CAPABILITIES_DISABLE, CONF_CAMERAS_ARRAY_CAST_DASHBOARD_DASHBOARD_PATH, CONF_CAMERAS_ARRAY_CAST_DASHBOARD_VIEW_PATH, CONF_CAMERAS_ARRAY_CAST_METHOD, @@ -49,7 +50,6 @@ import { CONF_CAMERAS_ARRAY_FRIGATE_ZONES, CONF_CAMERAS_ARRAY_GO2RTC_MODES, CONF_CAMERAS_ARRAY_GO2RTC_STREAM, - CONF_CAMERAS_ARRAY_HIDE, CONF_CAMERAS_ARRAY_ICON, CONF_CAMERAS_ARRAY_ID, CONF_CAMERAS_ARRAY_IMAGE_REFRESH_SECONDS, @@ -67,8 +67,9 @@ import { CONF_CAMERAS_ARRAY_TRIGGERS_OCCUPANCY, CONF_CAMERAS_ARRAY_WEBRTC_CARD_ENTITY, CONF_CAMERAS_ARRAY_WEBRTC_CARD_URL, - CONF_DIMENSIONS_ASPECT_RATIO, + CONF_CAMERAS, CONF_DIMENSIONS_ASPECT_RATIO_MODE, + CONF_DIMENSIONS_ASPECT_RATIO, CONF_DIMENSIONS_MAX_HEIGHT, CONF_DIMENSIONS_MIN_HEIGHT, CONF_IMAGE_MODE, @@ -91,7 +92,6 @@ import { CONF_LIVE_CONTROLS_THUMBNAILS_EVENTS_MEDIA_TYPE, CONF_LIVE_CONTROLS_THUMBNAILS_MEDIA_TYPE, CONF_LIVE_CONTROLS_THUMBNAILS_MODE, - CONF_LIVE_CONTROLS_TIMELINE_PAN_MODE, CONF_LIVE_CONTROLS_THUMBNAILS_SHOW_DETAILS, CONF_LIVE_CONTROLS_THUMBNAILS_SHOW_DOWNLOAD_CONTROL, CONF_LIVE_CONTROLS_THUMBNAILS_SHOW_FAVORITE_CONTROL, @@ -100,6 +100,7 @@ import { CONF_LIVE_CONTROLS_TIMELINE_CLUSTERING_THRESHOLD, CONF_LIVE_CONTROLS_TIMELINE_EVENTS_MEDIA_TYPE, CONF_LIVE_CONTROLS_TIMELINE_MODE, + CONF_LIVE_CONTROLS_TIMELINE_PAN_MODE, CONF_LIVE_CONTROLS_TIMELINE_SHOW_RECORDINGS, CONF_LIVE_CONTROLS_TIMELINE_STYLE, CONF_LIVE_CONTROLS_TIMELINE_WINDOW_SECONDS, @@ -157,8 +158,8 @@ import { CONF_MEDIA_VIEWER_TRANSITION_EFFECT, CONF_MEDIA_VIEWER_ZOOMABLE, CONF_MENU_ALIGNMENT, - CONF_MENU_BUTTONS, CONF_MENU_BUTTON_SIZE, + CONF_MENU_BUTTONS, CONF_MENU_POSITION, CONF_MENU_STYLE, CONF_PERFORMANCE_FEATURES_ANIMATED_PROGRESS_INDICATOR, @@ -182,14 +183,14 @@ import { CONF_VIEW_DEFAULT, CONF_VIEW_INTERACTION_SECONDS, CONF_VIEW_RESET_AFTER_INTERACTION, - CONF_VIEW_TRIGGERS, - CONF_VIEW_TRIGGERS_ACTIONS, CONF_VIEW_TRIGGERS_ACTIONS_INTERACTION_MODE, CONF_VIEW_TRIGGERS_ACTIONS_TRIGGER, CONF_VIEW_TRIGGERS_ACTIONS_UNTRIGGER, + CONF_VIEW_TRIGGERS_ACTIONS, CONF_VIEW_TRIGGERS_FILTER_SELECTED_CAMERA, CONF_VIEW_TRIGGERS_SHOW_TRIGGER_STATUS, CONF_VIEW_TRIGGERS_UNTRIGGER_SECONDS, + CONF_VIEW_TRIGGERS, CONF_VIEW_UPDATE_CYCLE_CAMERA, CONF_VIEW_UPDATE_FORCE, CONF_VIEW_UPDATE_SECONDS, @@ -208,6 +209,7 @@ import { const MENU_BUTTONS = 'buttons'; const MENU_CAMERAS = 'cameras'; +const MENU_CAMERAS_CAPABILITIES = 'cameras.capabilities'; const MENU_CAMERAS_CAST = 'cameras.cast'; const MENU_CAMERAS_DEPENDENCIES = 'cameras.dependencies'; const MENU_CAMERAS_DIMENSIONS = 'cameras.dimensions'; @@ -741,6 +743,50 @@ export class FrigateCardEditor extends LitElement implements LovelaceCardEditor }, ]; + protected _capabilities: EditorSelectOption[] = [ + { value: '', label: '' }, + { + value: 'live', + label: localize('config.cameras.capabilities.capabilities.live'), + }, + { + value: 'substream', + label: localize('config.cameras.capabilities.capabilities.substream'), + }, + { + value: 'clips', + label: localize('config.cameras.capabilities.capabilities.clips'), + }, + { + value: 'recordings', + label: localize('config.cameras.capabilities.capabilities.recordings'), + }, + { + value: 'snapshots', + label: localize('config.cameras.capabilities.capabilities.snapshots'), + }, + { + value: 'favorite-events', + label: localize('config.cameras.capabilities.capabilities.favorite-events'), + }, + { + value: 'favorite-recordings', + label: localize('config.cameras.capabilities.capabilities.favorite-recordings'), + }, + { + value: 'seek', + label: localize('config.cameras.capabilities.capabilities.seek'), + }, + { + value: 'ptz', + label: localize('config.cameras.capabilities.capabilities.ptz'), + }, + { + value: 'menu', + label: localize('config.cameras.capabilities.capabilities.menu'), + }, + ]; + public setConfig(config: RawFrigateCardConfig): void { // Note: This does not use Zod to parse the configuration, so it may be // partially or completely invalid. It's more useful to have a partially @@ -1688,10 +1734,6 @@ export class FrigateCardEditor extends LitElement implements LovelaceCardEditor ${this._renderStringInput( getArrayConfigPath(CONF_CAMERAS_ARRAY_ID, cameraIndex), )} - ${this._renderSwitch( - getArrayConfigPath(CONF_CAMERAS_ARRAY_HIDE, cameraIndex), - this._defaults.cameras.hide, - )} ${this._putInSubmenu( MENU_CAMERAS_ENGINE, true, @@ -1933,6 +1975,34 @@ export class FrigateCardEditor extends LitElement implements LovelaceCardEditor )} `, )} + ${this._putInSubmenu( + MENU_CAMERAS_CAPABILITIES, + cameraIndex, + 'config.cameras.capabilities.editor_label', + { name: 'mdi:cog-stop' }, + html` + ${this._renderOptionSelector( + getArrayConfigPath( + CONF_CAMERAS_ARRAY_CAPABILITIES_DISABLE, + cameraIndex, + ), + this._capabilities, + { + multiple: true, + }, + )} + ${this._renderOptionSelector( + getArrayConfigPath( + CONF_CAMERAS_ARRAY_CAPABILITIES_DISABLE_EXCEPT, + cameraIndex, + ), + this._capabilities, + { + multiple: true, + }, + )} + `, + )} ` : ``} diff --git a/src/localize/languages/en.json b/src/localize/languages/en.json index 25407e9c..6d4bfa88 100644 --- a/src/localize/languages/en.json +++ b/src/localize/languages/en.json @@ -10,6 +10,23 @@ "config": { "cameras": { "camera_entity": "Camera Entity", + "capabilities": { + "disable": "Disable", + "disable_except": "Disable except", + "editor_label": "Camera capabilities", + "capabilities": { + "clips": "Clips", + "favorite-events": "Favorite Events", + "favorite-recordings": "Favorite Recordings", + "live": "Live", + "menu": "Menu", + "ptz": "PTZ", + "recordings": "Recordings", + "seek": "Seeking", + "snapshots": "Snapshots", + "substream": "Substream" + } + }, "cast": { "dashboard": { "dashboard_path": "Dashboard path", @@ -50,7 +67,7 @@ "frigate": { "camera_name": "Frigate camera name (Autodetected from entity)", "client_id": "Frigate client id (For >1 Frigate server)", - "editor_label": "Frigate Options", + "editor_label": "Frigate options", "labels": "Frigate labels/object filters", "url": "Frigate server URL", "zones": "Frigate zones" @@ -87,7 +104,7 @@ "webrtc-card": "WebRTC Card (i.e. AlexxIT's WebRTC Card)" }, "motioneye": { - "editor_label": "MotionEye Options", + "editor_label": "MotionEye options", "images": { "directory_pattern": "Images directory pattern", "file_pattern": "Images file pattern" @@ -527,9 +544,9 @@ "no_camera_name": "Could not determine a Frigate camera name for camera (or one of its dependents), please specify either 'camera_entity' or 'camera_name'", "no_dashboard_or_view": "Both 'dashboard_path' and 'view_path' parameters are required for the 'dashboard' cast method", "no_live_camera": "The camera_entity parameter must be set and valid for this live provider", - "no_visible_cameras": "No visible cameras found, you must configure at least one non-hidden camera", + "no_supported_camera": "The selected camera does not support this view", + "no_supported_cameras": "No cameras support this view", "reconnecting": "Reconnecting", - "timeline_no_cameras": "No Frigate cameras to show in timeline", "too_many_automations": "Too many nested automation calls, please check your configuration for loops", "troubleshooting": "Check troubleshooting", "unknown": "Unknown error", diff --git a/src/localize/languages/it.json b/src/localize/languages/it.json index 94cb26f5..6af6a999 100644 --- a/src/localize/languages/it.json +++ b/src/localize/languages/it.json @@ -10,6 +10,23 @@ "config": { "cameras": { "camera_entity": "Entità della telecamera", + "capabilities": { + "disable": "", + "disable_except": "", + "editor_label": "", + "capabilities": { + "clips": "", + "favorite-events": "", + "favorite-recordings": "", + "live": "", + "menu": "", + "ptz": "", + "recordings": "", + "seek": "", + "snapshots": "", + "substream": "" + } + }, "cast": { "dashboard": { "dashboard_path": "", @@ -518,7 +535,8 @@ "no_camera_name": "Impossibile determinare un nome della telecamera in Frigate, si prega di specificare 'camera_enty' o 'camera_name'", "no_dashboard_or_view": "", "no_live_camera": "Il parametro fotocamera_enty deve essere impostato e valido per questo provider live", - "no_visible_cameras": "Nessuna telecamera visibile trovata, è necessario configurare almeno una telecamera non nascosta", + "no_supported_camera": "", + "no_supported_cameras": "", "reconnecting": "Riconnessione", "timeline_no_cameras": "Nessuna telecamera damostrare in Frigate nella timeline", "too_many_automations": "", diff --git a/src/localize/languages/pt-BR.json b/src/localize/languages/pt-BR.json index 85ee08a3..bec02933 100644 --- a/src/localize/languages/pt-BR.json +++ b/src/localize/languages/pt-BR.json @@ -10,6 +10,23 @@ "config": { "cameras": { "camera_entity": "Entidade da Câmera", + "capabilities": { + "disable": "", + "disable_except": "", + "editor_label": "", + "capabilities": { + "clips": "", + "favorite-events": "", + "favorite-recordings": "", + "live": "", + "menu": "", + "ptz": "", + "recordings": "", + "seek": "", + "snapshots": "", + "substream": "" + } + }, "cast": { "dashboard": { "dashboard_path": "", @@ -525,7 +542,8 @@ "no_camera_name": "Não foi possível determinar o nome da câmera da Frigate, especifique 'camera_entity' ou 'camera_name' para a câmera a seguir", "no_dashboard_or_view": "", "no_live_camera": "O parâmetro camera_entity deve ser definido e válido para este provedor ativo", - "no_visible_cameras": "Nenhuma câmera visível encontrada, você deve configurar pelo menos uma câmera não oculta", + "no_supported_camera": "", + "no_supported_cameras": "", "reconnecting": "Reconectando", "timeline_no_cameras": "Nenhuma câmera do Frigate para mostrar na linha do tempo", "too_many_automations": "", diff --git a/src/localize/languages/pt-PT.json b/src/localize/languages/pt-PT.json index 859fa85f..36460227 100644 --- a/src/localize/languages/pt-PT.json +++ b/src/localize/languages/pt-PT.json @@ -10,6 +10,23 @@ "config": { "cameras": { "camera_entity": "Entidade da Câmera", + "capabilities": { + "disable": "", + "disable_except": "", + "editor_label": "", + "capabilities": { + "clips": "", + "favorite-events": "", + "favorite-recordings": "", + "live": "", + "menu": "", + "ptz": "", + "recordings": "", + "seek": "", + "snapshots": "", + "substream": "" + } + }, "cast": { "dashboard": { "dashboard_path": "", @@ -511,7 +528,8 @@ "no_camera_name": "Não foi possível determinar o nome da câmera da Frigate, especifique 'camera_entity' ou 'camera_name' para a câmera a seguir", "no_dashboard_or_view": "", "no_live_camera": "O parâmetro camera_entity deve ser definido e válido para este serviço ativo", - "no_visible_cameras": "Sem camaras visiveis", + "no_supported_camera": "", + "no_supported_cameras": "", "reconnecting": "A voltar a ligar", "timeline_no_cameras": "Nenhuma câmera do Frigate para mostrar na linha do tempo", "too_many_automations": "", diff --git a/src/types.ts b/src/types.ts index d95880ed..40afdd63 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,4 +1,8 @@ -import { HomeAssistant, LovelaceCardConfig, Themes } from '@dermotduffy/custom-card-helpers'; +import { + HomeAssistant, + LovelaceCardConfig, + Themes, +} from '@dermotduffy/custom-card-helpers'; import { StyleInfo } from 'lit/directives/style-map.js'; import { z } from 'zod'; @@ -87,6 +91,46 @@ export interface CardHelpers { }>; } +export type PTZMovementType = 'relative' | 'continuous'; + +export interface PTZCapabilities { + panTilt?: PTZMovementType[]; + zoom?: PTZMovementType[]; + presets?: string[]; +} + +export interface CapabilitiesRaw { + live?: boolean; + substream?: boolean; + + clips?: boolean; + recordings?: boolean; + snapshots?: boolean; + + 'favorite-events'?: boolean; + 'favorite-recordings'?: boolean; + + seek?: boolean; + + ptz?: PTZCapabilities; + + menu?: boolean; +} + +export type CapabilityKey = keyof CapabilitiesRaw; +export const capabilityKeys: readonly [CapabilityKey, ...CapabilityKey[]] = [ + 'clips', + 'favorite-events', + 'favorite-recordings', + 'live', + 'menu', + 'ptz', + 'recordings', + 'seek', + 'snapshots', + 'substream', +] as const; + // ************************************************************************* // Home Assistant API types. // ************************************************************************* diff --git a/src/utils/media-to-view.ts b/src/utils/media-to-view.ts index e940c7c4..4fbe8fc8 100644 --- a/src/utils/media-to-view.ts +++ b/src/utils/media-to-view.ts @@ -1,6 +1,6 @@ import { ViewContext } from 'view'; import { CameraManager } from '../camera-manager/manager'; -import { MediaQuery } from '../camera-manager/types'; +import { CapabilitySearchOptions, MediaQuery } from '../camera-manager/types'; import { dispatchFrigateCardErrorEvent } from '../components/message'; import { CardWideConfig, FrigateCardView } from '../config/types'; import { MEDIA_CHUNK_SIZE_DEFAULT } from '../const'; @@ -30,9 +30,16 @@ export const changeViewToRecentEventsForCameraAndDependents = async ( useCache?: boolean; }, ): Promise => { + const capabilitySearch: CapabilitySearchOptions = + !options?.eventsMediaType || options?.eventsMediaType === 'all' + ? { + anyCapabilities: ['clips', 'snapshots'], + } + : options.eventsMediaType; + const cameraIDs = options?.allCameras - ? cameraManager.getStore().getVisibleCameraIDs() - : cameraManager.getStore().getAllDependentCameras(view.camera); + ? cameraManager.getStore().getCameraIDsWithCapability(capabilitySearch) + : cameraManager.getStore().getAllDependentCameras(view.camera, capabilitySearch); if (!cameraIDs.size) { return; } @@ -99,8 +106,8 @@ export const changeViewToRecentRecordingForCameraAndDependents = async ( }, ): Promise => { const cameraIDs = options?.allCameras - ? cameraManager.getStore().getVisibleCameraIDs() - : cameraManager.getStore().getAllDependentCameras(view.camera); + ? cameraManager.getStore().getCameraIDsWithCapability('recordings') + : cameraManager.getStore().getAllDependentCameras(view.camera, 'recordings'); if (!cameraIDs.size) { return; } diff --git a/src/utils/ptz.ts b/src/utils/ptz.ts index 6d31e8a2..2cde0016 100644 --- a/src/utils/ptz.ts +++ b/src/utils/ptz.ts @@ -1,8 +1,8 @@ -import { CameraManagerCameraCapabilities } from '../camera-manager/types'; +import { Capabilities } from '../camera-manager/capabilities'; import { FrigateCardPTZConfig, PTZ_CONTROL_ACTIONS } from '../config/types'; export const hasUsablePTZ = ( - capabilities: CameraManagerCameraCapabilities | null, + capabilities: Capabilities | null, config: FrigateCardPTZConfig, ): boolean => { for (const actionName of PTZ_CONTROL_ACTIONS) { @@ -10,5 +10,10 @@ export const hasUsablePTZ = ( return true; } } - return !!capabilities?.ptz; + const ptzCapabilities = capabilities?.getPTZCapabilities(); + return ( + !!ptzCapabilities?.panTilt?.length || + !!ptzCapabilities?.zoom?.length || + !!ptzCapabilities?.presets?.length + ); }; diff --git a/src/view/view-to-cameras.ts b/src/view/view-to-cameras.ts new file mode 100644 index 00000000..3f669277 --- /dev/null +++ b/src/view/view-to-cameras.ts @@ -0,0 +1,51 @@ +import { CameraManager } from '../camera-manager/manager'; +import { FrigateCardView } from '../config/types'; + +/** + * Get cameraIDs that are relevant for a given view name based on camera + * capability (if camera specified). + */ +export const getCameraIDsForViewName = ( + cameraManager: CameraManager, + viewName: FrigateCardView, + cameraID?: string, +): Set => { + switch (viewName) { + case 'image': + case 'diagnostics': + return cameraManager.getStore().getCameraIDs(); + + case 'live': + case 'clip': + case 'clips': + case 'snapshot': + case 'snapshots': + case 'recording': + case 'recordings': + const capability = + viewName === 'clip' + ? 'clips' + : viewName === 'snapshot' + ? 'snapshots' + : viewName === 'recording' + ? 'recordings' + : viewName; + return cameraID + ? cameraManager.getStore().getAllDependentCameras(cameraID, capability) + : cameraManager.getStore().getCameraIDsWithCapability(capability); + + case 'timeline': + return cameraManager.getStore().getCameraIDsWithCapability({ + anyCapabilities: ['clips', 'snapshots', 'recordings'], + }); + + case 'media': + return cameraID + ? cameraManager.getStore().getAllDependentCameras(cameraID, { + anyCapabilities: ['clips', 'snapshots', 'recordings'], + }) + : cameraManager.getStore().getCameraIDsWithCapability({ + anyCapabilities: ['clips', 'snapshots', 'recordings'], + }); + } +}; diff --git a/tests/camera-manager/camera.test.ts b/tests/camera-manager/camera.test.ts index fc14557d..46e5196b 100644 --- a/tests/camera-manager/camera.test.ts +++ b/tests/camera-manager/camera.test.ts @@ -5,8 +5,8 @@ import { GenericCameraManagerEngine } from '../../src/camera-manager/generic/eng import { EntityRegistryManager } from '../../src/utils/ha/entity-registry/index.js'; import { callHASubscribeMessageHandler, - createCameraCapabilities, createCameraConfig, + createCapabilities, createHASS, } from '../test-utils.js'; @@ -19,7 +19,7 @@ describe('Camera', () => { describe('should get capabilities', async () => { it('when populated', async () => { - const capabilities = createCameraCapabilities(); + const capabilities = createCapabilities(); const camera = new Camera(createCameraConfig(), new GenericCameraManagerEngine(), { capabilities: capabilities, }); diff --git a/tests/camera-manager/capabilities.test.ts b/tests/camera-manager/capabilities.test.ts new file mode 100644 index 00000000..9710e489 --- /dev/null +++ b/tests/camera-manager/capabilities.test.ts @@ -0,0 +1,118 @@ +import { describe, expect, it } from 'vitest'; +import { Capabilities } from '../../src/camera-manager/capabilities'; +import { CapabilityKey, PTZCapabilities, capabilityKeys } from '../../src/types'; + +describe('Capabilities', () => { + it('default capabilities', () => { + const capabilities = new Capabilities({}); + for (const key of capabilityKeys) { + expect(capabilities.has(key)).toBeFalsy(); + } + }); + + describe('simple presence checks', () => { + it.each([ + 'clips', + 'favorite-events', + 'favorite-recordings', + 'live', + 'menu', + 'recordings', + 'seek', + 'snapshots', + 'substream', + ] as const)('%s', (capability: CapabilityKey) => { + const capabilities = new Capabilities({ + [capability]: true, + }); + expect(capabilities.has(capability)).toBeTruthy(); + }); + }); + + describe('complex matches', () => { + it('string matcher', () => { + const capabilities = new Capabilities({ + live: true, + }); + expect(capabilities.matches('live')).toBeTruthy(); + expect(capabilities.matches('clips')).toBeFalsy(); + }); + + it('allCapabilities matcher', () => { + const capabilities = new Capabilities({ + live: true, + clips: true, + }); + expect(capabilities.matches({ allCapabilities: ['live', 'clips'] })).toBeTruthy(); + expect( + capabilities.matches({ allCapabilities: ['live', 'snapshots'] }), + ).toBeFalsy(); + }); + + it('anyCapabilities matcher', () => { + const capabilities = new Capabilities({ + live: true, + clips: true, + }); + expect(capabilities.matches({ anyCapabilities: ['live', 'clips'] })).toBeTruthy(); + expect( + capabilities.matches({ anyCapabilities: ['live', 'snapshots'] }), + ).toBeTruthy(); + expect( + capabilities.matches({ anyCapabilities: ['snapshots', 'menu'] }), + ).toBeFalsy(); + }); + }); + + describe('getPTZCapabilities', () => { + it('when unset', () => { + const capabilities = new Capabilities({}); + expect(capabilities.getPTZCapabilities()).toBeNull(); + }); + + it('when set', () => { + const ptz: PTZCapabilities = { + panTilt: ['continuous' as const], + presets: ['1', '2'], + }; + const capabilities = new Capabilities({ + ptz: ptz, + }); + expect(capabilities.getPTZCapabilities()).toBe(ptz); + }); + }); + + describe('disable capabilities', () => { + it('disable', () => { + const capabilities = new Capabilities( + { + live: true, + clips: true, + snapshots: true, + }, + { + disable: ['live', 'clips'], + }, + ); + expect(capabilities.has('live')).toBeFalsy(); + expect(capabilities.has('clips')).toBeFalsy(); + expect(capabilities.has('snapshots')).toBeTruthy(); + }); + + it('disableExcept', () => { + const capabilities = new Capabilities( + { + live: true, + clips: true, + snapshots: true, + }, + { + disableExcept: ['live', 'clips'], + }, + ); + expect(capabilities.has('live')).toBeTruthy(); + expect(capabilities.has('clips')).toBeTruthy(); + expect(capabilities.has('snapshots')).toBeFalsy(); + }); + }); +}); diff --git a/tests/camera-manager/frigate/camera.test.ts b/tests/camera-manager/frigate/camera.test.ts index bfa8bdbe..7852ae52 100644 --- a/tests/camera-manager/frigate/camera.test.ts +++ b/tests/camera-manager/frigate/camera.test.ts @@ -131,16 +131,6 @@ describe('FrigateCamera', () => { }); describe('should detect capabilities', () => { - const nonBirdseyeBaseCapabilities = { - canFavoriteEvents: true, - canFavoriteRecordings: true, - canSeek: true, - supportsClips: true, - supportsSnapshots: true, - supportsRecordings: true, - supportsTimeline: true, - }; - it('basic non-birdseye', async () => { const camera = new FrigateCamera( createCameraConfig({ @@ -153,7 +143,13 @@ describe('FrigateCamera', () => { await camera.initialize(createHASS(), mock()); - expect(camera.getCapabilities()).toEqual(nonBirdseyeBaseCapabilities); + expect(camera.getCapabilities()?.has('favorite-events')).toBeTruthy(); + expect(camera.getCapabilities()?.has('favorite-recordings')).toBeFalsy(); + expect(camera.getCapabilities()?.has('seek')).toBeTruthy(); + expect(camera.getCapabilities()?.has('clips')).toBeTruthy(); + expect(camera.getCapabilities()?.has('live')).toBeTruthy(); + expect(camera.getCapabilities()?.has('snapshots')).toBeTruthy(); + expect(camera.getCapabilities()?.has('recordings')).toBeTruthy(); expect(vi.mocked(getPTZInfo)).toBeCalled(); }); @@ -169,15 +165,13 @@ describe('FrigateCamera', () => { await camera.initialize(createHASS(), mock()); - expect(camera.getCapabilities()).toEqual({ - ...nonBirdseyeBaseCapabilities, - canFavoriteEvents: false, - canFavoriteRecordings: false, - supportsClips: false, - supportsSnapshots: false, - supportsRecordings: false, - supportsTimeline: false, - }); + expect(camera.getCapabilities()?.has('favorite-events')).toBeFalsy(); + expect(camera.getCapabilities()?.has('favorite-recordings')).toBeFalsy(); + expect(camera.getCapabilities()?.has('seek')).toBeFalsy(); + expect(camera.getCapabilities()?.has('clips')).toBeFalsy(); + expect(camera.getCapabilities()?.has('live')).toBeTruthy(); + expect(camera.getCapabilities()?.has('snapshots')).toBeFalsy(); + expect(camera.getCapabilities()?.has('recordings')).toBeFalsy(); expect(vi.mocked(getPTZInfo)).not.toBeCalled(); }); @@ -196,9 +190,7 @@ describe('FrigateCamera', () => { vi.mocked(getPTZInfo).mockRejectedValue(new Error()); await camera.initialize(createHASS(), mock()); - expect(camera.getCapabilities()).toEqual({ - ...nonBirdseyeBaseCapabilities, - }); + expect(camera.getCapabilities()?.has('ptz')).toBeFalsy(); expect(consoleSpy).toBeCalled(); }); @@ -220,13 +212,11 @@ describe('FrigateCamera', () => { }); await camera.initialize(createHASS(), mock()); - expect(camera.getCapabilities()).toEqual({ - ...nonBirdseyeBaseCapabilities, - ptz: { - panTilt: ['continuous'], - zoom: ['continuous'], - presets: ['preset01'], - }, + expect(camera.getCapabilities()?.has('ptz')).toBeTruthy(); + expect(camera.getCapabilities()?.getPTZCapabilities()).toEqual({ + panTilt: ['continuous'], + zoom: ['continuous'], + presets: ['preset01'], }); }); @@ -248,13 +238,11 @@ describe('FrigateCamera', () => { }); await camera.initialize(createHASS(), mock()); - expect(camera.getCapabilities()).toEqual({ - ...nonBirdseyeBaseCapabilities, - ptz: { - panTilt: ['relative'], - zoom: ['relative'], - presets: ['preset01'], - }, + expect(camera.getCapabilities()?.has('ptz')).toBeTruthy(); + expect(camera.getCapabilities()?.getPTZCapabilities()).toEqual({ + panTilt: ['relative'], + zoom: ['relative'], + presets: ['preset01'], }); }); }); diff --git a/tests/camera-manager/generic/engine-generic.test.ts b/tests/camera-manager/generic/engine-generic.test.ts index f0edd57e..1acf663b 100644 --- a/tests/camera-manager/generic/engine-generic.test.ts +++ b/tests/camera-manager/generic/engine-generic.test.ts @@ -34,15 +34,13 @@ describe('GenericCameraManagerEngine', () => { ); expect(camera.getConfig()).toEqual(config); - expect(camera.getCapabilities()).toEqual({ - canFavoriteEvents: false, - canFavoriteRecordings: false, - canSeek: false, - supportsClips: false, - supportsRecordings: false, - supportsSnapshots: false, - supportsTimeline: false, - }); + expect(camera.getCapabilities()).toBeTruthy(); + expect(camera.getCapabilities()?.has('favorite-events')).toBeFalsy(); + expect(camera.getCapabilities()?.has('favorite-recordings')).toBeFalsy(); + expect(camera.getCapabilities()?.has('seek')).toBeFalsy(); + expect(camera.getCapabilities()?.has('clips')).toBeFalsy(); + expect(camera.getCapabilities()?.has('recordings')).toBeFalsy(); + expect(camera.getCapabilities()?.has('snapshots')).toBeFalsy(); }); it('should generate default event query', () => { diff --git a/tests/camera-manager/manager.test.ts b/tests/camera-manager/manager.test.ts index 5aa825c9..282cf68e 100644 --- a/tests/camera-manager/manager.test.ts +++ b/tests/camera-manager/manager.test.ts @@ -3,6 +3,7 @@ import add from 'date-fns/add'; import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; import { mock } from 'vitest-mock-extended'; import { Camera } from '../../src/camera-manager/camera'; +import { Capabilities } from '../../src/camera-manager/capabilities'; import { CameraManagerEngine } from '../../src/camera-manager/engine'; import { CameraManagerEngineFactory } from '../../src/camera-manager/engine-factory.js'; import { @@ -16,7 +17,6 @@ import { CameraEndpoints, CameraEndpointsContext, CameraEvent, - CameraManagerCameraCapabilities, CameraManagerCameraMetadata, CameraManagerMediaCapabilities, Engine, @@ -27,7 +27,7 @@ import { QueryResultsType, QueryType, } from '../../src/camera-manager/types'; -import { sortMedia } from '../../src/camera-manager/utils'; +import { sortMedia } from '../../src/camera-manager/utils/sort-media'; import { CardController } from '../../src/card-controller/controller'; import { CameraConfig } from '../../src/config/types'; import { EntityRegistryManager } from '../../src/utils/ha/entity-registry'; @@ -35,8 +35,8 @@ import { ViewMedia } from '../../src/view/media'; import { TestViewMedia, createCamera, - createCameraCapabilities, createCameraConfig, + createCapabilities, createCardAPI, createConfig, createHASS, @@ -219,7 +219,7 @@ describe('CameraManager', async () => { cameras: { config?: CameraConfig; engineType?: Engine | null; - capabilties?: CameraManagerCameraCapabilities; + capabilties?: Capabilities; }[] = [{}], factory?: CameraManagerEngineFactory, ): CameraManager => { @@ -249,7 +249,7 @@ describe('CameraManager', async () => { createCamera( cameraConfig, mockEngine, - camera.capabilties ?? createCameraCapabilities(), + camera.capabilties ?? createCapabilities(), ), ); } @@ -367,19 +367,6 @@ describe('CameraManager', async () => { ); }); - it('with no cameras', async () => { - const api = createCardAPI(); - vi.mocked(api.getHASSManager().getHASS).mockReturnValue(createHASS()); - - const manager = createCameraManager(api, mock(), []); - expect(await manager.initializeCamerasFromConfig()).toBeFalsy(); - expect(api.getMessageManager().setErrorIfHigherPriority).toBeCalledWith( - new Error( - 'No visible cameras found, you must configure at least one non-hidden camera', - ), - ); - }); - it('should pass events to triggers manager', async () => { const api = createCardAPI(); vi.mocked(api.getHASSManager().getHASS).mockReturnValue(createHASS()); @@ -1043,74 +1030,72 @@ describe('CameraManager', async () => { vi.mocked(api.getHASSManager().getHASS).mockReturnValue(hass); expect(await manager.initializeCamerasFromConfig()).toBeTruthy(); - expect(manager.getCameraCapabilities('id')).toEqual(createCameraCapabilities()); + expect(manager.getCameraCapabilities('id')).toEqual(createCapabilities()); }); }); describe('should get aggregate camera capabilities', () => { it('without camera', () => { const manager = createCameraManager(createCardAPI()); - expect(manager.getAggregateCameraCapabilities()).toEqual({ - canFavoriteEvents: false, - canFavoriteRecordings: false, - canSeek: false, + const capabilities = manager.getAggregateCameraCapabilities(); - supportsClips: false, - supportsRecordings: false, - supportsSnapshots: false, - supportsTimeline: false, + expect(capabilities.has('favorite-events')).toBeFalsy(); + expect(capabilities.has('favorite-recordings')).toBeFalsy(); + expect(capabilities.has('seek')).toBeFalsy(); - supportsPTZ: false, - }); + expect(capabilities.has('live')).toBeFalsy(); + expect(capabilities.has('clips')).toBeFalsy(); + expect(capabilities.has('recordings')).toBeFalsy(); + expect(capabilities.has('snapshots')).toBeFalsy(); }); it('successfully', async () => { const api = createCardAPI(); const manager = createCameraManager(api, mock(), [ { - capabilties: { - canFavoriteEvents: false, - canFavoriteRecordings: false, - canSeek: false, + capabilties: new Capabilities({ + 'favorite-events': false, + 'favorite-recordings': false, + seek: false, - supportsClips: false, - supportsRecordings: false, - supportsSnapshots: false, - supportsTimeline: false, - }, + live: false, + clips: false, + recordings: false, + snapshots: false, + }), }, { config: createCameraConfig({ baseCameraConfig, id: 'another' }), - capabilties: { - canFavoriteEvents: true, - canFavoriteRecordings: true, - canSeek: true, + capabilties: new Capabilities({ + 'favorite-events': true, + 'favorite-recordings': true, + seek: true, - supportsClips: true, - supportsRecordings: true, - supportsSnapshots: true, - supportsTimeline: true, + live: true, + clips: true, + recordings: true, + snapshots: true, - ptz: {}, - }, + ptz: { + panTilt: ['continuous'], + }, + }), }, ]); const hass = createHASS(); vi.mocked(api.getHASSManager().getHASS).mockReturnValue(hass); expect(await manager.initializeCamerasFromConfig()).toBeTruthy(); - expect(manager.getAggregateCameraCapabilities()).toEqual({ - canFavoriteEvents: true, - canFavoriteRecordings: true, - canSeek: true, + const capabilities = manager.getAggregateCameraCapabilities(); - supportsClips: true, - supportsRecordings: true, - supportsSnapshots: true, - supportsTimeline: true, + expect(capabilities.has('favorite-events')).toBeTruthy(); + expect(capabilities.has('favorite-recordings')).toBeTruthy(); + expect(capabilities.has('seek')).toBeTruthy(); - supportsPTZ: true, - }); + expect(capabilities.has('live')).toBeTruthy(); + expect(capabilities.has('clips')).toBeTruthy(); + expect(capabilities.has('recordings')).toBeTruthy(); + expect(capabilities.has('snapshots')).toBeTruthy(); }); }); diff --git a/tests/camera-manager/store.test.ts b/tests/camera-manager/store.test.ts index 02b37e51..5c218ffb 100644 --- a/tests/camera-manager/store.test.ts +++ b/tests/camera-manager/store.test.ts @@ -1,12 +1,13 @@ import { describe, expect, it, vi } from 'vitest'; import { mock } from 'vitest-mock-extended'; import { Camera } from '../../src/camera-manager/camera.js'; +import { Capabilities } from '../../src/camera-manager/capabilities.js'; import { CameraManagerEngineFactory } from '../../src/camera-manager/engine-factory.js'; import { CameraManagerStore } from '../../src/camera-manager/store.js'; import { Engine } from '../../src/camera-manager/types.js'; import { EntityRegistryManager } from '../../src/utils/ha/entity-registry/index.js'; import { ResolvedMediaCache } from '../../src/utils/ha/resolved-media.js'; -import { createCameraConfig, TestViewMedia } from '../test-utils.js'; +import { TestViewMedia, createCameraConfig } from '../test-utils.js'; describe('CameraManagerStore', async () => { const configVisible = createCameraConfig({ @@ -56,11 +57,6 @@ describe('CameraManagerStore', async () => { expect(store.getCameraCount()).toBe(2); }); - it('getVisibleCameraCount', async () => { - const store = setupStore(); - expect(store.getVisibleCameraCount()).toBe(1); - }); - describe('getDefaultCameraID', () => { it('with camera', async () => { const store = setupStore(); @@ -127,11 +123,6 @@ describe('CameraManagerStore', async () => { expect(store.getCameraIDs()).toEqual(new Set(['camera-visible', 'camera-hidden'])); }); - it('getVisibleCameraIDs', async () => { - const store = setupStore(); - expect(store.getVisibleCameraIDs()).toEqual(new Set(['camera-visible'])); - }); - it('reset', async () => { const store = setupStore(); const cameras = [...store.getCameras().values()]; @@ -139,7 +130,6 @@ describe('CameraManagerStore', async () => { await store.reset(); expect(store.getCameraCount()).toBe(0); - expect(store.getVisibleCameraCount()).toBe(0); for (const camera of cameras) { expect(camera.destroy).toBeCalled(); } @@ -258,5 +248,60 @@ describe('CameraManagerStore', async () => { ); expect(store.getAllDependentCameras('one')).toEqual(new Set(['one', 'two'])); }); + + it('should return cameras with specific capabilities', () => { + const store = new CameraManagerStore(); + store.addCamera( + new Camera( + createCameraConfig({ + id: 'one', + dependencies: { + all_cameras: true, + }, + }), + engineGeneric, + ), + ); + store.addCamera( + new Camera( + createCameraConfig({ + id: 'two', + }), + engineGeneric, + { + capabilities: new Capabilities({ + clips: true, + }), + }, + ), + ); + expect(store.getAllDependentCameras('one', 'clips')).toEqual(new Set(['two'])); + }); + }); + + it('getCameraIDsWithCapability', () => { + const store = new CameraManagerStore(); + store.addCamera( + new Camera( + createCameraConfig({ + id: 'one', + }), + engineGeneric, + { + capabilities: new Capabilities({ + clips: true, + }), + }, + ), + ); + store.addCamera( + new Camera( + createCameraConfig({ + id: 'two', + }), + engineGeneric, + ), + ); + expect(store.getCameraIDsWithCapability('clips')).toEqual(new Set(['one'])); }); }); diff --git a/tests/camera-manager/utils.test.ts b/tests/camera-manager/utils.test.ts deleted file mode 100644 index 561d1df8..00000000 --- a/tests/camera-manager/utils.test.ts +++ /dev/null @@ -1,188 +0,0 @@ -import { describe, expect, it, vi } from 'vitest'; -import { - capEndDate, - convertRangeToCacheFriendlyTimes, - getCameraEntityFromConfig, - getDefaultGo2RTCEndpoint, - sortMedia, -} from '../../src/camera-manager/utils.js'; -import { CameraConfig, cameraConfigSchema } from '../../src/config/types.js'; -import { TestViewMedia, createCameraConfig } from '../test-utils.js'; - -describe('convertRangeToCacheFriendlyTimes', () => { - it('should return cache friendly within hour range', () => { - expect( - convertRangeToCacheFriendlyTimes({ - start: new Date('2023-04-29T14:01:02'), - end: new Date('2023-04-29T14:11:03'), - }), - ).toEqual({ - start: new Date('2023-04-29T14:00:00'), - end: new Date('2023-04-29T14:59:59.999'), - }); - }); - - it('should return cache friendly within day range', () => { - expect( - convertRangeToCacheFriendlyTimes({ - start: new Date('2023-04-29T14:01:02'), - end: new Date('2023-04-29T15:11:03'), - }), - ).toEqual({ - start: new Date('2023-04-29T00:00:00'), - end: new Date('2023-04-29T23:59:59.999'), - }); - }); - - it('should cap end date', () => { - vi.useFakeTimers(); - vi.setSystemTime(new Date('2023-04-29T14:25')); - expect( - convertRangeToCacheFriendlyTimes( - { - start: new Date('2023-04-29T14:01:02'), - end: new Date('2023-04-29T14:11:03'), - }, - { endCap: true }, - ), - ).toEqual({ - start: new Date('2023-04-29T14:00:00'), - end: new Date('2023-04-29T14:25:59.999'), - }); - vi.useRealTimers(); - }); -}); - -describe('capEndDate', () => { - it('should cap end date', () => { - const fakeNow = new Date('2023-04-29T14:25'); - vi.useFakeTimers(); - vi.setSystemTime(fakeNow); - - expect(capEndDate(new Date('2023-04-29T15:02'))).toEqual(fakeNow); - - vi.useRealTimers(); - }); - - it('should not cap end date', () => { - vi.useFakeTimers(); - vi.setSystemTime(new Date('2023-04-29T14:25')); - - const testDate = new Date('2023-04-29T14:24'); - expect(capEndDate(testDate)).toEqual(testDate); - - vi.useRealTimers(); - }); -}); - -describe('sortMedia', () => { - const media_1 = new TestViewMedia({ - id: 'id-1', - startTime: new Date('2023-04-29T14:25'), - cameraID: 'camera-1', - }); - const media_2 = new TestViewMedia({ - id: 'id-2', - startTime: new Date('2023-04-29T14:26'), - cameraID: 'camera-1', - }); - const media_3_dup_id = new TestViewMedia({ - id: 'id-2', - startTime: new Date('2023-04-29T14:26'), - cameraID: 'camera-1', - }); - const media_4_no_id = new TestViewMedia({ - id: null, - startTime: new Date('2023-04-29T14:27'), - cameraID: 'camera-1', - }); - - it('should sort sorted media', () => { - const media = [media_1, media_2]; - expect(sortMedia(media)).toEqual(media); - }); - it('should sort unsorted media', () => { - expect(sortMedia([media_2, media_1])).toEqual([media_1, media_2]); - }); - it('should remove duplicate id', () => { - expect(sortMedia([media_1, media_2, media_3_dup_id])).toEqual([media_1, media_2]); - }); - it('should sort by id when time not available', () => { - expect( - sortMedia([ - new TestViewMedia({ id: 'snake' }), - new TestViewMedia({ id: 'zebra' }), - new TestViewMedia({ id: 'aardvark' }), - ]), - ).toEqual([ - new TestViewMedia({ id: 'aardvark' }), - new TestViewMedia({ id: 'snake' }), - new TestViewMedia({ id: 'zebra' }), - ]); - }); - it('should remove de-duplicate by object if no id', () => { - expect(sortMedia([media_1, media_2, media_4_no_id, media_4_no_id])).toEqual([ - media_1, - media_2, - media_4_no_id, - ]); - }); -}); - -describe('getCameraEntityFromConfig', () => { - const createCameraConfig = (config: Partial): CameraConfig => { - return cameraConfigSchema.parse(config); - }; - - it('should get camera_entity', () => { - expect(getCameraEntityFromConfig(createCameraConfig({ camera_entity: 'foo' }))).toBe( - 'foo', - ); - }); - it('should get camera_entity from webrtc_card config', () => { - expect( - getCameraEntityFromConfig(createCameraConfig({ webrtc_card: { entity: 'bar' } })), - ).toBe('bar'); - }); - it('should get no camera_entity', () => { - expect(getCameraEntityFromConfig(createCameraConfig({}))).toBeNull(); - }); -}); - -describe('getDefaultGo2RTCEndpoint', () => { - it('with local configuration', () => { - expect( - getDefaultGo2RTCEndpoint( - createCameraConfig({ - go2rtc: { - stream: 'stream', - url: '/local/path', - }, - }), - ), - ).toEqual({ - endpoint: '/local/path/api/ws?src=stream', - sign: true, - }); - }); - - it('with remote configuration', () => { - expect( - getDefaultGo2RTCEndpoint( - createCameraConfig({ - go2rtc: { - stream: 'stream', - url: 'https://my-custom-go2rtc', - }, - }), - ), - ).toEqual({ - endpoint: 'https://my-custom-go2rtc/api/ws?src=stream', - sign: false, - }); - }); - - it('without configuration', () => { - expect(getDefaultGo2RTCEndpoint(createCameraConfig())).toBeNull(); - }); -}); diff --git a/tests/camera-manager/utils/camera-entity-from-config.test.ts b/tests/camera-manager/utils/camera-entity-from-config.test.ts new file mode 100644 index 00000000..ab2a63c3 --- /dev/null +++ b/tests/camera-manager/utils/camera-entity-from-config.test.ts @@ -0,0 +1,23 @@ +import { describe, expect, it } from 'vitest'; +import { getCameraEntityFromConfig } from '../../../src/camera-manager/utils/camera-entity-from-config.js'; +import { CameraConfig, cameraConfigSchema } from '../../../src/config/types.js'; + +describe('getCameraEntityFromConfig', () => { + const createCameraConfig = (config: Partial): CameraConfig => { + return cameraConfigSchema.parse(config); + }; + + it('should get camera_entity', () => { + expect(getCameraEntityFromConfig(createCameraConfig({ camera_entity: 'foo' }))).toBe( + 'foo', + ); + }); + it('should get camera_entity from webrtc_card config', () => { + expect( + getCameraEntityFromConfig(createCameraConfig({ webrtc_card: { entity: 'bar' } })), + ).toBe('bar'); + }); + it('should get no camera_entity', () => { + expect(getCameraEntityFromConfig(createCameraConfig({}))).toBeNull(); + }); +}); diff --git a/tests/camera-manager/utils/cap-end-date.test.ts b/tests/camera-manager/utils/cap-end-date.test.ts new file mode 100644 index 00000000..5976f8c1 --- /dev/null +++ b/tests/camera-manager/utils/cap-end-date.test.ts @@ -0,0 +1,24 @@ +import { describe, expect, it, vi } from 'vitest'; +import { capEndDate } from '../../../src/camera-manager/utils/cap-end-date'; + +describe('capEndDate', () => { + it('should cap end date', () => { + const fakeNow = new Date('2023-04-29T14:25'); + vi.useFakeTimers(); + vi.setSystemTime(fakeNow); + + expect(capEndDate(new Date('2023-04-29T15:02'))).toEqual(fakeNow); + + vi.useRealTimers(); + }); + + it('should not cap end date', () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date('2023-04-29T14:25')); + + const testDate = new Date('2023-04-29T14:24'); + expect(capEndDate(testDate)).toEqual(testDate); + + vi.useRealTimers(); + }); +}); diff --git a/tests/camera-manager/utils/go2rtc-endpoint.test.ts b/tests/camera-manager/utils/go2rtc-endpoint.test.ts new file mode 100644 index 00000000..62d913fb --- /dev/null +++ b/tests/camera-manager/utils/go2rtc-endpoint.test.ts @@ -0,0 +1,41 @@ +import { describe, expect, it } from 'vitest'; +import { getDefaultGo2RTCEndpoint } from '../../../src/camera-manager/utils/go2rtc-endpoint.js'; +import { createCameraConfig } from '../../test-utils.js'; + +describe('getDefaultGo2RTCEndpoint', () => { + it('with local configuration', () => { + expect( + getDefaultGo2RTCEndpoint( + createCameraConfig({ + go2rtc: { + stream: 'stream', + url: '/local/path', + }, + }), + ), + ).toEqual({ + endpoint: '/local/path/api/ws?src=stream', + sign: true, + }); + }); + + it('with remote configuration', () => { + expect( + getDefaultGo2RTCEndpoint( + createCameraConfig({ + go2rtc: { + stream: 'stream', + url: 'https://my-custom-go2rtc', + }, + }), + ), + ).toEqual({ + endpoint: 'https://my-custom-go2rtc/api/ws?src=stream', + sign: false, + }); + }); + + it('without configuration', () => { + expect(getDefaultGo2RTCEndpoint(createCameraConfig())).toBeNull(); + }); +}); diff --git a/tests/camera-manager/utils/range-to-cache-friendly.test.ts b/tests/camera-manager/utils/range-to-cache-friendly.test.ts new file mode 100644 index 00000000..0e87b064 --- /dev/null +++ b/tests/camera-manager/utils/range-to-cache-friendly.test.ts @@ -0,0 +1,46 @@ +import { describe, expect, it, vi } from 'vitest'; +import { convertRangeToCacheFriendlyTimes } from '../../../src/camera-manager/utils/range-to-cache-friendly'; + +describe('convertRangeToCacheFriendlyTimes', () => { + it('should return cache friendly within hour range', () => { + expect( + convertRangeToCacheFriendlyTimes({ + start: new Date('2023-04-29T14:01:02'), + end: new Date('2023-04-29T14:11:03'), + }), + ).toEqual({ + start: new Date('2023-04-29T14:00:00'), + end: new Date('2023-04-29T14:59:59.999'), + }); + }); + + it('should return cache friendly within day range', () => { + expect( + convertRangeToCacheFriendlyTimes({ + start: new Date('2023-04-29T14:01:02'), + end: new Date('2023-04-29T15:11:03'), + }), + ).toEqual({ + start: new Date('2023-04-29T00:00:00'), + end: new Date('2023-04-29T23:59:59.999'), + }); + }); + + it('should cap end date', () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date('2023-04-29T14:25')); + expect( + convertRangeToCacheFriendlyTimes( + { + start: new Date('2023-04-29T14:01:02'), + end: new Date('2023-04-29T14:11:03'), + }, + { endCap: true }, + ), + ).toEqual({ + start: new Date('2023-04-29T14:00:00'), + end: new Date('2023-04-29T14:25:59.999'), + }); + vi.useRealTimers(); + }); +}); diff --git a/tests/camera-manager/utils/sort-media.test.ts b/tests/camera-manager/utils/sort-media.test.ts new file mode 100644 index 00000000..f6cb8f80 --- /dev/null +++ b/tests/camera-manager/utils/sort-media.test.ts @@ -0,0 +1,57 @@ +import { describe, expect, it } from 'vitest'; +import { sortMedia } from '../../../src/camera-manager/utils/sort-media.js'; +import { TestViewMedia } from '../../test-utils.js'; + +describe('sortMedia', () => { + const media_1 = new TestViewMedia({ + id: 'id-1', + startTime: new Date('2023-04-29T14:25'), + cameraID: 'camera-1', + }); + const media_2 = new TestViewMedia({ + id: 'id-2', + startTime: new Date('2023-04-29T14:26'), + cameraID: 'camera-1', + }); + const media_3_dup_id = new TestViewMedia({ + id: 'id-2', + startTime: new Date('2023-04-29T14:26'), + cameraID: 'camera-1', + }); + const media_4_no_id = new TestViewMedia({ + id: null, + startTime: new Date('2023-04-29T14:27'), + cameraID: 'camera-1', + }); + + it('should sort sorted media', () => { + const media = [media_1, media_2]; + expect(sortMedia(media)).toEqual(media); + }); + it('should sort unsorted media', () => { + expect(sortMedia([media_2, media_1])).toEqual([media_1, media_2]); + }); + it('should remove duplicate id', () => { + expect(sortMedia([media_1, media_2, media_3_dup_id])).toEqual([media_1, media_2]); + }); + it('should sort by id when time not available', () => { + expect( + sortMedia([ + new TestViewMedia({ id: 'snake' }), + new TestViewMedia({ id: 'zebra' }), + new TestViewMedia({ id: 'aardvark' }), + ]), + ).toEqual([ + new TestViewMedia({ id: 'aardvark' }), + new TestViewMedia({ id: 'snake' }), + new TestViewMedia({ id: 'zebra' }), + ]); + }); + it('should remove de-duplicate by object if no id', () => { + expect(sortMedia([media_1, media_2, media_4_no_id, media_4_no_id])).toEqual([ + media_1, + media_2, + media_4_no_id, + ]); + }); +}); diff --git a/tests/card-controller/view-manager.test.ts b/tests/card-controller/view-manager.test.ts index 738b5919..9fd7b5d4 100644 --- a/tests/card-controller/view-manager.test.ts +++ b/tests/card-controller/view-manager.test.ts @@ -7,10 +7,9 @@ import { EventMediaQueries } from '../../src/view/media-queries'; import { MediaQueriesResults } from '../../src/view/media-queries-results'; import { View } from '../../src/view/view'; import { - createAggregateCameraCapabilities, - createCameraCapabilities, createCameraConfig, createCameraManager, + createCapabilities, createCardAPI, createConfig, createHASS, @@ -120,6 +119,7 @@ describe('ViewManager.setViewDefault', () => { createStore([ { cameraID: 'camera', + capabilities: createCapabilities({ live: true }), }, ]), ); @@ -150,9 +150,11 @@ describe('ViewManager.setViewDefault', () => { createStore([ { cameraID: 'camera_1', + capabilities: createCapabilities({ live: true }), }, { cameraID: 'camera_2', + capabilities: createCapabilities({ live: true }), }, ]), ); @@ -186,9 +188,11 @@ describe('ViewManager.setViewDefault', () => { createStore([ { cameraID: 'camera.kitchen', + capabilities: createCapabilities({ live: true }), }, { cameraID: 'camera.office', + capabilities: createCapabilities({ live: true }), }, ]), ); @@ -216,12 +220,13 @@ describe('ViewManager.setViewByParameters', () => { createStore([ { cameraID: 'camera.kitchen', + capabilities: createCapabilities({ clips: true }), }, ]), ); vi.mocked(api.getCameraManager().getAggregateCameraCapabilities).mockReturnValue( - createAggregateCameraCapabilities({ - supportsClips: true, + createCapabilities({ + clips: true, }), ); @@ -242,15 +247,17 @@ describe('ViewManager.setViewByParameters', () => { createStore([ { cameraID: 'camera.kitchen', + capabilities: createCapabilities({ clips: true }), }, { cameraID: 'camera.office', + capabilities: createCapabilities({ clips: true }), }, ]), ); vi.mocked(api.getCameraManager().getAggregateCameraCapabilities).mockReturnValue( - createAggregateCameraCapabilities({ - supportsClips: true, + createCapabilities({ + clips: true, }), ); vi.mocked(api.getConfigManager().getConfig).mockReturnValue(createConfig()); @@ -276,6 +283,7 @@ describe('ViewManager.setViewByParameters', () => { createStore([ { cameraID: 'camera.kitchen', + capabilities: createCapabilities({ live: true }), }, ]), ); @@ -302,49 +310,102 @@ describe('ViewManager.setViewByParameters', () => { expect(manager.getView()).toBeNull(); }); - it('should not set view by parameters without visible cameras', () => { - const api = createCardAPI(); - vi.mocked(api.getCameraManager).mockReturnValue(createCameraManager()); - vi.mocked(api.getCameraManager().getStore).mockReturnValue( - createStore([ - { - cameraID: 'camera.kitchen', - config: createCameraConfig({ - hide: true, - }), - }, - ]), - ); - vi.mocked(api.getConfigManager().getConfig).mockReturnValue(createConfig()); - - const manager = new ViewManager(api); - manager.setViewByParameters({ - viewName: 'live', - }); - - expect(manager.getView()).toBeNull(); - }); - describe('should handle unsupported view', () => { - it('without failsafe', () => { + it('without camera without failsafe', () => { const api = createCardAPI(); vi.mocked(api.getCameraManager).mockReturnValue(createCameraManager()); vi.mocked(api.getCameraManager().getStore).mockReturnValue( createStore([ { cameraID: 'camera.kitchen', + capabilities: createCapabilities({ snapshots: false }), + }, + ]), + ); + vi.mocked(api.getConfigManager().getConfig).mockReturnValue(createConfig()); + + const manager = new ViewManager(api); + manager.setViewByParameters({ + // Since no camera is specified, and no camera supports the capabilities + // necessary for this view, the view will be null. + viewName: 'snapshots', + }); + + expect(manager.getView()).toBeNull(); + }); + + it('without camera with failsafe', () => { + const api = createCardAPI(); + vi.mocked(api.getCameraManager).mockReturnValue(createCameraManager()); + vi.mocked(api.getCameraManager().getStore).mockReturnValue( + createStore([ + { + cameraID: 'camera.kitchen', + capabilities: createCapabilities({ snapshots: false }), + }, + { + cameraID: 'camera.office', + // No capabilities. + capabilities: null, + }, + ]), + ); + vi.mocked(api.getConfigManager().getConfig).mockReturnValue(createConfig()); + + const manager = new ViewManager(api); + manager.setViewByParameters({ + // Since no camera is specified, and no camera supports the capabilities + // necessary for this view, and since failSafe is specified, an error + // will be shown. + viewName: 'snapshots', + failSafe: true, + }); + + expect(manager.getView()).toBeNull(); + expect(manager.hasView()).toBeFalsy(); + expect(api.getMessageManager().setMessageIfHigherPriority).toBeCalledWith( + expect.objectContaining({ + type: 'error', + message: 'No cameras support this view', + context: { + view: 'snapshots', + cameras_capabilities: { + 'camera.kitchen': { + 'favorite-events': false, + 'favorite-recordings': false, + clips: false, + live: false, + recordings: false, + seek: false, + snapshots: false, + }, + }, + }, + }), + ); + }); + + it('with camera without failsafe', () => { + const api = createCardAPI(); + vi.mocked(api.getCameraManager).mockReturnValue(createCameraManager()); + vi.mocked(api.getCameraManager().getStore).mockReturnValue( + createStore([ + { + cameraID: 'camera.kitchen', + capabilities: createCapabilities({ snapshots: false }), }, ]), ); vi.mocked(api.getConfigManager().getConfig).mockReturnValue(createConfig()); vi.mocked(api.getCameraManager().getAggregateCameraCapabilities).mockReturnValue( - createAggregateCameraCapabilities({ - supportsSnapshots: false, + createCapabilities({ + snapshots: false, }), ); const manager = new ViewManager(api); manager.setViewByParameters({ + cameraID: 'camera.kitchen', viewName: 'snapshots', }); @@ -352,25 +413,28 @@ describe('ViewManager.setViewByParameters', () => { expect(manager.getView()).toBeNull(); }); - it('with failsafe', () => { + it('with camera with failsafe', () => { const api = createCardAPI(); vi.mocked(api.getCameraManager).mockReturnValue(createCameraManager()); vi.mocked(api.getCameraManager().getStore).mockReturnValue( createStore([ { cameraID: 'camera.kitchen', + capabilities: createCapabilities({ snapshots: false, live: true }), }, ]), ); vi.mocked(api.getConfigManager().getConfig).mockReturnValue(createConfig()); vi.mocked(api.getCameraManager().getAggregateCameraCapabilities).mockReturnValue( - createAggregateCameraCapabilities({ - supportsSnapshots: false, + createCapabilities({ + snapshots: false, + live: true, }), ); const manager = new ViewManager(api); manager.setViewByParameters({ + cameraID: 'camera.kitchen', viewName: 'snapshots', failSafe: true, }); @@ -378,6 +442,55 @@ describe('ViewManager.setViewByParameters', () => { expect(manager.hasView()).toBeTruthy(); expect(manager.getView()?.view).toBe('live'); }); + + it('with camera with failsafe when live unsupported', () => { + const api = createCardAPI(); + vi.mocked(api.getCameraManager).mockReturnValue(createCameraManager()); + vi.mocked(api.getCameraManager().getStore).mockReturnValue( + createStore([ + { + cameraID: 'camera.kitchen', + capabilities: createCapabilities({ snapshots: false, live: false }), + }, + ]), + ); + vi.mocked(api.getConfigManager().getConfig).mockReturnValue(createConfig()); + vi.mocked(api.getCameraManager().getAggregateCameraCapabilities).mockReturnValue( + createCapabilities({ + snapshots: false, + live: false, + }), + ); + + const manager = new ViewManager(api); + manager.setViewByParameters({ + cameraID: 'camera.kitchen', + viewName: 'snapshots', + failSafe: true, + }); + + expect(manager.hasView()).toBeFalsy(); + expect(manager.getView()).toBeNull(); + expect(api.getMessageManager().setMessageIfHigherPriority).toBeCalledWith( + expect.objectContaining({ + type: 'error', + message: 'The selected camera does not support this view', + context: { + view: 'snapshots', + camera: 'camera.kitchen', + camera_capabilities: { + 'favorite-events': false, + 'favorite-recordings': false, + clips: false, + live: false, + recordings: false, + seek: false, + snapshots: false, + }, + }, + }), + ); + }); }); describe('should set view by parameters and respect display mode in config for view', () => { @@ -394,14 +507,21 @@ describe('ViewManager.setViewByParameters', () => { createStore([ { cameraID: 'camera.kitchen', + capabilities: createCapabilities({ + live: true, + clips: true, + recordings: true, + snapshots: true, + }), }, ]), ); vi.mocked(api.getCameraManager().getAggregateCameraCapabilities).mockReturnValue( - createAggregateCameraCapabilities({ - supportsClips: true, - supportsRecordings: true, - supportsSnapshots: true, + createCapabilities({ + live: true, + clips: true, + recordings: true, + snapshots: true, }), ); @@ -445,14 +565,21 @@ describe('ViewManager.setViewByParameters', () => { createStore([ { cameraID: 'camera.kitchen', + capabilities: createCapabilities({ + live: true, + clips: true, + recordings: true, + snapshots: true, + }), }, ]), ); vi.mocked(api.getCameraManager().getAggregateCameraCapabilities).mockReturnValue( - createAggregateCameraCapabilities({ - supportsClips: true, - supportsRecordings: true, - supportsSnapshots: true, + createCapabilities({ + live: true, + clips: true, + recordings: true, + snapshots: true, }), ); @@ -502,18 +629,18 @@ describe('ViewManager.setViewWithNewDisplayMode', () => { createStore([ { cameraID: 'camera.kitchen', - capabilities: createCameraCapabilities({ - supportsClips: true, - supportsRecordings: true, - supportsSnapshots: true, + capabilities: createCapabilities({ + clips: true, + recordings: true, + snapshots: true, }), }, { cameraID: 'camera.office', - capabilities: createCameraCapabilities({ - supportsClips: true, - supportsRecordings: true, - supportsSnapshots: true, + capabilities: createCapabilities({ + clips: true, + recordings: true, + snapshots: true, }), }, ]), @@ -556,18 +683,18 @@ describe('ViewManager.setViewWithNewDisplayMode', () => { createStore([ { cameraID: 'camera.kitchen', - capabilities: createCameraCapabilities({ - supportsClips: true, - supportsRecordings: true, - supportsSnapshots: true, + capabilities: createCapabilities({ + clips: true, + recordings: true, + snapshots: true, }), }, { cameraID: 'camera.office', - capabilities: createCameraCapabilities({ - supportsClips: true, - supportsRecordings: true, - supportsSnapshots: true, + capabilities: createCapabilities({ + clips: true, + recordings: true, + snapshots: true, }), }, ]), @@ -614,18 +741,18 @@ describe('ViewManager.setViewWithNewDisplayMode', () => { createStore([ { cameraID: 'camera.kitchen', - capabilities: createCameraCapabilities({ - supportsClips: true, - supportsRecordings: true, - supportsSnapshots: true, + capabilities: createCapabilities({ + clips: true, + recordings: true, + snapshots: true, }), }, { cameraID: 'camera.office', - capabilities: createCameraCapabilities({ - supportsClips: true, - supportsRecordings: true, - supportsSnapshots: true, + capabilities: createCapabilities({ + clips: true, + recordings: true, + snapshots: true, }), }, ]), @@ -662,18 +789,18 @@ describe('ViewManager.setViewWithNewDisplayMode', () => { createStore([ { cameraID: 'camera.kitchen', - capabilities: createCameraCapabilities({ - supportsClips: true, - supportsRecordings: true, - supportsSnapshots: true, + capabilities: createCapabilities({ + clips: true, + recordings: true, + snapshots: true, }), }, { cameraID: 'camera.office', - capabilities: createCameraCapabilities({ - supportsClips: true, - supportsRecordings: true, - supportsSnapshots: true, + capabilities: createCapabilities({ + clips: true, + recordings: true, + snapshots: true, }), }, ]), @@ -873,7 +1000,7 @@ describe('ViewManager.setViewWithSubstream', () => { describe('ViewManager.isViewSupportedByCamera', () => { it.each([ - ['live' as const, true], + ['live' as const, false], ['image' as const, true], ['diagnostics' as const, true], ['clip' as const, false], @@ -891,15 +1018,15 @@ describe('ViewManager.isViewSupportedByCamera', () => { createStore([ { cameraID: 'camera.kitchen', - capabilities: { - canFavoriteEvents: false, - canFavoriteRecordings: false, - canSeek: false, - supportsClips: false, - supportsRecordings: false, - supportsSnapshots: false, - supportsTimeline: false, - }, + capabilities: createCapabilities({ + live: false, + 'favorite-events': false, + 'favorite-recordings': false, + seek: false, + clips: false, + recordings: false, + snapshots: false, + }), }, ]), ); diff --git a/tests/components-lib/media-filter-controller.test.ts b/tests/components-lib/media-filter-controller.test.ts index 3ade7447..e88052e3 100644 --- a/tests/components-lib/media-filter-controller.test.ts +++ b/tests/components-lib/media-filter-controller.test.ts @@ -3,6 +3,7 @@ import startOfDay from 'date-fns/startOfDay'; import sub from 'date-fns/sub'; import { LitElement } from 'lit'; import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; +import { Capabilities } from '../../src/camera-manager/capabilities'; import { CameraManagerStore } from '../../src/camera-manager/store'; import { QueryType } from '../../src/camera-manager/types'; import { @@ -10,21 +11,21 @@ import { MediaFilterCoreDefaults, MediaFilterCoreFavoriteSelection, MediaFilterCoreWhen, - MediaFilterMediaType + MediaFilterMediaType, } from '../../src/components-lib/media-filter-controller'; import { executeMediaQueryForViewWithErrorDispatching } from '../../src/utils/media-to-view'; import { EventMediaQueries, MediaQueries, - RecordingMediaQueries + RecordingMediaQueries, } from '../../src/view/media-queries'; import { - createAggregateCameraCapabilities, createCameraConfig, createCameraManager, + createCapabilities, createPerformanceConfig, createStore, - createView + createView, } from '../test-utils'; vi.mock('../../src/utils/media-to-view'); @@ -35,13 +36,22 @@ const createHost = (): LitElement => { return host; }; -const createCameraStore = (): CameraManagerStore => { +const createCameraStore = (options?: { + capabilities: Capabilities; +}): CameraManagerStore => { return createStore([ { cameraID: 'camera.kitchen', config: createCameraConfig({ camera_entity: 'camera.kitchen', }), + capabilities: + options?.capabilities ?? + new Capabilities({ + clips: true, + snapshots: true, + recordings: true, + }), }, ]); }; @@ -152,6 +162,27 @@ describe('MediaFilterController', () => { ]); }); + it('with camera that does not support media', () => { + const cameraManager = createCameraManager(); + vi.mocked(cameraManager.getCameraMetadata).mockReturnValue({ + title: 'Kitchen Camera', + icon: 'mdi:camera', + }); + vi.mocked(cameraManager.getStore).mockReturnValue( + createCameraStore({ + capabilities: new Capabilities({ + clips: false, + snapshots: false, + recordings: false, + }), + }), + ); + + const controller = new MediaFilterController(createHost()); + controller.computeCameraOptions(cameraManager); + expect(controller.getCameraOptions()).toEqual([]); + }); + it('without camera metadata', () => { const cameraManager = createCameraManager(); vi.mocked(cameraManager.getCameraMetadata).mockReturnValue(null); @@ -297,7 +328,7 @@ describe('MediaFilterController', () => { const view = createView({ query: new EventMediaQueries() }); const cameraManager = createCameraManager(); vi.mocked(cameraManager.getAggregateCameraCapabilities).mockReturnValue( - createAggregateCameraCapabilities({ canFavoriteEvents: true }), + createCapabilities({ 'favorite-events': true }), ); const controller = new MediaFilterController(createHost()); @@ -310,7 +341,7 @@ describe('MediaFilterController', () => { const view = createView({ query: new RecordingMediaQueries() }); const cameraManager = createCameraManager(); vi.mocked(cameraManager.getAggregateCameraCapabilities).mockReturnValue( - createAggregateCameraCapabilities({ canFavoriteRecordings: true }), + createCapabilities({ 'favorite-recordings': true }), ); const controller = new MediaFilterController(createHost()); @@ -915,7 +946,7 @@ describe('MediaFilterController', () => { }, ]), { - mediaType: MediaFilterMediaType.Recordings + mediaType: MediaFilterMediaType.Recordings, }, ], ])( diff --git a/tests/components-lib/menu-button-controller.test.ts b/tests/components-lib/menu-button-controller.test.ts index e72138f4..305233f5 100644 --- a/tests/components-lib/menu-button-controller.test.ts +++ b/tests/components-lib/menu-button-controller.test.ts @@ -18,7 +18,7 @@ import { ViewMedia } from '../../src/view/media'; import { MediaQueriesResults } from '../../src/view/media-queries-results'; import { View } from '../../src/view/view'; import { - createCameraCapabilities, + createCapabilities, createCameraConfig, createCameraManager, createCardAPI, @@ -108,7 +108,11 @@ describe('MenuButtonController', () => { it('with multiple cameras', () => { const cameraManager = createCameraManager(); vi.mocked(cameraManager.getStore).mockReturnValue( - createStore([{ cameraID: 'camera-1' }, { cameraID: 'camera-2' }]), + createStore([ + { cameraID: 'camera-1', capabilities: createCapabilities({ menu: true }) }, + { cameraID: 'camera-2', capabilities: createCapabilities({ menu: true }) }, + { cameraID: 'camera-3', capabilities: createCapabilities({ menu: false }) }, + ]), ); vi.mocked(cameraManager).getCameraMetadata.mockReturnValue({ title: 'title', @@ -181,7 +185,10 @@ describe('MenuButtonController', () => { cameraID: 'camera-1', config: createCameraConfig({ dependencies: { cameras: ['camera-2'] } }), }, - { cameraID: 'camera-2' }, + { + cameraID: 'camera-2', + capabilities: createCapabilities({ substream: true }), + }, ]), ); const buttons = calculateButtons(controller, { cameraManager: cameraManager }); @@ -208,7 +215,10 @@ describe('MenuButtonController', () => { cameraID: 'camera-1', config: createCameraConfig({ dependencies: { cameras: ['camera-2'] } }), }, - { cameraID: 'camera-2' }, + { + cameraID: 'camera-2', + capabilities: createCapabilities({ substream: true }), + }, ]), ); const view = createView({ @@ -248,18 +258,21 @@ describe('MenuButtonController', () => { camera_entity: 'camera.1', dependencies: { cameras: ['camera-2', 'camera-3'] }, }), + capabilities: createCapabilities({ substream: true }), }, { cameraID: 'camera-2', config: createCameraConfig({ camera_entity: 'camera.2', }), + capabilities: createCapabilities({ substream: true }), }, { cameraID: 'camera-3', config: createCameraConfig({ camera_entity: 'camera.3', }), + capabilities: createCapabilities({ substream: true }), }, ]), ); @@ -340,18 +353,21 @@ describe('MenuButtonController', () => { camera_entity: 'camera.1', dependencies: { cameras: ['camera-2', 'camera-3'] }, }), + capabilities: createCapabilities({ substream: true }), }, { cameraID: 'camera-2', config: createCameraConfig({ camera_entity: 'camera.2', }), + capabilities: createCapabilities({ substream: true }), }, { cameraID: 'camera-3', config: createCameraConfig({ camera_entity: 'camera.3', }), + capabilities: createCapabilities({ substream: true }), }, ]), ); @@ -1207,10 +1223,17 @@ describe('MenuButtonController', () => { it.each([['single' as const], ['grid' as const]])( '%s', (displayMode: ViewDisplayMode) => { - const view = createView({ view: 'live', displayMode: displayMode }); + const view = createView({ + camera: 'camera-1', + view: 'live', + displayMode: displayMode, + }); const cameraManager = createCameraManager(); vi.mocked(cameraManager.getStore).mockReturnValue( - createStore([{ cameraID: 'camera-1' }, { cameraID: 'camera-2' }]), + createStore([ + { cameraID: 'camera-1', capabilities: createCapabilities({ live: true }) }, + { cameraID: 'camera-2', capabilities: createCapabilities({ live: true }) }, + ]), ); expect( @@ -1239,7 +1262,7 @@ describe('MenuButtonController', () => { it('when the selected camera is not PTZ enabled', () => { const cameraManager = createCameraManager(); vi.mocked(cameraManager.getCameraCapabilities).mockReturnValue( - createCameraCapabilities(), + createCapabilities(), ); const buttons = calculateButtons(controller, { cameraManager: cameraManager }); @@ -1264,7 +1287,7 @@ describe('MenuButtonController', () => { it('when the selected camera is PTZ enabled', () => { const cameraManager = createCameraManager(); vi.mocked(cameraManager.getCameraCapabilities).mockReturnValue( - createCameraCapabilities({ ptz: {} }), + createCapabilities({ ptz: { panTilt: ['relative'] } }), ); const buttons = calculateButtons(controller, { cameraManager: cameraManager }); @@ -1288,7 +1311,7 @@ describe('MenuButtonController', () => { it('when the context has PTZ visiblity turned off', () => { const cameraManager = createCameraManager(); vi.mocked(cameraManager.getCameraCapabilities).mockReturnValue( - createCameraCapabilities({ ptz: {} }), + createCapabilities({ ptz: { panTilt: ['relative'] } }), ); const view = createView({ camera: 'camera-1', @@ -1328,9 +1351,9 @@ describe('MenuButtonController', () => { vi.mocked(cameraManager.getCameraCapabilities).mockImplementation( (cameraID: string) => { if (cameraID === 'camera-2') { - return createCameraCapabilities({ ptz: {} }); + return createCapabilities({ ptz: { panTilt: ['relative'] } }); } - return createCameraCapabilities(); + return createCapabilities(); }, ); const view = createView({ diff --git a/tests/components-lib/ptz-controller.test.ts b/tests/components-lib/ptz-controller.test.ts index 11f047a4..c64b792f 100644 --- a/tests/components-lib/ptz-controller.test.ts +++ b/tests/components-lib/ptz-controller.test.ts @@ -10,7 +10,7 @@ import { getActionConfigGivenAction, } from '../../src/utils/action.js'; import { - createCameraCapabilities, + createCapabilities, createCameraManager, createHASS, } from '../test-utils'; @@ -145,7 +145,7 @@ describe('PTZController', () => { const cameraManager = createCameraManager(); vi.mocked(cameraManager).getCameraCapabilities.mockReturnValue( - createCameraCapabilities({ + createCapabilities({ ptz: { panTilt: ['continuous'], zoom: ['continuous'], @@ -185,7 +185,7 @@ describe('PTZController', () => { const cameraManager = createCameraManager(); vi.mocked(cameraManager).getCameraCapabilities.mockReturnValue( - createCameraCapabilities({ + createCapabilities({ ptz: { panTilt: ['relative'], zoom: ['relative'], @@ -210,7 +210,7 @@ describe('PTZController', () => { const cameraManager = createCameraManager(); vi.mocked(cameraManager).getCameraCapabilities.mockReturnValue( - createCameraCapabilities({ + createCapabilities({ ptz: { presets: ['preset-foo'], }, @@ -237,7 +237,7 @@ describe('PTZController', () => { ['down' as const], ['zoom_in' as const], ['zoom_out' as const], - ])('%s', (actionName: PTZControlAction) => { + ])('configured %s', (actionName: PTZControlAction) => { const controller = new PTZController(document.createElement('div')); controller.setConfig( createConfig({ @@ -253,7 +253,7 @@ describe('PTZController', () => { }); }); - it('home', () => { + it('configured home', () => { const controller = new PTZController(document.createElement('div')); controller.setConfig( createConfig({ diff --git a/tests/config-mgmt.test.ts b/tests/config-mgmt.test.ts index 424821cf..3961f0e0 100644 --- a/tests/config-mgmt.test.ts +++ b/tests/config-mgmt.test.ts @@ -1804,245 +1804,9 @@ describe('should handle version specific upgrades', () => { ); }); }); - }); - describe('from condition object to condition array', () => { - describe('with view condition', () => { - it('elements', () => { - const config = { - type: 'custom:frigate-card', - cameras: [{ camera_entity: 'camera.office' }], - elements: [ - { - conditions: { - view: ['clips', 'snapshots'], - }, - }, - { - conditions: 'not an object', - }, - ], - }; - expect(upgradeConfig(config)).toBeTruthy(); - expect(config).toEqual({ - type: 'custom:frigate-card', - cameras: [{ camera_entity: 'camera.office' }], - elements: [ - { - conditions: [ - { - condition: 'view' as const, - views: ['clips', 'snapshots'], - }, - ], - }, - { - conditions: 'not an object', - }, - ], - }); - }); - - it('overrides', () => { - const config = { - type: 'custom:frigate-card', - cameras: [{ camera_entity: 'camera.office' }], - overrides: [ - { - conditions: { - view: ['clips', 'snapshots'], - }, - overrides: { - view: { - default: 'clips', - }, - }, - }, - ], - }; - - expect(upgradeConfig(config)).toBeTruthy(); - expect(config).toEqual({ - type: 'custom:frigate-card', - cameras: [{ camera_entity: 'camera.office' }], - overrides: [ - { - conditions: [ - { - condition: 'view' as const, - views: ['clips', 'snapshots'], - }, - ], - overrides: { - view: { - default: 'clips', - }, - }, - }, - ], - }); - }); - - it('automations', () => { - const config = { - type: 'custom:frigate-card', - cameras: [{ camera_entity: 'camera.office' }], - automations: [ - { - conditions: { - view: ['clips', 'snapshots'], - }, - actions: { - action: 'custom:frigate-card-action' as const, - frigate_card_action: 'live_substream_on' as const, - }, - }, - ], - }; - - expect(upgradeConfig(config)).toBeTruthy(); - expect(config).toEqual({ - type: 'custom:frigate-card', - cameras: [{ camera_entity: 'camera.office' }], - automations: [ - { - conditions: [ - { - condition: 'view' as const, - views: ['clips', 'snapshots'], - }, - ], - actions: { - action: 'custom:frigate-card-action' as const, - frigate_card_action: 'live_substream_on' as const, - }, - }, - ], - }); - }); - }); - - describe('with camera condition', () => { - it('elements', () => { - const config = { - type: 'custom:frigate-card', - cameras: [{ camera_entity: 'camera.office' }], - elements: [ - { - conditions: { - camera: ['camera_1', 'camera_2'], - }, - }, - { - conditions: 'not an object', - }, - ], - }; - expect(upgradeConfig(config)).toBeTruthy(); - expect(config).toEqual({ - type: 'custom:frigate-card', - cameras: [{ camera_entity: 'camera.office' }], - elements: [ - { - conditions: [ - { - condition: 'camera' as const, - cameras: ['camera_1', 'camera_2'], - }, - ], - }, - { - conditions: 'not an object', - }, - ], - }); - }); - - it('overrides', () => { - const config = { - type: 'custom:frigate-card', - cameras: [{ camera_entity: 'camera.office' }], - overrides: [ - { - conditions: { - camera: ['camera_1', 'camera_2'], - }, - overrides: { - view: { - default: 'clips', - }, - }, - }, - ], - }; - - expect(upgradeConfig(config)).toBeTruthy(); - expect(config).toEqual({ - type: 'custom:frigate-card', - cameras: [{ camera_entity: 'camera.office' }], - overrides: [ - { - conditions: [ - { - condition: 'camera' as const, - cameras: ['camera_1', 'camera_2'], - }, - ], - overrides: { - view: { - default: 'clips', - }, - }, - }, - ], - }); - }); - - it('automations', () => { - const config = { - type: 'custom:frigate-card', - cameras: [{ camera_entity: 'camera.office' }], - automations: [ - { - conditions: { - camera: ['camera_1', 'camera_2'], - }, - actions: { - action: 'custom:frigate-card-action' as const, - frigate_card_action: 'live_substream_on' as const, - }, - }, - ], - }; - - expect(upgradeConfig(config)).toBeTruthy(); - expect(config).toEqual({ - type: 'custom:frigate-card', - cameras: [{ camera_entity: 'camera.office' }], - automations: [ - { - conditions: [ - { - condition: 'camera' as const, - cameras: ['camera_1', 'camera_2'], - }, - ], - actions: { - action: 'custom:frigate-card-action' as const, - frigate_card_action: 'live_substream_on' as const, - }, - }, - ], - }); - }); - }); - - describe('with boolean conditions', () => { - describe.each([ - ['fullscreen' as const], - ['expand' as const], - ['media_loaded' as const], - ])('%s', (condition: string) => { + describe('from condition object to condition array', () => { + describe('with view condition', () => { it('elements', () => { const config = { type: 'custom:frigate-card', @@ -2050,7 +1814,7 @@ describe('should handle version specific upgrades', () => { elements: [ { conditions: { - [condition]: true, + view: ['clips', 'snapshots'], }, }, { @@ -2066,8 +1830,8 @@ describe('should handle version specific upgrades', () => { { conditions: [ { - condition: condition, - [condition]: true, + condition: 'view' as const, + views: ['clips', 'snapshots'], }, ], }, @@ -2085,7 +1849,7 @@ describe('should handle version specific upgrades', () => { overrides: [ { conditions: { - [condition]: true, + view: ['clips', 'snapshots'], }, overrides: { view: { @@ -2104,8 +1868,8 @@ describe('should handle version specific upgrades', () => { { conditions: [ { - condition: condition, - [condition]: true, + condition: 'view' as const, + views: ['clips', 'snapshots'], }, ], overrides: { @@ -2125,7 +1889,7 @@ describe('should handle version specific upgrades', () => { automations: [ { conditions: { - [condition]: true, + view: ['clips', 'snapshots'], }, actions: { action: 'custom:frigate-card-action' as const, @@ -2143,9 +1907,524 @@ describe('should handle version specific upgrades', () => { { conditions: [ { - condition: condition, + condition: 'view' as const, + views: ['clips', 'snapshots'], + }, + ], + actions: { + action: 'custom:frigate-card-action' as const, + frigate_card_action: 'live_substream_on' as const, + }, + }, + ], + }); + }); + }); + + describe('with camera condition', () => { + it('elements', () => { + const config = { + type: 'custom:frigate-card', + cameras: [{ camera_entity: 'camera.office' }], + elements: [ + { + conditions: { + camera: ['camera_1', 'camera_2'], + }, + }, + { + conditions: 'not an object', + }, + ], + }; + expect(upgradeConfig(config)).toBeTruthy(); + expect(config).toEqual({ + type: 'custom:frigate-card', + cameras: [{ camera_entity: 'camera.office' }], + elements: [ + { + conditions: [ + { + condition: 'camera' as const, + cameras: ['camera_1', 'camera_2'], + }, + ], + }, + { + conditions: 'not an object', + }, + ], + }); + }); + + it('overrides', () => { + const config = { + type: 'custom:frigate-card', + cameras: [{ camera_entity: 'camera.office' }], + overrides: [ + { + conditions: { + camera: ['camera_1', 'camera_2'], + }, + overrides: { + view: { + default: 'clips', + }, + }, + }, + ], + }; + + expect(upgradeConfig(config)).toBeTruthy(); + expect(config).toEqual({ + type: 'custom:frigate-card', + cameras: [{ camera_entity: 'camera.office' }], + overrides: [ + { + conditions: [ + { + condition: 'camera' as const, + cameras: ['camera_1', 'camera_2'], + }, + ], + overrides: { + view: { + default: 'clips', + }, + }, + }, + ], + }); + }); + + it('automations', () => { + const config = { + type: 'custom:frigate-card', + cameras: [{ camera_entity: 'camera.office' }], + automations: [ + { + conditions: { + camera: ['camera_1', 'camera_2'], + }, + actions: { + action: 'custom:frigate-card-action' as const, + frigate_card_action: 'live_substream_on' as const, + }, + }, + ], + }; + + expect(upgradeConfig(config)).toBeTruthy(); + expect(config).toEqual({ + type: 'custom:frigate-card', + cameras: [{ camera_entity: 'camera.office' }], + automations: [ + { + conditions: [ + { + condition: 'camera' as const, + cameras: ['camera_1', 'camera_2'], + }, + ], + actions: { + action: 'custom:frigate-card-action' as const, + frigate_card_action: 'live_substream_on' as const, + }, + }, + ], + }); + }); + }); + + describe('with boolean conditions', () => { + describe.each([ + ['fullscreen' as const], + ['expand' as const], + ['media_loaded' as const], + ])('%s', (condition: string) => { + it('elements', () => { + const config = { + type: 'custom:frigate-card', + cameras: [{ camera_entity: 'camera.office' }], + elements: [ + { + conditions: { [condition]: true, }, + }, + { + conditions: 'not an object', + }, + ], + }; + expect(upgradeConfig(config)).toBeTruthy(); + expect(config).toEqual({ + type: 'custom:frigate-card', + cameras: [{ camera_entity: 'camera.office' }], + elements: [ + { + conditions: [ + { + condition: condition, + [condition]: true, + }, + ], + }, + { + conditions: 'not an object', + }, + ], + }); + }); + + it('overrides', () => { + const config = { + type: 'custom:frigate-card', + cameras: [{ camera_entity: 'camera.office' }], + overrides: [ + { + conditions: { + [condition]: true, + }, + overrides: { + view: { + default: 'clips', + }, + }, + }, + ], + }; + + expect(upgradeConfig(config)).toBeTruthy(); + expect(config).toEqual({ + type: 'custom:frigate-card', + cameras: [{ camera_entity: 'camera.office' }], + overrides: [ + { + conditions: [ + { + condition: condition, + [condition]: true, + }, + ], + overrides: { + view: { + default: 'clips', + }, + }, + }, + ], + }); + }); + + it('automations', () => { + const config = { + type: 'custom:frigate-card', + cameras: [{ camera_entity: 'camera.office' }], + automations: [ + { + conditions: { + [condition]: true, + }, + actions: { + action: 'custom:frigate-card-action' as const, + frigate_card_action: 'live_substream_on' as const, + }, + }, + ], + }; + + expect(upgradeConfig(config)).toBeTruthy(); + expect(config).toEqual({ + type: 'custom:frigate-card', + cameras: [{ camera_entity: 'camera.office' }], + automations: [ + { + conditions: [ + { + condition: condition, + [condition]: true, + }, + ], + actions: { + action: 'custom:frigate-card-action' as const, + frigate_card_action: 'live_substream_on' as const, + }, + }, + ], + }); + }); + }); + }); + + describe('with state condition', () => { + it('elements', () => { + const config = { + type: 'custom:frigate-card', + cameras: [{ camera_entity: 'camera.office' }], + elements: [ + { + conditions: { + state: [ + { + entity: 'binary_sensor.first', + state: 'on', + }, + { + entity: 'binary_sensor.second', + state_not: 'off', + }, + {}, + ], + }, + }, + { + conditions: 'not an object', + }, + ], + }; + expect(upgradeConfig(config)).toBeTruthy(); + expect(config).toEqual({ + type: 'custom:frigate-card', + cameras: [{ camera_entity: 'camera.office' }], + elements: [ + { + conditions: [ + { + condition: 'state' as const, + entity: 'binary_sensor.first', + state: 'on', + }, + { + condition: 'state' as const, + entity: 'binary_sensor.second', + state_not: 'off', + }, + ], + }, + { + conditions: 'not an object', + }, + ], + }); + }); + + it('overrides', () => { + const config = { + type: 'custom:frigate-card', + cameras: [{ camera_entity: 'camera.office' }], + overrides: [ + { + conditions: { + state: [ + { + entity: 'binary_sensor.first', + state: 'on', + }, + { + entity: 'binary_sensor.second', + state_not: 'off', + }, + {}, + ], + }, + overrides: { + view: { + default: 'clips', + }, + }, + }, + ], + }; + + expect(upgradeConfig(config)).toBeTruthy(); + expect(config).toEqual({ + type: 'custom:frigate-card', + cameras: [{ camera_entity: 'camera.office' }], + overrides: [ + { + conditions: [ + { + condition: 'state' as const, + entity: 'binary_sensor.first', + state: 'on', + }, + { + condition: 'state' as const, + entity: 'binary_sensor.second', + state_not: 'off', + }, + ], + overrides: { + view: { + default: 'clips', + }, + }, + }, + ], + }); + }); + + it('automations', () => { + const config = { + type: 'custom:frigate-card', + cameras: [{ camera_entity: 'camera.office' }], + automations: [ + { + conditions: { + state: [ + { + entity: 'binary_sensor.first', + state: 'on', + }, + { + entity: 'binary_sensor.second', + state_not: 'off', + }, + {}, + ], + }, + + actions: { + action: 'custom:frigate-card-action' as const, + frigate_card_action: 'live_substream_on' as const, + }, + }, + ], + }; + + expect(upgradeConfig(config)).toBeTruthy(); + expect(config).toEqual({ + type: 'custom:frigate-card', + cameras: [{ camera_entity: 'camera.office' }], + automations: [ + { + conditions: [ + { + condition: 'state' as const, + entity: 'binary_sensor.first', + state: 'on', + }, + { + condition: 'state' as const, + entity: 'binary_sensor.second', + state_not: 'off', + }, + ], + actions: { + action: 'custom:frigate-card-action' as const, + frigate_card_action: 'live_substream_on' as const, + }, + }, + ], + }); + }); + }); + + describe('with media query condition', () => { + it('elements', () => { + const config = { + type: 'custom:frigate-card', + cameras: [{ camera_entity: 'camera.office' }], + elements: [ + { + conditions: { + media_query: 'query', + }, + }, + { + conditions: 'not an object', + }, + ], + }; + expect(upgradeConfig(config)).toBeTruthy(); + expect(config).toEqual({ + type: 'custom:frigate-card', + cameras: [{ camera_entity: 'camera.office' }], + elements: [ + { + conditions: [ + { + condition: 'screen' as const, + media_query: 'query', + }, + ], + }, + { + conditions: 'not an object', + }, + ], + }); + }); + + it('overrides', () => { + const config = { + type: 'custom:frigate-card', + cameras: [{ camera_entity: 'camera.office' }], + overrides: [ + { + conditions: { + media_query: 'query', + }, + overrides: { + view: { + default: 'clips', + }, + }, + }, + ], + }; + + expect(upgradeConfig(config)).toBeTruthy(); + expect(config).toEqual({ + type: 'custom:frigate-card', + cameras: [{ camera_entity: 'camera.office' }], + overrides: [ + { + conditions: [ + { + condition: 'screen' as const, + media_query: 'query', + }, + ], + overrides: { + view: { + default: 'clips', + }, + }, + }, + ], + }); + }); + + it('automations', () => { + const config = { + type: 'custom:frigate-card', + cameras: [{ camera_entity: 'camera.office' }], + automations: [ + { + conditions: { + media_query: 'query', + }, + actions: { + action: 'custom:frigate-card-action' as const, + frigate_card_action: 'live_substream_on' as const, + }, + }, + ], + }; + + expect(upgradeConfig(config)).toBeTruthy(); + expect(config).toEqual({ + type: 'custom:frigate-card', + cameras: [{ camera_entity: 'camera.office' }], + automations: [ + { + conditions: [ + { + condition: 'screen' as const, + media_query: 'query', + }, ], actions: { action: 'custom:frigate-card-action' as const, @@ -2158,280 +2437,25 @@ describe('should handle version specific upgrades', () => { }); }); - describe('with state condition', () => { - it('elements', () => { - const config = { - type: 'custom:frigate-card', - cameras: [{ camera_entity: 'camera.office' }], - elements: [ - { - conditions: { - state: [ - { - entity: 'binary_sensor.first', - state: 'on', - }, - { - entity: 'binary_sensor.second', - state_not: 'off', - }, - {}, - ], - }, - }, - { - conditions: 'not an object', - }, - ], - }; - expect(upgradeConfig(config)).toBeTruthy(); - expect(config).toEqual({ - type: 'custom:frigate-card', - cameras: [{ camera_entity: 'camera.office' }], - elements: [ - { - conditions: [ - { - condition: 'state' as const, - entity: 'binary_sensor.first', - state: 'on', - }, - { - condition: 'state' as const, - entity: 'binary_sensor.second', - state_not: 'off', - }, - ], - }, - { - conditions: 'not an object', - }, - ], - }); - }); - + describe('from hide to substream capability disable', () => { it('overrides', () => { const config = { type: 'custom:frigate-card', - cameras: [{ camera_entity: 'camera.office' }], - overrides: [ - { - conditions: { - state: [ - { - entity: 'binary_sensor.first', - state: 'on', - }, - { - entity: 'binary_sensor.second', - state_not: 'off', - }, - {}, - ], - }, - overrides: { - view: { - default: 'clips', - }, - }, - }, + cameras: [ + { camera_entity: 'camera.office', hide: true }, + { camera_entity: 'camera.sitting_room', hide: false }, ], }; expect(upgradeConfig(config)).toBeTruthy(); expect(config).toEqual({ type: 'custom:frigate-card', - cameras: [{ camera_entity: 'camera.office' }], - overrides: [ + cameras: [ { - conditions: [ - { - condition: 'state' as const, - entity: 'binary_sensor.first', - state: 'on', - }, - { - condition: 'state' as const, - entity: 'binary_sensor.second', - state_not: 'off', - }, - ], - overrides: { - view: { - default: 'clips', - }, - }, - }, - ], - }); - }); - - it('automations', () => { - const config = { - type: 'custom:frigate-card', - cameras: [{ camera_entity: 'camera.office' }], - automations: [ - { - conditions: { - state: [ - { - entity: 'binary_sensor.first', - state: 'on', - }, - { - entity: 'binary_sensor.second', - state_not: 'off', - }, - {}, - ], - }, - - actions: { - action: 'custom:frigate-card-action' as const, - frigate_card_action: 'live_substream_on' as const, - }, - }, - ], - }; - - expect(upgradeConfig(config)).toBeTruthy(); - expect(config).toEqual({ - type: 'custom:frigate-card', - cameras: [{ camera_entity: 'camera.office' }], - automations: [ - { - conditions: [ - { - condition: 'state' as const, - entity: 'binary_sensor.first', - state: 'on', - }, - { - condition: 'state' as const, - entity: 'binary_sensor.second', - state_not: 'off', - }, - ], - actions: { - action: 'custom:frigate-card-action' as const, - frigate_card_action: 'live_substream_on' as const, - }, - }, - ], - }); - }); - }); - - describe('with media query condition', () => { - it('elements', () => { - const config = { - type: 'custom:frigate-card', - cameras: [{ camera_entity: 'camera.office' }], - elements: [ - { - conditions: { - media_query: 'query', - }, - }, - { - conditions: 'not an object', - }, - ], - }; - expect(upgradeConfig(config)).toBeTruthy(); - expect(config).toEqual({ - type: 'custom:frigate-card', - cameras: [{ camera_entity: 'camera.office' }], - elements: [ - { - conditions: [ - { - condition: 'screen' as const, - media_query: 'query', - }, - ], - }, - { - conditions: 'not an object', - }, - ], - }); - }); - - it('overrides', () => { - const config = { - type: 'custom:frigate-card', - cameras: [{ camera_entity: 'camera.office' }], - overrides: [ - { - conditions: { - media_query: 'query', - }, - overrides: { - view: { - default: 'clips', - }, - }, - }, - ], - }; - - expect(upgradeConfig(config)).toBeTruthy(); - expect(config).toEqual({ - type: 'custom:frigate-card', - cameras: [{ camera_entity: 'camera.office' }], - overrides: [ - { - conditions: [ - { - condition: 'screen' as const, - media_query: 'query', - }, - ], - overrides: { - view: { - default: 'clips', - }, - }, - }, - ], - }); - }); - - it('automations', () => { - const config = { - type: 'custom:frigate-card', - cameras: [{ camera_entity: 'camera.office' }], - automations: [ - { - conditions: { - media_query: 'query', - }, - actions: { - action: 'custom:frigate-card-action' as const, - frigate_card_action: 'live_substream_on' as const, - }, - }, - ], - }; - - expect(upgradeConfig(config)).toBeTruthy(); - expect(config).toEqual({ - type: 'custom:frigate-card', - cameras: [{ camera_entity: 'camera.office' }], - automations: [ - { - conditions: [ - { - condition: 'screen' as const, - media_query: 'query', - }, - ], - actions: { - action: 'custom:frigate-card-action' as const, - frigate_card_action: 'live_substream_on' as const, - }, + camera_entity: 'camera.office', + capabilities: { disable_except: 'substream' }, }, + { camera_entity: 'camera.sitting_room' }, ], }); }); diff --git a/tests/config/types.test.ts b/tests/config/types.test.ts index 9ce96b3e..9ed3a817 100644 --- a/tests/config/types.test.ts +++ b/tests/config/types.test.ts @@ -22,7 +22,6 @@ describe('config defaults', () => { frigate: { client_id: 'frigate', }, - hide: false, image: { refresh_seconds: 1, }, diff --git a/tests/test-utils.ts b/tests/test-utils.ts index ab941520..0ae2a6f6 100644 --- a/tests/test-utils.ts +++ b/tests/test-utils.ts @@ -3,6 +3,7 @@ import { HassEntities, HassEntity } from 'home-assistant-js-websocket'; import { expect, vi } from 'vitest'; import { mock } from 'vitest-mock-extended'; import { Camera } from '../src/camera-manager/camera'; +import { Capabilities } from '../src/camera-manager/capabilities'; import { CameraManagerEngine } from '../src/camera-manager/engine'; import { FrigateEvent, FrigateRecording } from '../src/camera-manager/frigate/types'; import { GenericCameraManagerEngine } from '../src/camera-manager/generic/engine-generic'; @@ -10,10 +11,9 @@ import { CameraManager } from '../src/camera-manager/manager'; import { CameraManagerStore } from '../src/camera-manager/store'; import { CameraEventCallback, - CameraManagerCameraCapabilities, - CameraManagerCapabilities, CameraManagerMediaCapabilities, } from '../src/camera-manager/types'; +import { CapabilitiesRaw } from '../src/types'; import { ActionsManager } from '../src/card-controller/actions-manager'; import { AutoUpdateManager } from '../src/card-controller/auto-update-manager'; import { AutomationsManager } from '../src/card-controller/automations-manager'; @@ -75,7 +75,7 @@ export const createConfig = (config?: RawFrigateCardConfig): FrigateCardConfig = export const createCamera = ( config: CameraConfig, engine: CameraManagerEngine, - capabilities?: CameraManagerCameraCapabilities, + capabilities?: Capabilities, ): Camera => { return new Camera(config, engine, { capabilities: capabilities }); }; @@ -185,7 +185,7 @@ export const createStore = ( cameraID: string; engine?: CameraManagerEngine; config?: CameraConfig; - capabilities?: CameraManagerCameraCapabilities; + capabilities?: Capabilities | null; eventCallback?: CameraEventCallback; }[], ): CameraManagerStore => { @@ -196,7 +196,10 @@ export const createStore = ( cameraProps.config ?? createCameraConfig(), cameraProps.engine ?? new GenericCameraManagerEngine(eventCallback), { - capabilities: cameraProps.capabilities ?? createCameraCapabilities(), + capabilities: + cameraProps.capabilities === undefined + ? createCapabilities() + : cameraProps.capabilities ?? undefined, eventCallback: eventCallback, }, ); @@ -212,35 +215,17 @@ export const createCameraManager = (): CameraManager => { return cameraManager; }; -export const createAggregateCameraCapabilities = ( - capabilities?: Partial, -): CameraManagerCapabilities => { - return { - canFavoriteEvents: false, - canFavoriteRecordings: false, - canSeek: false, - supportsClips: false, - supportsRecordings: false, - supportsSnapshots: false, - supportsTimeline: false, - supportsPTZ: false, +export const createCapabilities = (capabilities?: CapabilitiesRaw): Capabilities => { + return new Capabilities({ + 'favorite-events': false, + 'favorite-recordings': false, + clips: false, + live: false, + recordings: false, + seek: false, + snapshots: false, ...capabilities, - }; -}; - -export const createCameraCapabilities = ( - capabilities?: Partial, -): CameraManagerCameraCapabilities => { - return { - canFavoriteEvents: false, - canFavoriteRecordings: false, - canSeek: false, - supportsClips: false, - supportsRecordings: false, - supportsSnapshots: false, - supportsTimeline: false, - ...capabilities, - }; + }); }; export const createMediaCapabilities = ( diff --git a/tests/utils/camera.test.ts b/tests/utils/camera.test.ts index c84b2d15..bdc32d48 100644 --- a/tests/utils/camera.test.ts +++ b/tests/utils/camera.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it, vi } from 'vitest'; +import { describe, expect, it } from 'vitest'; import { getCameraID } from '../../src/utils/camera.js'; import { createCameraConfig } from '../test-utils.js'; diff --git a/tests/utils/endpoint.test.ts b/tests/utils/endpoint.test.ts new file mode 100644 index 00000000..31c169e7 --- /dev/null +++ b/tests/utils/endpoint.test.ts @@ -0,0 +1,85 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { getEndpointAddressOrDispatchError } from '../../src/utils/endpoint'; +import { homeAssistantSignPath } from '../../src/utils/ha'; +import { createHASS } from '../test-utils'; + +vi.mock('../../src/utils/ha'); + +// @vitest-environment jsdom +describe('getEndpointAddressOrDispatchError', () => { + it('without signing', async () => { + expect( + await getEndpointAddressOrDispatchError( + document.createElement('div'), + createHASS(), + { + endpoint: 'http://example.com', + sign: false, + }, + ), + ).toBe('http://example.com'); + }); + + describe('with signing', () => { + afterEach(() => { + vi.clearAllMocks(); + }); + + it('successful', async () => { + vi.mocked(homeAssistantSignPath).mockResolvedValue('http://signed.com'); + + expect( + await getEndpointAddressOrDispatchError( + document.createElement('div'), + createHASS(), + { + endpoint: 'http://example.com', + sign: true, + }, + ), + ).toBe('ws://signed.com'); + }); + + it('with null response', async () => { + const element = document.createElement('div'); + const listener = vi.fn(); + element.addEventListener('frigate-card:message', listener); + + vi.mocked(homeAssistantSignPath).mockResolvedValue(null); + + expect( + await getEndpointAddressOrDispatchError(element, createHASS(), { + endpoint: 'http://example.com', + sign: true, + }), + ).toBeNull(); + + expect(listener).toBeCalledWith( + expect.objectContaining({ + detail: expect.objectContaining({ + message: 'Could not sign Home Assistant URL', + }), + }), + ); + }); + + it('with exception on signing', async () => { + const consoleSpy = vi.spyOn(global.console, 'warn').mockReturnValue(undefined); + + const element = document.createElement('div'); + const listener = vi.fn(); + element.addEventListener('frigate-card:message', listener); + + vi.mocked(homeAssistantSignPath).mockRejectedValue(new Error()); + + expect( + await getEndpointAddressOrDispatchError(element, createHASS(), { + endpoint: 'http://example.com', + sign: true, + }), + ).toBeNull(); + + expect(consoleSpy).toBeCalled(); + }); + }); +}); diff --git a/tests/utils/media-to-view.test.ts b/tests/utils/media-to-view.test.ts index 2a96605c..a8b06416 100644 --- a/tests/utils/media-to-view.test.ts +++ b/tests/utils/media-to-view.test.ts @@ -1,6 +1,6 @@ import add from 'date-fns/add'; import sub from 'date-fns/sub'; -import { beforeEach, describe, expect, it, Mock, vi } from 'vitest'; +import { beforeEach, describe, expect, it, Mock, MockedFunction, vi } from 'vitest'; import { QueryType } from '../../src/camera-manager/types'; import { changeViewToRecentEventsForCameraAndDependents, @@ -13,6 +13,7 @@ import { ViewMedia } from '../../src/view/media'; import { EventMediaQueries } from '../../src/view/media-queries'; import { createCameraManager, + createCapabilities, createPerformanceConfig, createStore, createView, @@ -21,8 +22,8 @@ import { const createElementListenForView = (): { element: HTMLElement; - viewHandler: Mock; - messageHandler: Mock; + viewHandler: EventListener; + messageHandler: EventListener; } => { const element = document.createElement('div'); @@ -114,6 +115,7 @@ describe('changeViewToRecentEventsForCameraAndDependents', () => { createStore([ { cameraID: 'camera', + capabilities: createCapabilities({ clips: true }), }, ]), ); @@ -138,7 +140,9 @@ describe('changeViewToRecentEventsForCameraAndDependents', () => { }, ); expect(elementHandler.viewHandler).toBeCalled(); - expect(getMediaFromHandlerCall(elementHandler.viewHandler)).toBe(mediaArray); + expect(getMediaFromHandlerCall(vi.mocked(elementHandler.viewHandler))).toBe( + mediaArray, + ); }); it('should dispatch error message on fail', async () => { @@ -150,6 +154,7 @@ describe('changeViewToRecentEventsForCameraAndDependents', () => { createStore([ { cameraID: 'camera', + capabilities: createCapabilities({ clips: true }), }, ]), ); @@ -177,6 +182,7 @@ describe('changeViewToRecentEventsForCameraAndDependents', () => { createStore([ { cameraID: 'camera', + capabilities: createCapabilities({ clips: true }), }, ]), ); @@ -208,6 +214,7 @@ describe('changeViewToRecentEventsForCameraAndDependents', () => { createStore([ { cameraID: 'camera', + capabilities: createCapabilities({ clips: true }), }, ]), ); @@ -246,6 +253,7 @@ describe('changeViewToRecentEventsForCameraAndDependents', () => { createStore([ { cameraID: 'camera', + capabilities: createCapabilities({ [mediaType]: true }), }, ]), ); @@ -440,6 +448,7 @@ describe('changeViewToRecentRecordingForCameraAndDependents', () => { createStore([ { cameraID: 'camera', + capabilities: createCapabilities({ recordings: true }), }, ]), ); @@ -464,7 +473,9 @@ describe('changeViewToRecentRecordingForCameraAndDependents', () => { }, ); expect(elementHandler.viewHandler).toBeCalled(); - expect(getMediaFromHandlerCall(elementHandler.viewHandler)).toBe(mediaArray); + expect(getMediaFromHandlerCall(vi.mocked(elementHandler.viewHandler))).toBe( + mediaArray, + ); }); it('should respect media chunk size', async () => { @@ -473,6 +484,7 @@ describe('changeViewToRecentRecordingForCameraAndDependents', () => { createStore([ { cameraID: 'camera', + capabilities: createCapabilities({ recordings: true }), }, ]), ); @@ -504,6 +516,7 @@ describe('changeViewToRecentRecordingForCameraAndDependents', () => { createStore([ { cameraID: 'camera', + capabilities: createCapabilities({ recordings: true }), }, ]), ); diff --git a/tests/utils/ptz.test.ts b/tests/utils/ptz.test.ts index 3aefcf1d..272882e5 100644 --- a/tests/utils/ptz.test.ts +++ b/tests/utils/ptz.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from 'vitest'; import { FrigateCardPTZConfig, frigateCardPTZSchema } from '../../src/config/types'; import { hasUsablePTZ } from '../../src/utils/ptz'; -import { createCameraCapabilities } from '../test-utils'; +import { createCapabilities } from '../test-utils'; const createPTZConfig = ( config?: Partial, @@ -13,7 +13,7 @@ describe('hasUsablePTZ', () => { it('should return true with manual actions', () => { expect( hasUsablePTZ( - createCameraCapabilities(), + createCapabilities(), createPTZConfig({ actions_left: {}, }), @@ -23,14 +23,16 @@ describe('hasUsablePTZ', () => { it('should return true with capabilities', () => { expect( hasUsablePTZ( - createCameraCapabilities({ - ptz: {}, + createCapabilities({ + ptz: { + panTilt: ['continuous'], + }, }), createPTZConfig(), ), ).toBeTruthy(); }); it('should return false with manual actions or capabilities', () => { - expect(hasUsablePTZ(createCameraCapabilities(), createPTZConfig())).toBeFalsy(); + expect(hasUsablePTZ(createCapabilities(), createPTZConfig())).toBeFalsy(); }); }); diff --git a/tests/view/view-to-cameras.test.ts b/tests/view/view-to-cameras.test.ts new file mode 100644 index 00000000..0d90ad73 --- /dev/null +++ b/tests/view/view-to-cameras.test.ts @@ -0,0 +1,79 @@ +import { describe, expect, it, vi } from 'vitest'; +import { FrigateCardView } from '../../src/config/types'; +import { CapabilityKey } from '../../src/types'; +import { getCameraIDsForViewName } from '../../src/view/view-to-cameras'; +import { + createCameraConfig, + createCameraManager, + createCapabilities, + createStore, +} from '../test-utils'; + +describe('getCameraIDsForViewName', () => { + describe('views that are always supported', () => { + it.each([['image' as const], ['diagnostics' as const]])( + '%s', + (viewName: FrigateCardView) => { + const cameraManager = createCameraManager(); + vi.mocked(cameraManager.getStore).mockReturnValue( + createStore([ + { + cameraID: 'camera-1', + config: createCameraConfig({ dependencies: { cameras: ['camera-2'] } }), + }, + { cameraID: 'camera-2' }, + ]), + ); + + expect(getCameraIDsForViewName(cameraManager, viewName)).toEqual( + new Set(['camera-1', 'camera-2']), + ); + expect(getCameraIDsForViewName(cameraManager, viewName, 'camera-1')).toEqual( + new Set(['camera-1', 'camera-2']), + ); + expect(getCameraIDsForViewName(cameraManager, viewName, 'camera-2')).toEqual( + new Set(['camera-1', 'camera-2']), + ); + }, + ); + }); + + describe('views that respect dependencies and need a capability', () => { + it.each([ + ['live' as const, 'live' as const], + ['clip' as const, 'clips' as const], + ['clips' as const, 'clips' as const], + ['snapshot' as const, 'snapshots' as const], + ['snapshots' as const, 'snapshots' as const], + ['recording' as const, 'recordings' as const], + ['recordings' as const, 'recordings' as const], + ['media' as const, 'clips' as const], + ['media' as const, 'snapshots' as const], + ['media' as const, 'recordings' as const], + ])('%s', (viewName: FrigateCardView, capabilityKey: CapabilityKey) => { + const cameraManager = createCameraManager(); + vi.mocked(cameraManager.getStore).mockReturnValue( + createStore([ + { + cameraID: 'camera-1', + config: createCameraConfig({ dependencies: { cameras: ['camera-2'] } }), + }, + { + cameraID: 'camera-2', + capabilities: createCapabilities({ [capabilityKey]: true }), + }, + ]), + ); + + expect(getCameraIDsForViewName(cameraManager, viewName)).toEqual( + new Set(['camera-2']), + ); + expect(getCameraIDsForViewName(cameraManager, viewName, 'camera-1')).toEqual( + new Set(['camera-2']), + ); + expect(getCameraIDsForViewName(cameraManager, viewName, 'camera-2')).toEqual( + new Set(['camera-2']), + ); + }); + }); +}); diff --git a/vite.config.ts b/vite.config.ts index 2c0b04a2..afc7ade6 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -10,6 +10,7 @@ const FULL_COVERAGE_FILES_RELATIVE = [ 'camera-manager/frigate/util.ts', 'camera-manager/generic/*.ts', 'camera-manager/motioneye/icon.ts', + 'camera-manager/utils/*.ts', 'card-controller/*.ts', 'components-lib/cached-value-controller.ts', 'components-lib/media-filter-controller.ts', @@ -29,6 +30,7 @@ const FULL_COVERAGE_FILES_RELATIVE = [ 'utils/diagnostics.ts', 'utils/download.ts', 'utils/embla/**/*.ts', + 'utils/endpoint.ts', 'utils/ha/entity-registry/types.ts', 'utils/ha/types.ts', 'utils/media-info.ts', @@ -72,21 +74,9 @@ export default defineConfig({ coverage: { // Favor istanbul for coverage over v8 due to better accuracy. provider: 'istanbul', - thresholds: { - // Thresholds will automatically be updated as coverage improves to avoid - // back-sliding. - autoUpdate: true, - - // Expected thresholds for anything that does not have 100% coverage - // yet. - statements: 10.69, - branches: 4.9, - functions: 7.81, - lines: 10.94, - ...calculateFullCoverageThresholds(), }, }, }, -}); \ No newline at end of file +});