diff --git a/README.md b/README.md index 21f905d4..ec370777 100644 --- a/README.md +++ b/README.md @@ -55,7 +55,7 @@ That's it! ### Manual Resource Management -For most users, HACS should automatically add the necessary resources. Should this auto-registration does not work, you will need to complete one additional step. +For most users, HACS should automatically add the necessary resources. Should this auto-registration not work you will need to complete one additional step. #### Lovelace in "Storage mode" (default) @@ -167,11 +167,11 @@ See the [fully expanded cameras configuration example](#config-expanded-cameras) ##### Engine Capabilities -|Engine|Live|Supports clips|Supports Snapshots|Supports Recordings|Supports Timeline|Favorite events|Favorite recordings| -| - | - | - | - | - | - | - | - | -|`frigate`| :white_check_mark: | :white_check_mark: | :white_check_mark: | :white_check_mark: | :white_check_mark: | :white_check_mark: | :heavy_multiplication_x: | -|`generic`| :white_check_mark: | :heavy_multiplication_x: | :heavy_multiplication_x: | :heavy_multiplication_x: | :heavy_multiplication_x: | :heavy_multiplication_x: | :heavy_multiplication_x: | -|`motioneye`| :white_check_mark: | :white_check_mark: | :white_check_mark: | :heavy_multiplication_x: | :white_check_mark: | :heavy_multiplication_x: | :heavy_multiplication_x: | +|Engine|Live|Supports clips|Supports Snapshots|Supports Recordings|Supports Timeline|Supports PTZ out of the box|Supports manually configured PTZ|Favorite events|Favorite recordings| +| - | - | - | - | - | - | - | - | - | - | +|`frigate`| :white_check_mark: | :white_check_mark: | :white_check_mark: | :white_check_mark: | :white_check_mark: | :white_check_mark: | :white_check_mark: | :white_check_mark: | :heavy_multiplication_x: | +|`generic`| :white_check_mark: | :heavy_multiplication_x: | :heavy_multiplication_x: | :heavy_multiplication_x: | :heavy_multiplication_x: | :heavy_multiplication_x: | :white_check_mark: | :heavy_multiplication_x: | :heavy_multiplication_x: | +|`motioneye`| :white_check_mark: | :white_check_mark: | :white_check_mark: | :heavy_multiplication_x: | :white_check_mark: | :heavy_multiplication_x: | :white_check_mark: | :heavy_multiplication_x: | :heavy_multiplication_x: | ##### Live providers supported per Engine @@ -485,6 +485,7 @@ menu: | `timeline` | :white_check_mark: | The `timeline` menu button: show the event timeline. | | `media_player` | :white_check_mark: | The `media_player` menu button: sends the visible media to a remote media player. Supports Frigate clips, snapshots and live camera (only for cameras that specify a `camera_entity` and only using the default HA stream (equivalent to the `ha` live provider). `jsmpeg` or `webrtc-card` are not supported, although live can still be played as long as `camera_entity` is specified. In the player list, a `tap` will send the media to the player, a `hold` will stop the media on the player. | | `microphone` | :white_check_mark: | The `microphone` button allows usage of 2-way audio in certain configurations. See [Using 2-way audio](#using-2-way-audio). | +| `show_ptz` | :white_check_mark: | The `show_ptz` button shows or hide the PTZ controls. | ##### Configuration on each button @@ -628,6 +629,30 @@ live: See [Using 2-way audio](#using-2-way-audio) for more information about the very particular requirements that must be followed for 2-way audio to work. + + +#### Live: PTZ + +Controls a PTZ (Pan Tilt Zoom) controller overlay. All configuration is under: + +```yaml +live: + ptz: +``` + +| Option | Default | Overridable | Description | +| - | - | - | - | +| `mode` | `on` | :white_check_mark: | When `on` will show a PTZ control if so configured (manually, or by the camera engine), if `off` will not show any control. | +| `position` | `bottom-right` | :white_check_mark: | Whether to position the control on the `top-left`, `top-right`, `bottom-left` or `bottom-right`. This may be overridden by using the `style` parameter to precisely control placement. | +| `actions_left`, `actions_right`, `actions_up`, `actions_down`, `actions_zoom_in`, `actions_zoom_out`, `actions_home` | Default is set by camera engine of the selected camera | :white_check_mark: | The [Home Assistant actions](https://www.home-assistant.io/dashboards/actions/) to call when this icon is interacted with. | +| `orientation` | `horizontal` | :white_check_mark: | Whether to show a `vertical` or `horizontal` PTZ control. | +| `hide_pan_tilt` | `false` | :white_check_mark: | When `true` the Pan & Tilt buttons of the control is hidden | +| `hide_zoom` | `false` | :white_check_mark: | When `true` the Zoom button of the control is hidden | +| `hide_home` | `false` | :white_check_mark: | When `true` the Home button of the control is hidden | +| `data_left`, `data_right`, `data_up`, `data_down`, `data_zoom_in`, `data_zoom_out`, `data_home` | | :white_check_mark: | Shorthand for a `tap_action` that calls the `service` with the data provided in this argument. Internally, this is just translated into the longer-form `actions_[button]`. If both `actions_X` and `data_X` are specified, `actions_X` takes priority. This is compatible with [AlexxIT's WebRTC Card PTZ configuration](https://github.com/AlexxIT/WebRTC/wiki/PTZ-Config-Examples). | +| `service` | | :white_check_mark: | An optional Home Assistant service to call when the `data_` parameters are used. | +| `style` | | :white_check_mark: | Optionally position and style the element using CSS. Similar to [Picture Element styling](https://www.home-assistant.io/dashboards/picture-elements/#how-to-use-the-style-object), except without any default, e.g. `left: 42%` | + #### Live: Display @@ -1315,8 +1340,6 @@ elements to add special Frigate card functionality. | `custom:frigate-card-menu-submenu` | Add a configurable submenu dropdown. See [configuration below](#frigate-card-menu-submenu).| | `custom:frigate-card-menu-submenu-select` | Add a submenu based on a `select` or `input_select`. See [configuration below](#frigate-card-submenu-select).| | `custom:frigate-card-conditional` | Restrict a set of elements to only render when the card is showing particular a particular [view](#views). See [configuration below](#frigate-card-conditional).| -| `custom:frigate-card-ptz` | Add a PTZ (Pan Tilt Zoom) controller overlay. See [configuration below](#frigate-card-ptz).| - **Note**: ℹ️ Manual positioning of custom menu icons or submenus via the `style` parameter is not supported as the menu buttons displayed are context sensitive @@ -1377,19 +1400,6 @@ Parameters for the `custom:frigate-card-conditional` element: `elements` | The elements to render. Can be any supported element, include additional condition or custom elements. | | `conditions` | A set of conditions that must evaluate to true in order for the elements to be rendered. See [Frigate Card Conditions](#frigate-card-conditions). | -#### `custom:frigate-card-ptz` - -Parameters for the `custom:frigate-card-ptz` element: - -| Parameter | Default | Description | -| ------------- | - | -------------------------------------------- | -| `type` | | Must be `custom:frigate-card-ptz`. | -| `style` | `translate(-50%, -50%)` | Position and style the element using CSS. See [Picture Element styling](https://www.home-assistant.io/dashboards/picture-elements/#how-to-use-the-style-object). | -| `orientation` | `vertical` | Whether to show a `vertical` or `horizontal` PTZ control. | -| `actions_left`, `actions_right`, `actions_up`, `actions_down`, `actions_zoom_in`, `actions_zoom_out`, `actions_home` | The [Home Assistant actions](https://www.home-assistant.io/dashboards/actions/) to call when this icon is interacted with. | -| `data_left`, `data_right`, `data_up`, `data_down`, `data_zoom_in`, `data_zoom_out`, `data_home` | Shorthand for a `tap_action` that calls the `service` with the data provided in this argument. Internally, this is just translated into the longer-form `actions_[button]`. If both `actions_X` and `data_X` are specified, `actions_X` takes priority. This is compatible with [AlexxIT's WebRTC Card PTZ configuration](https://github.com/AlexxIT/WebRTC/wiki/PTZ-Config-Examples). | -| `service` | | An optional Home Assistant service to call when the `data_` parameters are used. | - ### Special Actions @@ -1399,7 +1409,7 @@ Parameters for the `custom:frigate-card-ptz` element: | Parameter | Description | | - | - | | `action` | Must be `custom:frigate-card-action`. | -| `frigate_card_action` | Call a Frigate Card action. Acceptable values are `default`, `clip`, `clips`, `image`, `live`, `recording`, `recordings`, `snapshot`, `snapshots`, `download`, `timeline`, `camera_ui`, `fullscreen`, `camera_select`, `menu_toggle`, `media_player`, `live_substream_on`, `live_substream_off`, `live_substream_select`, `expand`, `microphone_mute`, `microphone_unmute`, `mute`, `unmute`, `play`, `pause`, `screenshot`| +| `frigate_card_action` | Call a Frigate Card action. Acceptable values are `default`, `clip`, `clips`, `image`, `live`, `recording`, `recordings`, `snapshot`, `snapshots`, `download`, `timeline`, `camera_ui`, `fullscreen`, `camera_select`, `menu_toggle`, `media_player`, `live_substream_on`, `live_substream_off`, `live_substream_select`, `expand`, `microphone_mute`, `microphone_unmute`, `mute`, `unmute`, `play`, `pause`, `screenshot`, `show_ptz`, `ptz`| @@ -1421,6 +1431,8 @@ Parameters for the `custom:frigate-card-ptz` element: |`mute`, `unmute`| Mute or unmute the loaded media. | |`play`, `pause`| Play or pause the loaded media. | |`screenshot`| Take a screenshot of the loaded media (e.g. a still from a video). | +|`show_ptz`| Show or hide the PTZ controls. Takes a `show_ptz` boolean parameter to indicate whether the controls show be shown or not. | +|`ptz`| Execute a native PTZ action (only for native out-of-the-box PTZ camera engines, e.g. Frigate). Takes a required `ptz_action` parameter that is one of `left`, `right`, `up`, `down`, `zoom_in`, `zoom_out` or `preset`. Takes an optional `ptz_phase` parameter that is one of `start` or `stop` to start or stop the movement discretely. Takes an optional `ptz_preset` parameter as the preset to execute when the `ptz_action` parameter is `preset`. | @@ -1438,6 +1450,7 @@ This card supports several different views: |`recordings`|Shows a gallery of recent (last day) recordings for this camera and its dependents.| |`recording`|Shows a viewer for the most recent recording for this camera. Can also be accessed by holding down the `recordings` menu icon.| |`image`|Shows a static image specified by the `image` parameter, can be used as a discrete default view or a screensaver (via `view.timeout_seconds`).| +|`timeline`|Shows an event timeline.| ### Navigating From A Snapshot To A Clip @@ -2012,6 +2025,11 @@ menu: enabled: false alignment: matching icon: mdi:play + show_ptz: + priority: 50 + enabled: false + alignment: matching + icon: mdi:pan button_size: 40 ``` @@ -2066,6 +2084,29 @@ live: microphone: always_connected: false disconnect_seconds: 60 + ptz: + mode: on + position: bottom-right + orientation: horizontal + hide_pan_tilt: false + hide_zoom: false + hide_home: false + style: + # Optionally override the default style. + right: 5% + # Manually specifying actions. + actions_left: + tap_action: + action: call-service + service: sonoff.send_command + service_data: + device: '048123' + cmd: left + # Equivalent short form PTZ actions (only right button shown) + service: sonoff.send_command + data_right: + device: '048123' + cmd: right display: mode: single grid_selected_width_factor: 2 @@ -2432,31 +2473,6 @@ elements: state: on state_not: off media_loaded: true - # Full form PTZ actions (only left button shown). - - type: custom:frigate-card-ptz - orientation: vertical - style: - transform: none - right: 5% - top: 50% - actions_left: - tap_action: - action: call-service - service: sonoff.send_command - service_data: - device: '048123' - cmd: left - # Equivalent short form PTZ actions (only left button shown) - - type: custom:frigate-card-ptz - orientation: vertical - style: - transform: none - right: 20px - top: 180px - service: sonoff.send_command - data_left: - device: '048123' - cmd: left ``` @@ -2562,6 +2578,21 @@ elements: tap_action: action: custom:frigate-card-action frigate_card_action: screenshot + - type: custom:frigate-card-menu-icon + icon: mdi:alpha-p-circle + title: Show PTZ + tap_action: + action: custom:frigate-card-action + frigate_card_action: show_ptz + show_ptz: true + - type: custom:frigate-card-menu-icon + icon: mdi:alpha-q-circle + title: Native PTZ Preset + tap_action: + action: custom:frigate-card-action + frigate_card_action: ptz + ptz_action: preset + ptz_preset: 'doorway ``` @@ -3416,44 +3447,33 @@ elements: ``` -### Using a PTZ picture element +### Using a PTZ control -The card supports a custom PTZ element (`custom:frigate-card-ptz`) to conveniently control pan, tilt and zoom for cameras. +The card supports using PTZ controls to conveniently control pan, tilt and zoom for cameras.
- Expand: Using the native PTZ picture element + Expand: Using the PTZ controls -This example shows the native PTZ element when the `live` or `image` view is displayed and the stream (media) has loaded. +This example shows the PTZ controls of the `live` view. Note that if your camera engine supports it (e.g. `frigate`) this will just work out of the box with no configuration at all. ```yaml [...] -elements: - - type: custom:frigate-card-conditional - conditions: - media_loaded: true - view: - - live - - image - elements: - - type: custom:frigate-card-ptz - orientation: horizontal - style: - transform: none - right: 20px - top: 180px - service: sonoff.send_command - data_left: - device: '048123' - cmd: left - data_right: - device: '048123' - cmd: right - data_up: - device: '048123' - cmd: up - data_down: - device: '048123' - cmd: down +live: + ptz: + orientation: horizontal + service: sonoff.send_command + data_left: + device: '048123' + cmd: left + data_right: + device: '048123' + cmd: right + data_up: + device: '048123' + cmd: up + data_down: + device: '048123' + cmd: down ```
diff --git a/src/action-handler-directive.ts b/src/action-handler-directive.ts index 73fe78ab..c61f028e 100644 --- a/src/action-handler-directive.ts +++ b/src/action-handler-directive.ts @@ -32,6 +32,7 @@ class ActionHandler extends HTMLElement implements ActionHandler { protected doubleClickTimer = new Timer(); protected held = false; + protected started = false; public connectedCallback(): void { [ @@ -83,7 +84,12 @@ class ActionHandler extends HTMLElement implements ActionHandler { this.held = true; }); - fireEvent(element, 'action', { action: 'start_tap' }); + // Without this check we get double start_tap events from touchstart and + // mousedown events (on Android). + if (!this.started) { + this.started = true; + fireEvent(element, 'action', { action: 'start_tap' }); + } }; const end = (ev: Event): void => { @@ -105,6 +111,7 @@ class ActionHandler extends HTMLElement implements ActionHandler { this.holdTimer.stop(); + this.started = false; fireEvent(element, 'action', { action: 'end_tap' }); if (options?.hasHold && this.held) { diff --git a/src/camera-manager/browse-media/engine-browse-media.ts b/src/camera-manager/browse-media/engine-browse-media.ts index f6eea202..22c41b9b 100644 --- a/src/camera-manager/browse-media/engine-browse-media.ts +++ b/src/camera-manager/browse-media/engine-browse-media.ts @@ -15,14 +15,14 @@ import { Entity } from '../../utils/ha/entity-registry/types'; import { ResolvedMediaCache, resolveMedia } from '../../utils/ha/resolved-media'; import { ViewMedia } from '../../view/media'; import { RequestCache } from '../cache'; +import { Camera } from '../camera'; import { CameraManagerEngine } from '../engine'; import { CameraInitializationError } from '../error'; import { GenericCameraManagerEngine } from '../generic/engine-generic'; import { rangesOverlap } from '../range'; +import { CameraManagerReadOnlyConfigStore } from '../store'; import { - CameraConfigs, CameraEndpoint, - CameraManagerCameraCapabilities, CameraManagerMediaCapabilities, DataQuery, EventQuery, @@ -141,7 +141,7 @@ export class BrowseMediaCameraManagerEngine hass: HomeAssistant, entityRegistryManager: EntityRegistryManager, cameraConfig: CameraConfig, - ): Promise { + ): Promise { const entity = cameraConfig.camera_entity ? await entityRegistryManager.getEntity(hass, cameraConfig.camera_entity) : null; @@ -152,11 +152,20 @@ export class BrowseMediaCameraManagerEngine ); } this._cameraEntities.set(cameraConfig.camera_entity, entity); - return cameraConfig; + + return new Camera(cameraConfig, this, { + canFavoriteEvents: false, + canFavoriteRecordings: false, + canSeek: false, + supportsClips: true, + supportsRecordings: false, + supportsSnapshots: true, + supportsTimeline: true, + }); } public generateDefaultEventQuery( - _cameras: CameraConfigs, + _store: CameraManagerReadOnlyConfigStore, cameraIDs: Set, query: PartialEventQuery, ): EventQuery[] | null { @@ -191,21 +200,6 @@ export class BrowseMediaCameraManagerEngine return null; } - public getCameraCapabilities( - cameraConfig: CameraConfig, - ): CameraManagerCameraCapabilities | null { - const parentCapabilities = super.getCameraCapabilities(cameraConfig); - if (!parentCapabilities) { - return null; - } - return { - ...parentCapabilities, - supportsClips: true, - supportsSnapshots: true, - supportsTimeline: true, - }; - } - // eslint-disable-next-line @typescript-eslint/no-unused-vars public getMediaCapabilities(_media: ViewMedia): CameraManagerMediaCapabilities { return { diff --git a/src/camera-manager/camera.ts b/src/camera-manager/camera.ts new file mode 100644 index 00000000..dd6990ee --- /dev/null +++ b/src/camera-manager/camera.ts @@ -0,0 +1,44 @@ +import { CameraConfig } from '../config/types'; +import { localize } from '../localize/localize'; +import { CameraManagerEngine } from './engine'; +import { CameraNoIDError } from './error'; +import { CameraManagerCameraCapabilities } from './types'; + +export class Camera { + protected _config: CameraConfig; + protected _engine: CameraManagerEngine; + protected _capabilities: CameraManagerCameraCapabilities; + + constructor( + config: CameraConfig, + engine: CameraManagerEngine, + capabilities: CameraManagerCameraCapabilities, + ) { + this._config = config; + this._engine = engine; + this._capabilities = capabilities; + } + + public getConfig(): CameraConfig { + return this._config; + } + + public setID(cameraID: string): void { + this._config.id = cameraID; + } + + public getID(): string { + if (this._config.id) { + return this._config.id; + } + throw new CameraNoIDError(localize('error.no_camera_id')); + } + + public getEngine(): CameraManagerEngine { + return this._engine; + } + + public getCapabilities(): CameraManagerCameraCapabilities { + return this._capabilities; + } +} diff --git a/src/camera-manager/engine.ts b/src/camera-manager/engine.ts index c2d8710c..8f013cbc 100644 --- a/src/camera-manager/engine.ts +++ b/src/camera-manager/engine.ts @@ -1,14 +1,18 @@ import { HomeAssistant } from '@dermotduffy/custom-card-helpers'; -import { CameraConfig } from '../config/types'; +import { + CameraConfig, + PTZAction, + PTZPhase, +} from '../config/types'; import { ExtendedHomeAssistant } from '../types'; import { EntityRegistryManager } from '../utils/ha/entity-registry'; import { ViewMedia } from '../view/media'; +import { Camera } from './camera'; +import { CameraManagerReadOnlyConfigStore } from './store'; import { - CameraConfigs, CameraEndpoint, CameraEndpoints, CameraEndpointsContext, - CameraManagerCameraCapabilities, CameraManagerCameraMetadata, CameraManagerMediaCapabilities, DataQuery, @@ -37,57 +41,57 @@ export interface CameraManagerEngine { hass: HomeAssistant, entityRegistryManager: EntityRegistryManager, cameraConfig: CameraConfig, - ): Promise; + ): Promise; generateDefaultEventQuery( - cameras: CameraConfigs, + store: CameraManagerReadOnlyConfigStore, cameraIDs: Set, query: PartialEventQuery, ): EventQuery[] | null; generateDefaultRecordingQuery( - cameras: CameraConfigs, + store: CameraManagerReadOnlyConfigStore, cameraIDs: Set, query: PartialRecordingQuery, ): RecordingQuery[] | null; generateDefaultRecordingSegmentsQuery( - cameras: CameraConfigs, + store: CameraManagerReadOnlyConfigStore, cameraIDs: Set, query: PartialRecordingSegmentsQuery, ): RecordingSegmentsQuery[] | null; getEvents( hass: HomeAssistant, - cameras: CameraConfigs, + store: CameraManagerReadOnlyConfigStore, query: EventQuery, engineOptions?: EngineOptions, ): Promise; getRecordings( hass: HomeAssistant, - cameras: CameraConfigs, + store: CameraManagerReadOnlyConfigStore, query: RecordingQuery, engineOptions?: EngineOptions, ): Promise; getRecordingSegments( hass: HomeAssistant, - cameras: CameraConfigs, + store: CameraManagerReadOnlyConfigStore, query: RecordingSegmentsQuery, engineOptions?: EngineOptions, ): Promise; generateMediaFromEvents( hass: HomeAssistant, - cameras: CameraConfigs, + store: CameraManagerReadOnlyConfigStore, query: EventQuery, results: QueryReturnType, ): ViewMedia[] | null; generateMediaFromRecordings( hass: HomeAssistant, - cameras: CameraConfigs, + store: CameraManagerReadOnlyConfigStore, query: RecordingQuery, results: QueryReturnType, ): ViewMedia[] | null; @@ -109,7 +113,7 @@ export interface CameraManagerEngine { getMediaSeekTime( hass: HomeAssistant, - cameras: CameraConfigs, + store: CameraManagerReadOnlyConfigStore, media: ViewMedia, target: Date, engineOptions?: EngineOptions, @@ -117,7 +121,7 @@ export interface CameraManagerEngine { getMediaMetadata( hass: HomeAssistant, - cameras: CameraConfigs, + store: CameraManagerReadOnlyConfigStore, query: MediaMetadataQuery, engineOptions?: EngineOptions, ): Promise; @@ -127,14 +131,20 @@ export interface CameraManagerEngine { cameraConfig: CameraConfig, ): CameraManagerCameraMetadata; - getCameraCapabilities( - cameraConfig: CameraConfig, - ): CameraManagerCameraCapabilities | null; - getMediaCapabilities(media: ViewMedia): CameraManagerMediaCapabilities | null; getCameraEndpoints( cameraConfig: CameraConfig, context?: CameraEndpointsContext, ): CameraEndpoints | null; + + executePTZAction( + hass: HomeAssistant, + cameraConfig: CameraConfig, + action: PTZAction, + options?: { + phase?: PTZPhase, + preset?: string, + } + ): Promise; } diff --git a/src/camera-manager/error.ts b/src/camera-manager/error.ts index 61f9e7e5..38cb3561 100644 --- a/src/camera-manager/error.ts +++ b/src/camera-manager/error.ts @@ -1,3 +1,4 @@ import { FrigateCardError } from '../types.js'; export class CameraInitializationError extends FrigateCardError {} +export class CameraNoIDError extends FrigateCardError {} diff --git a/src/camera-manager/frigate/engine-frigate.ts b/src/camera-manager/frigate/engine-frigate.ts index 0a4dd6ea..f7936f3d 100644 --- a/src/camera-manager/frigate/engine-frigate.ts +++ b/src/camera-manager/frigate/engine-frigate.ts @@ -9,11 +9,12 @@ import orderBy from 'lodash-es/orderBy'; import throttle from 'lodash-es/throttle'; import uniq from 'lodash-es/uniq'; import uniqWith from 'lodash-es/uniqWith'; -import { CameraConfig } from '../../config/types'; +import { CameraConfig, PTZAction, PTZPhase } from '../../config/types'; import { localize } from '../../localize/localize'; import { ExtendedHomeAssistant } from '../../types'; import { allPromises, + errorToConsole, formatDate, prettifyTitle, runWhenIdleIfSupported, @@ -24,6 +25,7 @@ import { Entity } from '../../utils/ha/entity-registry/types'; import { ViewMedia } from '../../view/media'; import { ViewMediaClassifier } from '../../view/media-classifier'; import { RecordingSegmentsCache, RequestCache } from '../cache'; +import { Camera } from '../camera'; import { CAMERA_MANAGER_ENGINE_EVENT_LIMIT_DEFAULT, CameraManagerEngine, @@ -31,12 +33,11 @@ import { import { CameraInitializationError } from '../error'; import { GenericCameraManagerEngine } from '../generic/engine-generic'; import { DateRange } from '../range'; +import { CameraManagerReadOnlyConfigStore } from '../store'; import { - CameraConfigs, CameraEndpoint, CameraEndpoints, CameraEndpointsContext, - CameraManagerCameraCapabilities, CameraManagerCameraMetadata, CameraManagerMediaCapabilities, DataQuery, @@ -51,6 +52,8 @@ import { PartialEventQuery, PartialRecordingQuery, PartialRecordingSegmentsQuery, + PTZCapabilities, + PTZMovementType, QueryResults, QueryResultsType, QueryReturnType, @@ -74,12 +77,14 @@ import { getRecordingSegments, getRecordingsSummary, retainEvent, + getPTZInfo, } from './requests'; import { FrigateEventQueryResults, FrigateRecording, FrigateRecordingQueryResults, FrigateRecordingSegmentsQueryResults, + PTZInfo, } from './types'; const EVENT_REQUEST_CACHE_MAX_AGE_SECONDS = 60; @@ -144,7 +149,7 @@ export class FrigateCameraManagerEngine hass: HomeAssistant, entityRegistryManager: EntityRegistryManager, cameraConfig: CameraConfig, - ): Promise { + ): Promise { const hasCameraName = !!cameraConfig.frigate?.camera_name; const hasAutoTriggers = cameraConfig.triggers.motion || cameraConfig.triggers.occupancy; @@ -207,7 +212,64 @@ export class FrigateCameraManagerEngine cameraConfig.triggers.entities = uniq(cameraConfig.triggers.entities); } - return cameraConfig; + const ptz = await this._getPTZCapabilities(hass, cameraConfig); + + const isBirdseye = this._isBirdseye(cameraConfig); + return new Camera(cameraConfig, this, { + canFavoriteEvents: !isBirdseye, + canFavoriteRecordings: !isBirdseye, + canSeek: true, + supportsClips: !isBirdseye, + supportsSnapshots: !isBirdseye, + supportsRecordings: !isBirdseye, + supportsTimeline: !isBirdseye, + + ...(ptz && { ptz: ptz }), + }); + } + + protected async _getPTZCapabilities( + hass: HomeAssistant, + cameraConfig: CameraConfig, + ): Promise { + if (!cameraConfig.frigate.camera_name) { + return null; + } + + let ptzInfo: PTZInfo | null = null; + try { + ptzInfo = await getPTZInfo( + hass, + cameraConfig.frigate.client_id, + cameraConfig.frigate.camera_name, + ); + } catch (e) { + errorToConsole(e as Error); + return null; + } + + const panTilt: PTZMovementType[] = [ + ...(ptzInfo.features?.includes('pt') ? ['continuous' as const] : []), + ...(ptzInfo.features?.includes('pt-r') ? ['relative' as const] : []), + ]; + const zoom: PTZMovementType[] = [ + ...(ptzInfo.features?.includes('zoom') ? ['continuous' as const] : []), + ...(ptzInfo.features?.includes('zoom-r') ? ['relative' as const] : []), + ]; + const presets = ptzInfo.presets; + + if (panTilt.length || zoom.length || presets?.length) { + return { + ...(panTilt && { panTilt: panTilt }), + ...(zoom && { zoom: zoom }), + ...(presets && { presets: presets }), + }; + } + return null; + } + + protected _isBirdseye(cameraConfig: CameraConfig): boolean { + return cameraConfig.frigate.camera_name === CAMERA_BIRDSEYE; } /** @@ -330,13 +392,11 @@ export class FrigateCameraManagerEngine } public generateDefaultEventQuery( - cameras: CameraConfigs, + store: CameraManagerReadOnlyConfigStore, cameraIDs: Set, query?: PartialEventQuery, ): EventQuery[] | null { - const relevantCameraConfigs = Array.from(cameraIDs).map((cameraID) => - cameras.get(cameraID), - ); + const relevantCameraConfigs = [...store.getCameraConfigs(cameraIDs)]; // If all cameras specify exactly the same zones or labels (incl. none), we // can use a single batch query which will be better performance wise, @@ -365,7 +425,7 @@ export class FrigateCameraManagerEngine const output: EventQuery[] = []; for (const cameraID of cameraIDs) { - const cameraConfig = cameras.get(cameraID); + const cameraConfig = store.getCameraConfig(cameraID); if (cameraConfig) { output.push({ type: QueryType.Event, @@ -384,7 +444,7 @@ export class FrigateCameraManagerEngine } public generateDefaultRecordingQuery( - _cameras: CameraConfigs, + _store: CameraManagerReadOnlyConfigStore, cameraIDs: Set, query?: PartialRecordingQuery, ): RecordingQuery[] { @@ -398,7 +458,7 @@ export class FrigateCameraManagerEngine } public generateDefaultRecordingSegmentsQuery( - _cameras: CameraConfigs, + _store: CameraManagerReadOnlyConfigStore, cameraIDs: Set, query: PartialRecordingSegmentsQuery, ): RecordingSegmentsQuery[] | null { @@ -431,12 +491,12 @@ export class FrigateCameraManagerEngine } protected _buildInstanceToCameraIDMapFromQuery( - cameras: CameraConfigs, + store: CameraManagerReadOnlyConfigStore, cameraIDs: Set, ): Map> { const output: Map> = new Map(); for (const cameraID of cameraIDs) { - const cameraConfig = this._getQueryableCameraConfig(cameras, cameraID); + const cameraConfig = this._getQueryableCameraConfig(store, cameraID); const clientID = cameraConfig?.frigate.client_id; if (clientID) { if (!output.has(clientID)) { @@ -449,12 +509,12 @@ export class FrigateCameraManagerEngine } protected _getFrigateCameraNamesForCameraIDs( - cameras: CameraConfigs, + store: CameraManagerReadOnlyConfigStore, cameraIDs: Set, ): Set { const output = new Set(); for (const cameraID of cameraIDs) { - const cameraConfig = this._getQueryableCameraConfig(cameras, cameraID); + const cameraConfig = this._getQueryableCameraConfig(store, cameraID); if (cameraConfig?.frigate.camera_name) { output.add(cameraConfig.frigate.camera_name); } @@ -464,7 +524,7 @@ export class FrigateCameraManagerEngine public async getEvents( hass: HomeAssistant, - cameras: CameraConfigs, + store: CameraManagerReadOnlyConfigStore, query: EventQuery, engineOptions?: EngineOptions, ): Promise { @@ -487,7 +547,7 @@ export class FrigateCameraManagerEngine const nativeQuery: NativeFrigateEventQuery = { instance_id: instanceID, - cameras: Array.from(this._getFrigateCameraNamesForCameraIDs(cameras, cameraIDs)), + cameras: Array.from(this._getFrigateCameraNamesForCameraIDs(store, cameraIDs)), ...(query.what && { labels: Array.from(query.what) }), ...(query.where && { zones: Array.from(query.where) }), ...(query.tags && { sub_labels: Array.from(query.tags) }), @@ -518,10 +578,7 @@ export class FrigateCameraManagerEngine // Frigate allows multiple cameras to be searched for events in a single // query. Break them down into groups of cameras per Frigate instance, then // query once per instance for all cameras in that instance. - const instances = this._buildInstanceToCameraIDMapFromQuery( - cameras, - query.cameraIDs, - ); + const instances = this._buildInstanceToCameraIDMapFromQuery(store, query.cameraIDs); await Promise.all( Array.from(instances.keys()).map((instanceID) => @@ -533,7 +590,7 @@ export class FrigateCameraManagerEngine public async getRecordings( hass: HomeAssistant, - cameras: CameraConfigs, + store: CameraManagerReadOnlyConfigStore, query: RecordingQuery, engineOptions?: EngineOptions, ): Promise { @@ -551,7 +608,7 @@ export class FrigateCameraManagerEngine return; } - const cameraConfig = this._getQueryableCameraConfig(cameras, cameraID); + const cameraConfig = this._getQueryableCameraConfig(store, cameraID); if (!cameraConfig || !cameraConfig.frigate.camera_name) { return; } @@ -619,7 +676,7 @@ export class FrigateCameraManagerEngine public async getRecordingSegments( hass: HomeAssistant, - cameras: CameraConfigs, + store: CameraManagerReadOnlyConfigStore, query: RecordingSegmentsQuery, engineOptions?: EngineOptions, ): Promise { @@ -630,7 +687,7 @@ export class FrigateCameraManagerEngine cameraID: string, ): Promise => { const query = { ...baseQuery, cameraIDs: new Set([cameraID]) }; - const cameraConfig = this._getQueryableCameraConfig(cameras, cameraID); + const cameraConfig = this._getQueryableCameraConfig(store, cameraID); if (!cameraConfig || !cameraConfig.frigate.camera_name) { return; } @@ -687,12 +744,12 @@ export class FrigateCameraManagerEngine Array.from(query.cameraIDs).map((cameraID) => processQuery(query, cameraID)), ); - runWhenIdleIfSupported(() => this._throttledSegmentGarbageCollector(hass, cameras)); + runWhenIdleIfSupported(() => this._throttledSegmentGarbageCollector(hass, store)); return output.size ? output : null; } protected _getCameraIDMatch( - cameras: CameraConfigs, + store: CameraManagerReadOnlyConfigStore, query: DataQuery, instanceID: string, cameraName: string, @@ -704,7 +761,7 @@ export class FrigateCameraManagerEngine if (query.cameraIDs.size === 1) { return [...query.cameraIDs][0]; } - for (const [cameraID, cameraConfig] of cameras.entries()) { + for (const [cameraID, cameraConfig] of store.getCameraConfigEntries()) { if ( cameraConfig.frigate.client_id === instanceID && cameraConfig.frigate.camera_name === cameraName @@ -717,7 +774,7 @@ export class FrigateCameraManagerEngine public generateMediaFromEvents( _hass: HomeAssistant, - cameras: CameraConfigs, + store: CameraManagerReadOnlyConfigStore, query: EventQuery, results: QueryReturnType, ): ViewMedia[] | null { @@ -728,7 +785,7 @@ export class FrigateCameraManagerEngine const output: ViewMedia[] = []; for (const event of results.events) { const cameraID = this._getCameraIDMatch( - cameras, + store, query, results.instanceID, event.camera, @@ -736,7 +793,7 @@ export class FrigateCameraManagerEngine if (!cameraID) { continue; } - const cameraConfig = this._getQueryableCameraConfig(cameras, cameraID); + const cameraConfig = this._getQueryableCameraConfig(store, cameraID); if (!cameraConfig) { continue; } @@ -771,7 +828,7 @@ export class FrigateCameraManagerEngine public generateMediaFromRecordings( hass: HomeAssistant, - cameras: CameraConfigs, + store: CameraManagerReadOnlyConfigStore, _query: RecordingQuery, results: QueryReturnType, ): ViewMedia[] | null { @@ -781,7 +838,7 @@ export class FrigateCameraManagerEngine const output: ViewMedia[] = []; for (const recording of results.recordings) { - const cameraConfig = this._getQueryableCameraConfig(cameras, recording.cameraID); + const cameraConfig = this._getQueryableCameraConfig(store, recording.cameraID); if (!cameraConfig) { continue; } @@ -809,7 +866,7 @@ export class FrigateCameraManagerEngine public async getMediaSeekTime( hass: HomeAssistant, - cameras: CameraConfigs, + store: CameraManagerReadOnlyConfigStore, media: ViewMedia, target: Date, engineOptions?: EngineOptions, @@ -828,7 +885,7 @@ export class FrigateCameraManagerEngine type: QueryType.RecordingSegments, }; - const results = await this.getRecordingSegments(hass, cameras, query, engineOptions); + const results = await this.getRecordingSegments(hass, store, query, engineOptions); if (results) { return this._getSeekTimeInSegments( @@ -843,11 +900,11 @@ export class FrigateCameraManagerEngine } protected _getQueryableCameraConfig( - cameras: CameraConfigs, + store: CameraManagerReadOnlyConfigStore, cameraID: string, ): CameraConfig | null { - const cameraConfig = cameras.get(cameraID); - if (!cameraConfig || cameraConfig.frigate.camera_name == CAMERA_BIRDSEYE) { + const cameraConfig = store.getCameraConfig(cameraID); + if (!cameraConfig || this._isBirdseye(cameraConfig)) { return null; } return cameraConfig; @@ -865,7 +922,7 @@ export class FrigateCameraManagerEngine public async getMediaMetadata( hass: HomeAssistant, - cameras: CameraConfigs, + store: CameraManagerReadOnlyConfigStore, query: MediaMetadataQuery, engineOptions?: EngineOptions, ): Promise { @@ -885,16 +942,13 @@ export class FrigateCameraManagerEngine const days: Set = new Set(); const tags: Set = new Set(); - const instances = this._buildInstanceToCameraIDMapFromQuery( - cameras, - query.cameraIDs, - ); + const instances = this._buildInstanceToCameraIDMapFromQuery(store, query.cameraIDs); const processEventSummary = async ( instanceID: string, cameraIDs: Set, ): Promise => { - const cameraNames = this._getFrigateCameraNamesForCameraIDs(cameras, cameraIDs); + const cameraNames = this._getFrigateCameraNamesForCameraIDs(store, cameraIDs); for (const entry of await getEventSummary(hass, instanceID)) { if (!cameraNames.has(entry.camera)) { // If this entry applies to a camera that *is* in this Frigate @@ -919,7 +973,7 @@ export class FrigateCameraManagerEngine const processRecordings = async (cameraIDs: Set): Promise => { const recordings = await this.getRecordings( hass, - cameras, + store, { type: QueryType.Recording, cameraIDs: cameraIDs, @@ -977,7 +1031,7 @@ export class FrigateCameraManagerEngine */ protected async _garbageCollectSegments( hass: HomeAssistant, - cameras: CameraConfigs, + store: CameraManagerReadOnlyConfigStore, ): Promise { const cameraIDs = this._recordingSegmentsCache.getCameraIDs(); const recordingQuery: RecordingQuery = { @@ -992,7 +1046,7 @@ export class FrigateCameraManagerEngine return `${cameraID}/${startTime.getDate()}/${startTime.getHours()}`; }; - const results = await this.getRecordings(hass, cameras, recordingQuery); + const results = await this.getRecordings(hass, store, recordingQuery); if (!results) { return; } @@ -1053,21 +1107,6 @@ export class FrigateCameraManagerEngine return seekMilliseconds / 1000; } - public getCameraCapabilities( - cameraConfig: CameraConfig, - ): CameraManagerCameraCapabilities { - const isBirdseye = cameraConfig.frigate.camera_name === CAMERA_BIRDSEYE; - return { - canFavoriteEvents: !isBirdseye, - canFavoriteRecordings: !isBirdseye, - canSeek: true, - supportsClips: !isBirdseye, - supportsSnapshots: !isBirdseye, - supportsRecordings: !isBirdseye, - supportsTimeline: !isBirdseye, - }; - } - public getMediaCapabilities(media: ViewMedia): CameraManagerMediaCapabilities { return { canFavorite: ViewMediaClassifier.isEvent(media), @@ -1189,4 +1228,44 @@ export class FrigateCameraManagerEngine ...(webrtcCard && { webrtcCard: webrtcCard }), }; } + + public async executePTZAction( + hass: HomeAssistant, + cameraConfig: CameraConfig, + action: PTZAction, + options?: { + phase?: PTZPhase; + preset?: string; + }, + ): Promise { + const cameraEntity = cameraConfig.camera_entity; + + if (action === 'preset' && !options?.preset) { + return; + } + + // Awkward translation between card action and service parameters: + // https://github.com/blakeblackshear/frigate-hass-integration/blob/dev/custom_components/frigate/services.yaml + await hass.callService('frigate', 'ptz', { + entity_id: cameraEntity, + action: + options?.phase === 'stop' + ? 'stop' + : action === 'zoom_in' || action === 'zoom_out' + ? 'zoom' + : action === 'preset' + ? 'preset' + : 'move', + ...(options?.phase !== 'stop' && { + argument: + action === 'zoom_in' + ? 'in' + : action === 'zoom_out' + ? 'out' + : action === 'preset' + ? options?.preset + : action, + }), + }); + } } diff --git a/src/camera-manager/frigate/requests.ts b/src/camera-manager/frigate/requests.ts index 6bb5fc8b..c019f934 100644 --- a/src/camera-manager/frigate/requests.ts +++ b/src/camera-manager/frigate/requests.ts @@ -8,6 +8,8 @@ import { eventSummarySchema, FrigateEvent, frigateEventsSchema, + PTZInfo, + ptzInfoSchema, recordingSegmentsSchema, RecordingSummary, recordingSummarySchema, @@ -151,3 +153,20 @@ export const getEventSummary = async ( true, ); }; + +export const getPTZInfo = async ( + hass: HomeAssistant, + clientID: string, + cameraName: string, +): Promise => { + return await homeAssistantWSRequest( + hass, + ptzInfoSchema, + { + type: 'frigate/ptz/info', + instance_id: clientID, + camera: cameraName, + }, + true, + ); +}; diff --git a/src/camera-manager/frigate/types.ts b/src/camera-manager/frigate/types.ts index 8c923401..1d3bf95d 100644 --- a/src/camera-manager/frigate/types.ts +++ b/src/camera-manager/frigate/types.ts @@ -76,6 +76,13 @@ export const eventSummarySchema = z .array(); export type EventSummary = z.infer; +export const ptzInfoSchema = z.object({ + name: z.string().optional(), + features: z.string().array().optional(), + presets: z.string().array().optional(), +}); +export type PTZInfo = z.infer; + // ============================== // Frigate concrete query results // ============================== diff --git a/src/camera-manager/generic/engine-generic.ts b/src/camera-manager/generic/engine-generic.ts index 3a5fe04e..ed8986a4 100644 --- a/src/camera-manager/generic/engine-generic.ts +++ b/src/camera-manager/generic/engine-generic.ts @@ -1,18 +1,22 @@ /* eslint-disable @typescript-eslint/no-unused-vars */ import { HomeAssistant } from '@dermotduffy/custom-card-helpers'; -import { CameraConfig } from '../../config/types'; +import { + CameraConfig, + PTZAction, + PTZPhase +} from '../../config/types'; import { ExtendedHomeAssistant } from '../../types'; import { getEntityIcon, getEntityTitle } from '../../utils/ha'; import { EntityRegistryManager } from '../../utils/ha/entity-registry'; import { ViewMedia } from '../../view/media'; +import { Camera } from '../camera'; import { CameraManagerEngine } from '../engine'; +import { CameraManagerReadOnlyConfigStore } from '../store'; import { - CameraConfigs, CameraEndpoint, CameraEndpoints, CameraEndpointsContext, - CameraManagerCameraCapabilities, CameraManagerCameraMetadata, CameraManagerMediaCapabilities, DataQuery, @@ -29,7 +33,7 @@ import { RecordingQuery, RecordingQueryResultsMap, RecordingSegmentsQuery, - RecordingSegmentsQueryResultsMap, + RecordingSegmentsQueryResultsMap } from '../types'; import { getCameraEntityFromConfig, getDefaultGo2RTCEndpoint } from '../utils.js'; @@ -42,12 +46,20 @@ export class GenericCameraManagerEngine implements CameraManagerEngine { _hass: HomeAssistant, _entityRegistryManager: EntityRegistryManager, cameraConfig: CameraConfig, - ): Promise { - return cameraConfig; + ): Promise { + return new Camera(cameraConfig, this, { + canFavoriteEvents: false, + canFavoriteRecordings: false, + canSeek: false, + supportsClips: false, + supportsRecordings: false, + supportsSnapshots: false, + supportsTimeline: false, + }); } public generateDefaultEventQuery( - _cameras: CameraConfigs, + _store: CameraManagerReadOnlyConfigStore, _cameraIDs: Set, _query: PartialEventQuery, ): EventQuery[] | null { @@ -55,7 +67,7 @@ export class GenericCameraManagerEngine implements CameraManagerEngine { } public generateDefaultRecordingQuery( - _cameras: CameraConfigs, + _store: CameraManagerReadOnlyConfigStore, _cameraIDs: Set, _query: PartialRecordingQuery, ): RecordingQuery[] | null { @@ -63,7 +75,7 @@ export class GenericCameraManagerEngine implements CameraManagerEngine { } public generateDefaultRecordingSegmentsQuery( - _cameras: CameraConfigs, + _store: CameraManagerReadOnlyConfigStore, _cameraIDs: Set, _query: PartialRecordingSegmentsQuery, ): RecordingSegmentsQuery[] | null { @@ -72,7 +84,7 @@ export class GenericCameraManagerEngine implements CameraManagerEngine { public async getEvents( _hass: HomeAssistant, - _cameras: CameraConfigs, + _store: CameraManagerReadOnlyConfigStore, _query: EventQuery, _engineOptions?: EngineOptions, ): Promise { @@ -81,7 +93,7 @@ export class GenericCameraManagerEngine implements CameraManagerEngine { public async getRecordings( _hass: HomeAssistant, - _cameras: CameraConfigs, + _store: CameraManagerReadOnlyConfigStore, _query: RecordingQuery, _engineOptions?: EngineOptions, ): Promise { @@ -90,7 +102,7 @@ export class GenericCameraManagerEngine implements CameraManagerEngine { public async getRecordingSegments( _hass: HomeAssistant, - _cameras: CameraConfigs, + _store: CameraManagerReadOnlyConfigStore, _query: RecordingSegmentsQuery, _engineOptions?: EngineOptions, ): Promise { @@ -99,7 +111,7 @@ export class GenericCameraManagerEngine implements CameraManagerEngine { public generateMediaFromEvents( _hass: HomeAssistant, - _cameras: CameraConfigs, + _store: CameraManagerReadOnlyConfigStore, _query: EventQuery, _results: QueryReturnType, ): ViewMedia[] | null { @@ -108,7 +120,7 @@ export class GenericCameraManagerEngine implements CameraManagerEngine { public generateMediaFromRecordings( _hass: HomeAssistant, - _cameras: CameraConfigs, + _store: CameraManagerReadOnlyConfigStore, _query: RecordingQuery, _results: QueryReturnType, ): ViewMedia[] | null { @@ -138,7 +150,7 @@ export class GenericCameraManagerEngine implements CameraManagerEngine { public async getMediaSeekTime( _hass: HomeAssistant, - _cameras: CameraConfigs, + _store: CameraManagerReadOnlyConfigStore, _media: ViewMedia, _target: Date, _engineOptions?: EngineOptions, @@ -148,7 +160,7 @@ export class GenericCameraManagerEngine implements CameraManagerEngine { public async getMediaMetadata( _hass: HomeAssistant, - _cameras: CameraConfigs, + _store: CameraManagerReadOnlyConfigStore, _query: MediaMetadataQuery, _engineOptions?: EngineOptions, ): Promise { @@ -173,20 +185,6 @@ export class GenericCameraManagerEngine implements CameraManagerEngine { }; } - public getCameraCapabilities( - _cameraConfig: CameraConfig, - ): CameraManagerCameraCapabilities | null { - return { - canFavoriteEvents: false, - canFavoriteRecordings: false, - canSeek: false, - supportsClips: false, - supportsRecordings: false, - supportsSnapshots: false, - supportsTimeline: false, - }; - } - public getMediaCapabilities(_media: ViewMedia): CameraManagerMediaCapabilities | null { return null; } @@ -202,4 +200,16 @@ export class GenericCameraManagerEngine implements CameraManagerEngine { } : null; } + + public async executePTZAction( + _hass: HomeAssistant, + _cameraConfig: CameraConfig, + _action: PTZAction, + _options?: { + phase?: PTZPhase; + preset?: string; + }, + ): Promise { + // Pass. + } } diff --git a/src/camera-manager/manager.ts b/src/camera-manager/manager.ts index 5ca659e8..efcefd4f 100644 --- a/src/camera-manager/manager.ts +++ b/src/camera-manager/manager.ts @@ -1,16 +1,14 @@ -import { HomeAssistant } from '@dermotduffy/custom-card-helpers'; import add from 'date-fns/add'; import cloneDeep from 'lodash-es/cloneDeep'; import merge from 'lodash-es/merge.js'; import sum from 'lodash-es/sum'; import { CardCameraAPI } from '../card-controller/types.js'; -import { CameraConfig, CamerasConfig } from '../config/types.js'; +import { CameraConfig, CamerasConfig, PTZAction, PTZPhase } from '../config/types.js'; import { MEDIA_CHUNK_SIZE_DEFAULT } from '../const.js'; import { localize } from '../localize/localize.js'; import { allPromises, arrayify, setify } from '../utils/basic.js'; import { getCameraID } from '../utils/camera.js'; import { log } from '../utils/debug.js'; -import { EntityRegistryManager } from '../utils/ha/entity-registry/index.js'; import { ViewMedia } from '../view/media.js'; import { CameraManagerEngineFactory } from './engine-factory.js'; import { CameraManagerEngine } from './engine.js'; @@ -49,7 +47,7 @@ import { RecordingSegmentsQuery, RecordingSegmentsQueryResults, RecordingSegmentsQueryResultsMap, - ResultsMap, + ResultsMap } from './types.js'; import { sortMedia } from './utils.js'; @@ -102,31 +100,26 @@ export interface ExtendedMediaQueryResult { results: ViewMedia[]; } -interface InitializedCamera { - inputConfig: CameraConfig; - initializedConfig: CameraConfig; - engine: CameraManagerEngine; -} - export class CameraManager { protected _api: CardCameraAPI; protected _engineFactory: CameraManagerEngineFactory; - protected _store = new CameraManagerStore(); + protected _store: CameraManagerStore; - constructor(api: CardCameraAPI) { + constructor(api: CardCameraAPI, store?: CameraManagerStore) { this._api = api; this._engineFactory = new CameraManagerEngineFactory( this._api.getEntityRegistryManager(), this._api.getResolvedMediaCache(), ); + this._store = store ?? new CameraManagerStore(); } - public async initializeCamerasFromConfig(): Promise { + public async initializeCamerasFromConfig(): Promise { const config = this._api.getConfigManager().getConfig(); const hass = this._api.getHASSManager().getHASS(); if (!config || !hass) { - return; + return false; } this._store.reset(); @@ -143,7 +136,9 @@ export class CameraManager { await this._initializeCameras(cameras); } catch (e: unknown) { this._api.getMessageManager().setErrorIfHigherPriority(e); + return false; } + return true; } protected async _getEnginesForCameras( @@ -172,7 +167,9 @@ export class CameraManager { if (!engine || !engineType) { throw new CameraInitializationError( localize('error.no_camera_engine'), - cameraConfig, + // Camera initialization may modify the configuration. Keep the + // original config unchanged. + cloneDeep(cameraConfig), ); } engines.set(engineType, engine); @@ -181,27 +178,6 @@ export class CameraManager { return output; } - protected async _initializeCamera( - hass: HomeAssistant, - engine: CameraManagerEngine, - entityRegistryManager: EntityRegistryManager, - inputCameraConfig: CameraConfig, - ): Promise { - const initializedConfig = await engine.initializeCamera( - hass, - entityRegistryManager, - // Camera initialization may modify the configuration. Keep the original - // for display in error messages to avoid user confusion. - cloneDeep(inputCameraConfig), - ); - - return { - inputConfig: inputCameraConfig, - initializedConfig: initializedConfig, - engine: engine, - }; - } - protected async _initializeCameras(camerasConfig: CamerasConfig): Promise { const initializationStartTime = new Date(); const hass = this._api.getHASSManager().getHASS(); @@ -229,12 +205,11 @@ export class CameraManager { const engineByConfig = await this._getEnginesForCameras(camerasConfig); // Configuration is initialized in parallel. - const results = await allPromises( + const cameras = await allPromises( engineByConfig.entries(), async ([cameraConfig, engine]) => - await this._initializeCamera( + await engine.initializeCamera( hass, - engine, this._api.getEntityRegistryManager(), cameraConfig, ), @@ -242,27 +217,26 @@ export class CameraManager { // Do the additions based off the result-order, to ensure the map order is // preserved. - results.forEach((result) => { - const id = getCameraID(result.initializedConfig); + cameras.forEach((camera) => { + const cameraID = getCameraID(camera.getConfig()); - if (!id) { + if (!cameraID) { throw new CameraInitializationError( localize('error.no_camera_id'), - result.inputConfig, + camera.getConfig(), ); } - if (this._store.hasCameraID(id)) { + if (this._store.hasCameraID(cameraID)) { throw new CameraInitializationError( localize('error.duplicate_camera_id'), - result.inputConfig, + camera.getConfig(), ); } // Always ensure the actual ID used in the card is in the configuration itself. - result.initializedConfig.id = id; - - this._store.addCamera(id, result.initializedConfig, result.engine); + camera.setID(cameraID); + this._store.addCamera(camera); }); if (!this._store.getVisibleCameraCount()) { @@ -372,20 +346,16 @@ export class CameraManager { for (const [engine, cameraIDs] of engines) { let queries: DataQuery[] | null = null; if (QueryClassifier.isEventQuery(partialQuery)) { - queries = engine.generateDefaultEventQuery( - this._store.getVisibleCameras(), - cameraIDs, - partialQuery, - ); + queries = engine.generateDefaultEventQuery(this._store, cameraIDs, partialQuery); } else if (QueryClassifier.isRecordingQuery(partialQuery)) { queries = engine.generateDefaultRecordingQuery( - this._store.getVisibleCameras(), + this._store, cameraIDs, partialQuery, ); } else if (QueryClassifier.isRecordingSegmentsQuery(partialQuery)) { queries = engine.generateDefaultRecordingSegmentsQuery( - this._store.getVisibleCameras(), + this._store, cameraIDs, partialQuery, ); @@ -596,7 +566,7 @@ export class CameraManager { return null; } - return await engine.getMediaSeekTime(hass, this._store.getCameras(), media, target); + return await engine.getMediaSeekTime(hass, this._store, media, target); } protected async _handleQuery( @@ -624,28 +594,28 @@ export class CameraManager { if (QueryClassifier.isEventQuery(query)) { engineResult = (await engine.getEvents( hass, - this._store.getCameras(), + this._store, query, engineOptions, )) as Map> | null; } else if (QueryClassifier.isRecordingQuery(query)) { engineResult = (await engine.getRecordings( hass, - this._store.getCameras(), + this._store, query, engineOptions, )) as Map> | null; } else if (QueryClassifier.isRecordingSegmentsQuery(query)) { engineResult = (await engine.getRecordingSegments( hass, - this._store.getCameras(), + this._store, query, engineOptions, )) as Map> | null; } else if (QueryClassifier.isMediaMetadataQuery(query)) { engineResult = (await engine.getMediaMetadata( hass, - this._store.getCameras(), + this._store, query, engineOptions, )) as Map> | null; @@ -710,22 +680,12 @@ export class CameraManager { QueryClassifier.isEventQuery(query) && QueryResultClassifier.isEventQueryResult(result) ) { - media = engine.generateMediaFromEvents( - hass, - this._store.getCameras(), - query, - result, - ); + media = engine.generateMediaFromEvents(hass, this._store, query, result); } else if ( QueryClassifier.isRecordingQuery(query) && QueryResultClassifier.isRecordingQuery(result) ) { - media = engine.generateMediaFromRecordings( - hass, - this._store.getCameras(), - query, - result, - ); + media = engine.generateMediaFromRecordings(hass, this._store, query, result); } if (media) { mediaArray.push(...media); @@ -761,12 +721,7 @@ export class CameraManager { public getCameraCapabilities( cameraID: string, ): CameraManagerCameraCapabilities | null { - const cameraConfig = this._store.getCameraConfig(cameraID); - const engine = this._store.getEngineForCameraID(cameraID); - if (!cameraConfig || !engine) { - return null; - } - return engine.getCameraCapabilities(cameraConfig); + return this._store.getCamera(cameraID)?.getCapabilities() ?? null; } public getAggregateCameraCapabilities( @@ -787,6 +742,26 @@ export class CameraManager { supportsRecordings: perCameraCapabilities.some((cap) => cap?.supportsRecordings), supportsSnapshots: perCameraCapabilities.some((cap) => cap?.supportsSnapshots), supportsTimeline: perCameraCapabilities.some((cap) => cap?.supportsTimeline), + + supportsPTZ: perCameraCapabilities.some((cap) => !!cap?.ptz), }; } + + public async executePTZAction( + cameraID: string, + action: PTZAction, + options: { + phase?: PTZPhase; + preset?: string; + }, + ): Promise { + const hass = this._api.getHASSManager().getHASS(); + const engine = this._store.getEngineForCameraID(cameraID); + const cameraConfig = this._store.getCameraConfig(cameraID); + + if (!engine || !cameraConfig || !hass) { + return; + } + return engine.executePTZAction(hass, cameraConfig, action, options); + } } diff --git a/src/camera-manager/motioneye/engine-motioneye.ts b/src/camera-manager/motioneye/engine-motioneye.ts index c7116239..f326b08f 100644 --- a/src/camera-manager/motioneye/engine-motioneye.ts +++ b/src/camera-manager/motioneye/engine-motioneye.ts @@ -8,25 +8,23 @@ import { CameraConfig } from '../../config/types'; import { allPromises, formatDate, isValidDate } from '../../utils/basic'; import { BrowseMediaStep, - BrowseMediaTarget, + BrowseMediaTarget } from '../../utils/ha/browse-media/browse-media-manager'; import { - BROWSE_MEDIA_CACHE_SECONDS, - BrowseMedia, - MEDIA_CLASS_IMAGE, + BrowseMedia, BROWSE_MEDIA_CACHE_SECONDS, MEDIA_CLASS_IMAGE, MEDIA_CLASS_VIDEO, - RichBrowseMedia, + RichBrowseMedia } from '../../utils/ha/browse-media/types'; import { ViewMedia } from '../../view/media'; import { BrowseMediaCameraManagerEngine, getViewMediaFromBrowseMediaArray, - isMediaWithinDates, + isMediaWithinDates } from '../browse-media/engine-browse-media'; import { BrowseMediaMetadata } from '../browse-media/types'; import { CAMERA_MANAGER_ENGINE_EVENT_LIMIT_DEFAULT } from '../engine'; +import { CameraManagerReadOnlyConfigStore } from '../store'; import { - CameraConfigs, CameraEndpoint, CameraEndpoints, CameraEndpointsContext, @@ -41,7 +39,7 @@ import { MediaMetadataQueryResultsMap, QueryResults, QueryResultsType, - QueryReturnType, + QueryReturnType } from '../types'; import motioneyeLogo from './assets/motioneye-logo.svg'; import { MotionEyeEventQueryResults } from './types'; @@ -126,7 +124,7 @@ export class MotionEyeCameraManagerEngine extends BrowseMediaCameraManagerEngine // Get media directories that match a given criteria. protected async _getMatchingDirectories( hass: HomeAssistant, - cameras: CameraConfigs, + store: CameraManagerReadOnlyConfigStore, cameraID: string, matchOptions?: { start?: Date; @@ -136,11 +134,11 @@ export class MotionEyeCameraManagerEngine extends BrowseMediaCameraManagerEngine } | null, engineOptions?: EngineOptions, ): Promise[] | null> { - const cameraEntityID = cameras.get(cameraID)?.camera_entity; + const cameraConfig = store.getCameraConfig(cameraID); + const cameraEntityID = cameraConfig?.camera_entity; const entity = cameraEntityID ? this._cameraEntities.get(cameraEntityID) : null; const configID = entity?.config_entry_id; const deviceID = entity?.device_id; - const cameraConfig = cameras.get(cameraID); if (!configID || !deviceID || !cameraConfig) { return null; @@ -206,7 +204,7 @@ export class MotionEyeCameraManagerEngine extends BrowseMediaCameraManagerEngine public async getEvents( hass: HomeAssistant, - cameras: CameraConfigs, + store: CameraManagerReadOnlyConfigStore, query: EventQuery, engineOptions?: EngineOptions, ): Promise { @@ -225,14 +223,14 @@ export class MotionEyeCameraManagerEngine extends BrowseMediaCameraManagerEngine return; } - const cameraConfig = cameras.get(cameraID); + const cameraConfig = store.getCameraConfig(cameraID); if (!cameraConfig) { return; } const directories = await this._getMatchingDirectories( hass, - cameras, + store, cameraID, perCameraQuery, engineOptions, @@ -309,7 +307,7 @@ export class MotionEyeCameraManagerEngine extends BrowseMediaCameraManagerEngine public generateMediaFromEvents( _hass: HomeAssistant, - _cameras: CameraConfigs, + _store: CameraManagerReadOnlyConfigStore, _query: EventQuery, results: QueryReturnType, ): ViewMedia[] | null { @@ -321,7 +319,7 @@ export class MotionEyeCameraManagerEngine extends BrowseMediaCameraManagerEngine public async getMediaMetadata( hass: HomeAssistant, - cameras: CameraConfigs, + store: CameraManagerReadOnlyConfigStore, query: MediaMetadataQuery, engineOptions?: EngineOptions, ): Promise { @@ -340,7 +338,7 @@ export class MotionEyeCameraManagerEngine extends BrowseMediaCameraManagerEngine const getDaysForCamera = async (cameraID: string): Promise => { const directories = await this._getMatchingDirectories( hass, - cameras, + store, cameraID, null, engineOptions, diff --git a/src/camera-manager/store.ts b/src/camera-manager/store.ts index b5ec864e..752a6aaf 100644 --- a/src/camera-manager/store.ts +++ b/src/camera-manager/store.ts @@ -1,7 +1,8 @@ import { CameraConfig } from '../config/types'; import { ViewMedia } from '../view/media'; +import { Camera } from './camera'; import { CameraManagerEngine } from './engine'; -import { CameraConfigs, Engine } from './types'; +import { Engine } from './types'; type CameraManagerEngineCameraIDMap = Map>; @@ -10,74 +11,82 @@ export interface CameraManagerReadOnlyConfigStore { getCameraConfigForMedia(media: ViewMedia): CameraConfig | null; hasCameraID(cameraID: string): boolean; - hasVisibleCameraID(cameraID: string): boolean; + getCamera(cameraID: string): Camera | null; getCameraCount(): number; getVisibleCameraCount(): number; - getCameras(): CameraConfigs; - getVisibleCameras(): CameraConfigs; + getCameraConfigs(cameraIDs?: Iterable): IterableIterator; + getCameraConfigEntries( + cameraIDs?: Iterable, + ): IterableIterator<[string, CameraConfig]>; getCameraIDs(): Set; getVisibleCameraIDs(): Set; + + getAllDependentCameras(cameraID: string): Set; } export class CameraManagerStore implements CameraManagerReadOnlyConfigStore { - protected _allConfigs: Map = new Map(); - protected _visibleConfigs: Map = new Map(); - protected _enginesByCamera: Map = new Map(); + protected _cameras: Map = new Map(); protected _enginesByType: Map = new Map(); - public addCamera( - cameraID: string, - cameraConfig: CameraConfig, - engine: CameraManagerEngine, - ): void { - if (!cameraConfig.hide) { - this._visibleConfigs.set(cameraID, cameraConfig); - } - this._allConfigs.set(cameraID, cameraConfig); - this._enginesByCamera.set(cameraID, engine); - this._enginesByType.set(engine.getEngineType(), engine); + public addCamera(camera: Camera): void { + this._cameras.set(camera.getID(), camera); + this._enginesByType.set(camera.getEngine().getEngineType(), camera.getEngine()); } - public reset(): void { - this._allConfigs.clear(); - this._visibleConfigs.clear(); - this._enginesByCamera.clear(); + this._cameras.clear(); this._enginesByType.clear(); } + public getCamera(cameraID: string): Camera | null { + return this._cameras.get(cameraID) ?? null; + } public getCameraConfig(cameraID: string): CameraConfig | null { - return this._allConfigs.get(cameraID) ?? null; + return this._cameras.get(cameraID)?.getConfig() ?? null; } public hasCameraID(cameraID: string): boolean { - return this._allConfigs.has(cameraID); - } - public hasVisibleCameraID(cameraID: string): boolean { - return this._visibleConfigs.has(cameraID); + return this._cameras.has(cameraID); } public getCameraCount(): number { - return this._allConfigs.size; + return this._cameras.size; } public getVisibleCameraCount(): number { - return this._visibleConfigs.size; + return this.getVisibleCameraIDs().size; + } + + public getCameras(): Map { + return this._cameras; } - public getCameras(): CameraConfigs { - return this._allConfigs; + public *getCameraConfigs( + cameraIDs?: Iterable, + ): IterableIterator { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + for (const [_cameraID, config] of this.getCameraConfigEntries(cameraIDs)) { + yield config; + } } - public getVisibleCameras(): CameraConfigs { - return this._visibleConfigs; + public *getCameraConfigEntries( + cameraIDs?: Iterable, + ): IterableIterator<[string, CameraConfig]> { + for (const cameraID of cameraIDs ?? this._cameras.keys()) { + const config = this.getCameraConfig(cameraID); + + if (config) { + yield [cameraID, config]; + } + } } public getCameraIDs(): Set { - return new Set(this._allConfigs.keys()); + return new Set(this._cameras.keys()); } public getVisibleCameraIDs(): Set { - return new Set(this._visibleConfigs.keys()); + return this._getMatchingCameraIDs((camera) => !camera.getConfig().hide); } public getCameraConfigForMedia(media: ViewMedia): CameraConfig | null { @@ -89,7 +98,7 @@ export class CameraManagerStore implements CameraManagerReadOnlyConfigStore { } public getEngineForCameraID(cameraID: string): CameraManagerEngine | null { - return this._enginesByCamera.get(cameraID) ?? null; + return this._cameras.get(cameraID)?.getEngine() ?? null; } public getEnginesForCameraIDs( @@ -114,7 +123,42 @@ export class CameraManagerStore implements CameraManagerReadOnlyConfigStore { return this.getEngineForCameraID(media.getCameraID()); } - public getAllEngines(): CameraManagerEngine[] { - return [...this._enginesByType.values()]; + /** + * Get all cameras that depend on a given camera. + * @param cameraManager The camera manager. + * @param cameraID ID of the target camera. + * @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(); + const getDependentCameras = (cameraID: string): void => { + const cameraConfig = this.getCameraConfig(cameraID); + if (cameraConfig) { + cameraIDs.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); + } + } + } + }; + 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; } } diff --git a/src/camera-manager/types.ts b/src/camera-manager/types.ts index a9c6906e..9f4353bd 100644 --- a/src/camera-manager/types.ts +++ b/src/camera-manager/types.ts @@ -1,4 +1,4 @@ -import { CameraConfig, FrigateCardView } from '../config/types'; +import { FrigateCardView } from '../config/types'; import { ViewMedia } from '../view/media'; // ==== @@ -91,6 +91,14 @@ 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; @@ -102,8 +110,14 @@ interface BaseCapabilities { supportsTimeline: boolean; } -export type CameraManagerCapabilities = BaseCapabilities; -export type CameraManagerCameraCapabilities = BaseCapabilities; +export interface CameraManagerCapabilities extends BaseCapabilities { + supportsPTZ: boolean; +} + +export interface CameraManagerCameraCapabilities extends BaseCapabilities { + ptz?: PTZCapabilities; +} + export interface CameraManagerMediaCapabilities { canFavorite: boolean; canDownload: boolean; @@ -132,8 +146,6 @@ export interface CameraEndpoints { webrtcCard?: CameraEndpoint; } -export type CameraConfigs = Map; - export interface EngineOptions { useCache?: boolean; } diff --git a/src/card-controller/actions-manager.ts b/src/card-controller/actions-manager.ts index 37d50b5a..68a0572f 100644 --- a/src/card-controller/actions-manager.ts +++ b/src/card-controller/actions-manager.ts @@ -216,6 +216,22 @@ export class ActionsManager { .getViewManager() .setViewWithNewDisplayMode(frigateCardAction.display_mode); break; + case 'ptz': + const cameraID = this._api.getViewManager().getView()?.camera; + if (cameraID) { + this._api + .getCameraManager() + .executePTZAction(cameraID, frigateCardAction.ptz_action, { + phase: frigateCardAction.ptz_phase, + preset: frigateCardAction.ptz_preset, + }); + } + break; + case 'show_ptz': + this._api + .getViewManager() + .setViewWithNewContext({ live: { ptzVisible: frigateCardAction.show_ptz } }); + break; default: console.warn(`Frigate card received unknown card action: ${action}`); } diff --git a/src/card-controller/initialization-manager.ts b/src/card-controller/initialization-manager.ts index c6fa07da..1b630fc3 100644 --- a/src/card-controller/initialization-manager.ts +++ b/src/card-controller/initialization-manager.ts @@ -13,7 +13,7 @@ export enum InitializationAspect { export class InitializationManager { protected _api: CardInitializerAPI; - protected _initializer; + protected _initializer: Initializer; constructor(api: CardInitializerAPI, initializer?: Initializer) { this._api = api; @@ -21,11 +21,15 @@ export class InitializationManager { } public isInitializedMandatory(): boolean { - return this._initializer.isInitializedMultiple([ - InitializationAspect.LANGUAGES, - InitializationAspect.SIDE_LOAD_ELEMENTS, - InitializationAspect.CAMERAS, - ]); + return ( + this._initializer.isInitializedMultiple([ + InitializationAspect.LANGUAGES, + InitializationAspect.SIDE_LOAD_ELEMENTS, + InitializationAspect.CAMERAS, + ]) && + // If there's no view, re-initialize (e.g. config changes). + this._api.getViewManager().hasView() + ); } /** @@ -80,7 +84,6 @@ export class InitializationManager { } } - this._api.getCardElementManager().update(); return true; } diff --git a/src/card-controller/media-player-manager.ts b/src/card-controller/media-player-manager.ts index e6af01bc..2cda70be 100644 --- a/src/card-controller/media-player-manager.ts +++ b/src/card-controller/media-player-manager.ts @@ -25,10 +25,10 @@ export class MediaPlayerManager { return this._mediaPlayers.length > 0; } - public async initialize(): Promise { + public async initialize(): Promise { const hass = this._api.getHASSManager().getHASS(); if (!hass) { - return; + return false; } const isValidMediaPlayer = (entityID: string): boolean => { @@ -66,6 +66,8 @@ export class MediaPlayerManager { const entity = mediaPlayerEntities?.get(entityID); return !entity || !entity.hidden_by; }); + + return true; } public async stop(mediaPlayer: string): Promise { diff --git a/src/card-controller/microphone-manager.ts b/src/card-controller/microphone-manager.ts index d2d878b0..303a79c6 100644 --- a/src/card-controller/microphone-manager.ts +++ b/src/card-controller/microphone-manager.ts @@ -16,7 +16,7 @@ export class MicrophoneManager { this._api = api; } - public async connect(): Promise { + public async connect(): Promise { try { this._stream = await navigator.mediaDevices.getUserMedia({ audio: true, @@ -24,15 +24,19 @@ export class MicrophoneManager { }); } catch (e: unknown) { errorToConsole(e as Error); + this._stream = null; + this._api.getCardElementManager().update(); + return false; } this._setMute(); + return true; } public async disconnect(): Promise { this._stream?.getTracks().forEach((track) => track.stop()); - this._stream = undefined; + this._stream = undefined; this._api.getCardElementManager().update(); } diff --git a/src/card-controller/query-string-manager.ts b/src/card-controller/query-string-manager.ts index faf1f813..41840f84 100644 --- a/src/card-controller/query-string-manager.ts +++ b/src/card-controller/query-string-manager.ts @@ -1,5 +1,5 @@ import { FrigateCardCustomAction, FrigateCardViewAction } from '../config/types'; -import { createFrigateCardCustomAction } from '../utils/action.js'; +import { createFrigateCardCameraAction, createFrigateCardSimpleAction } from '../utils/action.js'; import { CardQueryStringAPI } from './types'; import { ViewManagerSetViewParameters } from './view-manager'; @@ -139,8 +139,7 @@ export class QueryStringManager { case 'camera_select': case 'live_substream_select': if (value) { - customAction = createFrigateCardCustomAction(action, { - camera: value, + customAction = createFrigateCardCameraAction(action, value, { cardID: cardID, }); } @@ -160,7 +159,7 @@ export class QueryStringManager { case 'snapshot': case 'snapshots': case 'timeline': - customAction = createFrigateCardCustomAction(action, { + customAction = createFrigateCardSimpleAction(action, { cardID: cardID, }); break; diff --git a/src/card-controller/triggers-manager.ts b/src/card-controller/triggers-manager.ts index 4850b01e..9e9ab222 100644 --- a/src/card-controller/triggers-manager.ts +++ b/src/card-controller/triggers-manager.ts @@ -28,8 +28,14 @@ export class TriggersManager { const now = new Date(); let triggerChanges = false; - const cameras = this._api.getCameraManager().getStore().getVisibleCameras(); - for (const [cameraID, config] of cameras?.entries()) { + const visibleCameraIDs = this._api + .getCameraManager() + .getStore() + .getVisibleCameraIDs(); + for (const [cameraID, config] of this._api + .getCameraManager() + .getStore() + .getCameraConfigEntries(visibleCameraIDs)) { const triggerEntities = config.triggers.entities; const diffs = getHassDifferences(hass, oldHass, triggerEntities, { stateOnly: true, diff --git a/src/card-controller/view-manager.ts b/src/card-controller/view-manager.ts index 39c95702..569cb323 100644 --- a/src/card-controller/view-manager.ts +++ b/src/card-controller/view-manager.ts @@ -1,7 +1,6 @@ import { ViewContext } from 'view'; import { FrigateCardConfig, FrigateCardView, ViewDisplayMode } from '../config/types'; import { View } from '../view/view'; -import { getAllDependentCameras } from '../utils/camera'; import { log } from '../utils/debug'; import { executeMediaQueryForView } from '../utils/media-to-view'; import { CardViewAPI } from './types'; @@ -28,6 +27,10 @@ export class ViewManager { return this._view; } + public hasView(): boolean { + return !!this.getView(); + } + public setView(view: View): void { this._setView(view); } @@ -255,7 +258,7 @@ export class ViewManager { protected _createViewWithNextStream(baseView: View): View { const dependencies = [ - ...getAllDependentCameras(this._api.getCameraManager(), baseView.camera), + ...this._api.getCameraManager().getStore().getAllDependentCameras(baseView.camera), ]; if (dependencies.length <= 1) { return baseView.clone(); diff --git a/src/card.ts b/src/card.ts index a759833d..527b710a 100644 --- a/src/card.ts +++ b/src/card.ts @@ -163,6 +163,12 @@ class FrigateCard extends LitElement { } protected shouldUpdate(): boolean { + // Always allow messages to render, as a message may be generated during + // initialization. + if (this._controller.getMessageManager().hasMessage()) { + return true; + } + if (!this._controller.getInitializationManager().isInitializedMandatory()) { this._controller.getInitializationManager().initializeMandatory(); return false; @@ -295,6 +301,12 @@ class FrigateCard extends LitElement { .view=${this._controller.getViewManager().getView()} .cameraManager=${cameraManager} .resolvedMediaCache=${this._controller.getResolvedMediaCache()} + .nonOverriddenConfig=${this._controller + .getConfigManager() + .getNonOverriddenConfig()} + .overriddenConfig=${this._controller.getConfigManager().getConfig()} + .cardWideConfig=${this._controller.getConfigManager().getCardWideConfig()} + .rawConfig=${this._controller.getConfigManager().getRawConfig()} .configManager=${this._controller.getConfigManager()} .conditionsManagerEpoch=${this._controller .getConditionsManager() diff --git a/src/components-lib/menu-controller.ts b/src/components-lib/menu-controller.ts index ac561641..56639fb6 100644 --- a/src/components-lib/menu-controller.ts +++ b/src/components-lib/menu-controller.ts @@ -1,24 +1,29 @@ import { HomeAssistant } from '@dermotduffy/custom-card-helpers'; import { StyleInfo } from 'lit/directives/style-map'; import { CameraManager } from '../camera-manager/manager'; +import { MediaPlayerManager } from '../card-controller/media-player-manager'; +import { MicrophoneManager } from '../card-controller/microphone-manager'; import { - FRIGATE_CARD_VIEWS_USER_SPECIFIED, FrigateCardConfig, FrigateCardCustomAction, - MenuItem + FRIGATE_CARD_VIEWS_USER_SPECIFIED, + MenuItem, } from '../config/types'; import { FRIGATE_BUTTON_MENU_ICON } from '../const'; import { localize } from '../localize/localize.js'; +import { MediaLoadedInfo } from '../types'; import { - MediaLoadedInfo, -} from '../types'; -import { View } from '../view/view'; -import { createFrigateCardCustomAction } from '../utils/action'; -import { getAllDependentCameras } from '../utils/camera'; -import { MediaPlayerManager } from '../card-controller/media-player-manager'; -import { MicrophoneManager } from '../card-controller/microphone-manager'; + createFrigateCardCameraAction, + createFrigateCardDisplayModeAction, + createFrigateCardMediaPlayerAction, + createFrigateCardShowPTZAction, + createFrigateCardSimpleAction, +} from '../utils/action'; import { getEntityIcon, getEntityTitle } from '../utils/ha'; +import { hasUsablePTZ } from '../utils/ptz'; import { hasSubstream } from '../utils/substream'; +import { View } from '../view/view'; + export interface MenuButtonControllerOptions { currentMediaLoadedInfo?: MediaLoadedInfo | null; showCameraUIButton?: boolean; @@ -55,15 +60,19 @@ export class MenuButtonController { view: View, options?: MenuButtonControllerOptions, ): MenuItem[] { - const visibleCameras = cameraManager.getStore().getVisibleCameras(); + const visibleCameraIDs = cameraManager.getStore().getVisibleCameraIDs(); const selectedCameraID = view.camera; const selectedCameraConfig = cameraManager .getStore() .getCameraConfig(selectedCameraID); - const allSelectedCameraIDs = getAllDependentCameras(cameraManager, selectedCameraID); + const allSelectedCameraIDs = cameraManager + .getStore() + .getAllDependentCameras(selectedCameraID); const selectedMedia = view.queryResults?.getSelectedResult(); - const cameraCapabilities = + const selectedCameraCapabilities = + cameraManager.getCameraCapabilities(selectedCameraID); + const aggregateCapabilities = cameraManager.getAggregateCameraCapabilities(allSelectedCameraIDs); const mediaCapabilities = selectedMedia ? cameraManager?.getMediaCapabilities(selectedMedia) @@ -79,30 +88,31 @@ export class MenuButtonController { title: localize('config.menu.buttons.frigate'), tap_action: config.menu?.style === 'hidden' - ? (createFrigateCardCustomAction('menu_toggle') as FrigateCardCustomAction) - : (createFrigateCardCustomAction('default') as FrigateCardCustomAction), - hold_action: createFrigateCardCustomAction( + ? (createFrigateCardSimpleAction('menu_toggle') as FrigateCardCustomAction) + : (createFrigateCardSimpleAction('default') as FrigateCardCustomAction), + hold_action: createFrigateCardSimpleAction( 'diagnostics', ) as FrigateCardCustomAction, }); - if (visibleCameras.size) { - const menuItems = Array.from(visibleCameras, ([cameraID, config]) => { - const action = createFrigateCardCustomAction('camera_select', { - camera: cameraID, - }); - const metadata = cameraManager.getCameraMetadata(cameraID); - - return { - enabled: true, - icon: metadata?.icon, - entity: config.camera_entity, - state_color: true, - title: metadata?.title, - selected: selectedCameraID === cameraID, - ...(action && { tap_action: action }), - }; - }); + if (visibleCameraIDs.size) { + const menuItems = Array.from( + cameraManager.getStore().getCameraConfigEntries(visibleCameraIDs), + ([cameraID, config]) => { + const action = createFrigateCardCameraAction('camera_select', cameraID); + const metadata = cameraManager.getCameraMetadata(cameraID); + + return { + enabled: true, + icon: metadata?.icon, + entity: config.camera_entity, + state_color: true, + title: metadata?.title, + selected: selectedCameraID === cameraID, + ...(action && { tap_action: action }), + }; + }, + ); buttons.push({ icon: 'mdi:video-switch', @@ -127,15 +137,16 @@ export class MenuButtonController { title: localize('config.menu.buttons.substreams'), ...config.menu.buttons.substreams, type: 'custom:frigate-card-menu-icon', - tap_action: createFrigateCardCustomAction( + tap_action: createFrigateCardSimpleAction( hasSubstream(view) ? 'live_substream_off' : 'live_substream_on', ) as FrigateCardCustomAction, }); } else if (dependencies.length > 2) { const menuItems = Array.from(dependencies, (cameraID) => { - const action = createFrigateCardCustomAction('live_substream_select', { - camera: cameraID, - }); + const action = createFrigateCardCameraAction( + 'live_substream_select', + cameraID, + ); const metadata = cameraManager.getCameraMetadata(cameraID) ?? undefined; const cameraConfig = cameraManager.getStore().getCameraConfig(cameraID); return { @@ -169,48 +180,48 @@ export class MenuButtonController { type: 'custom:frigate-card-menu-icon', title: localize('config.view.views.live'), style: view.is('live') ? this._getEmphasizedStyle() : {}, - tap_action: createFrigateCardCustomAction('live') as FrigateCardCustomAction, + tap_action: createFrigateCardSimpleAction('live') as FrigateCardCustomAction, }); - if (cameraCapabilities?.supportsClips) { + if (aggregateCapabilities?.supportsClips) { buttons.push({ icon: 'mdi:filmstrip', ...config.menu.buttons.clips, type: 'custom:frigate-card-menu-icon', title: localize('config.view.views.clips'), style: view?.is('clips') ? this._getEmphasizedStyle() : {}, - tap_action: createFrigateCardCustomAction('clips') as FrigateCardCustomAction, - hold_action: createFrigateCardCustomAction('clip') as FrigateCardCustomAction, + tap_action: createFrigateCardSimpleAction('clips') as FrigateCardCustomAction, + hold_action: createFrigateCardSimpleAction('clip') as FrigateCardCustomAction, }); } - if (cameraCapabilities?.supportsSnapshots) { + if (aggregateCapabilities?.supportsSnapshots) { buttons.push({ icon: 'mdi:camera', ...config.menu.buttons.snapshots, type: 'custom:frigate-card-menu-icon', title: localize('config.view.views.snapshots'), style: view?.is('snapshots') ? this._getEmphasizedStyle() : {}, - tap_action: createFrigateCardCustomAction( + tap_action: createFrigateCardSimpleAction( 'snapshots', ) as FrigateCardCustomAction, - hold_action: createFrigateCardCustomAction( + hold_action: createFrigateCardSimpleAction( 'snapshot', ) as FrigateCardCustomAction, }); } - if (cameraCapabilities?.supportsRecordings) { + if (aggregateCapabilities?.supportsRecordings) { buttons.push({ icon: 'mdi:album', ...config.menu.buttons.recordings, type: 'custom:frigate-card-menu-icon', title: localize('config.view.views.recordings'), style: view.is('recordings') ? this._getEmphasizedStyle() : {}, - tap_action: createFrigateCardCustomAction( + tap_action: createFrigateCardSimpleAction( 'recordings', ) as FrigateCardCustomAction, - hold_action: createFrigateCardCustomAction( + hold_action: createFrigateCardSimpleAction( 'recording', ) as FrigateCardCustomAction, }); @@ -222,19 +233,19 @@ export class MenuButtonController { type: 'custom:frigate-card-menu-icon', title: localize('config.view.views.image'), style: view?.is('image') ? this._getEmphasizedStyle() : {}, - tap_action: createFrigateCardCustomAction('image') as FrigateCardCustomAction, + tap_action: createFrigateCardSimpleAction('image') as FrigateCardCustomAction, }); // Don't show the timeline button unless there's at least one non-birdseye // camera with a Frigate camera name. - if (cameraCapabilities?.supportsTimeline) { + if (aggregateCapabilities?.supportsTimeline) { buttons.push({ icon: 'mdi:chart-gantt', ...config.menu.buttons.timeline, type: 'custom:frigate-card-menu-icon', title: localize('config.view.views.timeline'), style: view.is('timeline') ? this._getEmphasizedStyle() : {}, - tap_action: createFrigateCardCustomAction('timeline') as FrigateCardCustomAction, + tap_action: createFrigateCardSimpleAction('timeline') as FrigateCardCustomAction, }); } @@ -244,7 +255,7 @@ export class MenuButtonController { ...config.menu.buttons.download, type: 'custom:frigate-card-menu-icon', title: localize('config.menu.buttons.download'), - tap_action: createFrigateCardCustomAction('download') as FrigateCardCustomAction, + tap_action: createFrigateCardSimpleAction('download') as FrigateCardCustomAction, }); } @@ -254,7 +265,7 @@ export class MenuButtonController { ...config.menu.buttons.camera_ui, type: 'custom:frigate-card-menu-icon', title: localize('config.menu.buttons.camera_ui'), - tap_action: createFrigateCardCustomAction( + tap_action: createFrigateCardSimpleAction( 'camera_ui', ) as FrigateCardCustomAction, }); @@ -279,16 +290,16 @@ export class MenuButtonController { style: forbidden || muted ? {} : this._getEmphasizedStyle(true), ...(!forbidden && buttonType === 'momentary' && { - start_tap_action: createFrigateCardCustomAction( + start_tap_action: createFrigateCardSimpleAction( 'microphone_unmute', ) as FrigateCardCustomAction, - end_tap_action: createFrigateCardCustomAction( + end_tap_action: createFrigateCardSimpleAction( 'microphone_mute', ) as FrigateCardCustomAction, }), ...(!forbidden && buttonType === 'toggle' && { - tap_action: createFrigateCardCustomAction( + tap_action: createFrigateCardSimpleAction( options.microphoneManager.isMuted() ? 'microphone_unmute' : 'microphone_mute', @@ -303,7 +314,7 @@ export class MenuButtonController { ...config.menu.buttons.fullscreen, type: 'custom:frigate-card-menu-icon', title: localize('config.menu.buttons.fullscreen'), - tap_action: createFrigateCardCustomAction( + tap_action: createFrigateCardSimpleAction( 'fullscreen', ) as FrigateCardCustomAction, style: options?.inFullscreenMode ? this._getEmphasizedStyle() : {}, @@ -315,7 +326,7 @@ export class MenuButtonController { ...config.menu.buttons.expand, type: 'custom:frigate-card-menu-icon', title: localize('config.menu.buttons.expand'), - tap_action: createFrigateCardCustomAction('expand') as FrigateCardCustomAction, + tap_action: createFrigateCardSimpleAction('expand') as FrigateCardCustomAction, style: options?.inExpandedMode ? this._getEmphasizedStyle() : {}, }); @@ -328,14 +339,8 @@ export class MenuButtonController { .map((playerEntityID) => { const title = getEntityTitle(hass, playerEntityID) || playerEntityID; const state = hass.states[playerEntityID]; - const playAction = createFrigateCardCustomAction('media_player', { - media_player: playerEntityID, - media_player_action: 'play', - }); - const stopAction = createFrigateCardCustomAction('media_player', { - media_player: playerEntityID, - media_player_action: 'stop', - }); + const playAction = createFrigateCardMediaPlayerAction(playerEntityID, 'play'); + const stopAction = createFrigateCardMediaPlayerAction(playerEntityID, 'stop'); const disabled = !state || state.state === 'unavailable'; return { @@ -368,7 +373,7 @@ export class MenuButtonController { ...config.menu.buttons.play, type: 'custom:frigate-card-menu-icon', title: localize('config.menu.buttons.play'), - tap_action: createFrigateCardCustomAction( + tap_action: createFrigateCardSimpleAction( paused ? 'play' : 'pause', ) as FrigateCardCustomAction, }); @@ -381,7 +386,7 @@ export class MenuButtonController { ...config.menu.buttons.mute, type: 'custom:frigate-card-menu-icon', title: localize('config.menu.buttons.mute'), - tap_action: createFrigateCardCustomAction( + tap_action: createFrigateCardSimpleAction( muted ? 'unmute' : 'mute', ) as FrigateCardCustomAction, }); @@ -394,29 +399,39 @@ export class MenuButtonController { ...config.menu.buttons.screenshot, type: 'custom:frigate-card-menu-icon', title: localize('config.menu.buttons.screenshot'), - tap_action: createFrigateCardCustomAction( + tap_action: createFrigateCardSimpleAction( 'screenshot', ) as FrigateCardCustomAction, }); } - if (view.supportsMultipleDisplayModes() && visibleCameras.size > 1) { + if (view.supportsMultipleDisplayModes() && visibleCameraIDs.size > 1) { const isGrid = view.isGrid(); - const action = createFrigateCardCustomAction('display_mode_select', { - display_mode: isGrid ? 'single' : 'grid', + buttons.push({ + icon: isGrid ? 'mdi:grid-off' : 'mdi:grid', + ...config.menu.buttons.display_mode, + style: isGrid ? this._getEmphasizedStyle() : {}, + type: 'custom:frigate-card-menu-icon', + title: isGrid + ? localize('display_modes.single') + : localize('display_modes.grid'), + tap_action: createFrigateCardDisplayModeAction(isGrid ? 'single' : 'grid'), + }); + } + + if (hasUsablePTZ(selectedCameraCapabilities, config.live.controls.ptz)) { + const isOn = + view.context?.live?.ptzVisible === false + ? false + : config.live.controls.ptz.mode === 'on'; + buttons.push({ + icon: 'mdi:pan', + ...config.menu.buttons.ptz, + style: isOn ? this._getEmphasizedStyle() : {}, + type: 'custom:frigate-card-menu-icon', + title: localize('config.menu.buttons.ptz'), + tap_action: createFrigateCardShowPTZAction(!isOn), }); - /* istanbul ignore else: the else path cannot be reached -- @preserve */ - if (action) { - buttons.push({ - icon: isGrid ? 'mdi:grid-off' : 'mdi:grid', - ...config.menu.buttons.display_mode, - type: 'custom:frigate-card-menu-icon', - title: isGrid - ? localize('display_modes.single') - : localize('display_modes.grid'), - tap_action: action, - }); - } } const styledDynamicButtons = this._dynamicMenuButtons.map((button) => ({ diff --git a/src/components-lib/ptz-controller.ts b/src/components-lib/ptz-controller.ts new file mode 100644 index 00000000..1db58905 --- /dev/null +++ b/src/components-lib/ptz-controller.ts @@ -0,0 +1,170 @@ +import { HASSDomEvent, HomeAssistant } from '@dermotduffy/custom-card-helpers'; +import { CameraManager } from '../camera-manager/manager'; +import { + Actions, + ActionsConfig, + FrigateCardPTZAction, + FrigateCardPTZActions, + FrigateCardPTZConfig, + PTZAction, + PTZControlAction, + PTZ_CONTROL_ACTIONS, +} from '../config/types'; +import { + frigateCardHandleActionConfig, + getActionConfigGivenAction, +} from '../utils/action'; + +export class PTZController { + private _host: HTMLElement; + + private _config: FrigateCardPTZConfig | null = null; + private _hass: HomeAssistant | null = null; + private _cameraManager: CameraManager | null = null; + private _cameraID: string | null = null; + private _actions: FrigateCardPTZActions | null = null; + private _forceVisibility?: boolean; + + constructor(host: HTMLElement) { + this._host = host; + } + + public setConfig(config?: FrigateCardPTZConfig) { + this._config = config ?? null; + + this._host.setAttribute('data-orientation', config?.orientation ?? 'horizontal'); + this._host.setAttribute('data-position', config?.position ?? 'bottom-right'); + this._host.setAttribute( + 'style', + Object.entries(config?.style ?? {}) + .map(([k, v]) => `${k}:${v}`) + .join(';'), + ); + } + + public getConfig(): FrigateCardPTZConfig | null { + return this._config; + } + + public setHASS(hass?: HomeAssistant) { + this._hass = hass ?? null; + } + + public setCamera(cameraManager?: CameraManager, cameraID?: string) { + this._cameraManager = cameraManager ?? null; + this._cameraID = cameraID ?? null; + + this._calculateActions(); + } + + public setForceVisibility(forceVisibility?: boolean): void { + this._forceVisibility = forceVisibility; + } + + public handleAction( + ev: HASSDomEvent<{ action: string }>, + config?: ActionsConfig | null, + ): void { + // Nothing else has the configuration for this action, so don't let it + // propagate further. + ev.stopPropagation(); + + const interaction: string = ev.detail.action; + const action = getActionConfigGivenAction(interaction, config); + if (config && action && this._hass) { + frigateCardHandleActionConfig(this._host, this._hass, config, interaction, action); + } + } + + public getPTZActions(actionName: PTZControlAction): Actions | null { + const propertyName = 'actions_' + actionName; + return this._config?.[propertyName] ?? this._actions?.[propertyName] ?? null; + } + + private _hasAnyAction(): boolean { + for (const actionName of PTZ_CONTROL_ACTIONS) { + if ('actions_' + actionName in (this._actions ?? {})) { + return true; + } + } + return false; + } + + public shouldDisplay(): boolean { + return this._forceVisibility === false + ? false + : this._config?.mode === 'on' && this._hasAnyAction(); + } + + private _calculateActions(): void { + const getDefaultAction = ( + ptzAction: PTZAction, + options?: { + phase?: 'start' | 'stop'; + preset?: string; + }, + ): FrigateCardPTZAction => ({ + action: 'fire-dom-event', + frigate_card_action: 'ptz', + ptz_action: ptzAction, + ...(options?.phase && { ptz_phase: options.phase }), + ...(options?.preset && { ptz_preset: options.preset }), + }); + + const getDefaultActions = ( + ptzAction: PTZAction, + continuous: boolean, + preset?: string, + ): Actions => + continuous + ? { + start_tap_action: getDefaultAction(ptzAction, { + phase: 'start', + preset: preset, + }), + end_tap_action: getDefaultAction(ptzAction, { + phase: 'stop', + preset: preset, + }), + } + : { + tap_action: getDefaultAction(ptzAction, { preset: preset }), + }; + + if (!this._cameraManager || !this._cameraID) { + return; + } + + const ptzCapabilities = this._cameraManager.getCameraCapabilities( + this._cameraID, + )?.ptz; + + const defaultActions: FrigateCardPTZActions = {}; + const panTilt = ptzCapabilities?.panTilt; + const zoom = ptzCapabilities?.zoom; + const presets = ptzCapabilities?.presets; + + if (panTilt?.length) { + const continuous = panTilt.includes('continuous'); + defaultActions.actions_up = getDefaultActions('up', continuous); + defaultActions.actions_down = getDefaultActions('down', continuous); + defaultActions.actions_left = getDefaultActions('left', continuous); + defaultActions.actions_right = getDefaultActions('right', continuous); + } + + if (zoom?.length) { + const continuous = zoom.includes('continuous'); + defaultActions.actions_zoom_in = getDefaultActions('zoom_in', continuous); + defaultActions.actions_zoom_out = getDefaultActions('zoom_out', continuous); + } + + if (presets?.length) { + defaultActions.actions_home = getDefaultActions('preset', false, presets[0]); + } + + this._actions = { + ...defaultActions, + ...this._config, + }; + } +} diff --git a/src/components/carousel.ts b/src/components/carousel.ts index 453db5d1..6b223fad 100644 --- a/src/components/carousel.ts +++ b/src/components/carousel.ts @@ -69,7 +69,14 @@ export class FrigateCardCarousel extends LitElement { this.setAttribute('direction', this.direction); } - const destroyProperties = ['direction', 'dragFree', 'transitionEffect'] as const; + const destroyProperties = [ + 'direction', + 'dragEnabled', + 'dragFree', + 'loop', + 'plugins', + 'transitionEffect', + ] as const; if (destroyProperties.some((prop) => changedProps.has(prop))) { this._carousel?.destroy(); this._carousel = null; diff --git a/src/components/elements.ts b/src/components/elements.ts index 06d63dc7..d8d7b563 100644 --- a/src/components/elements.ts +++ b/src/components/elements.ts @@ -1,19 +1,18 @@ -import { HASSDomEvent, HomeAssistant } from '@dermotduffy/custom-card-helpers'; +import { HomeAssistant } from '@dermotduffy/custom-card-helpers'; import { CSSResultGroup, + html, LitElement, PropertyValues, TemplateResult, - html, unsafeCSS, } from 'lit'; import { customElement, property, state } from 'lit/decorators.js'; -import { classMap } from 'lit/directives/class-map.js'; -import { actionHandler } from '../action-handler-directive.js'; import { - Actions, - ActionsConfig, - FrigateCardPTZConfig, + ConditionsManagerEpoch, + evaluateConditionViaEvent, +} from '../card-controller/conditions-manager.js'; +import { FrigateConditional, MenuIcon, MenuItem, @@ -23,19 +22,9 @@ import { PictureElements, } from '../config/types.js'; import { localize } from '../localize/localize.js'; -import ptzStyle from '../scss/elements-ptz.scss'; import elementsStyle from '../scss/elements.scss'; import { FrigateCardError } from '../types.js'; -import { - frigateCardHandleActionConfig, - frigateCardHasAction, - getActionConfigGivenAction, -} from '../utils/action.js'; -import { dispatchFrigateCardEvent } from '../utils/basic.js'; -import { - ConditionsManagerEpoch, - evaluateConditionViaEvent, -} from '../card-controller/conditions-manager.js'; +import { dispatchFrigateCardEvent, errorToConsole } from '../utils/basic.js'; import { dispatchFrigateCardErrorEvent } from './message.js'; /* A note on picture element rendering: @@ -122,7 +111,7 @@ export class FrigateCardElementsCore extends LitElement { try { element.setConfig(config); } catch (e) { - console.error(e); + errorToConsole(e as Error, console.error); throw new FrigateCardError(localize('error.invalid_elements_config')); } return element; @@ -342,114 +331,6 @@ export class FrigateCardElementsMenuSubmenu extends FrigateCardElementsBaseMenuI @customElement('frigate-card-menu-submenu-select') export class FrigateCardElementsMenuSubmenuSelect extends FrigateCardElementsBaseMenuIcon {} -@customElement('frigate-card-ptz') -export class FrigateCardPTZ extends LitElement { - @property({ attribute: false }) - public hass?: HomeAssistant; - - @state() - protected _config: FrigateCardPTZConfig | null = null; - - /** - * Set the card config. - * @param config The configuration. - */ - public setConfig(config: FrigateCardPTZConfig): void { - this._config = config; - } - - /** - * Called before each update. - */ - protected willUpdate(changedProps: PropertyValues): void { - if (changedProps.has('_config')) { - this.setAttribute('data-orientation', this._config?.orientation ?? 'vertical'); - } - } - - /** - * Handle a PTZ action. - * @param ev The actionHandler event. - * @param config The action configuration. - */ - protected _actionHandler( - ev: HASSDomEvent<{ action: string }>, - config?: ActionsConfig, - ): void { - // Nothing else has the configuration for this action, so don't let it - // propagate further. - ev.stopPropagation(); - - const interaction: string = ev.detail.action; - const action = getActionConfigGivenAction(interaction, config); - if (config && action && this.hass) { - frigateCardHandleActionConfig(this, this.hass, config, interaction, action); - } - } - - /** - * Render the elements. - * @returns A rendered template or void. - */ - protected render(): TemplateResult | void { - if (!this._config) { - return; - } - const renderIcon = ( - name: string, - icon: string, - actions?: Actions, - ): TemplateResult => { - const hasHold = frigateCardHasAction(actions?.hold_action); - const hasDoubleClick = frigateCardHasAction(actions?.double_tap_action); - const classes = { - [name]: true, - disabled: !actions, - }; - - return html` this._actionHandler(ev, actions)} - >`; - }; - - return html`
-
- ${renderIcon('right', 'mdi:arrow-right', this._config.actions_right)} - ${renderIcon('left', 'mdi:arrow-left', this._config.actions_left)} - ${renderIcon('up', 'mdi:arrow-up', this._config.actions_up)} - ${renderIcon('down', 'mdi:arrow-down', this._config.actions_down)} -
- ${this._config.actions_zoom_in || this._config.actions_zoom_out - ? html`
- ${renderIcon('zoom_in', 'mdi:plus', this._config.actions_zoom_in)} - ${renderIcon('zoom_out', 'mdi:minus', this._config.actions_zoom_out)} -
` - : html``} - ${this._config.actions_home - ? html` -
- ${renderIcon('home', 'mdi:home', this._config.actions_home)} -
- ` - : html``} -
`; - } - - /** - * Return compiled CSS styles. - */ - static get styles(): CSSResultGroup { - return unsafeCSS(ptzStyle); - } -} - declare global { interface HTMLElementTagNameMap { 'frigate-card-conditional': FrigateCardElementsConditional; @@ -459,6 +340,5 @@ declare global { 'frigate-card-menu-state-icon': FrigateCardElementsMenuStateIcon; 'frigate-card-menu-icon': FrigateCardElementsMenuIcon; 'frigate-card-elements-core': FrigateCardElementsCore; - 'frigate-card-ptz': FrigateCardPTZ; } } diff --git a/src/components/live/live.ts b/src/components/live/live.ts index 1056387b..23d58d7c 100644 --- a/src/components/live/live.ts +++ b/src/components/live/live.ts @@ -13,7 +13,7 @@ import { ifDefined } from 'lit/directives/if-defined.js'; import { keyed } from 'lit/directives/keyed.js'; import { createRef, Ref, ref } from 'lit/directives/ref.js'; import { CameraManager } from '../../camera-manager/manager.js'; -import { CameraConfigs, CameraEndpoints } from '../../camera-manager/types.js'; +import { CameraEndpoints } from '../../camera-manager/types.js'; import { ConditionsManagerEpoch, getOverriddenConfig, @@ -45,6 +45,7 @@ import { AutoLazyLoad } from '../../utils/embla/plugins/auto-lazy-load/auto-lazy import { AutoMediaActions } from '../../utils/embla/plugins/auto-media-actions/auto-media-actions.js'; import AutoMediaLoadedInfo from '../../utils/embla/plugins/auto-media-loaded-info/auto-media-loaded-info.js'; import AutoSize from '../../utils/embla/plugins/auto-size/auto-size.js'; +import { getStateObjOrDispatchError } from '../../utils/get-state-obj.js'; import { dispatchExistingMediaLoadedInfoAsEvent, dispatchMediaUnloadedEvent, @@ -55,18 +56,20 @@ import { dispatchViewContextChangeEvent, View } from '../../view/view.js'; import { EmblaCarouselPlugins } from '../carousel.js'; import { renderMessage } from '../message.js'; import '../next-prev-control.js'; +import '../ptz.js'; +import { FrigateCardPTZ } from '../ptz.js'; import '../surround.js'; import '../title-control.js'; import { FrigateCardTitleControl, getDefaultTitleConfigForView, } from '../title-control.js'; -import { getStateObjOrDispatchError } from '../../utils/get-state-obj.js'; interface LiveViewContext { // A cameraID override (used for dependencies/substreams to force a different // camera to be live rather than the camera selected in the view). overrides?: Map; + ptzVisible?: boolean; } declare module 'view' { @@ -387,6 +390,10 @@ export class FrigateCardLiveCarousel extends LitElement { // Index between camera name and slide number. protected _cameraToSlide: Record = {}; protected _refTitleControl: Ref = createRef(); + protected _refPTZControl: Ref = createRef(); + + @state() + protected _mediaHasLoaded = false; protected _getTransitionEffect(): TransitionEffect { return ( @@ -449,25 +456,20 @@ export class FrigateCardLiveCarousel extends LitElement { } protected _getSlides(): [TemplateResult[], Record] { - let cameras: CameraConfigs | null = null; - if (this.viewFilterCameraID) { - const config = this.cameraManager - ?.getStore() - .getCameraConfig(this.viewFilterCameraID); - if (config) { - cameras = new Map([[this.viewFilterCameraID, config]]); - } - } else { - cameras = this.cameraManager?.getStore().getVisibleCameras() ?? null; - } - if (!cameras) { + if (!this.cameraManager) { return [[], {}]; } + const cameraIDs = this.viewFilterCameraID + ? new Set([this.viewFilterCameraID]) + : this.cameraManager.getStore().getVisibleCameraIDs(); + const slides: TemplateResult[] = []; const cameraToSlide: Record = {}; - for (const [cameraID, cameraConfig] of cameras) { + for (const [cameraID, cameraConfig] of this.cameraManager + .getStore() + .getCameraConfigEntries(cameraIDs)) { const liveCameraID = this.view?.context?.live?.overrides?.get(cameraID) ?? cameraID; const liveCameraConfig = @@ -487,9 +489,9 @@ export class FrigateCardLiveCarousel extends LitElement { } protected _setViewHandler(ev: CustomEvent): void { - const cameras = this.cameraManager?.getStore().getVisibleCameras(); - if (cameras && ev.detail.index !== this._getSelectedCameraIndex()) { - this._setViewCameraID(Array.from(cameras.keys())[ev.detail.index]); + const cameraIDs = this.cameraManager?.getStore().getVisibleCameraIDs(); + if (cameraIDs && ev.detail.index !== this._getSelectedCameraIndex()) { + this._setViewCameraID([...cameraIDs][ev.detail.index]); } } @@ -575,22 +577,23 @@ export class FrigateCardLiveCarousel extends LitElement { } protected _getCameraIDsOfNeighbors(): [string | null, string | null] { - const cameras = this.cameraManager?.getStore().getVisibleCameras(); - if (this.viewFilterCameraID || !cameras || !this.view || !this.hass) { + const cameraIDs = this.cameraManager + ? [...this.cameraManager.getStore().getVisibleCameraIDs()] + : []; + if (this.viewFilterCameraID || cameraIDs.length <= 1 || !this.view || !this.hass) { return [null, null]; } const cameraID = this.viewFilterCameraID ?? this.view.camera; - const keys = Array.from(cameras.keys()); - const currentIndex = keys.indexOf(cameraID); + const currentIndex = cameraIDs.indexOf(cameraID); - if (currentIndex < 0 || cameras.size <= 1) { + if (currentIndex < 0) { return [null, null]; } return [ - keys[currentIndex > 0 ? currentIndex - 1 : cameras.size - 1], - keys[currentIndex + 1 < cameras.size ? currentIndex + 1 : 0], + cameraIDs[currentIndex > 0 ? currentIndex - 1 : cameraIDs.length - 1], + cameraIDs[currentIndex + 1 < cameraIDs.length ? currentIndex + 1 : 0], ]; } @@ -608,18 +611,19 @@ export class FrigateCardLiveCarousel extends LitElement { const hasMultipleCameras = slides.length > 1; const [prevID, nextID] = this._getCameraIDsOfNeighbors(); - const overrideCameraID = (cameraID: string): string => { + const getOverrideCameraID = (cameraID: string): string => { return this.view?.context?.live?.overrides?.get(cameraID) ?? cameraID; }; const cameraMetadataPrevious = prevID - ? this.cameraManager.getCameraMetadata(overrideCameraID(prevID)) + ? this.cameraManager.getCameraMetadata(getOverrideCameraID(prevID)) : null; + const cameraID = this.viewFilterCameraID ?? this.view.camera; const cameraMetadataCurrent = this.cameraManager.getCameraMetadata( - overrideCameraID(this.viewFilterCameraID ?? this.view.camera), + getOverrideCameraID(cameraID), ); const cameraMetadataNext = nextID - ? this.cameraManager.getCameraMetadata(overrideCameraID(nextID)) + ? this.cameraManager.getCameraMetadata(getOverrideCameraID(nextID)) : null; const titleConfig = getDefaultTitleConfigForView( @@ -656,6 +660,10 @@ export class FrigateCardLiveCarousel extends LitElement { if (this._refTitleControl.value) { this._refTitleControl.value.show(); } + this._mediaHasLoaded = true; + }} + @frigate-card:media:unloaded=${() => { + this._mediaHasLoaded = false; }} > + + ${cameraMetadataCurrent && titleConfig ? html`, ): Promise { - const cameras = this.cameraManager?.getStore().getVisibleCameras(); - if (!this.hass || !cameras || !this.cameraManager || !this.view) { + const visibleCameraIDs = this.cameraManager?.getStore().getVisibleCameraIDs(); + if (!this.hass || !visibleCameraIDs || !this.cameraManager || !this.view) { return; } @@ -182,7 +182,7 @@ class FrigateCardMediaFilter extends ScopedRegistryHost(LitElement) { }; const cameraIDs = - getArrayValueAsSet(this._refCamera.value?.value) ?? new Set(cameras.keys()); + getArrayValueAsSet(this._refCamera.value?.value) ?? visibleCameraIDs; const mediaType = this._refMediaType.value?.value as | MediaFilterMediaType | undefined; @@ -268,9 +268,9 @@ class FrigateCardMediaFilter extends ScopedRegistryHost(LitElement) { protected willUpdate(changedProps: PropertyValues): void { if (changedProps.has('cameraManager')) { - const cameras = this.cameraManager?.getStore().getVisibleCameras(); + const cameras = this.cameraManager?.getStore().getVisibleCameraIDs(); if (cameras) { - this._cameraOptions = Array.from(cameras.keys()).map((cameraID) => ({ + this._cameraOptions = [...cameras].map((cameraID) => ({ value: cameraID, label: this.hass ? this.cameraManager?.getCameraMetadata(cameraID)?.title ?? '' @@ -319,8 +319,8 @@ class FrigateCardMediaFilter extends ScopedRegistryHost(LitElement) { protected _getDefaultsFromView(): MediaFilterCoreDefaults | null { const queries = this.view?.query?.getQueries(); - const cameras = this.cameraManager?.getStore().getVisibleCameras(); - if (!this.view || !queries || !cameras) { + const visibleCameraIDs = this.cameraManager?.getStore().getVisibleCameraIDs(); + if (!this.view || !queries || !visibleCameraIDs) { return null; } @@ -337,7 +337,7 @@ class FrigateCardMediaFilter extends ScopedRegistryHost(LitElement) { ); // 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, cameras)) { + if (cameraIDSets.length === 1 && !isEqual(queries[0].cameraIDs, visibleCameraIDs)) { cameraIDs = [...queries[0].cameraIDs]; } diff --git a/src/components/menu.ts b/src/components/menu.ts index c791fd07..bc19712b 100644 --- a/src/components/menu.ts +++ b/src/components/menu.ts @@ -97,7 +97,7 @@ export class FrigateCardMenu extends LitElement { * @param action The action to check. * @returns `true` if the action toggles the menu, `false` otherwise. */ - protected _isMenuToggleAction(action: ActionType | undefined): boolean { + protected _isMenuToggleAction(action: ActionType | null): boolean { // Determine if this action is a Frigate card action, if so handle it // internally. if (!action) { diff --git a/src/components/ptz.ts b/src/components/ptz.ts new file mode 100644 index 00000000..eed0d6e2 --- /dev/null +++ b/src/components/ptz.ts @@ -0,0 +1,131 @@ +import { HASSDomEvent, HomeAssistant } from '@dermotduffy/custom-card-helpers'; +import { + CSSResultGroup, + html, + LitElement, + PropertyValues, + TemplateResult, + unsafeCSS +} from 'lit'; +import { customElement, property } from 'lit/decorators.js'; +import { classMap } from 'lit/directives/class-map.js'; +import { actionHandler } from '../action-handler-directive.js'; +import { CameraManager } from '../camera-manager/manager.js'; +import { PTZController } from '../components-lib/ptz-controller.js'; +import { Actions, FrigateCardPTZConfig } from '../config/types.js'; +import { localize } from '../localize/localize.js'; +import ptzStyle from '../scss/ptz.scss'; +import { frigateCardHasAction } from '../utils/action.js'; + +@customElement('frigate-card-ptz') +export class FrigateCardPTZ extends LitElement { + @property({ attribute: false }) + public hass?: HomeAssistant; + + @property({ attribute: false }) + public config?: FrigateCardPTZConfig; + + @property({ attribute: false }) + public cameraManager?: CameraManager; + + @property({ attribute: false }) + public cameraID?: string; + + @property({ attribute: false }) + public forceVisibility?: boolean; + + protected _controller = new PTZController(this); + + protected willUpdate(changedProps: PropertyValues): void { + if (changedProps.has('config')) { + this._controller.setConfig(this.config); + } + if (changedProps.has('hass')) { + this._controller.setHASS(this.hass); + } + if (changedProps.has('cameraManager') || changedProps.has('cameraID')) { + this._controller.setCamera(this.cameraManager, this.cameraID); + } + if (changedProps.has('forceVisibility')) { + this._controller.setForceVisibility(this.forceVisibility); + } + } + + protected render(): TemplateResult | void { + if (!this._controller.shouldDisplay()) { + return; + } + + const renderIcon = ( + name: string, + icon: string, + actions: Actions | null, + ): TemplateResult => { + const classes = { + [name]: true, + disabled: !actions, + }; + + return html`) => + this._controller.handleAction(ev, actions)} + >`; + }; + + const config = this._controller.getConfig(); + const actionsZoomIn = this._controller.getPTZActions('zoom_in'); + const actionsZoomOut = this._controller.getPTZActions('zoom_out'); + const actionsHome = this._controller.getPTZActions('home'); + + return html`
+ ${!config?.hide_pan_tilt + ? html`
+ ${renderIcon( + 'right', + 'mdi:arrow-right', + this._controller.getPTZActions('right'), + )} + ${renderIcon( + 'left', + 'mdi:arrow-left', + this._controller.getPTZActions('left'), + )} + ${renderIcon('up', 'mdi:arrow-up', this._controller.getPTZActions('up'))} + ${renderIcon( + 'down', + 'mdi:arrow-down', + this._controller.getPTZActions('down'), + )} +
` + : ''} + ${!config?.hide_zoom && (actionsZoomIn || actionsZoomOut) + ? html`
+ ${renderIcon('zoom_in', 'mdi:plus', actionsZoomIn)} + ${renderIcon('zoom_out', 'mdi:minus', actionsZoomOut)} +
` + : html``} + ${!config?.hide_home && actionsHome + ? html` +
${renderIcon('home', 'mdi:home', actionsHome)}
+ ` + : html``} +
`; + } + + static get styles(): CSSResultGroup { + return unsafeCSS(ptzStyle); + } +} + +declare global { + interface HTMLElementTagNameMap { + 'frigate-card-ptz': FrigateCardPTZ; + } +} diff --git a/src/components/surround.ts b/src/components/surround.ts index e2efa85f..853f1a92 100644 --- a/src/components/surround.ts +++ b/src/components/surround.ts @@ -16,7 +16,6 @@ import { import basicBlockStyle from '../scss/basic-block.scss'; import { ClipsOrSnapshotsOrAll, ExtendedHomeAssistant } from '../types.js'; import { contentsChanged, dispatchFrigateCardEvent } from '../utils/basic.js'; -import { getAllDependentCameras } from '../utils/camera.js'; import { changeViewToRecentEventsForCameraAndDependents } from '../utils/media-to-view'; import { View } from '../view/view.js'; import './surround-basic.js'; @@ -140,7 +139,8 @@ export class FrigateCardSurround extends LitElement { if (this.view?.is('live')) { return this.view.isGrid() ? this.cameraManager?.getStore().getVisibleCameraIDs() ?? null - : getAllDependentCameras(this.cameraManager, this.view.camera); + : this.cameraManager?.getStore().getAllDependentCameras(this.view.camera) ?? + null; } if (this.view.isViewerView()) { return this.view.query?.getQueryCameraIDs() ?? null; diff --git a/src/components/thumbnail-carousel.ts b/src/components/thumbnail-carousel.ts index 6ab71228..3237464e 100644 --- a/src/components/thumbnail-carousel.ts +++ b/src/components/thumbnail-carousel.ts @@ -15,6 +15,7 @@ import { ExtendedHomeAssistant } from '../types.js'; import { stopEventFromActivatingCardWideActions } from '../utils/action.js'; import { dispatchFrigateCardEvent } from '../utils/basic.js'; import { CarouselDirection } from '../utils/embla/carousel-controller.js'; +import AutoSize from '../utils/embla/plugins/auto-size/auto-size.js'; import { MediaQueriesResults } from '../view/media-queries-results'; import { View } from '../view/view.js'; import './carousel.js'; @@ -136,6 +137,7 @@ export class FrigateCardThumbnailCarousel extends LitElement { return html` diff --git a/src/components/views.ts b/src/components/views.ts index da524511..8fbf9af8 100644 --- a/src/components/views.ts +++ b/src/components/views.ts @@ -9,10 +9,17 @@ import { import { customElement, property } from 'lit/decorators.js'; import { classMap } from 'lit/directives/class-map.js'; import { CameraManager } from '../camera-manager/manager.js'; -import { ConditionsManagerEpoch, getOverridesByKey } from '../card-controller/conditions-manager.js'; +import { + ConditionsManagerEpoch, + getOverridesByKey, +} from '../card-controller/conditions-manager.js'; +import { + CardWideConfig, + FrigateCardConfig, + RawFrigateCardConfig, +} from '../config/types.js'; import viewsStyle from '../scss/views.scss'; import { ExtendedHomeAssistant } from '../types.js'; -import { ConfigManager } from '../card-controller/config-manager.js'; import { ResolvedMediaCache } from '../utils/ha/resolved-media.js'; import { View } from '../view/view.js'; import './surround.js'; @@ -32,7 +39,16 @@ export class FrigateCardViews extends LitElement { public cameraManager?: CameraManager; @property({ attribute: false }) - public configManager?: ConfigManager; + public nonOverriddenConfig?: FrigateCardConfig; + + @property({ attribute: false }) + public overriddenConfig?: FrigateCardConfig; + + @property({ attribute: false }) + public cardWideConfig?: CardWideConfig; + + @property({ attribute: false }) + public rawConfig?: RawFrigateCardConfig; @property({ attribute: false }) public resolvedMediaCache?: ResolvedMediaCache; @@ -94,21 +110,21 @@ export class FrigateCardViews extends LitElement { return ( // Special case: Never preload for diagnostics -- we want that to be as // minimal as possible. - !!this.configManager?.getConfig()?.live.preload && !this.view?.is('diagnostics') + !!this.overriddenConfig?.live.preload && !this.view?.is('diagnostics') ); } protected render(): TemplateResult | void { - const config = this.configManager?.getConfig(); - const nonOverriddenConfig = this.configManager?.getNonOverriddenConfig(); - const cardWideConfig = this.configManager?.getCardWideConfig(); - const rawConfig = this.configManager?.getRawConfig(); - // Only essential items should be added to the below list, since we want the // overall views pane to render in ~almost all cases (e.g. for a camera // initialization error to display, `view` and `cameraConfig` may both be // undefined, but we still want to render). - if (!this.hass || !config || !nonOverriddenConfig || !cardWideConfig) { + if ( + !this.hass || + !this.overriddenConfig || + !this.nonOverriddenConfig || + !this.cardWideConfig + ) { return html``; } @@ -122,17 +138,17 @@ export class FrigateCardViews extends LitElement { }; const thumbnailConfig = this.view?.is('live') - ? config.live.controls.thumbnails + ? this.overriddenConfig.live.controls.thumbnails : this.view?.isViewerView() - ? config.media_viewer.controls.thumbnails + ? this.overriddenConfig.media_viewer.controls.thumbnails : this.view?.is('timeline') - ? config.timeline.controls.thumbnails + ? this.overriddenConfig.timeline.controls.thumbnails : undefined; const miniTimelineConfig = this.view?.is('live') - ? config.live.controls.timeline + ? this.overriddenConfig.live.controls.timeline : this.view?.isViewerView() - ? config.media_viewer.controls.timeline + ? this.overriddenConfig.media_viewer.controls.timeline : undefined; const cameraConfig = this.view @@ -144,16 +160,16 @@ export class FrigateCardViews extends LitElement { .hass=${this.hass} .view=${this.view} .fetchMedia=${this.view?.is('live') - ? config.live.controls.thumbnails.media + ? this.overriddenConfig.live.controls.thumbnails.media : undefined} .thumbnailConfig=${!this.hide ? thumbnailConfig : undefined} .timelineConfig=${!this.hide ? miniTimelineConfig : undefined} .cameraManager=${this.cameraManager} - .cardWideConfig=${cardWideConfig} + .cardWideConfig=${this.cardWideConfig} > ${!this.hide && this.view?.is('image') && cameraConfig ? html` ` : ``} @@ -176,10 +192,10 @@ export class FrigateCardViews extends LitElement { ` @@ -188,16 +204,16 @@ export class FrigateCardViews extends LitElement { ? html` ` : ``} ${!this.hide && this.view?.is('diagnostics') ? html` ` : ``} @@ -213,12 +229,15 @@ export class FrigateCardViews extends LitElement { diff --git a/src/config-mgmt.ts b/src/config-mgmt.ts index 7d484646..fc81696a 100644 --- a/src/config-mgmt.ts +++ b/src/config-mgmt.ts @@ -3,7 +3,7 @@ import get from 'lodash-es/get'; import isEqual from 'lodash-es/isEqual'; import set from 'lodash-es/set'; import unset from 'lodash-es/unset'; -import { RawFrigateCardConfig } from './config/types'; +import { RawFrigateCardConfig, RawFrigateCardConfigArray } from './config/types'; import { CONF_CAMERAS, CONF_CAMERAS_GLOBAL_IMAGE, @@ -303,7 +303,7 @@ export const deleteTransform = function (_value: unknown): number | null | undef }; // ************************************************************************* -// Upgrade Related Functions: Specific Transforms +// Upgrade Related Functions: Specific Transforms / Upgraders // ************************************************************************* /** @@ -360,6 +360,71 @@ const frigateUIActionTransform = (data: unknown): boolean => { return false; }; +/** + * Transform element PTZ to native live PTZ. + * @param data Input data. + * @returns `true` if the configuration was modified. + */ +const upgradePTZElementsToLive = function (): (data: unknown) => boolean { + return function (data: unknown): boolean { + if ( + typeof data !== 'object' || + !data || + !(CONF_ELEMENTS in data) || + !Array.isArray(data[CONF_ELEMENTS]) + ) { + return false; + } + + let foundPTZ = false; + const movePTZ = (element: RawFrigateCardConfig): void => { + if (!foundPTZ) { + if (!get(data, 'live.controls.ptz')) { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const { type: _, ...newPTZ } = element; + set(data, 'live.controls.ptz', newPTZ); + } + foundPTZ = true; + } + }; + + const processElements = ( + elements: RawFrigateCardConfigArray, + ): RawFrigateCardConfigArray => { + const newElements: RawFrigateCardConfigArray = []; + for (const element of elements) { + if (element['type'] === 'custom:frigate-card-ptz') { + movePTZ(element); + } else if ( + (element['type'] === 'conditional' || + element['type'] === 'custom:frigate-card-conditional') && + Array.isArray(element['elements']) + ) { + const newConditionalElements = processElements(element['elements']); + if (newConditionalElements.length) { + element['elements'] = newConditionalElements; + newElements.push(element); + } + } else { + newElements.push(element); + } + } + return newElements; + }; + + const newElements = processElements(data[CONF_ELEMENTS]); + + if (foundPTZ) { + if (newElements.length) { + data[CONF_ELEMENTS] = newElements; + } else { + delete data[CONF_ELEMENTS]; + } + } + return foundPTZ; + }; +}; + const UPGRADES = [ // v4.0.0 -> v4.1.0 upgradeArrayOfObjects( @@ -408,4 +473,5 @@ const UPGRADES = [ typeof data === 'object' && data ? data : {}, ); }, + upgradePTZElementsToLive(), ]; diff --git a/src/config/types.ts b/src/config/types.ts index a6140877..3b7d12e6 100644 --- a/src/config/types.ts +++ b/src/config/types.ts @@ -65,6 +65,19 @@ const MEDIA_ACTION_POSITIVE_CONDITIONS = [ export type AutoUnmuteCondition = (typeof MEDIA_ACTION_POSITIVE_CONDITIONS)[number]; export type AutoPlayCondition = (typeof MEDIA_ACTION_POSITIVE_CONDITIONS)[number]; +const PTZ_BASE_ACTIONS = ['left', 'right', 'up', 'down', 'zoom_in', 'zoom_out'] as const; + +// PTZ actions as used by the PTZ control (includes a 'home' button). +export const PTZ_CONTROL_ACTIONS = [...PTZ_BASE_ACTIONS, 'home'] as const; +export type PTZControlAction = (typeof PTZ_CONTROL_ACTIONS)[number]; + +// PTZ actions as used by the camera manager (includes generic presets). +const PTZ_ACTIONS = [...PTZ_BASE_ACTIONS, 'preset'] as const; +export type PTZAction = (typeof PTZ_ACTIONS)[number]; + +const PTZ_PHASES = ['start', 'stop'] as const; +export type PTZPhase = (typeof PTZ_PHASES)[number]; + // ************************************************************************* // View Display Mode // ************************************************************************* @@ -201,32 +214,22 @@ const FRIGATE_CARD_GENERAL_ACTIONS = [ 'camera_ui', 'default', 'diagnostics', - 'expand', 'download', + 'expand', 'fullscreen', - 'menu_toggle', - 'mute', - 'live_substream_on', 'live_substream_off', + 'live_substream_on', + 'menu_toggle', 'microphone_mute', 'microphone_unmute', - 'play', + 'mute', 'pause', + 'play', 'screenshot', 'unmute', ] as const; export type FrigateCardGeneralAction = (typeof FRIGATE_CARD_GENERAL_ACTIONS)[number]; -const FRIGATE_CARD_ACTIONS = [ - ...FRIGATE_CARD_VIEWS_USER_SPECIFIED, - ...FRIGATE_CARD_GENERAL_ACTIONS, - 'camera_select', - 'live_substream_select', - 'media_player', - 'display_mode_select', -] as const; -export type FrigateCardAction = (typeof FRIGATE_CARD_ACTIONS)[number]; - const frigateCardViewActionSchema = frigateCardCustomActionsBaseSchema.extend({ frigate_card_action: z.enum(FRIGATE_CARD_VIEWS_USER_SPECIFIED), }); @@ -240,9 +243,6 @@ const frigateCardCameraSelectActionSchema = frigateCardCustomActionsBaseSchema.e frigate_card_action: z.literal('camera_select'), camera: z.string(), }); -export type FrigateCardCameraSelectAction = z.infer< - typeof frigateCardCameraSelectActionSchema ->; const frigateCardLiveDependencySelectActionSchema = frigateCardCustomActionsBaseSchema.extend({ @@ -263,6 +263,19 @@ const frigateCardViewDisplayModeActionSchema = frigateCardCustomActionsBaseSchem }, ); +const frigateCardPTZActionSchema = frigateCardCustomActionsBaseSchema.extend({ + frigate_card_action: z.literal('ptz'), + ptz_action: z.enum(PTZ_ACTIONS), + ptz_phase: z.enum(PTZ_PHASES).optional(), + ptz_preset: z.string().optional(), +}); +export type FrigateCardPTZAction = z.infer; + +const frigateCardShowPTZActionSchema = frigateCardCustomActionsBaseSchema.extend({ + frigate_card_action: z.literal('show_ptz'), + show_ptz: z.boolean(), +}); + export const frigateCardCustomActionSchema = z.union([ frigateCardViewActionSchema, frigateCardGeneralActionSchema, @@ -270,6 +283,8 @@ export const frigateCardCustomActionSchema = z.union([ frigateCardLiveDependencySelectActionSchema, frigateCardMediaPlayerActionSchema, frigateCardViewDisplayModeActionSchema, + frigateCardPTZActionSchema, + frigateCardShowPTZActionSchema, ]); export type FrigateCardCustomAction = z.infer; @@ -481,53 +496,8 @@ export const frigateConditionalSchema = z.object({ conditions: frigateCardConditionSchema, elements: z.lazy(() => pictureElementsSchema), }); - export type FrigateConditional = z.infer; -// ************************************************************************* -// Custom Element Configuration: PTZ -// ************************************************************************* - -export const frigateCardPTZSchema = z.preprocess( - // To avoid lots of YAML duplication, provide an easy way to just specify the - // service data as actions for each PTZ icon, and it will be preprocessed into - // the full form. This also provides compatability with the AlexIT/WebRTC PTZ - // configuration. - (data) => { - if (!data || typeof data !== 'object' || !data['service']) { - return data; - } - const out = { ...data }; - ['left', 'right', 'up', 'down', 'zoom_in', 'zoom_out', 'home'].forEach((name) => { - if (`data_${name}` in data && !(`actions_${name}` in data)) { - out[`actions_${name}`] = { - tap_action: { - action: 'call-service', - service: data['service'], - data: data[`data_${name}`], - }, - }; - delete out[`data_${name}`]; - } - }); - return out; - }, - z.object({ - type: z.literal('custom:frigate-card-ptz'), - style: z.object({}).passthrough().optional(), - orientation: z.enum(['vertical', 'horizontal']).default('vertical').optional(), - service: z.string().optional(), - actions_left: actionsBaseSchema.optional(), - actions_right: actionsBaseSchema.optional(), - actions_up: actionsBaseSchema.optional(), - actions_down: actionsBaseSchema.optional(), - actions_zoom_in: actionsBaseSchema.optional(), - actions_zoom_out: actionsBaseSchema.optional(), - actions_home: actionsBaseSchema.optional(), - }), -); -export type FrigateCardPTZConfig = z.infer; - // ************************************************************************* // Custom Element Configuration: Stock Picture Elements + Custom // ************************************************************************* @@ -540,7 +510,6 @@ const pictureElementSchema = z.union([ menuSubmenuSchema, menuSubmenuSelectSchema, frigateConditionalSchema, - frigateCardPTZSchema, stateBadgeIconSchema, stateIconSchema, stateLabelSchema, @@ -818,6 +787,70 @@ const jsmpegConfigSchema = z.object({ .optional(), }); +const frigateCardPTZActions = z.object({ + actions_left: actionsBaseSchema.optional(), + actions_right: actionsBaseSchema.optional(), + actions_up: actionsBaseSchema.optional(), + actions_down: actionsBaseSchema.optional(), + actions_zoom_in: actionsBaseSchema.optional(), + actions_zoom_out: actionsBaseSchema.optional(), + actions_home: actionsBaseSchema.optional(), +}); +export type FrigateCardPTZActions = z.infer; + +const livePTZControlsDefaults = { + orientation: 'horizontal' as const, + mode: 'on' as const, + hide_pan_tilt: false, + hide_zoom: false, + hide_home: false, + position: 'bottom-right' as const, +}; + +export const frigateCardPTZSchema = z.preprocess( + // To avoid lots of YAML duplication, provide an easy way to just specify the + // service data as actions for each PTZ icon, and it will be preprocessed into + // the full form. This also provides compatability with the AlexIT/WebRTC PTZ + // configuration. + (data) => { + if (!data || typeof data !== 'object' || !data['service']) { + return data; + } + const out = { ...data }; + PTZ_CONTROL_ACTIONS.forEach((name) => { + if (`data_${name}` in data && !(`actions_${name}` in data)) { + out[`actions_${name}`] = { + tap_action: { + action: 'call-service', + service: data['service'], + data: data[`data_${name}`], + }, + }; + delete out[`data_${name}`]; + } + }); + return out; + }, + frigateCardPTZActions.extend({ + mode: z.enum(['off', 'on']).default(livePTZControlsDefaults.mode), + position: z + .enum(['top-left', 'top-right', 'bottom-left', 'bottom-right']) + .default(livePTZControlsDefaults.position), + + orientation: z + .enum(['vertical', 'horizontal']) + .default(livePTZControlsDefaults.orientation), + + hide_pan_tilt: z.boolean().default(livePTZControlsDefaults.hide_pan_tilt), + hide_zoom: z.boolean().default(livePTZControlsDefaults.hide_zoom), + hide_home: z.boolean().default(livePTZControlsDefaults.hide_home), + + service: z.string().optional(), + style: z.object({}).passthrough().optional(), + }), +); +export type FrigateCardPTZConfig = z.infer; + const liveThumbnailControlsDefaults = { ...thumbnailControlsDefaults, media: 'all' as const, @@ -842,6 +875,7 @@ const liveConfigDefault = { size: 48, style: 'chevrons' as const, }, + ptz: livePTZControlsDefaults, thumbnails: liveThumbnailControlsDefaults, timeline: miniTimelineConfigDefault, }, @@ -872,6 +906,7 @@ const liveOverridableConfigSchema = z ), }) .default(liveConfigDefault.controls.next_previous), + ptz: frigateCardPTZSchema.default(liveConfigDefault.controls.ptz), thumbnails: livethumbnailsControlSchema.default( liveConfigDefault.controls.thumbnails, ), @@ -934,7 +969,7 @@ const castConfigDefault = { method: 'standard' as const, }; -export const castSchema = z.object({ +const castSchema = z.object({ method: z.enum(['standard', 'dashboard']).default(castConfigDefault.method).optional(), dashboard: z .object({ @@ -1170,6 +1205,7 @@ const menuConfigDefault = { recordings: hiddenButtonDefault, screenshot: hiddenButtonDefault, display_mode: visibleButtonDefault, + ptz: hiddenButtonDefault, }, button_size: 40, }; @@ -1220,6 +1256,7 @@ const menuConfigSchema = z display_mode: visibleButtonSchema.default( menuConfigDefault.buttons.display_mode, ), + ptz: hiddenButtonSchema.default(menuConfigDefault.buttons.ptz), }) .default(menuConfigDefault.buttons), button_size: z.number().min(BUTTON_SIZE_MIN).default(menuConfigDefault.button_size), @@ -1411,13 +1448,13 @@ const overridesSchema = z // Automation Configuration // ************************************************************************* -const automationActionSchema = actionSchema.array().optional(); +const automationActionSchema = actionSchema.array(); export type AutomationActions = z.infer; const automationSchema = z.object({ conditions: frigateCardConditionSchema, - actions: automationActionSchema, - actions_not: automationActionSchema, + actions: automationActionSchema.optional(), + actions_not: automationActionSchema.optional(), }); export type Automation = z.infer; diff --git a/src/const.ts b/src/const.ts index 98097196..d2a2a41b 100644 --- a/src/const.ts +++ b/src/const.ts @@ -98,7 +98,7 @@ export const CONF_MEDIA_GALLERY_CONTROLS_THUMBNAILS_SHOW_TIMELINE_CONTROL = export const CONF_MEDIA_GALLERY_CONTROLS_THUMBNAILS_SIZE = `${CONF_MEDIA_GALLERY}.controls.thumbnails.size` as const; -export const CONF_MEDIA_VIEWER = 'media_viewer' as const; +const CONF_MEDIA_VIEWER = 'media_viewer' as const; export const CONF_MEDIA_VIEWER_AUTO_PLAY = `${CONF_MEDIA_VIEWER}.auto_play` as const; export const CONF_MEDIA_VIEWER_AUTO_PAUSE = `${CONF_MEDIA_VIEWER}.auto_pause` as const; export const CONF_MEDIA_VIEWER_AUTO_MUTE = `${CONF_MEDIA_VIEWER}.auto_mute` as const; @@ -169,6 +169,17 @@ export const CONF_LIVE_CONTROLS_NEXT_PREVIOUS_STYLE = `${CONF_LIVE}.controls.next_previous.style` as const; export const CONF_LIVE_CONTROLS_NEXT_PREVIOUS_SIZE = `${CONF_LIVE}.controls.next_previous.size` as const; +export const CONF_LIVE_CONTROLS_PTZ_HIDE_HOME = + `${CONF_LIVE}.controls.ptz.hide_home` as const; +export const CONF_LIVE_CONTROLS_PTZ_HIDE_PAN_TILT = + `${CONF_LIVE}.controls.ptz.hide_pan_tilt` as const; +export const CONF_LIVE_CONTROLS_PTZ_HIDE_ZOOM = + `${CONF_LIVE}.controls.ptz.hide_zoom` as const; +export const CONF_LIVE_CONTROLS_PTZ_MODE = `${CONF_LIVE}.controls.ptz.mode` as const; +export const CONF_LIVE_CONTROLS_PTZ_ORIENTATION = + `${CONF_LIVE}.controls.ptz.orientation` as const; +export const CONF_LIVE_CONTROLS_PTZ_POSITION = + `${CONF_LIVE}.controls.ptz.position` as const; export const CONF_LIVE_CONTROLS_THUMBNAILS_MEDIA = `${CONF_LIVE}.controls.thumbnails.media` as const; export const CONF_LIVE_CONTROLS_THUMBNAILS_MODE = @@ -257,17 +268,10 @@ export const CONF_MENU_STYLE = `${CONF_MENU}.style` as const; export const CONF_MENU_BUTTON_SIZE = `${CONF_MENU}.button_size` as const; export const CONF_MENU_BUTTONS = `${CONF_MENU}.buttons` as const; -export const CONF_MENU_BUTTONS_CAMERAS = `${CONF_MENU}.buttons.cameras` as const; -export const CONF_MENU_BUTTONS_CLIPS = `${CONF_MENU}.buttons.clips` as const; -export const CONF_MENU_BUTTONS_DOWNLOAD = `${CONF_MENU}.buttons.download` as const; export const CONF_MENU_BUTTONS_FRIGATE = `${CONF_MENU}.buttons.frigate` as const; export const CONF_MENU_BUTTONS_CAMERA_UI = `${CONF_MENU}.buttons.camera_ui` as const; -export const CONF_MENU_BUTTONS_FULLSCREEN = `${CONF_MENU}.buttons.fullscreen` as const; -export const CONF_MENU_BUTTONS_IMAGE = `${CONF_MENU}.buttons.image` as const; -export const CONF_MENU_BUTTONS_LIVE = `${CONF_MENU}.buttons.live` as const; export const CONF_MENU_BUTTONS_MEDIA_PLAYER = `${CONF_MENU}.buttons.media_player` as const; -export const CONF_MENU_BUTTONS_SNAPSHOTS = `${CONF_MENU}.buttons.snapshots` as const; export const CONF_MENU_BUTTONS_TIMELINE = `${CONF_MENU}.buttons.timeline` as const; const CONF_DIMENSIONS = 'dimensions' as const; diff --git a/src/editor.ts b/src/editor.ts index 294b0dd3..d981a550 100644 --- a/src/editor.ts +++ b/src/editor.ts @@ -80,6 +80,12 @@ import { CONF_LIVE_CONTROLS_BUILTIN, CONF_LIVE_CONTROLS_NEXT_PREVIOUS_SIZE, CONF_LIVE_CONTROLS_NEXT_PREVIOUS_STYLE, + CONF_LIVE_CONTROLS_PTZ_HIDE_HOME, + CONF_LIVE_CONTROLS_PTZ_HIDE_PAN_TILT, + CONF_LIVE_CONTROLS_PTZ_HIDE_ZOOM, + CONF_LIVE_CONTROLS_PTZ_MODE, + CONF_LIVE_CONTROLS_PTZ_ORIENTATION, + CONF_LIVE_CONTROLS_PTZ_POSITION, CONF_LIVE_CONTROLS_THUMBNAILS_MEDIA, CONF_LIVE_CONTROLS_THUMBNAILS_MODE, CONF_LIVE_CONTROLS_THUMBNAILS_SHOW_DETAILS, @@ -211,6 +217,7 @@ const MENU_CAMERAS_WEBRTC_CARD = 'cameras.webrtc_card'; const MENU_IMAGE_LAYOUT = 'image.layout'; const MENU_LIVE_CONTROLS = 'live.controls'; const MENU_LIVE_CONTROLS_NEXT_PREVIOUS = 'live.controls.next_previous'; +const MENU_LIVE_CONTROLS_PTZ = 'live.controls.ptz'; const MENU_LIVE_CONTROLS_THUMBNAILS = 'live.controls.thumbnails'; const MENU_LIVE_CONTROLS_TIMELINE = 'live.controls.timeline'; const MENU_LIVE_CONTROLS_TITLE = 'live.controls.title'; @@ -575,6 +582,45 @@ export class FrigateCardEditor extends LitElement implements LovelaceCardEditor { value: 'dashboard', label: localize('config.cameras.cast.methods.dashboard') }, ]; + protected _ptzModes: EditorSelectOption[] = [ + { value: '', label: '' }, + { value: 'on', label: localize('config.live.controls.ptz.modes.on') }, + { value: 'off', label: localize('config.live.controls.ptz.modes.off') }, + { value: 'auto', label: localize('config.live.controls.ptz.modes.auto') }, + ]; + + protected _ptzOrientations: EditorSelectOption[] = [ + { value: '', label: '' }, + { + value: 'vertical', + label: localize('config.live.controls.ptz.orientations.vertical'), + }, + { + value: 'horizontal', + label: localize('config.live.controls.ptz.orientations.horizontal'), + }, + ]; + + protected _ptzPositions: EditorSelectOption[] = [ + { value: '', label: '' }, + { + value: 'top-left', + label: localize('config.live.controls.ptz.positions.top-left'), + }, + { + value: 'top-right', + label: localize('config.live.controls.ptz.positions.top-right'), + }, + { + value: 'bottom-left', + label: localize('config.live.controls.ptz.positions.bottom-left'), + }, + { + value: 'bottom-right', + label: localize('config.live.controls.ptz.positions.bottom-right'), + }, + ]; + 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 @@ -1886,6 +1932,8 @@ export class FrigateCardEditor extends LitElement implements LovelaceCardEditor ${this._renderMenuButton('play') /* */} ${this._renderMenuButton('mute') /* */} ${this._renderMenuButton('screenshot')} + ${this._renderMenuButton('display_mode')} + ${this._renderMenuButton('ptz')} ` : ''} @@ -1981,6 +2029,47 @@ export class FrigateCardEditor extends LitElement implements LovelaceCardEditor CONF_LIVE_CONTROLS_TIMELINE_SHOW_RECORDINGS, this._defaults.live.controls.timeline.show_recordings, )} + ${this._putInSubmenu( + MENU_LIVE_CONTROLS_PTZ, + true, + 'config.live.controls.ptz.editor_label', + { name: 'mdi:pan' }, + html` + ${this._renderOptionSelector( + CONF_LIVE_CONTROLS_PTZ_MODE, + this._ptzModes, + )} + ${this._renderOptionSelector( + CONF_LIVE_CONTROLS_PTZ_POSITION, + this._ptzPositions, + )} + ${this._renderOptionSelector( + CONF_LIVE_CONTROLS_PTZ_ORIENTATION, + this._ptzOrientations, + )} + ${this._renderSwitch( + CONF_LIVE_CONTROLS_PTZ_HIDE_PAN_TILT, + this._defaults.live.controls.ptz.hide_pan_tilt, + { + label: localize('config.live.controls.ptz.hide_pan_tilt'), + }, + )} + ${this._renderSwitch( + CONF_LIVE_CONTROLS_PTZ_HIDE_ZOOM, + this._defaults.live.controls.ptz.hide_pan_tilt, + { + label: localize('config.live.controls.ptz.hide_zoom'), + }, + )} + ${this._renderSwitch( + CONF_LIVE_CONTROLS_PTZ_HIDE_HOME, + this._defaults.live.controls.ptz.hide_home, + { + label: localize('config.live.controls.ptz.hide_home'), + }, + )} + `, + )} `, )} ${this._renderMediaLayout( diff --git a/src/localize/languages/en.json b/src/localize/languages/en.json index de13a746..64b2c503 100644 --- a/src/localize/languages/en.json +++ b/src/localize/languages/en.json @@ -233,7 +233,31 @@ "auto_play": "Automatically play live cameras", "auto_unmute": "Automatically unmute live cameras", "controls": { - "editor_label": "Live Controls" + "editor_label": "Live Controls", + "ptz": { + "editor_label": "PTZ", + "mode": "Mode", + "modes": { + "on": "On", + "off": "Off", + "auto": "Automatic" + }, + "orientation": "Orientation", + "orientations": { + "vertical": "Vertical", + "horizontal": "Horizontal" + }, + "position": "Position", + "positions": { + "top-left": "Top left", + "top-right": "Top right", + "bottom-left": "Bottom left", + "bottom-right": "Bottom right" + }, + "hide_zoom": "Hide zoom control", + "hide_pan_tilt": "Hide pan & tilt control", + "hide_home": "Hide home control" + } }, "draggable": "Live cameras view can be dragged/swiped", "layout": "Live Layout", @@ -301,6 +325,7 @@ "mute": "Mute / Unmute", "play": "Play / Pause", "priority": "Priority", + "ptz": "Show PTZ controls", "recordings": "Recordings", "screenshot": "Screenshot", "snapshots": "Snapshots", diff --git a/src/localize/languages/it.json b/src/localize/languages/it.json index cbefd91d..e4af3d0f 100644 --- a/src/localize/languages/it.json +++ b/src/localize/languages/it.json @@ -233,7 +233,31 @@ "auto_play": "Gioca automaticamente le telecamere dal vivo", "auto_unmute": "Riattiva automaticamente l'audio delle telecamere live", "controls": { - "editor_label": "Controlli dal vivo" + "editor_label": "Controlli dal vivo", + "ptz": { + "editor_label": "", + "mode": "", + "modes": { + "on": "", + "off": "", + "auto": "" + }, + "orientation": "", + "orientations": { + "vertical": "", + "horizontal": "" + }, + "position": "", + "positions": { + "top-left": "", + "top-right": "", + "bottom-left": "", + "bottom-right": "" + }, + "hide_zoom": "", + "hide_pan_tilt": "", + "hide_home": "" + } }, "draggable": "Il Visualizzatore eventi può essere trascinato oppure puoi scorrere", "layout": "Disposizione dal vivo", @@ -299,6 +323,7 @@ "mute": "", "play": "", "priority": "Priorità", + "ptz": "", "screenshot": "", "snapshots": "Istantanee", "substreams": "Flusso/i secondario/i", @@ -523,4 +548,4 @@ }, "select_date": "Scegli la data" } -} \ No newline at end of file +} diff --git a/src/localize/languages/pt-BR.json b/src/localize/languages/pt-BR.json index aa8ecc21..4028931f 100644 --- a/src/localize/languages/pt-BR.json +++ b/src/localize/languages/pt-BR.json @@ -233,7 +233,31 @@ "auto_play": "Reproduzir câmeras ao vivo automaticamente", "auto_unmute": "Ativar automaticamente o som das câmeras ao vivo", "controls": { - "editor_label": "Controles da visualização ao vivo" + "editor_label": "Controles da visualização ao vivo", + "ptz": { + "editor_label": "", + "mode": "", + "modes": { + "on": "", + "off": "", + "auto": "" + }, + "orientation": "", + "orientations": { + "vertical": "", + "horizontal": "" + }, + "position": "", + "positions": { + "top-left": "", + "top-right": "", + "bottom-left": "", + "bottom-right": "" + }, + "hide_zoom": "", + "hide_pan_tilt": "", + "hide_home": "" + } }, "draggable": "A visualização ao vivo das câmeras pode ser arrastada/deslizada", "layout": "Layout dinâmico", @@ -300,6 +324,7 @@ "mute": "", "play": "", "priority": "Prioridade", + "ptz": "", "recordings": "Gravações", "screenshot": "", "snapshots": "Instantâneos", diff --git a/src/localize/languages/pt-PT.json b/src/localize/languages/pt-PT.json index df256cee..daebd3ed 100644 --- a/src/localize/languages/pt-PT.json +++ b/src/localize/languages/pt-PT.json @@ -226,7 +226,31 @@ "auto_play": "Reproduzir câmeras ao vivo automaticamente", "auto_unmute": "Ativar automaticamente o som das câmeras ao vivo", "controls": { - "editor_label": "Controles da visualização ao vivo" + "editor_label": "Controles da visualização ao vivo", + "ptz": { + "editor_label": "", + "mode": "", + "modes": { + "on": "", + "off": "", + "auto": "" + }, + "orientation": "", + "orientations": { + "vertical": "", + "horizontal": "" + }, + "position": "", + "positions": { + "top-left": "", + "top-right": "", + "bottom-left": "", + "bottom-right": "" + }, + "hide_zoom": "", + "hide_pan_tilt": "", + "hide_home": "" + } }, "draggable": "A visualização ao vivo das câmeras pode ser arrastada/deslizada", "layout": "layout", @@ -292,6 +316,7 @@ "mute": "", "play": "", "priority": "Prioridade", + "ptz": "", "screenshot": "", "snapshots": "Instantâneos", "substreams": "substreams", diff --git a/src/localize/localize.ts b/src/localize/localize.ts index 2ecac5dd..76e67a72 100644 --- a/src/localize/localize.ts +++ b/src/localize/localize.ts @@ -52,7 +52,7 @@ export function getLanguage(hass?: HomeAssistant): string { /** * Load required languages. */ -export const loadLanguages = async (hass: HomeAssistant): Promise => { +export const loadLanguages = async (hass: HomeAssistant): Promise => { const lang = getLanguage(hass); if (lang === 'it') { languages[lang] = await import('./languages/it.json'); @@ -65,6 +65,7 @@ export const loadLanguages = async (hass: HomeAssistant): Promise => { if (lang) { frigateCardLanguage = lang; } + return true; }; /** diff --git a/src/patches/ha-hls-player.ts b/src/patches/ha-hls-player.ts index 56f12e7f..89c6920f 100644 --- a/src/patches/ha-hls-player.ts +++ b/src/patches/ha-hls-player.ts @@ -12,11 +12,11 @@ import { css, CSSResultGroup, html, TemplateResult, unsafeCSS } from 'lit'; import { customElement } from 'lit/decorators.js'; import { query } from 'lit/decorators/query.js'; -import { screenshotMedia } from '../utils/screenshot.js'; import { dispatchErrorMessageEvent } from '../components/message.js'; import liveHAComponentsStyle from '../scss/live-ha-components.scss'; import { FrigateCardMediaPlayer } from '../types.js'; import { mayHaveAudio } from '../utils/audio.js'; +import { errorToConsole } from '../utils/basic.js'; import { dispatchMediaLoadedEvent, dispatchMediaPauseEvent, @@ -26,6 +26,7 @@ import { import { hideMediaControlsTemporarily, MEDIA_LOAD_CONTROLS_HIDE_SECONDS, setControlsOnVideo } from '../utils/media.js'; +import { screenshotMedia } from '../utils/screenshot.js'; customElements.whenDefined('ha-hls-player').then(() => { @customElement('frigate-card-ha-hls-player') @@ -97,7 +98,7 @@ customElements.whenDefined('ha-hls-player').then(() => { // Use native Frigate card error handling for fatal errors. return dispatchErrorMessageEvent(this, this._error); } else { - console.error(this._error); + errorToConsole(this._error, console.error); } } return html` diff --git a/src/scss/live-image.scss b/src/scss/live-image.scss index cec0abb6..e3950e11 100644 --- a/src/scss/live-image.scss +++ b/src/scss/live-image.scss @@ -9,6 +9,5 @@ ha-icon { position: absolute; top: 10px; right: 10px; - opacity: 50%; - color: white; + color: var(--primary-color); } diff --git a/src/scss/elements-ptz.scss b/src/scss/ptz.scss similarity index 91% rename from src/scss/elements-ptz.scss rename to src/scss/ptz.scss index 34bfbb4a..b21e62bb 100644 --- a/src/scss/elements-ptz.scss +++ b/src/scss/ptz.scss @@ -1,7 +1,7 @@ -// Modified from / inspired by: +// Inspired by: // https://github.com/AlexxIT/WebRTC/blob/master/custom_components/webrtc/www/webrtc-camera.js :host { - position: relative; + position: absolute; width: fit-content; height: fit-content; @@ -9,6 +9,19 @@ --frigate-card-ptz-icon-size: 24px; } +:host([data-position$='-left']) { + left: 5%; +} +:host([data-position$='-right']) { + right: 5%; +} +:host([data-position^='top-']) { + top: 5%; +} +:host([data-position^='bottom-']) { + bottom: 5%; +} + /***************** * Main Containers *****************/ diff --git a/src/utils/action.ts b/src/utils/action.ts index 8b0e60d4..27bb17c6 100644 --- a/src/utils/action.ts +++ b/src/utils/action.ts @@ -7,10 +7,10 @@ import { import { Actions, ActionType, - FrigateCardAction, FrigateCardCustomAction, frigateCardCustomActionSchema, - ViewDisplayMode, + FrigateCardGeneralAction, + FrigateCardUserSpecifiedView, } from '../config/types.js'; /** @@ -30,59 +30,75 @@ export function convertActionToFrigateCardCustomAction( return parseResult.success ? parseResult.data : null; } -/** - * Create a Frigate card custom action. - * @param action The Frigate card action string (e.g. 'fullscreen') - * @returns A FrigateCardCustomAction for that action string or null. - */ -export function createFrigateCardCustomAction( - action: FrigateCardAction, - args?: { +export function createFrigateCardSimpleAction( + action: FrigateCardGeneralAction | FrigateCardUserSpecifiedView, + options?: { cardID?: string; - camera?: string; - media_player?: string; - media_player_action?: 'play' | 'stop'; - display_mode?: ViewDisplayMode; }, ): FrigateCardCustomAction | null { - if (action === 'camera_select' || action === 'live_substream_select') { - if (!args?.camera) { - return null; - } - return { - action: 'fire-dom-event', - frigate_card_action: action, - camera: args.camera as string, - ...(args.cardID && { card_id: args.cardID }), - }; - } - if (action === 'media_player') { - if (!args?.media_player || !args.media_player_action) { - return null; - } - return { - action: 'fire-dom-event', - frigate_card_action: action, - media_player: args.media_player, - media_player_action: args.media_player_action, - ...(args.cardID && { card_id: args.cardID }), - }; - } - if (action === 'display_mode_select') { - if (!args?.display_mode) { - return null; - } - return { - action: 'fire-dom-event', - frigate_card_action: action, - display_mode: args?.display_mode, - ...(args.cardID && { card_id: args.cardID }), - }; - } return { action: 'fire-dom-event', frigate_card_action: action, - ...(args?.cardID && { card_id: args.cardID }), + ...(options?.cardID && { card_id: options.cardID }), + }; +} + +export function createFrigateCardCameraAction( + action: 'camera_select' | 'live_substream_select', + camera: string, + options?: { + cardID?: string; + }, +): FrigateCardCustomAction { + return { + action: 'fire-dom-event', + frigate_card_action: action, + camera: camera, + ...(options?.cardID && { card_id: options.cardID }), + }; +} + +export function createFrigateCardMediaPlayerAction( + mediaPlayer: string, + mediaPlayerAction: 'play' | 'stop', + options?: { + cardID?: string; + }, +): FrigateCardCustomAction { + return { + action: 'fire-dom-event', + frigate_card_action: 'media_player', + media_player: mediaPlayer, + media_player_action: mediaPlayerAction, + ...(options?.cardID && { card_id: options.cardID }), + }; +} + +export function createFrigateCardDisplayModeAction( + displayMode: 'single' | 'grid', + options?: { + cardID?: string; + }, +): FrigateCardCustomAction { + return { + action: 'fire-dom-event', + frigate_card_action: 'display_mode_select', + display_mode: displayMode, + ...(options?.cardID && { card_id: options.cardID }), + }; +} + +export function createFrigateCardShowPTZAction( + showPTZ: boolean, + options?: { + cardID?: string; + }, +): FrigateCardCustomAction { + return { + action: 'fire-dom-event', + frigate_card_action: 'show_ptz', + show_ptz: showPTZ, + ...(options?.cardID && { card_id: options.cardID }), }; } @@ -94,10 +110,10 @@ export function createFrigateCardCustomAction( */ export function getActionConfigGivenAction( interaction?: string, - config?: Actions, -): ActionType | ActionType[] | undefined { + config?: Actions | null, +): ActionType | ActionType[] | null { if (!interaction || !config) { - return undefined; + return null; } if (interaction == 'tap' && config.tap_action) { return config.tap_action; @@ -110,7 +126,7 @@ export function getActionConfigGivenAction( } else if (interaction == 'start_tap' && config.start_tap_action) { return config.start_tap_action; } - return undefined; + return null; } /** @@ -132,7 +148,7 @@ export const frigateCardHandleActionConfig = ( entity?: string; }, action: string, - actionConfig?: ActionType | ActionType[], + actionConfig?: ActionType | ActionType[] | null, ): boolean => { // Only allow a tap action to use a default non-config (the more-info config). if (actionConfig || action == 'tap') { @@ -149,7 +165,7 @@ export const frigateCardHandleAction = ( camera_image?: string; entity?: string; }, - actionConfig: ActionType | ActionType[] | undefined, + actionConfig?: ActionType | ActionType[] | null, ): void => { // ActionConfig vs ActionType: // * There is a slight typing (but not functional) difference between @@ -161,7 +177,12 @@ export const frigateCardHandleAction = ( handleActionConfig(node, hass, config, action as ActionConfig | undefined), ); } else { - handleActionConfig(node, hass, config, actionConfig as ActionConfig | undefined); + handleActionConfig( + node, + hass, + config, + (actionConfig ?? undefined) as ActionConfig | undefined, + ); } }; diff --git a/src/utils/camera.ts b/src/utils/camera.ts index a72a507a..0a561890 100644 --- a/src/utils/camera.ts +++ b/src/utils/camera.ts @@ -1,4 +1,3 @@ -import { CameraManager } from '../camera-manager/manager.js'; import { CameraConfig, RawFrigateCardConfig } from '../config/types.js'; /** @@ -30,48 +29,3 @@ export function getCameraID( '' ); } - -/** - * Get all cameras that depend on a given camera. - * @param cameraManager The camera manager. - * @param cameraID ID of the target camera. - * @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). - */ -export function getAllDependentCameras( - cameraManager: CameraManager, - cameraID: string, -): Set; -export function getAllDependentCameras( - cameraManager?: CameraManager, - cameraID?: string, -): Set | null; -export function getAllDependentCameras( - cameraManager?: CameraManager, - cameraID?: string, -): Set | null { - if (!cameraManager || !cameraID) { - return null; - } - const cameras = cameraManager.getStore().getCameras(); - - const cameraIDs: Set = new Set(); - const getDependentCameras = (cameraID: string): void => { - const cameraConfig = cameras.get(cameraID); - if (cameraConfig) { - cameraIDs.add(cameraID); - const dependentCameras: Set = new Set(); - cameraConfig.dependencies.cameras.forEach((item) => dependentCameras.add(item)); - if (cameraConfig.dependencies.all_cameras) { - cameras.forEach((_, key) => dependentCameras.add(key)); - } - for (const eventCameraID of dependentCameras) { - if (!cameraIDs.has(eventCameraID)) { - getDependentCameras(eventCameraID); - } - } - } - }; - getDependentCameras(cameraID); - return cameraIDs; -} diff --git a/src/utils/initializer/initializer.ts b/src/utils/initializer/initializer.ts index a8033865..ef3ea8b7 100644 --- a/src/utils/initializer/initializer.ts +++ b/src/utils/initializer/initializer.ts @@ -5,7 +5,7 @@ enum InitializationState { INITIALIZED = 'initialized', } -type InitializationCallback = () => Promise; +type InitializationCallback = () => Promise; /** * Manages initialization state & calling initializers. @@ -43,8 +43,11 @@ export class Initializer { if (state !== InitializationState.INITIALIZING) { if (initializer) { this._state.set(aspect, InitializationState.INITIALIZING); - await initializer(); - this._state.set(aspect, InitializationState.INITIALIZED); + if (await initializer()) { + this._state.set(aspect, InitializationState.INITIALIZED); + } else { + this.uninitialize(aspect); + } } else { this._state.set(aspect, InitializationState.INITIALIZED); } diff --git a/src/utils/media-to-view.ts b/src/utils/media-to-view.ts index ac3bc44c..bfd58328 100644 --- a/src/utils/media-to-view.ts +++ b/src/utils/media-to-view.ts @@ -14,7 +14,6 @@ import { import { MediaQueriesResults } from '../view/media-queries-results'; import { View } from '../view/view'; import { errorToConsole } from './basic'; -import { getAllDependentCameras } from './camera.js'; type ResultSelectType = 'latest' | 'time' | 'none'; @@ -32,7 +31,7 @@ export const changeViewToRecentEventsForCameraAndDependents = async ( ): Promise => { const cameraIDs = options?.allCameras ? cameraManager.getStore().getVisibleCameraIDs() - : getAllDependentCameras(cameraManager, view.camera); + : cameraManager.getStore().getAllDependentCameras(view.camera); if (!cameraIDs.size) { return; } @@ -98,7 +97,7 @@ export const changeViewToRecentRecordingForCameraAndDependents = async ( ): Promise => { const cameraIDs = options?.allCameras ? cameraManager.getStore().getVisibleCameraIDs() - : getAllDependentCameras(cameraManager, view.camera); + : cameraManager.getStore().getAllDependentCameras(view.camera); if (!cameraIDs.size) { return; } diff --git a/src/utils/ptz.ts b/src/utils/ptz.ts new file mode 100644 index 00000000..6d31e8a2 --- /dev/null +++ b/src/utils/ptz.ts @@ -0,0 +1,14 @@ +import { CameraManagerCameraCapabilities } from '../camera-manager/types'; +import { FrigateCardPTZConfig, PTZ_CONTROL_ACTIONS } from '../config/types'; + +export const hasUsablePTZ = ( + capabilities: CameraManagerCameraCapabilities | null, + config: FrigateCardPTZConfig, +): boolean => { + for (const actionName of PTZ_CONTROL_ACTIONS) { + if ('actions_' + actionName in config) { + return true; + } + } + return !!capabilities?.ptz; +}; diff --git a/src/view/view.ts b/src/view/view.ts index e2a1de01..a402b3ad 100644 --- a/src/view/view.ts +++ b/src/view/view.ts @@ -5,6 +5,7 @@ import { dispatchFrigateCardEvent } from '../utils/basic.js'; import { MediaQueries } from './media-queries'; import { MediaQueriesClassifier } from './media-queries-classifier.js'; import { MediaQueriesResults } from './media-queries-results'; +import merge from 'lodash-es/merge'; interface ViewEvolveParameters { view?: FrigateCardView; @@ -177,7 +178,7 @@ export class View { * @returns This view. */ public mergeInContext(context?: ViewContext): View { - this.context = { ...this.context, ...context }; + this.context = merge(this.context ?? {}, this.context, context); return this; } diff --git a/tests/camera-manager/camera.test.ts b/tests/camera-manager/camera.test.ts new file mode 100644 index 00000000..358203d8 --- /dev/null +++ b/tests/camera-manager/camera.test.ts @@ -0,0 +1,57 @@ +import { describe, expect, it } from 'vitest'; +import { Camera } from '../../src/camera-manager/camera.js'; +import { GenericCameraManagerEngine } from '../../src/camera-manager/generic/engine-generic.js'; +import { createCameraCapabilities, createCameraConfig } from '../test-utils.js'; + +describe('Camera', () => { + it('should get config', async () => { + const config = createCameraConfig(); + const camera = new Camera( + config, + new GenericCameraManagerEngine(), + createCameraCapabilities(), + ); + expect(camera.getConfig()).toBe(config); + }); + + it('should get capabilities', async () => { + const capabilities = createCameraCapabilities(); + const camera = new Camera( + createCameraConfig(), + new GenericCameraManagerEngine(), + capabilities, + ); + expect(camera.getCapabilities()).toBe(capabilities); + }); + + it('should get engine', async () => { + const engine = new GenericCameraManagerEngine(); + const camera = new Camera(createCameraConfig(), engine, createCameraCapabilities()); + expect(camera.getEngine()).toBe(engine); + }); + + it('should set and get id', async () => { + const config = createCameraConfig(); + const camera = new Camera( + config, + new GenericCameraManagerEngine(), + createCameraCapabilities(), + ); + camera.setID('foo'); + expect(camera.getID()).toBe('foo'); + expect(camera.getConfig().id).toBe('foo'); + }); + + it('should throw without id', async () => { + const config = createCameraConfig(); + const camera = new Camera( + config, + new GenericCameraManagerEngine(), + createCameraCapabilities(), + ); + expect(() => camera.getID()).toThrowError( + 'Could not determine camera id for the following ' + + "camera, may need to set 'id' parameter manually", + ); + }); +}); diff --git a/tests/camera-manager/frigate/engine-frigate.test.ts b/tests/camera-manager/frigate/engine-frigate.test.ts index 81f7ebd1..61acff83 100644 --- a/tests/camera-manager/frigate/engine-frigate.test.ts +++ b/tests/camera-manager/frigate/engine-frigate.test.ts @@ -9,6 +9,7 @@ import { FrigateEvent, eventSchema } from '../../../src/camera-manager/frigate/t import { CameraConfig, FrigateCardView, + PTZAction, RawFrigateCardConfig, } from '../../../src/config/types'; import { ViewMedia } from '../../../src/view/media'; @@ -392,3 +393,101 @@ describe('getCameraEndpoints', () => { }); }); }); + +describe('executePTZAction', () => { + describe('preset', () => { + it('should reject without preset argument', () => { + const hass = createHASS(); + const cameraConfig = createCameraConfig({ camera_entity: 'camera.office' }); + + createEngine().executePTZAction(hass, cameraConfig, 'preset'); + + expect(hass.callService).not.toBeCalled(); + }); + + it('should succeed', () => { + const hass = createHASS(); + const cameraConfig = createCameraConfig({ camera_entity: 'camera.office' }); + + createEngine().executePTZAction(hass, cameraConfig, 'preset', { + preset: 'preset-foo', + }); + + expect(hass.callService).toBeCalledWith('frigate', 'ptz', { + entity_id: 'camera.office', + action: 'preset', + argument: 'preset-foo', + }); + }); + }); + + describe('zoom', () => { + describe.each([['zoom_in' as const], ['zoom_out' as const]])( + '%s', + (actionName: PTZAction) => { + it('start', () => { + const hass = createHASS(); + const cameraConfig = createCameraConfig({ camera_entity: 'camera.office' }); + + createEngine().executePTZAction(hass, cameraConfig, actionName); + + expect(hass.callService).toBeCalledWith('frigate', 'ptz', { + entity_id: 'camera.office', + action: 'zoom', + argument: actionName === 'zoom_in' ? 'in' : 'out', + }); + }); + + it('stop', () => { + const hass = createHASS(); + const cameraConfig = createCameraConfig({ camera_entity: 'camera.office' }); + + createEngine().executePTZAction(hass, cameraConfig, actionName, { + phase: 'stop', + }); + + expect(hass.callService).toBeCalledWith('frigate', 'ptz', { + entity_id: 'camera.office', + action: 'stop', + }); + }); + }, + ); + }); + + describe('move', () => { + describe.each([ + ['left' as const], + ['right' as const], + ['up' as const], + ['down' as const], + ])('%s', (actionName: PTZAction) => { + it('start', () => { + const hass = createHASS(); + const cameraConfig = createCameraConfig({ camera_entity: 'camera.office' }); + + createEngine().executePTZAction(hass, cameraConfig, actionName); + + expect(hass.callService).toBeCalledWith('frigate', 'ptz', { + entity_id: 'camera.office', + action: 'move', + argument: actionName, + }); + }); + + it('stop', () => { + const hass = createHASS(); + const cameraConfig = createCameraConfig({ camera_entity: 'camera.office' }); + + createEngine().executePTZAction(hass, cameraConfig, actionName, { + phase: 'stop', + }); + + expect(hass.callService).toBeCalledWith('frigate', 'ptz', { + entity_id: 'camera.office', + action: 'stop', + }); + }); + }); + }); +}); diff --git a/tests/camera-manager/frigate/requests.test.ts b/tests/camera-manager/frigate/requests.test.ts new file mode 100644 index 00000000..461c1df2 --- /dev/null +++ b/tests/camera-manager/frigate/requests.test.ts @@ -0,0 +1,218 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { + getEvents, + getEventSummary, + getPTZInfo, + getRecordingSegments, + getRecordingsSummary, + retainEvent, +} from '../../../src/camera-manager/frigate/requests'; +import { + EventSummary, + eventSummarySchema, + FrigateEvent, + frigateEventsSchema, + ptzInfoSchema, + recordingSegmentsSchema, + recordingSummarySchema, + retainResultSchema, +} from '../../../src/camera-manager/frigate/types'; +import { RecordingSegment } from '../../../src/camera-manager/types'; +import { homeAssistantWSRequest } from '../../../src/utils/ha'; +import { createFrigateEvent, createHASS } from '../../test-utils'; + +vi.mock('../../../src/utils/ha'); + +describe('frigate requests', () => { + afterEach(() => { + vi.resetAllMocks(); + }); + it('should get recordings summary', async () => { + const recordingSummary = { + events: 0, + hours: [], + day: new Date(), + }; + const hass = createHASS(); + vi.mocked(homeAssistantWSRequest).mockResolvedValue(recordingSummary); + expect(await getRecordingsSummary(hass, 'clientID', 'camera.office')).toBe( + recordingSummary, + ); + expect(homeAssistantWSRequest).toBeCalledWith( + hass, + recordingSummarySchema, + expect.objectContaining({ + type: 'frigate/recordings/summary', + instance_id: 'clientID', + camera: 'camera.office', + timezone: Intl.DateTimeFormat().resolvedOptions().timeZone, + }), + true, + ); + }); + + it('should get recordings segments', async () => { + const recordingSegments: RecordingSegment[] = [ + { + start_time: 0, + end_time: 1, + id: 'foo', + }, + ]; + const hass = createHASS(); + vi.mocked(homeAssistantWSRequest).mockResolvedValue(recordingSegments); + expect( + await getRecordingSegments(hass, { + instance_id: 'clientID', + camera: 'camera.office', + after: 1, + before: 0, + }), + ).toBe(recordingSegments); + expect(homeAssistantWSRequest).toBeCalledWith( + hass, + recordingSegmentsSchema, + expect.objectContaining({ + type: 'frigate/recordings/get', + instance_id: 'clientID', + camera: 'camera.office', + after: 1, + before: 0, + }), + true, + ); + }); + + describe('should retain event', async () => { + it('successfully', async () => { + vi.mocked(homeAssistantWSRequest).mockResolvedValue({ + success: true, + message: 'success', + }); + + const hass = createHASS(); + retainEvent(hass, 'clientID', 'eventID', true); + + expect(homeAssistantWSRequest).toBeCalledWith( + hass, + retainResultSchema, + expect.objectContaining({ + type: 'frigate/event/retain', + instance_id: 'clientID', + event_id: 'eventID', + retain: true, + }), + true, + ); + }); + + it('unsuccessfully', async () => { + vi.mocked(homeAssistantWSRequest).mockResolvedValue({ + success: false, + message: 'failed', + }); + + const hass = createHASS(); + await expect(retainEvent(hass, 'clientID', 'eventID', true)).rejects.toThrowError( + /Could not retain event/, + ); + expect(homeAssistantWSRequest).toBeCalledWith( + hass, + retainResultSchema, + expect.objectContaining({ + type: 'frigate/event/retain', + instance_id: 'clientID', + event_id: 'eventID', + retain: true, + }), + true, + ); + }); + }); + + it('should get events', async () => { + const events: FrigateEvent[] = [createFrigateEvent()]; + const hass = createHASS(); + vi.mocked(homeAssistantWSRequest).mockResolvedValue(events); + expect( + await getEvents(hass, { + instance_id: 'clientID', + cameras: ['camera.office'], + labels: ['person'], + zones: ['zone'], + after: 0, + before: 1, + limit: 10, + has_clip: true, + has_snapshot: true, + favorites: true, + }), + ).toBe(events); + expect(homeAssistantWSRequest).toBeCalledWith( + hass, + frigateEventsSchema, + expect.objectContaining({ + type: 'frigate/events/get', + instance_id: 'clientID', + cameras: ['camera.office'], + labels: ['person'], + zones: ['zone'], + after: 0, + before: 1, + limit: 10, + has_clip: true, + has_snapshot: true, + favorites: true, + }), + true, + ); + }); + + it('should get event summary', async () => { + const eventSummary: EventSummary = [ + { + camera: 'camera.office', + day: '2023-10-29', + label: 'person', + sub_label: null, + zones: ['door'], + }, + ]; + const hass = createHASS(); + vi.mocked(homeAssistantWSRequest).mockResolvedValue(eventSummary); + expect(await getEventSummary(hass, 'clientID')).toBe(eventSummary); + expect(homeAssistantWSRequest).toBeCalledWith( + hass, + eventSummarySchema, + expect.objectContaining({ + type: 'frigate/events/summary', + instance_id: 'clientID', + timezone: Intl.DateTimeFormat().resolvedOptions().timeZone, + }), + true, + ); + }); + + it('should get PTZ info', async () => { + const ptzInfo = [ + { + name: 'camera.office', + features: ['zoom', 'zoom-r'], + presets: ['preset01', 'preset02'], + }, + ]; + const hass = createHASS(); + vi.mocked(homeAssistantWSRequest).mockResolvedValue(ptzInfo); + expect(await getPTZInfo(hass, 'clientID', 'camera.office')).toBe(ptzInfo); + expect(homeAssistantWSRequest).toBeCalledWith( + hass, + ptzInfoSchema, + expect.objectContaining({ + type: 'frigate/ptz/info', + instance_id: 'clientID', + camera: 'camera.office', + }), + true, + ); + }); +}); diff --git a/tests/camera-manager/generic/engine-generic.test.ts b/tests/camera-manager/generic/engine-generic.test.ts index 3ee4b706..c023c808 100644 --- a/tests/camera-manager/generic/engine-generic.test.ts +++ b/tests/camera-manager/generic/engine-generic.test.ts @@ -9,6 +9,7 @@ import { createCameraConfig, createHASS, createStateEntity, + createStore, } from '../../test-utils'; const createEngine = (): GenericCameraManagerEngine => { @@ -26,19 +27,29 @@ describe('GenericCameraManagerEngine', () => { it('should initialize camera', async () => { const config = createGenericCameraConfig(); - expect( - await createEngine().initializeCamera( - createHASS(), - mock(), - config, - ), - ).toEqual(config); + const camera = await createEngine().initializeCamera( + createHASS(), + mock(), + config, + ); + + expect(camera.getConfig()).toEqual(config); + expect(camera.getCapabilities()).toEqual({ + canFavoriteEvents: false, + canFavoriteRecordings: false, + canSeek: false, + supportsClips: false, + supportsRecordings: false, + supportsSnapshots: false, + supportsTimeline: false, + }); }); it('should generate default event query', () => { + const engine = createEngine(); expect( - createEngine().generateDefaultEventQuery( - new Map([['camera-1', createGenericCameraConfig()]]), + engine.generateDefaultEventQuery( + createStore([{ cameraID: 'camera-1', engine: engine }]), new Set(['camera-1']), {}, ), @@ -46,9 +57,10 @@ describe('GenericCameraManagerEngine', () => { }); it('should generate default recording query', () => { + const engine = createEngine(); expect( - createEngine().generateDefaultRecordingQuery( - new Map([['camera-1', createGenericCameraConfig()]]), + engine.generateDefaultRecordingQuery( + createStore([{ cameraID: 'camera-1', engine: engine }]), new Set(['camera-1']), {}, ), @@ -56,9 +68,10 @@ describe('GenericCameraManagerEngine', () => { }); it('should generate default recording segments query', () => { + const engine = createEngine(); expect( - createEngine().generateDefaultRecordingSegmentsQuery( - new Map([['camera-1', createGenericCameraConfig()]]), + engine.generateDefaultRecordingSegmentsQuery( + createStore([{ cameraID: 'camera-1', engine: engine }]), new Set(['camera-1']), {}, ), @@ -66,30 +79,33 @@ describe('GenericCameraManagerEngine', () => { }); it('should get events', async () => { + const engine = createEngine(); expect( - await createEngine().getEvents( + await engine.getEvents( createHASS(), - new Map([['camera-1', createGenericCameraConfig()]]), + createStore([{ cameraID: 'camera-1', engine: engine }]), { type: QueryType.Event, cameraIDs: new Set(['camera-1']) }, ), ).toBeNull(); }); it('should get recordings', async () => { + const engine = createEngine(); expect( - await createEngine().getRecordings( + await engine.getRecordings( createHASS(), - new Map([['camera-1', createGenericCameraConfig()]]), + createStore([{ cameraID: 'camera-1', engine: engine }]), { type: QueryType.Recording, cameraIDs: new Set(['camera-1']) }, ), ).toBeNull(); }); it('should get recording segments', async () => { + const engine = createEngine(); expect( - await createEngine().getRecordingSegments( + await engine.getRecordingSegments( createHASS(), - new Map([['camera-1', createGenericCameraConfig()]]), + createStore([{ cameraID: 'camera-1', engine: engine }]), { type: QueryType.RecordingSegments, cameraIDs: new Set(['camera-1']), @@ -101,10 +117,11 @@ describe('GenericCameraManagerEngine', () => { }); it('should generate media from events', async () => { + const engine = createEngine(); expect( - createEngine().generateMediaFromEvents( + engine.generateMediaFromEvents( createHASS(), - new Map([['camera-1', createGenericCameraConfig()]]), + createStore([{ cameraID: 'camera-1', engine: engine }]), { type: QueryType.Event, cameraIDs: new Set(['camera-1']), @@ -118,10 +135,11 @@ describe('GenericCameraManagerEngine', () => { }); it('should generate media from recordings', async () => { + const engine = createEngine(); expect( - createEngine().generateMediaFromRecordings( + engine.generateMediaFromRecordings( createHASS(), - new Map([['camera-1', createGenericCameraConfig()]]), + createStore([{ cameraID: 'camera-1', engine: engine }]), { type: QueryType.Recording, cameraIDs: new Set(['camera-1']), @@ -167,10 +185,11 @@ describe('GenericCameraManagerEngine', () => { }); it('should get media seek time', async () => { + const engine = createEngine(); expect( - await createEngine().getMediaSeekTime( + await engine.getMediaSeekTime( createHASS(), - new Map([['camera-1', createGenericCameraConfig()]]), + createStore([{ cameraID: 'camera-1', engine: engine }]), new TestViewMedia(), new Date(), ), @@ -178,10 +197,11 @@ describe('GenericCameraManagerEngine', () => { }); it('should get media metadata', async () => { + const engine = createEngine(); expect( - await createEngine().getMediaMetadata( + await engine.getMediaMetadata( createHASS(), - new Map([['camera-1', createGenericCameraConfig()]]), + createStore([{ cameraID: 'camera-1', engine: engine }]), { type: QueryType.MediaMetadata, cameraIDs: new Set(['camera-1']) }, ), ).toBeNull(); @@ -264,18 +284,6 @@ describe('GenericCameraManagerEngine', () => { }); }); - it('should get camera capabilities metadata', async () => { - expect(createEngine().getCameraCapabilities(createGenericCameraConfig())).toEqual({ - canFavoriteEvents: false, - canFavoriteRecordings: false, - canSeek: false, - supportsClips: false, - supportsRecordings: false, - supportsSnapshots: false, - supportsTimeline: false, - }); - }); - it('should get media capabilities', () => { expect(createEngine().getMediaCapabilities(new TestViewMedia())).toBeNull(); }); @@ -303,4 +311,10 @@ describe('GenericCameraManagerEngine', () => { }); }); }); + + it('should execute PTZ action', () => { + const hass = createHASS(); + createEngine().executePTZAction(hass, createCameraConfig(), 'left'); + expect(hass.callService).not.toBeCalled(); + }); }); diff --git a/tests/camera-manager/store.test.ts b/tests/camera-manager/store.test.ts index bd66f082..384f43e7 100644 --- a/tests/camera-manager/store.test.ts +++ b/tests/camera-manager/store.test.ts @@ -1,15 +1,23 @@ import { describe, expect, it } from 'vitest'; import { mock } from 'vitest-mock-extended'; +import { Camera } from '../../src/camera-manager/camera.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 { TestViewMedia, createCameraConfig } from '../test-utils.js'; +import { + TestViewMedia, + createCameraCapabilities, + createCameraConfig, +} from '../test-utils.js'; describe('CameraManagerStore', async () => { - const config_visible = createCameraConfig(); - const config_hidden = createCameraConfig({ + const configVisible = createCameraConfig({ + id: 'camera-visible', + }); + const configHidden = createCameraConfig({ + id: 'camera-hidden', hide: true, }); @@ -21,71 +29,100 @@ describe('CameraManagerStore', async () => { const engineGeneric = await engineFactory.createEngine(Engine.Generic); const engineFrigate = await engineFactory.createEngine(Engine.Frigate); - const setupStore = async (): Promise => { + const setupStore = (): CameraManagerStore => { const store = new CameraManagerStore(); - store.addCamera('camera-visible', config_visible, engineGeneric); - store.addCamera('camera-hidden', config_hidden, engineFrigate); + store.addCamera( + new Camera(configVisible, engineGeneric, createCameraCapabilities()), + ); + store.addCamera(new Camera(configHidden, engineFrigate, createCameraCapabilities())); return store; }; it('getCameraConfig', async () => { - const store = await setupStore(); - expect(store.getCameraConfig('camera-visible')).toBe(config_visible); - expect(store.getCameraConfig('camera-hidden')).toBe(config_hidden); + const store = setupStore(); + expect(store.getCameraConfig('camera-visible')).toBe(configVisible); + expect(store.getCameraConfig('camera-hidden')).toBe(configHidden); expect(store.getCameraConfig('camera-not-exist')).toBeNull(); }); it('hasCameraID', async () => { - const store = await setupStore(); + const store = setupStore(); expect(store.hasCameraID('camera-visible')).toBeTruthy(); expect(store.hasCameraID('camera-hidden')).toBeTruthy(); }); - it('hasVisibleCameraID', async () => { - const store = await setupStore(); - expect(store.hasVisibleCameraID('camera-visible')).toBeTruthy(); - expect(store.hasVisibleCameraID('camera-hidden')).toBeFalsy(); - }); - it('getCameraCount', async () => { - const store = await setupStore(); + const store = setupStore(); expect(store.getCameraCount()).toBe(2); }); it('getVisibleCameraCount', async () => { - const store = await setupStore(); + const store = setupStore(); expect(store.getVisibleCameraCount()).toBe(1); }); - it('getCameras', async () => { - const store = await setupStore(); - expect(store.getCameras()).toEqual( - new Map([ - ['camera-visible', config_visible], - ['camera-hidden', config_hidden], - ]), - ); + describe('getCamera', async () => { + it('present', async () => { + const store = setupStore(); + expect(store.getCamera('camera-visible')?.getConfig()).toEqual(configVisible); + }); + + it('absent', async () => { + const store = setupStore(); + expect(store.getCamera('not-a-camera')).toBeNull(); + }); }); - it('getVisibleCameras', async () => { - const store = await setupStore(); - expect(store.getVisibleCameras()).toEqual( - new Map([['camera-visible', config_visible]]), - ); + describe('getCameraConfigs', async () => { + it('all', async () => { + const store = setupStore(); + expect([...store.getCameraConfigs()]).toEqual([configVisible, configHidden]); + }); + + it('named', async () => { + const store = setupStore(); + expect([...store.getCameraConfigs(['camera-visible', 'not-a-camera'])]).toEqual([ + configVisible, + ]); + }); + }); + + describe('getCameraConfigEntries', async () => { + it('all', async () => { + const store = setupStore(); + expect([...store.getCameraConfigEntries()]).toEqual([ + ['camera-visible', configVisible], + ['camera-hidden', configHidden], + ]); + }); + + it('named', async () => { + const store = setupStore(); + expect([ + ...store.getCameraConfigEntries(['camera-visible', 'not-a-camera']), + ]).toEqual([['camera-visible', configVisible]]); + }); + }); + + it('getCameras', async () => { + const store = setupStore(); + expect([...store.getCameras().keys()]).toEqual(['camera-visible', 'camera-hidden']); + expect(store.getCameras().get('camera-visible')?.getConfig()).toEqual(configVisible); + expect(store.getCameras().get('camera-hidden')?.getConfig()).toEqual(configHidden); }); it('getCameraIDs', async () => { - const store = await setupStore(); + const store = setupStore(); expect(store.getCameraIDs()).toEqual(new Set(['camera-visible', 'camera-hidden'])); }); it('getVisibleCameraIDs', async () => { - const store = await setupStore(); + const store = setupStore(); expect(store.getVisibleCameraIDs()).toEqual(new Set(['camera-visible'])); }); it('reset', async () => { - const store = await setupStore(); + const store = setupStore(); store.reset(); @@ -94,24 +131,24 @@ describe('CameraManagerStore', async () => { }); it('getCameraConfigForMedia', async () => { - const store = await setupStore(); + const store = setupStore(); const media_1 = new TestViewMedia({ cameraID: 'camera-visible' }); - expect(store.getCameraConfigForMedia(media_1)).toBe(config_visible); + expect(store.getCameraConfigForMedia(media_1)).toBe(configVisible); const media_2 = new TestViewMedia({ cameraID: 'camera-not-exist' }); expect(store.getCameraConfigForMedia(media_2)).toBeNull(); }); it('getEngineOfType', async () => { - const store = await setupStore(); + const store = setupStore(); expect(store.getEngineOfType(Engine.Generic)).toBe(engineGeneric); expect(store.getEngineOfType(Engine.Frigate)).toBe(engineFrigate); expect(store.getEngineOfType(Engine.MotionEye)).toBeNull(); }); it('getEngineForCameraID', async () => { - const store = await setupStore(); + const store = setupStore(); expect(store.getEngineForCameraID('camera-visible')).toBe(engineGeneric); expect(store.getEngineForCameraID('camera-hidden')).toBe(engineFrigate); expect(store.getEngineForCameraID('camera-not-exist')).toBeNull(); @@ -119,13 +156,23 @@ describe('CameraManagerStore', async () => { describe('getEnginesForCameraIDs', async () => { it('empty input', async () => { - const store = await setupStore(); + const store = setupStore(); expect(store.getEnginesForCameraIDs(new Set())).toBeNull(); }); it('multiple cameras', async () => { - const store = await setupStore(); - store.addCamera('camera-visible2', config_visible, engineGeneric); + const store = setupStore(); + store.addCamera( + new Camera( + { + ...configVisible, + id: 'camera-visible2', + }, + engineGeneric, + createCameraCapabilities(), + ), + ); + expect( store.getEnginesForCameraIDs( new Set([ @@ -145,13 +192,61 @@ describe('CameraManagerStore', async () => { }); it('getEngineForMedia', async () => { - const store = await setupStore(); + const store = setupStore(); const media = new TestViewMedia({ cameraID: 'camera-visible' }); expect(store.getEngineForMedia(media)).toBe(engineGeneric); }); - it('getAllEngines', async () => { - const store = await setupStore(); - expect(store.getAllEngines()).toEqual([engineGeneric, engineFrigate]); + describe('getAllDependentCameras', () => { + it('should return dependent cameras', () => { + const store = new CameraManagerStore(); + store.addCamera( + new Camera( + createCameraConfig({ + id: 'one', + dependencies: { + cameras: ['two', 'three'], + }, + }), + engineGeneric, + createCameraCapabilities(), + ), + ); + store.addCamera( + new Camera( + createCameraConfig({ + id: 'two', + }), + engineGeneric, + createCameraCapabilities(), + ), + ); + expect(store.getAllDependentCameras('one')).toEqual(new Set(['one', 'two'])); + }); + it('should return all cameras', () => { + const store = new CameraManagerStore(); + store.addCamera( + new Camera( + createCameraConfig({ + id: 'one', + dependencies: { + all_cameras: true, + }, + }), + engineGeneric, + createCameraCapabilities(), + ), + ); + store.addCamera( + new Camera( + createCameraConfig({ + id: 'two', + }), + engineGeneric, + createCameraCapabilities(), + ), + ); + expect(store.getAllDependentCameras('one')).toEqual(new Set(['one', 'two'])); + }); }); }); diff --git a/tests/card-controller/actions-manager.test.ts b/tests/card-controller/actions-manager.test.ts index 8496f4f0..52c794f1 100644 --- a/tests/card-controller/actions-manager.test.ts +++ b/tests/card-controller/actions-manager.test.ts @@ -23,7 +23,6 @@ import { } from '../test-utils'; vi.mock('../../src/utils/action.js'); -vi.mock('../../src/camera-manager/manager.js'); const createAction = ( action: Record, @@ -708,6 +707,62 @@ describe('ActionsManager.executeAction', () => { expect(api.getViewManager().setViewWithNewDisplayMode).toBeCalledWith('grid'); }); + describe('should handle ptz action', () => { + it('with selected camera', async () => { + const api = createCardAPI(); + const manager = new ActionsManager(api); + vi.mocked(api.getViewManager().getView).mockReturnValue( + createView({ camera: 'camera.office' }), + ); + + await manager.executeAction( + createAction({ + frigate_card_action: 'ptz', + ptz_action: 'left', + })!, + ); + + expect(api.getCameraManager().executePTZAction).toBeCalledWith( + 'camera.office', + 'left', + { + phase: undefined, + preset: undefined, + }, + ); + }); + + it('without selected camera', async () => { + const api = createCardAPI(); + const manager = new ActionsManager(api); + + await manager.executeAction( + createAction({ + frigate_card_action: 'ptz', + ptz_action: 'left', + })!, + ); + + expect(api.getCameraManager().executePTZAction).not.toBeCalled(); + }); + }); + + it('should handle show_ptz action', async () => { + const api = createCardAPI(); + const manager = new ActionsManager(api); + + await manager.executeAction( + createAction({ + frigate_card_action: 'show_ptz', + show_ptz: true, + })!, + ); + + expect(api.getViewManager().setViewWithNewContext).toBeCalledWith( + expect.objectContaining({ live: { ptzVisible: true } }), + ); + }); + it('should handle unknown action', async () => { const manager = new ActionsManager(createCardAPI()); diff --git a/tests/card-controller/hass-manager.test.ts b/tests/card-controller/hass-manager.test.ts index cb10b2ae..0fbe2b97 100644 --- a/tests/card-controller/hass-manager.test.ts +++ b/tests/card-controller/hass-manager.test.ts @@ -1,6 +1,6 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { CardController } from '../../src/card-controller/controller'; import { HASSManager } from '../../src/card-controller/hass-manager'; -import { CardHASSAPI } from '../../src/card-controller/types'; import { createCameraConfig, createCameraManager, @@ -8,12 +8,11 @@ import { createConfig, createHASS, createStateEntity, + createStore, createView, } from '../test-utils'; -vi.mock('../../src/camera-manager/manager.js'); - -const createAPIWithoutMediaPlayers = (): CardHASSAPI => { +const createAPIWithoutMediaPlayers = (): CardController => { const api = createCardAPI(); vi.mocked(api.getMediaPlayerManager().getMediaPlayers).mockReturnValue([]); return api; @@ -158,20 +157,20 @@ describe('HASSManager', () => { describe('should set default view when', () => { it('selected camera trigger entity changes', () => { - const cameraManager = createCameraManager({ - configs: new Map([ - [ - 'camera.foo', - createCameraConfig({ + const api = createAPIWithoutMediaPlayers(); + vi.mocked(api.getCameraManager).mockReturnValue(createCameraManager()); + vi.mocked(api.getCameraManager().getStore).mockReturnValue( + createStore([ + { + cameraID: 'camera.foo', + config: createCameraConfig({ triggers: { entities: ['binary_sensor.motion'], }, }), - ], + }, ]), - }); - const api = createAPIWithoutMediaPlayers(); - vi.mocked(api.getCameraManager).mockReturnValue(cameraManager); + ); vi.mocked(api.getViewManager().getView).mockReturnValue( createView({ camera: 'camera.foo', @@ -189,20 +188,20 @@ describe('HASSManager', () => { }); it('selected camera is unknown', () => { - const cameraManager = createCameraManager({ - configs: new Map([ - [ - 'camera.foo', - createCameraConfig({ + const api = createAPIWithoutMediaPlayers(); + vi.mocked(api.getCameraManager).mockReturnValue(createCameraManager()); + vi.mocked(api.getCameraManager().getStore).mockReturnValue( + createStore([ + { + cameraID: 'camera.foo', + config: createCameraConfig({ triggers: { entities: ['binary_sensor.motion'], }, }), - ], + }, ]), - }); - const api = createAPIWithoutMediaPlayers(); - vi.mocked(api.getCameraManager).mockReturnValue(cameraManager); + ); vi.mocked(api.getViewManager().getView).mockReturnValue( createView({ camera: 'camera.UNKNOWN', diff --git a/tests/card-controller/initialization-manager.test.ts b/tests/card-controller/initialization-manager.test.ts index c9f05c32..7ee8bd0b 100644 --- a/tests/card-controller/initialization-manager.test.ts +++ b/tests/card-controller/initialization-manager.test.ts @@ -18,9 +18,37 @@ describe('InitializationManager', () => { vi.resetAllMocks(); }); - it('should not be initialized', () => { - const manager = new InitializationManager(createCardAPI()); - expect(manager.isInitializedMandatory()).toBeFalsy(); + describe('should correctly determine when mandatory initialization is required', () => { + it('without aspects', () => { + const api = createCardAPI(); + const initializer = mock(); + const manager = new InitializationManager(api, initializer); + + initializer.isInitializedMultiple.mockReturnValue(false); + + expect(manager.isInitializedMandatory()).toBeFalsy(); + }); + + it('without view', () => { + const api = createCardAPI(); + const initializer = mock(); + const manager = new InitializationManager(api, initializer); + + initializer.isInitializedMultiple.mockReturnValue(true); + + expect(manager.isInitializedMandatory()).toBeFalsy(); + }); + + it('with aspects and view', () => { + const api = createCardAPI(); + const initializer = mock(); + const manager = new InitializationManager(api, initializer); + + initializer.isInitializedMultiple.mockReturnValue(true); + vi.mocked(api.getViewManager().hasView).mockReturnValue(true); + + expect(manager.isInitializedMandatory()).toBeTruthy(); + }); }); describe('should initialize mandatory', () => { @@ -41,7 +69,9 @@ describe('InitializationManager', () => { vi.mocked(api.getHASSManager().getHASS).mockReturnValue(createHASS()); vi.mocked(api.getConfigManager().hasConfig).mockReturnValue(true); vi.mocked(api.getMessageManager().hasMessage).mockReturnValue(false); - vi.mocked(api.getQueryStringManager().hasViewRelatedActions).mockReturnValue(false); + vi.mocked(api.getQueryStringManager().hasViewRelatedActions).mockReturnValue( + false, + ); const manager = new InitializationManager(api); expect(await manager.initializeMandatory()).toBeTruthy(); @@ -50,7 +80,6 @@ describe('InitializationManager', () => { expect(sideLoadHomeAssistantElements).toBeCalled(); expect(api.getCameraManager().initializeCamerasFromConfig).toBeCalled(); expect(api.getViewManager().setViewDefault).toBeCalled(); - expect(api.getCardElementManager().update).toBeCalled(); }); it('successfully with querystring view', async () => { @@ -71,7 +100,9 @@ describe('InitializationManager', () => { vi.mocked(api.getHASSManager().getHASS).mockReturnValue(createHASS()); vi.mocked(api.getConfigManager().hasConfig).mockReturnValue(true); vi.mocked(api.getMessageManager().hasMessage).mockReturnValue(true); - vi.mocked(api.getQueryStringManager().hasViewRelatedActions).mockReturnValue(false); + vi.mocked(api.getQueryStringManager().hasViewRelatedActions).mockReturnValue( + false, + ); const manager = new InitializationManager(api); expect(await manager.initializeMandatory()).toBeTruthy(); @@ -134,7 +165,6 @@ describe('InitializationManager', () => { expect(await manager.initializeBackgroundIfNecessary()).toBeTruthy(); expect(api.getMediaPlayerManager().initialize).not.toBeCalled(); expect(api.getMicrophoneManager().connect).not.toBeCalled(); - expect(api.getCardElementManager().update).not.toBeCalled(); }); it('successfully with all inititalizers', async () => { diff --git a/tests/card-controller/media-player-manager.test.ts b/tests/card-controller/media-player-manager.test.ts index 411cd046..836f544e 100644 --- a/tests/card-controller/media-player-manager.test.ts +++ b/tests/card-controller/media-player-manager.test.ts @@ -1,20 +1,19 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { mock } from 'vitest-mock-extended'; +import { MediaPlayerManager } from '../../src/card-controller/media-player-manager'; +import { MEDIA_PLAYER_SUPPORT_BROWSE_MEDIA } from '../../src/const'; +import { ExtendedHomeAssistant } from '../../src/types'; +import { EntityRegistryManager } from '../../src/utils/ha/entity-registry'; import { - TestViewMedia, createCameraConfig, createCameraManager, createCardAPI, createHASS, createRegistryEntity, createStateEntity, + createStore, + TestViewMedia, } from '../test-utils'; -import { MediaPlayerManager } from '../../src/card-controller/media-player-manager'; -import { ExtendedHomeAssistant } from '../../src/types'; -import { MEDIA_PLAYER_SUPPORT_BROWSE_MEDIA } from '../../src/const'; -import { EntityRegistryManager } from '../../src/utils/ha/entity-registry'; -import { mock } from 'vitest-mock-extended'; - -vi.mock('../../src/camera-manager/manager.js'); const createHASSWithMediaPlayers = (): ExtendedHomeAssistant => { const attributesSupported = { @@ -145,9 +144,7 @@ describe('MediaPlayerManager', () => { describe('live', () => { it('without camera config', async () => { const api = createCardAPI(); - const cameraManager = createCameraManager(); - vi.mocked(cameraManager.getStore().getCameraConfig).mockReturnValue(null); - vi.mocked(api.getCameraManager).mockReturnValue(cameraManager); + vi.mocked(api.getCameraManager).mockReturnValue(createCameraManager()); vi.mocked(api.getHASSManager().getHASS).mockReturnValue(createHASS()); const manager = new MediaPlayerManager(api); @@ -159,28 +156,33 @@ describe('MediaPlayerManager', () => { describe('using standard method', () => { it('successfully', async () => { const api = createCardAPI(); - const cameraManager = createCameraManager(); - vi.mocked(cameraManager.getStore().getCameraConfig).mockReturnValue( - createCameraConfig({ - camera_entity: 'camera.foo', - }), + vi.mocked(api.getCameraManager).mockReturnValue(createCameraManager()); + vi.mocked(api.getCameraManager().getStore).mockReturnValue( + createStore([ + { + cameraID: 'camera.foo', + config: createCameraConfig({ + camera_entity: 'camera.foo', + }), + }, + ]), ); - vi.mocked(cameraManager.getCameraMetadata).mockReturnValue({ + vi.mocked(api.getCameraManager().getCameraMetadata).mockReturnValue({ title: 'camera title', icon: 'icon', }); - vi.mocked(api.getCameraManager).mockReturnValue(cameraManager); - const hass = createHASS({ - 'camera.foo': createStateEntity({ - attributes: { - entity_picture: 'http://thumbnail', - }, + vi.mocked(api.getHASSManager().getHASS).mockReturnValue( + createHASS({ + 'camera.foo': createStateEntity({ + attributes: { + entity_picture: 'http://thumbnail', + }, + }), }), - }); - vi.mocked(api.getHASSManager().getHASS).mockReturnValue(hass); + ); const manager = new MediaPlayerManager(api); - await manager.playLive('media_player.foo', 'camera'); + await manager.playLive('media_player.foo', 'camera.foo'); expect(api.getHASSManager().getHASS()?.callService).toBeCalledWith( 'media_player', @@ -199,32 +201,41 @@ describe('MediaPlayerManager', () => { it('without camera_entity', async () => { const api = createCardAPI(); - const cameraManager = createCameraManager(); - vi.mocked(cameraManager.getStore().getCameraConfig).mockReturnValue( - createCameraConfig({}), + vi.mocked(api.getCameraManager).mockReturnValue(createCameraManager()); + + vi.mocked(api.getCameraManager().getStore).mockReturnValue( + createStore([ + { + cameraID: 'camera.foo', + config: createCameraConfig(), + }, + ]), ); - vi.mocked(api.getCameraManager).mockReturnValue(cameraManager); vi.mocked(api.getHASSManager().getHASS).mockReturnValue(createHASS()); const manager = new MediaPlayerManager(api); - await manager.playLive('media_player.foo', 'camera'); + await manager.playLive('media_player.foo', 'camera.foo'); expect(api.getHASSManager().getHASS()?.callService).not.toBeCalled(); }); it('without title and thumbnail', async () => { const api = createCardAPI(); - const cameraManager = createCameraManager(); - vi.mocked(cameraManager.getStore().getCameraConfig).mockReturnValue( - createCameraConfig({ - camera_entity: 'camera.foo', - }), + vi.mocked(api.getCameraManager).mockReturnValue(createCameraManager()); + vi.mocked(api.getCameraManager().getStore).mockReturnValue( + createStore([ + { + cameraID: 'camera.foo', + config: createCameraConfig({ + camera_entity: 'camera.foo', + }), + }, + ]), ); - vi.mocked(api.getCameraManager).mockReturnValue(cameraManager); vi.mocked(api.getHASSManager().getHASS).mockReturnValue(createHASS()); const manager = new MediaPlayerManager(api); - await manager.playLive('media_player.foo', 'camera'); + await manager.playLive('media_player.foo', 'camera.foo'); expect(api.getHASSManager().getHASS()?.callService).toBeCalledWith( 'media_player', @@ -242,25 +253,29 @@ describe('MediaPlayerManager', () => { describe('using dashboard method', () => { it('successfully', async () => { const api = createCardAPI(); - const cameraManager = createCameraManager(); - vi.mocked(cameraManager.getStore().getCameraConfig).mockReturnValue( - createCameraConfig({ - camera_entity: 'camera.foo', - cast: { - method: 'dashboard', - dashboard: { - dashboard_path: 'dashboard_path', - view_path: 'view_path', - }, + vi.mocked(api.getCameraManager).mockReturnValue(createCameraManager()); + vi.mocked(api.getCameraManager().getStore).mockReturnValue( + createStore([ + { + cameraID: 'camera.foo', + config: createCameraConfig({ + camera_entity: 'camera.foo', + cast: { + method: 'dashboard', + dashboard: { + dashboard_path: 'dashboard_path', + view_path: 'view_path', + }, + }, + }), }, - }), + ]), ); vi.mocked(api.getQueryStringManager().generateQueryString).mockReturnValue(''); - vi.mocked(api.getCameraManager).mockReturnValue(cameraManager); vi.mocked(api.getHASSManager().getHASS).mockReturnValue(createHASS()); const manager = new MediaPlayerManager(api); - await manager.playLive('media_player.foo', 'camera'); + await manager.playLive('media_player.foo', 'camera.foo'); expect(api.getHASSManager().getHASS()?.callService).toBeCalledWith( 'cast', @@ -275,24 +290,28 @@ describe('MediaPlayerManager', () => { it('without hass', async () => { const api = createCardAPI(); - const cameraManager = createCameraManager(); - vi.mocked(cameraManager.getStore().getCameraConfig).mockReturnValue( - createCameraConfig({ - camera_entity: 'camera.foo', - cast: { - method: 'dashboard', - dashboard: { - dashboard_path: 'dashboard_path', - view_path: 'view_path', - }, + vi.mocked(api.getCameraManager).mockReturnValue(createCameraManager()); + vi.mocked(api.getCameraManager().getStore).mockReturnValue( + createStore([ + { + cameraID: 'camera.foo', + config: createCameraConfig({ + camera_entity: 'camera.foo', + cast: { + method: 'dashboard', + dashboard: { + dashboard_path: 'dashboard_path', + view_path: 'view_path', + }, + }, + }), }, - }), + ]), ); - vi.mocked(api.getCameraManager).mockReturnValue(cameraManager); vi.mocked(api.getHASSManager().getHASS).mockReturnValue(null); const manager = new MediaPlayerManager(api); - await manager.playLive('media_player.foo', 'camera'); + await manager.playLive('media_player.foo', 'camera.foo'); // No actual test can be performed here as nothing observable happens. // This test serves only as code-coverage long-tail. @@ -301,21 +320,25 @@ describe('MediaPlayerManager', () => { it('without required configuration', async () => { const api = createCardAPI(); - const cameraManager = createCameraManager(); - vi.mocked(cameraManager.getStore().getCameraConfig).mockReturnValue( - createCameraConfig({ - camera_entity: 'camera.foo', - cast: { - method: 'dashboard', + vi.mocked(api.getCameraManager).mockReturnValue(createCameraManager()); + vi.mocked(api.getCameraManager().getStore).mockReturnValue( + createStore([ + { + cameraID: 'camera.foo', + config: createCameraConfig({ + camera_entity: 'camera.foo', + cast: { + method: 'dashboard', + }, + }), }, - }), + ]), ); - vi.mocked(api.getCameraManager).mockReturnValue(cameraManager); vi.mocked(api.getHASSManager().getHASS).mockReturnValue(createHASS()); const manager = new MediaPlayerManager(api); - await manager.playLive('media_player.foo', 'camera'); + await manager.playLive('media_player.foo', 'camera.foo'); expect( vi.mocked(api.getMessageManager().setMessageIfHigherPriority), diff --git a/tests/card-controller/microphone-manager.test.ts b/tests/card-controller/microphone-manager.test.ts index 9f6b4f78..fe8355e8 100644 --- a/tests/card-controller/microphone-manager.test.ts +++ b/tests/card-controller/microphone-manager.test.ts @@ -63,7 +63,7 @@ describe('MicrophoneManager', () => { const manager = new MicrophoneManager(api); navigatorMock.mediaDevices.getUserMedia.mockRejectedValue(new Error()); - await manager.connect(); + expect(await manager.connect()).toBeFalsy(); expect(manager.isConnected()).toBeFalsy(); expect(manager.isForbidden()).toBeTruthy(); diff --git a/tests/card-controller/triggers-manager.test.ts b/tests/card-controller/triggers-manager.test.ts index 7379f69e..d14d2f7a 100644 --- a/tests/card-controller/triggers-manager.test.ts +++ b/tests/card-controller/triggers-manager.test.ts @@ -1,8 +1,9 @@ import add from 'date-fns/add'; import { HassEntities } from 'home-assistant-js-websocket'; import { beforeEach, describe, expect, it, vi } from 'vitest'; -import { ScanOptions } from '../../src/config/types'; +import { CardController } from '../../src/card-controller/controller'; import { TriggersManager } from '../../src/card-controller/triggers-manager'; +import { ScanOptions } from '../../src/config/types'; import { createCameraConfig, createCameraManager, @@ -10,18 +11,17 @@ import { createConfig, createHASS, createStateEntity, + createStore, createView, } from '../test-utils'; -vi.mock('../../src/camera-manager/manager.js'); - // Creating and mocking a trigger API is a lot of boilerplate, this convenience // function reduces it. const createTriggerAPI = (options?: { config?: Partial; hassStates?: HassEntities; interaction?: boolean; -}) => { +}): CardController => { const api = createCardAPI(); vi.mocked(api.getConfigManager().getConfig).mockReturnValue( createConfig({ @@ -37,19 +37,18 @@ const createTriggerAPI = (options?: { vi.mocked(api.getHASSManager().getHASS).mockReturnValue( createHASS(options?.hassStates), ); - vi.mocked(api.getCameraManager).mockReturnValue( - createCameraManager({ - configs: new Map([ - [ - 'camera_1', - createCameraConfig({ - triggers: { - entities: ['binary_sensor.motion'], - }, - }), - ], - ]), - }), + vi.mocked(api.getCameraManager).mockReturnValue(createCameraManager()); + vi.mocked(api.getCameraManager().getStore).mockReturnValue( + createStore([ + { + cameraID: 'camera_1', + config: createCameraConfig({ + triggers: { + entities: ['binary_sensor.motion'], + }, + }), + }, + ]), ); vi.mocked(api.getInteractionManager().hasInteraction).mockReturnValue( options?.interaction ?? false, diff --git a/tests/card-controller/view-manager.test.ts b/tests/card-controller/view-manager.test.ts index 516a2ec3..07844a7a 100644 --- a/tests/card-controller/view-manager.test.ts +++ b/tests/card-controller/view-manager.test.ts @@ -1,20 +1,24 @@ import { describe, expect, it, vi } from 'vitest'; import { QueryType } from '../../src/camera-manager/types'; -import { FrigateCardView } from '../../src/config/types'; -import { getAllDependentCameras } from '../../src/utils/camera'; import { ViewManager } from '../../src/card-controller/view-manager'; +import { FrigateCardView } from '../../src/config/types'; +import { executeMediaQueryForView } from '../../src/utils/media-to-view'; import { EventMediaQueries } from '../../src/view/media-queries'; +import { MediaQueriesResults } from '../../src/view/media-queries-results'; +import { View } from '../../src/view/view'; import { + createCameraCapabilities, + createCameraConfig, createCameraManager, createCardAPI, createConfig, createHASS, + createStore, createView, - generateViewMediaArray, + generateViewMediaArray } from '../test-utils'; -vi.mock('../../src/camera-manager/manager.js'); -vi.mock('../../src/utils/camera'); +vi.mock('../../src/utils/media-to-view'); describe('ViewManager.setView', () => { it('should set view', () => { @@ -29,6 +33,7 @@ describe('ViewManager.setView', () => { manager.setView(view); expect(manager.getView()).toBe(view); + expect(manager.hasView()).toBeTruthy(); expect(api.getMediaLoadedInfoManager().clear).toBeCalled(); expect(api.getCardElementManager().scrollReset).toBeCalled(); expect(api.getMessageManager().reset).toBeCalled(); @@ -101,6 +106,7 @@ describe('ViewManager.reset', () => { manager.reset(); expect(manager.getView()).toBeNull(); + expect(manager.hasView()).toBeFalsy(); }); }); @@ -109,6 +115,13 @@ describe('ViewManager.setViewDefault', () => { const api = createCardAPI(); vi.mocked(api.getConfigManager().getConfig).mockReturnValue(createConfig()); vi.mocked(api.getCameraManager).mockReturnValue(createCameraManager()); + vi.mocked(api.getCameraManager().getStore).mockReturnValue( + createStore([ + { + cameraID: 'camera', + }, + ]), + ); const manager = new ViewManager(api); manager.setViewDefault(); @@ -130,12 +143,18 @@ describe('ViewManager.setViewDefault', () => { }); it('should cycle camera when configured', () => { - const cameraManager = createCameraManager(); - vi.mocked(cameraManager.getStore().getVisibleCameraIDs).mockReturnValue( - new Set(['camera_1', 'camera_2']), - ); const api = createCardAPI(); - vi.mocked(api.getCameraManager).mockReturnValue(cameraManager); + vi.mocked(api.getCameraManager).mockReturnValue(createCameraManager()); + vi.mocked(api.getCameraManager().getStore).mockReturnValue( + createStore([ + { + cameraID: 'camera_1', + }, + { + cameraID: 'camera_2', + }, + ]), + ); vi.mocked(api.getConfigManager().getConfig).mockReturnValue( createConfig({ view: { @@ -160,13 +179,19 @@ describe('ViewManager.setViewDefault', () => { }); it('should respect parameters', () => { - const cameraManager = createCameraManager(); - vi.mocked(cameraManager.getStore().getVisibleCameraIDs).mockReturnValue( - new Set(['camera.kitchen', 'camera.office']), - ); const api = createCardAPI(); + vi.mocked(api.getCameraManager).mockReturnValue(createCameraManager()); + vi.mocked(api.getCameraManager().getStore).mockReturnValue( + createStore([ + { + cameraID: 'camera.kitchen', + }, + { + cameraID: 'camera.office', + }, + ]), + ); vi.mocked(api.getConfigManager().getConfig).mockReturnValue(createConfig()); - vi.mocked(api.getCameraManager).mockReturnValue(cameraManager); const manager = new ViewManager(api); manager.setViewDefault({ @@ -186,60 +211,84 @@ describe('ViewManager.setViewByParameters', () => { const api = createCardAPI(); vi.mocked(api.getConfigManager().getConfig).mockReturnValue(createConfig()); vi.mocked(api.getCameraManager).mockReturnValue(createCameraManager()); + vi.mocked(api.getCameraManager().getStore).mockReturnValue( + createStore([ + { + cameraID: 'camera.kitchen', + }, + ]), + ); + vi.mocked(api.getCameraManager().getCameraCapabilities).mockReturnValue( + createCameraCapabilities({ + supportsClips: true, + }), + ); const manager = new ViewManager(api); manager.setViewByParameters({ - cameraID: 'camera', + cameraID: 'camera.kitchen', viewName: 'clips', }); expect(manager.getView()?.view).toBe('clips'); - expect(manager.getView()?.camera).toBe('camera'); + expect(manager.getView()?.camera).toBe('camera.kitchen'); }); it('should set view by parameters using existing view if unspecified', () => { - const cameraManager = createCameraManager(); - vi.mocked(cameraManager.getStore().getVisibleCameraIDs).mockReturnValue( - new Set(['camera_1', 'camera_2']), - ); - const api = createCardAPI(); + vi.mocked(api.getCameraManager).mockReturnValue(createCameraManager()); + vi.mocked(api.getCameraManager().getStore).mockReturnValue( + createStore([ + { + cameraID: 'camera.kitchen', + }, + { + cameraID: 'camera.office', + }, + ]), + ); + vi.mocked(api.getCameraManager().getCameraCapabilities).mockReturnValue( + createCameraCapabilities({ + supportsClips: true, + }), + ); vi.mocked(api.getConfigManager().getConfig).mockReturnValue(createConfig()); - vi.mocked(api.getCameraManager).mockReturnValue(cameraManager); const manager = new ViewManager(api); manager.setViewByParameters({ - cameraID: 'camera_1', + cameraID: 'camera.kitchen', viewName: 'clips', }); manager.setViewByParameters({ - cameraID: 'camera_2', + cameraID: 'camera.office', }); expect(manager.getView()?.view).toBe('clips'); - expect(manager.getView()?.camera).toBe('camera_2'); + expect(manager.getView()?.camera).toBe('camera.office'); }); it('should set view by parameters using config as fallback', () => { - const cameraManager = createCameraManager(); - vi.mocked(cameraManager.getStore().getVisibleCameraIDs).mockReturnValue( - new Set(['camera_1', 'camera_2']), - ); - const api = createCardAPI(); + vi.mocked(api.getCameraManager).mockReturnValue(createCameraManager()); + vi.mocked(api.getCameraManager().getStore).mockReturnValue( + createStore([ + { + cameraID: 'camera.kitchen', + }, + ]), + ); vi.mocked(api.getConfigManager().getConfig).mockReturnValue(createConfig()); - vi.mocked(api.getCameraManager).mockReturnValue(cameraManager); const manager = new ViewManager(api); manager.setViewByParameters({ - cameraID: 'camera_1', + cameraID: 'camera.kitchen', // No prior view, and no specified view. This could happen during query // string based initialization. }); expect(manager.getView()?.view).toBe('live'); - expect(manager.getView()?.camera).toBe('camera_1'); + expect(manager.getView()?.camera).toBe('camera.kitchen'); }); it('should not set view by parameters without config', () => { @@ -253,12 +302,19 @@ describe('ViewManager.setViewByParameters', () => { }); it('should not set view by parameters without visible cameras', () => { - const cameraManager = createCameraManager(); - vi.mocked(cameraManager.getStore().getVisibleCameraIDs).mockReturnValue(new Set()); - 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()); - vi.mocked(api.getCameraManager).mockReturnValue(cameraManager); const manager = new ViewManager(api); manager.setViewByParameters({ @@ -278,6 +334,21 @@ describe('ViewManager.setViewByParameters', () => { ])('%s', (viewName: FrigateCardView) => { const api = createCardAPI(); vi.mocked(api.getCameraManager).mockReturnValue(createCameraManager()); + vi.mocked(api.getCameraManager().getStore).mockReturnValue( + createStore([ + { + cameraID: 'camera.kitchen', + }, + ]), + ); + vi.mocked(api.getCameraManager().getCameraCapabilities).mockReturnValue( + createCameraCapabilities({ + supportsClips: true, + supportsRecordings: true, + supportsSnapshots: true, + }), + ); + vi.mocked(api.getConfigManager()).getConfig.mockReturnValue( createConfig({ media_viewer: { @@ -295,7 +366,7 @@ describe('ViewManager.setViewByParameters', () => { const manager = new ViewManager(api); manager.setViewByParameters({ - cameraID: 'camera', + cameraID: 'camera.kitchen', viewName: viewName, }); @@ -314,43 +385,31 @@ describe('ViewManager.setViewByParameters', () => { const api = createCardAPI(); vi.mocked(api.getConfigManager().getConfig).mockReturnValue(createConfig()); vi.mocked(api.getCameraManager).mockReturnValue(createCameraManager()); + vi.mocked(api.getCameraManager().getStore).mockReturnValue( + createStore([ + { + cameraID: 'camera.kitchen', + }, + ]), + ); + vi.mocked(api.getCameraManager().getCameraCapabilities).mockReturnValue( + createCameraCapabilities({ + supportsClips: true, + supportsRecordings: true, + supportsSnapshots: true, + }), + ); + const manager = new ViewManager(api); manager.setViewByParameters({ - cameraID: 'camera', + cameraID: 'camera.kitchen', viewName: viewName, }); expect(manager.getView()?.displayMode).toBe('single'); }); }); - - it('should set view by parameters using config as fallback', () => { - const cameraManager = createCameraManager(); - vi.mocked(cameraManager.getStore().getVisibleCameraIDs).mockReturnValue( - new Set(['camera_1', 'camera_2']), - ); - - const api = createCardAPI(); - vi.mocked(api.getConfigManager().getConfig).mockReturnValue(createConfig()); - vi.mocked(api.getCameraManager).mockReturnValue(cameraManager); - vi.mocked(getAllDependentCameras).mockReturnValue( - new Set(['camera_1', 'camera_1_hd']), - ); - - const manager = new ViewManager(api); - manager.setViewByParameters({ - cameraID: 'camera_1', - viewName: 'live', - substream: 'camera_1_hd', - }); - - expect(manager.getView()?.view).toBe('live'); - expect(manager.getView()?.camera).toBe('camera_1'); - expect(manager.getView()?.context?.live?.overrides).toEqual( - new Map([['camera_1', 'camera_1_hd']]), - ); - }); }); // @vitest-environment jsdom @@ -360,6 +419,7 @@ describe('ViewManager.setViewWithNewDisplayMode', () => { vi.mocked(api.getConfigManager().getConfig).mockReturnValue(createConfig()); vi.mocked(api.getHASSManager().getHASS).mockReturnValue(createHASS()); vi.mocked(api.getCameraManager).mockReturnValue(createCameraManager()); + const manager = new ViewManager(api); manager.setView(createView()); @@ -380,20 +440,40 @@ describe('ViewManager.setViewWithNewDisplayMode', () => { }); it('should set display mode to grid and create new query', async () => { - const cameraManager = createCameraManager(); - vi.mocked(cameraManager.getStore().getVisibleCameraCount).mockReturnValue(2); - vi.mocked(cameraManager.getStore().getVisibleCameraIDs).mockReturnValue( - new Set(['camera_1', 'camera_2']), - ); - const api = createCardAPI(); - vi.mocked(api.getCameraManager).mockReturnValue(cameraManager); + vi.mocked(api.getCameraManager).mockReturnValue(createCameraManager()); + vi.mocked(api.getCameraManager().getStore).mockReturnValue( + createStore([ + { + cameraID: 'camera.kitchen', + capabilities: createCameraCapabilities({ + supportsClips: true, + supportsRecordings: true, + supportsSnapshots: true, + }), + }, + { + cameraID: 'camera.office', + capabilities: createCameraCapabilities({ + supportsClips: true, + supportsRecordings: true, + supportsSnapshots: true, + }), + }, + ]), + ); const hass = createHASS(); vi.mocked(api.getHASSManager()).getHASS.mockReturnValue(hass); - const media = generateViewMediaArray({ count: 5 }); - vi.mocked(cameraManager.executeMediaQueries).mockResolvedValue(media); + const mediaArray = generateViewMediaArray({ count: 5 }); + vi.mocked(executeMediaQueryForView).mockResolvedValue( + new View({ + view: 'clip', + camera: 'camera.kitchen', + queryResults: new MediaQueriesResults({ results: mediaArray }), + }), + ); const manager = new ViewManager(api); const query = new EventMediaQueries([ @@ -410,39 +490,50 @@ describe('ViewManager.setViewWithNewDisplayMode', () => { await manager.setViewWithNewDisplayMode('grid'); - expect(manager.getView()?.queryResults?.getResults()).toBe(media); - expect(cameraManager.executeMediaQueries).toBeCalledWith( - expect.arrayContaining([ - expect.objectContaining({ - type: 'event-query', - cameraIDs: new Set(['camera_1', 'camera_2']), - hasClip: true, - }), - ]), - ); + expect(manager.getView()?.queryResults?.getResults()).toBe(mediaArray); }); it('should set display mode to single and create new query', async () => { - const cameraManager = createCameraManager(); - vi.mocked(cameraManager.getStore().getVisibleCameraCount).mockReturnValue(2); - vi.mocked(cameraManager.getStore().getVisibleCameraIDs).mockReturnValue( - new Set(['camera_1', 'camera_2']), - ); - const api = createCardAPI(); - vi.mocked(api.getCameraManager).mockReturnValue(cameraManager); + vi.mocked(api.getCameraManager).mockReturnValue(createCameraManager()); + vi.mocked(api.getCameraManager().getStore).mockReturnValue( + createStore([ + { + cameraID: 'camera.kitchen', + capabilities: createCameraCapabilities({ + supportsClips: true, + supportsRecordings: true, + supportsSnapshots: true, + }), + }, + { + cameraID: 'camera.office', + capabilities: createCameraCapabilities({ + supportsClips: true, + supportsRecordings: true, + supportsSnapshots: true, + }), + }, + ]), + ); const hass = createHASS(); vi.mocked(api.getHASSManager()).getHASS.mockReturnValue(hass); - const media = generateViewMediaArray({ count: 5 }); - vi.mocked(cameraManager.executeMediaQueries).mockResolvedValue(media); + const mediaArray = generateViewMediaArray({ count: 5 }); + vi.mocked(executeMediaQueryForView).mockResolvedValue( + new View({ + view: 'clip', + camera: 'camera.kitchen', + queryResults: new MediaQueriesResults({ results: mediaArray }), + }), + ); const manager = new ViewManager(api); const query = new EventMediaQueries([ { type: QueryType.Event, - cameraIDs: new Set(['camera_1', 'camera_2']), + cameraIDs: new Set(['camera.kitchen', 'camera.office']), hasClip: true, }, ]); @@ -450,53 +541,58 @@ describe('ViewManager.setViewWithNewDisplayMode', () => { manager.setView( createView({ view: 'clip', - camera: 'camera_2', + camera: 'camera.office', query: query, }), ); await manager.setViewWithNewDisplayMode('single'); - expect(manager.getView()?.queryResults?.getResults()).toBe(media); - expect(cameraManager.executeMediaQueries).toBeCalledWith( - expect.arrayContaining([ - expect.objectContaining({ - type: 'event-query', - cameraIDs: new Set(['camera_2']), - hasClip: true, - }), - ]), - ); + expect(manager.getView()?.queryResults?.getResults()).toBe(mediaArray); }); it('should set display mode to single and handle failed new query', async () => { - const cameraManager = createCameraManager(); - vi.mocked(cameraManager.getStore().getVisibleCameraCount).mockReturnValue(2); - vi.mocked(cameraManager.getStore().getVisibleCameraIDs).mockReturnValue( - new Set(['camera_1', 'camera_2']), - ); - const api = createCardAPI(); - vi.mocked(api.getCameraManager).mockReturnValue(cameraManager); + vi.mocked(api.getCameraManager).mockReturnValue(createCameraManager()); + vi.mocked(api.getCameraManager().getStore).mockReturnValue( + createStore([ + { + cameraID: 'camera.kitchen', + capabilities: createCameraCapabilities({ + supportsClips: true, + supportsRecordings: true, + supportsSnapshots: true, + }), + }, + { + cameraID: 'camera.office', + capabilities: createCameraCapabilities({ + supportsClips: true, + supportsRecordings: true, + supportsSnapshots: true, + }), + }, + ]), + ); const manager = new ViewManager(api); const query = new EventMediaQueries([ { type: QueryType.Event, - cameraIDs: new Set(['camera_1', 'camera_2']), + cameraIDs: new Set(['camera.kitchen', 'camera.office']), hasClip: true, }, ]); const originalView = createView({ view: 'clip', - camera: 'camera_2', + camera: 'camera.office', query: query, }); manager.setView(originalView); // Query execution fails / returns null. - vi.mocked(cameraManager.executeMediaQueries).mockRejectedValue(null); + vi.mocked(executeMediaQueryForView).mockRejectedValue(null); await manager.setViewWithNewDisplayMode('single'); @@ -504,14 +600,28 @@ describe('ViewManager.setViewWithNewDisplayMode', () => { }); it('should set display mode and handle empty new query results', async () => { - const cameraManager = createCameraManager(); - vi.mocked(cameraManager.getStore().getVisibleCameraCount).mockReturnValue(2); - vi.mocked(cameraManager.getStore().getVisibleCameraIDs).mockReturnValue( - new Set(['camera_1', 'camera_2']), - ); - const api = createCardAPI(); - vi.mocked(api.getCameraManager).mockReturnValue(cameraManager); + vi.mocked(api.getCameraManager).mockReturnValue(createCameraManager()); + vi.mocked(api.getCameraManager().getStore).mockReturnValue( + createStore([ + { + cameraID: 'camera.kitchen', + capabilities: createCameraCapabilities({ + supportsClips: true, + supportsRecordings: true, + supportsSnapshots: true, + }), + }, + { + cameraID: 'camera.office', + capabilities: createCameraCapabilities({ + supportsClips: true, + supportsRecordings: true, + supportsSnapshots: true, + }), + }, + ]), + ); vi.mocked(api.getHASSManager().getHASS).mockReturnValue(createHASS()); const manager = new ViewManager(api); @@ -519,20 +629,20 @@ describe('ViewManager.setViewWithNewDisplayMode', () => { const query = new EventMediaQueries([ { type: QueryType.Event, - cameraIDs: new Set(['camera_1']), + cameraIDs: new Set(['camera.kitchen']), hasClip: true, }, ]); const originalView = createView({ view: 'clip', - camera: 'camera_2', + camera: 'camera.office', query: query, }); manager.setView(originalView); await manager.setViewWithNewDisplayMode('grid'); - vi.mocked(cameraManager.executeMediaQueries).mockResolvedValue(null); + vi.mocked(executeMediaQueryForView).mockResolvedValue(null); // Empty queries will not be executed, so view will not be changed. expect(manager.getView()?.displayMode).toBeNull(); @@ -541,13 +651,21 @@ describe('ViewManager.setViewWithNewDisplayMode', () => { describe('ViewManager.setViewWithSubstream', () => { it('should set new equal view with no dependencies', () => { + const api = createCardAPI(); + vi.mocked(api.getCameraManager).mockReturnValue(createCameraManager()); + vi.mocked(api.getCameraManager().getStore).mockReturnValue( + createStore([ + { + cameraID: 'camera.kitchen', + }, + ]), + ); + const manager = new ViewManager(api); const view = createView({ view: 'live', camera: 'camera', }); - vi.mocked(getAllDependentCameras).mockReturnValue(new Set(['camera'])); - const manager = new ViewManager(createCardAPI()); manager.setView(view); manager.setViewWithSubstream(); @@ -557,39 +675,71 @@ describe('ViewManager.setViewWithSubstream', () => { }); it('should set new view with next substream', () => { + const api = createCardAPI(); + vi.mocked(api.getCameraManager).mockReturnValue(createCameraManager()); + vi.mocked(api.getCameraManager().getStore).mockReturnValue( + createStore([ + { + cameraID: 'camera.kitchen', + config: createCameraConfig({ + dependencies: { + cameras: ['camera.kitchen_hd'], + }, + }), + }, + { + cameraID: 'camera.kitchen_hd', + }, + ]), + ); + const manager = new ViewManager(api); const view = createView({ view: 'live', - camera: 'camera', + camera: 'camera.kitchen', }); - vi.mocked(getAllDependentCameras).mockReturnValue(new Set(['camera', 'camera2'])); - const manager = new ViewManager(createCardAPI()); manager.setView(view); manager.setViewWithSubstream(); expect(manager.getView()?.context?.live?.overrides).toEqual( - new Map([['camera', 'camera2']]), + new Map([['camera.kitchen', 'camera.kitchen_hd']]), ); }); it('should set new view with next substream when view has invalid substream', () => { + const api = createCardAPI(); + vi.mocked(api.getCameraManager).mockReturnValue(createCameraManager()); + vi.mocked(api.getCameraManager().getStore).mockReturnValue( + createStore([ + { + cameraID: 'camera.kitchen', + config: createCameraConfig({ + dependencies: { + cameras: ['camera.kitchen_hd'], + }, + }), + }, + { + cameraID: 'camera.kitchen_hd', + }, + ]), + ); + const manager = new ViewManager(api); const view = createView({ view: 'live', - camera: 'camera', + camera: 'camera.kitchen', context: { live: { - overrides: new Map([['camera', 'camera-that-does-not-exist']]), + overrides: new Map([['camera.kitchen', 'camera-that-does-not-exist']]), }, }, }); - vi.mocked(getAllDependentCameras).mockReturnValue(new Set(['camera', 'camera2'])); - const manager = new ViewManager(createCardAPI()); manager.setView(view); manager.setViewWithSubstream(); expect(manager.getView()?.context?.live?.overrides).toEqual( - new Map([['camera', 'camera']]), + new Map([['camera.kitchen', 'camera.kitchen']]), ); }); @@ -680,17 +830,23 @@ describe('ViewManager.isViewSupportedByCamera', () => { ['media' as const, false], ])('%s', (viewName: FrigateCardView, expected: boolean) => { const api = createCardAPI(); - const cameraManager = createCameraManager(); - vi.mocked(cameraManager.getCameraCapabilities).mockReturnValue({ - canFavoriteEvents: false, - canFavoriteRecordings: false, - canSeek: false, - supportsClips: false, - supportsRecordings: false, - supportsSnapshots: false, - supportsTimeline: false, - }); - vi.mocked(api.getCameraManager).mockReturnValue(cameraManager); + vi.mocked(api.getCameraManager).mockReturnValue(createCameraManager()); + vi.mocked(api.getCameraManager().getStore).mockReturnValue( + createStore([ + { + cameraID: 'camera.kitchen', + capabilities: { + canFavoriteEvents: false, + canFavoriteRecordings: false, + canSeek: false, + supportsClips: false, + supportsRecordings: false, + supportsSnapshots: false, + supportsTimeline: false, + }, + }, + ]), + ); const manager = new ViewManager(api); expect(manager.isViewSupportedByCamera('camera', viewName)).toBe(expected); diff --git a/tests/components-lib/menu-controller.test.ts b/tests/components-lib/menu-controller.test.ts index 590e27ac..b5de796b 100644 --- a/tests/components-lib/menu-controller.test.ts +++ b/tests/components-lib/menu-controller.test.ts @@ -12,11 +12,12 @@ import { } from '../../src/components-lib/menu-controller'; import { FrigateCardConfig, MenuItem, ViewDisplayMode } from '../../src/config/types'; import { FrigateCardMediaPlayer } from '../../src/types'; -import { createFrigateCardCustomAction } from '../../src/utils/action'; +import { createFrigateCardSimpleAction } from '../../src/utils/action'; import { ViewMedia } from '../../src/view/media'; import { MediaQueriesResults } from '../../src/view/media-queries-results'; import { View } from '../../src/view/view'; import { + createAggregateCameraCapabilities, createCameraCapabilities, createCameraConfig, createCameraManager, @@ -26,10 +27,10 @@ import { createMediaCapabilities, createMediaLoadedInfo, createStateEntity, + createStore, createView, } from '../test-utils'; -vi.mock('../../src/camera-manager/manager.js'); vi.mock('../../src/utils/media-player-controller.js'); vi.mock('../../src/card-controller/microphone-manager.js'); @@ -42,16 +43,15 @@ const calculateButtons = ( view?: View; }, ): MenuItem[] => { + let cameraManager: CameraManager | null = options?.cameraManager ?? null; + if (!cameraManager) { + cameraManager = createCameraManager(); + } + return controller.calculateButtons( options?.hass ?? createHASS(), options?.config ?? createConfig(), - options?.cameraManager ?? - createCameraManager({ - configs: new Map([ - ['camera-1', createCameraConfig()], - ['camera-2', createCameraConfig()], - ]), - }), + cameraManager, options?.view ?? createView({ camera: 'camera-1' }), options, ); @@ -79,8 +79,8 @@ describe('MenuButtonController', () => { priority: 50, type: 'custom:frigate-card-menu-icon', title: 'Frigate menu / Default view', - tap_action: createFrigateCardCustomAction('menu_toggle'), - hold_action: createFrigateCardCustomAction('diagnostics'), + tap_action: createFrigateCardSimpleAction('menu_toggle'), + hold_action: createFrigateCardSimpleAction('diagnostics'), }); }); @@ -94,19 +94,17 @@ describe('MenuButtonController', () => { priority: 50, type: 'custom:frigate-card-menu-icon', title: 'Frigate menu / Default view', - tap_action: createFrigateCardCustomAction('default'), - hold_action: createFrigateCardCustomAction('diagnostics'), + tap_action: createFrigateCardSimpleAction('default'), + hold_action: createFrigateCardSimpleAction('diagnostics'), }); }); it('should have cameras menu', () => { - const cameraManager = createCameraManager({ - configs: new Map([ - ['camera-1', createCameraConfig()], - ['camera-2', createCameraConfig()], - ]), - }); - mock(cameraManager).getCameraMetadata.mockReturnValue({ + const cameraManager = createCameraManager(); + vi.mocked(cameraManager.getStore).mockReturnValue( + createStore([{ cameraID: 'camera-1' }, { cameraID: 'camera-2' }]), + ); + vi.mocked(cameraManager).getCameraMetadata.mockReturnValue({ title: 'title', icon: 'icon', }); @@ -150,14 +148,12 @@ describe('MenuButtonController', () => { }); it('should not have a cameras menu without a visible camera', () => { - const cameraManager = createCameraManager({ - configs: new Map([ - ['camera-1', createCameraConfig()], - ['camera-2', createCameraConfig()], + const cameraManager = createCameraManager(); + vi.mocked(cameraManager.getStore).mockReturnValue( + createStore([ + { cameraID: 'camera-1', config: createCameraConfig({ hide: true }) }, ]), - }); - - vi.mocked(cameraManager.getStore()).getVisibleCameras.mockReturnValue(new Map()); + ); const buttons = calculateButtons(controller, { cameraManager: cameraManager }); expect(buttons).not.toEqual( @@ -170,12 +166,16 @@ describe('MenuButtonController', () => { }); it('should have substream button with single dependency', () => { - const cameraManager = createCameraManager({ - configs: new Map([ - ['camera-1', createCameraConfig({ dependencies: { cameras: ['camera-2'] } })], - ['camera-2', createCameraConfig()], + const cameraManager = createCameraManager(); + vi.mocked(cameraManager.getStore).mockReturnValue( + createStore([ + { + cameraID: 'camera-1', + config: createCameraConfig({ dependencies: { cameras: ['camera-2'] } }), + }, + { cameraID: 'camera-2' }, ]), - }); + ); const buttons = calculateButtons(controller, { cameraManager: cameraManager }); expect(buttons).toContainEqual({ @@ -193,12 +193,17 @@ describe('MenuButtonController', () => { }); it('should have substream button selected with single dependency', () => { - const cameraManager = createCameraManager({ - configs: new Map([ - ['camera-1', createCameraConfig({ dependencies: { cameras: ['camera-2'] } })], - ['camera-2', createCameraConfig()], + const cameraManager = createCameraManager(); + vi.mocked(cameraManager.getStore).mockReturnValue( + createStore([ + { + cameraID: 'camera-1', + config: createCameraConfig({ dependencies: { cameras: ['camera-2'] } }), + }, + { cameraID: 'camera-2' }, ]), - }); + ); + const view = createView({ camera: 'camera-1', context: { @@ -227,29 +232,31 @@ describe('MenuButtonController', () => { }); it('should have substream menu without substream on with multiple dependencies', () => { - const cameraManager = createCameraManager({ - configs: new Map([ - [ - 'camera-1', - createCameraConfig({ + const cameraManager = createCameraManager(); + vi.mocked(cameraManager.getStore).mockReturnValue( + createStore([ + { + cameraID: 'camera-1', + config: createCameraConfig({ camera_entity: 'camera.1', dependencies: { cameras: ['camera-2', 'camera-3'] }, }), - ], - [ - 'camera-2', - createCameraConfig({ + }, + { + cameraID: 'camera-2', + config: createCameraConfig({ camera_entity: 'camera.2', }), - ], - [ - 'camera-3', - createCameraConfig({ + }, + { + cameraID: 'camera-3', + config: createCameraConfig({ camera_entity: 'camera.3', }), - ], + }, ]), - }); + ); + // Return different metadata depending on the camera to test multiple code // paths. mock(cameraManager).getCameraMetadata.mockImplementation( @@ -316,29 +323,30 @@ describe('MenuButtonController', () => { }); it('should have substream menu with substream on with multiple dependencies', () => { - const cameraManager = createCameraManager({ - configs: new Map([ - [ - 'camera-1', - createCameraConfig({ + const cameraManager = createCameraManager(); + vi.mocked(cameraManager.getStore).mockReturnValue( + createStore([ + { + cameraID: 'camera-1', + config: createCameraConfig({ camera_entity: 'camera.1', dependencies: { cameras: ['camera-2', 'camera-3'] }, }), - ], - [ - 'camera-2', - createCameraConfig({ + }, + { + cameraID: 'camera-2', + config: createCameraConfig({ camera_entity: 'camera.2', }), - ], - [ - 'camera-3', - createCameraConfig({ + }, + { + cameraID: 'camera-3', + config: createCameraConfig({ camera_entity: 'camera.3', }), - ], + }, ]), - }); + ); const view = createView({ camera: 'camera-1', @@ -436,8 +444,8 @@ describe('MenuButtonController', () => { it('should have styled clips menu button in clips view', () => { const cameraManager = createCameraManager(); - mock(cameraManager).getAggregateCameraCapabilities.mockReturnValue( - createCameraCapabilities({ supportsClips: true }), + vi.mocked(cameraManager.getAggregateCameraCapabilities).mockReturnValue( + createAggregateCameraCapabilities({ supportsClips: true }), ); const buttons = calculateButtons(controller, { cameraManager: cameraManager, @@ -457,8 +465,8 @@ describe('MenuButtonController', () => { it('should have unstyled clips menu button in non-clips view', () => { const cameraManager = createCameraManager(); - mock(cameraManager).getAggregateCameraCapabilities.mockReturnValue( - createCameraCapabilities({ supportsClips: true }), + vi.mocked(cameraManager.getAggregateCameraCapabilities).mockReturnValue( + createAggregateCameraCapabilities({ supportsClips: true }), ); const buttons = calculateButtons(controller, { cameraManager: cameraManager }); expect(buttons).toContainEqual({ @@ -475,8 +483,8 @@ describe('MenuButtonController', () => { it('should have styled snapshots menu button in snapshots view', () => { const cameraManager = createCameraManager(); - mock(cameraManager).getAggregateCameraCapabilities.mockReturnValue( - createCameraCapabilities({ supportsSnapshots: true }), + vi.mocked(cameraManager.getAggregateCameraCapabilities).mockReturnValue( + createAggregateCameraCapabilities({ supportsSnapshots: true }), ); const buttons = calculateButtons(controller, { cameraManager: cameraManager, @@ -496,8 +504,8 @@ describe('MenuButtonController', () => { it('should have unstyled snapshots menu button in non-snapshots view', () => { const cameraManager = createCameraManager(); - mock(cameraManager).getAggregateCameraCapabilities.mockReturnValue( - createCameraCapabilities({ supportsSnapshots: true }), + vi.mocked(cameraManager.getAggregateCameraCapabilities).mockReturnValue( + createAggregateCameraCapabilities({ supportsSnapshots: true }), ); const buttons = calculateButtons(controller, { cameraManager: cameraManager }); expect(buttons).toContainEqual({ @@ -514,8 +522,8 @@ describe('MenuButtonController', () => { it('should have styled recordings menu button in recordings view', () => { const cameraManager = createCameraManager(); - mock(cameraManager).getAggregateCameraCapabilities.mockReturnValue( - createCameraCapabilities({ supportsRecordings: true }), + vi.mocked(cameraManager.getAggregateCameraCapabilities).mockReturnValue( + createAggregateCameraCapabilities({ supportsRecordings: true }), ); const buttons = calculateButtons(controller, { cameraManager: cameraManager, @@ -535,8 +543,8 @@ describe('MenuButtonController', () => { it('should have unstyled recordings menu button in non-recordings view', () => { const cameraManager = createCameraManager(); - mock(cameraManager).getAggregateCameraCapabilities.mockReturnValue( - createCameraCapabilities({ supportsRecordings: true }), + vi.mocked(cameraManager.getAggregateCameraCapabilities).mockReturnValue( + createAggregateCameraCapabilities({ supportsRecordings: true }), ); const buttons = calculateButtons(controller, { cameraManager: cameraManager }); expect(buttons).toContainEqual({ @@ -581,8 +589,8 @@ describe('MenuButtonController', () => { it('should have styled timeline menu button in timeline view', () => { const cameraManager = createCameraManager(); - mock(cameraManager).getAggregateCameraCapabilities.mockReturnValue( - createCameraCapabilities({ supportsTimeline: true }), + vi.mocked(cameraManager.getAggregateCameraCapabilities).mockReturnValue( + createAggregateCameraCapabilities({ supportsTimeline: true }), ); const buttons = calculateButtons(controller, { cameraManager: cameraManager, @@ -601,8 +609,8 @@ describe('MenuButtonController', () => { it('should have unstyled timeline menu button in non-timeline view', () => { const cameraManager = createCameraManager(); - mock(cameraManager).getAggregateCameraCapabilities.mockReturnValue( - createCameraCapabilities({ supportsTimeline: true }), + vi.mocked(cameraManager.getAggregateCameraCapabilities).mockReturnValue( + createAggregateCameraCapabilities({ supportsTimeline: true }), ); const buttons = calculateButtons(controller, { cameraManager: cameraManager, @@ -622,15 +630,15 @@ describe('MenuButtonController', () => { vi.stubGlobal('navigator', { userAgent: 'foo' }); const cameraManager = createCameraManager(); + vi.mocked(cameraManager.getMediaCapabilities).mockReturnValue( + createMediaCapabilities({ canDownload: true }), + ); const view = createView({ queryResults: new MediaQueriesResults({ results: [new ViewMedia('clip', 'camera-1')], selectedIndex: 0, }), }); - mock(cameraManager).getMediaCapabilities.mockReturnValue( - createMediaCapabilities({ canDownload: true }), - ); const buttons = calculateButtons(controller, { cameraManager: cameraManager, view: view, @@ -653,15 +661,15 @@ describe('MenuButtonController', () => { }); const cameraManager = createCameraManager(); + vi.mocked(cameraManager.getMediaCapabilities).mockReturnValue( + createMediaCapabilities({ canDownload: true }), + ); const view = createView({ queryResults: new MediaQueriesResults({ results: [new ViewMedia('clip', 'camera-1')], selectedIndex: 0, }), }); - mock(cameraManager).getMediaCapabilities.mockReturnValue( - createMediaCapabilities({ canDownload: true }), - ); const buttons = calculateButtons(controller, { cameraManager: cameraManager, view: view, @@ -906,16 +914,18 @@ describe('MenuButtonController', () => { }); it('should have media players button', () => { - const cameraManager = createCameraManager({ - configs: new Map([ - [ - 'camera-1', - createCameraConfig({ + const cameraManager = createCameraManager(); + vi.mocked(cameraManager.getStore).mockReturnValue( + createStore([ + { + cameraID: 'camera-1', + config: createCameraConfig({ camera_entity: 'camera.1', }), - ], + }, ]), - }); + ); + const mediaPlayerController = mock(); mediaPlayerController.hasMediaPlayers.mockReturnValue(true); mediaPlayerController.getMediaPlayers.mockReturnValue(['media_player.tv']); @@ -961,16 +971,17 @@ describe('MenuButtonController', () => { }); it('should disable media players button when entity not found', () => { - const cameraManager = createCameraManager({ - configs: new Map([ - [ - 'camera-1', - createCameraConfig({ + const cameraManager = createCameraManager(); + vi.mocked(cameraManager.getStore).mockReturnValue( + createStore([ + { + cameraID: 'camera-1', + config: createCameraConfig({ camera_entity: 'camera.1', }), - ], + }, ]), - }); + ); const mediaPlayerController = mock(); mediaPlayerController.hasMediaPlayers.mockReturnValue(true); mediaPlayerController.getMediaPlayers.mockReturnValue(['not_a_real_player']); @@ -1109,7 +1120,13 @@ describe('MenuButtonController', () => { '%s', (displayMode: ViewDisplayMode) => { const view = createView({ view: 'live', displayMode: displayMode }); - expect(calculateButtons(controller, { view: view })).toContainEqual({ + const cameraManager = createCameraManager(); + vi.mocked(cameraManager.getStore).mockReturnValue( + createStore([{ cameraID: 'camera-1' }, { cameraID: 'camera-2' }]), + ); + expect( + calculateButtons(controller, { cameraManager: cameraManager, view: view }), + ).toContainEqual({ icon: displayMode === 'single' ? 'mdi:grid' : 'mdi:grid-off', enabled: true, priority: 50, @@ -1118,6 +1135,7 @@ describe('MenuButtonController', () => { displayMode === 'grid' ? 'Show single media viewer' : 'Show media viewer for each camera in a grid', + style: displayMode === 'grid' ? { color: 'var(--primary-color, white)' } : {}, tap_action: { action: 'fire-dom-event', frigate_card_action: 'display_mode_select', @@ -1128,6 +1146,85 @@ describe('MenuButtonController', () => { ); }); + describe('should have show ptz button', () => { + it('when the selected camera is not PTZ enabled', () => { + const cameraManager = createCameraManager(); + vi.mocked(cameraManager.getCameraCapabilities).mockReturnValue( + createCameraCapabilities(), + ); + + const buttons = calculateButtons(controller, { cameraManager: cameraManager }); + expect(buttons).not.toContainEqual({ + enabled: false, + icon: 'mdi:pan', + priority: 50, + style: { + color: 'var(--primary-color, white)', + }, + tap_action: { + action: 'fire-dom-event', + frigate_card_action: 'show_ptz', + show_ptz: false, + }, + title: 'Show PTZ controls', + type: 'custom:frigate-card-menu-icon', + }); + }); + + it('when the selected camera is PTZ enabled', () => { + const cameraManager = createCameraManager(); + vi.mocked(cameraManager.getCameraCapabilities).mockReturnValue( + createCameraCapabilities({ ptz: {} }), + ); + + const buttons = calculateButtons(controller, { cameraManager: cameraManager }); + expect(buttons).toContainEqual({ + enabled: false, + icon: 'mdi:pan', + priority: 50, + style: { + color: 'var(--primary-color, white)', + }, + tap_action: { + action: 'fire-dom-event', + frigate_card_action: 'show_ptz', + show_ptz: false, + }, + title: 'Show PTZ controls', + type: 'custom:frigate-card-menu-icon', + }); + }); + + it('when the context has PTZ visiblity turned off', () => { + const cameraManager = createCameraManager(); + vi.mocked(cameraManager.getCameraCapabilities).mockReturnValue( + createCameraCapabilities({ ptz: {} }), + ); + const view = createView({ + camera: 'camera-1', + context: { live: { ptzVisible: false } }, + }); + + const buttons = calculateButtons(controller, { + cameraManager: cameraManager, + view: view, + }); + expect(buttons).toContainEqual({ + enabled: false, + icon: 'mdi:pan', + priority: 50, + style: {}, + tap_action: { + action: 'fire-dom-event', + frigate_card_action: 'show_ptz', + show_ptz: true, + }, + title: 'Show PTZ controls', + type: 'custom:frigate-card-menu-icon', + }); + }); + }); + it('should handle dynamic buttons', () => { const button: MenuItem = { ...dynamicButton, diff --git a/tests/components-lib/ptz-controller.test.ts b/tests/components-lib/ptz-controller.test.ts new file mode 100644 index 00000000..11f047a4 --- /dev/null +++ b/tests/components-lib/ptz-controller.test.ts @@ -0,0 +1,316 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { PTZController } from '../../src/components-lib/ptz-controller'; +import { + FrigateCardPTZConfig, + frigateCardPTZSchema, + PTZControlAction, +} from '../../src/config/types'; +import { + frigateCardHandleActionConfig, + getActionConfigGivenAction, +} from '../../src/utils/action.js'; +import { + createCameraCapabilities, + createCameraManager, + createHASS, +} from '../test-utils'; + +vi.mock('../../src/utils/action.js'); + +const createConfig = (config?: Partial): FrigateCardPTZConfig => { + return frigateCardPTZSchema.parse({ + ...config, + }); +}; + +// @vitest-environment jsdom +describe('PTZController', () => { + beforeEach(() => { + vi.resetAllMocks(); + }); + + it('should be creatable', () => { + const controller = new PTZController(document.createElement('div')); + expect(controller).toBeTruthy(); + }); + + it('should get config creatable', () => { + const controller = new PTZController(document.createElement('div')); + const config = createConfig(); + controller.setConfig(config); + expect(controller.getConfig()).toBe(config); + }); + + describe('should set element attributes', () => { + describe('orientation', () => { + describe('with config', () => { + it.each([['horizontal' as const], ['vertical' as const]])( + '%s', + (orientation: 'horizontal' | 'vertical') => { + const element = document.createElement('div'); + const controller = new PTZController(element); + controller.setConfig(createConfig({ orientation: orientation })); + expect(element.getAttribute('data-orientation')).toBe(orientation); + }, + ); + }); + it('without config', () => { + const element = document.createElement('div'); + const controller = new PTZController(element); + controller.setConfig(); + expect(element.getAttribute('data-orientation')).toBe('horizontal'); + }); + }); + describe('position', () => { + describe('with config', () => { + it.each([ + ['top-left' as const], + ['top-right' as const], + ['bottom-left' as const], + ['bottom-right' as const], + ])( + '%s', + (position: 'top-left' | 'top-right' | 'bottom-left' | 'bottom-right') => { + const element = document.createElement('div'); + const controller = new PTZController(element); + controller.setConfig(createConfig({ position: position })); + expect(element.getAttribute('data-position')).toBe(position); + }, + ); + }); + it('without config', () => { + const element = document.createElement('div'); + const controller = new PTZController(element); + controller.setConfig(); + expect(element.getAttribute('data-position')).toBe('bottom-right'); + }); + }); + it('with style in config', () => { + const element = document.createElement('div'); + const controller = new PTZController(element); + controller.setConfig(createConfig({ style: { transform: 'none', left: '50%' } })); + expect(element.getAttribute('style')).toBe('transform:none;left:50%'); + }); + }); + + describe('should respect mode', () => { + it('off', () => { + const controller = new PTZController(document.createElement('div')); + expect(controller.shouldDisplay()).toBeFalsy(); + }); + it('off but forced on', () => { + const controller = new PTZController(document.createElement('div')); + controller.setForceVisibility(true); + // No actions, no rendering, no matter what. + expect(controller.shouldDisplay()).toBeFalsy(); + }); + it('on without actions', () => { + const controller = new PTZController(document.createElement('div')); + controller.setConfig(createConfig({ mode: 'on' })); + expect(controller.shouldDisplay()).toBeFalsy(); + }); + it('on with actions', () => { + const controller = new PTZController(document.createElement('div')); + controller.setConfig(createConfig({ mode: 'on', actions_left: {} })); + controller.setCamera(createCameraManager(), 'camera.office'); + expect(controller.shouldDisplay()).toBeTruthy(); + }); + it('on with actions but forced off', () => { + const controller = new PTZController(document.createElement('div')); + controller.setConfig(createConfig({ mode: 'on', actions_left: {} })); + controller.setCamera(createCameraManager(), 'camera.office'); + controller.setForceVisibility(false); + expect(controller.shouldDisplay()).toBeFalsy(); + }); + }); + + describe('should get PTZ actions', () => { + it('without config', () => { + const controller = new PTZController(document.createElement('div')); + expect(controller.getPTZActions('left')).toBeNull(); + }); + + describe('using defaults', () => { + describe('with continous PTZ', () => { + it.each([ + ['left' as const], + ['right' as const], + ['up' as const], + ['down' as const], + ['zoom_in' as const], + ['zoom_out' as const], + ])('%s', (actionName: PTZControlAction) => { + const controller = new PTZController(document.createElement('div')); + controller.setConfig(createConfig()); + + const cameraManager = createCameraManager(); + vi.mocked(cameraManager).getCameraCapabilities.mockReturnValue( + createCameraCapabilities({ + ptz: { + panTilt: ['continuous'], + zoom: ['continuous'], + }, + }), + ); + controller.setCamera(cameraManager, 'camera.office'); + + expect(controller.getPTZActions(actionName)).toEqual({ + start_tap_action: { + action: 'fire-dom-event', + frigate_card_action: 'ptz', + ptz_action: actionName, + ptz_phase: 'start', + }, + end_tap_action: { + action: 'fire-dom-event', + frigate_card_action: 'ptz', + ptz_action: actionName, + ptz_phase: 'stop', + }, + }); + }); + }); + + describe('with relative PTZ', () => { + it.each([ + ['left' as const], + ['right' as const], + ['up' as const], + ['down' as const], + ['zoom_in' as const], + ['zoom_out' as const], + ])('%s', (actionName: PTZControlAction) => { + const controller = new PTZController(document.createElement('div')); + controller.setConfig(createConfig()); + + const cameraManager = createCameraManager(); + vi.mocked(cameraManager).getCameraCapabilities.mockReturnValue( + createCameraCapabilities({ + ptz: { + panTilt: ['relative'], + zoom: ['relative'], + }, + }), + ); + controller.setCamera(cameraManager, 'camera.office'); + + expect(controller.getPTZActions(actionName)).toEqual({ + tap_action: { + action: 'fire-dom-event', + frigate_card_action: 'ptz', + ptz_action: actionName, + }, + }); + }); + }); + + it('home', () => { + const controller = new PTZController(document.createElement('div')); + controller.setConfig(createConfig()); + + const cameraManager = createCameraManager(); + vi.mocked(cameraManager).getCameraCapabilities.mockReturnValue( + createCameraCapabilities({ + ptz: { + presets: ['preset-foo'], + }, + }), + ); + controller.setCamera(cameraManager, 'camera.office'); + + expect(controller.getPTZActions('home')).toEqual({ + tap_action: { + action: 'fire-dom-event', + frigate_card_action: 'ptz', + ptz_action: 'preset', + ptz_preset: 'preset-foo', + }, + }); + }); + }); + + describe('using config', () => { + it.each([ + ['left' as const], + ['right' as const], + ['up' as const], + ['down' as const], + ['zoom_in' as const], + ['zoom_out' as const], + ])('%s', (actionName: PTZControlAction) => { + const controller = new PTZController(document.createElement('div')); + controller.setConfig( + createConfig({ + [`actions_${actionName}`]: { + argument: actionName, + }, + }), + ); + controller.setCamera(createCameraManager(), 'camera.office'); + + expect(controller.getPTZActions(actionName)).toEqual({ + argument: actionName, + }); + }); + + it('home', () => { + const controller = new PTZController(document.createElement('div')); + controller.setConfig( + createConfig({ + actions_home: { + argument: 'home', + }, + }), + ); + controller.setCamera(createCameraManager(), 'camera.office'); + + expect(controller.getPTZActions('home')).toEqual({ + argument: 'home', + }); + }); + }); + }); + + describe('should handle action', () => { + it('without hass or action', () => { + const controller = new PTZController(document.createElement('div')); + controller.setHASS(); + controller.setCamera(); + controller.handleAction( + new CustomEvent<{ action: string }>('@action', { detail: { action: 'tap' } }), + ); + expect(frigateCardHandleActionConfig).not.toBeCalled(); + }); + + it('with action', () => { + const element = document.createElement('div'); + const controller = new PTZController(element); + const hass = createHASS(); + controller.setHASS(hass); + controller.setConfig( + createConfig({ + [`actions_left`]: {}, + }), + ); + const tapAction = { + action: 'none' as const, + }; + const actionsConfig = { + tap_action: tapAction, + }; + vi.mocked(getActionConfigGivenAction).mockReturnValue(tapAction); + + controller.handleAction( + new CustomEvent<{ action: string }>('@action', { detail: { action: 'tap' } }), + actionsConfig, + ); + expect(frigateCardHandleActionConfig).toBeCalledWith( + element, + hass, + actionsConfig, + 'tap', + actionsConfig.tap_action, + ); + }); + }); +}); diff --git a/tests/config-mgmt.test.ts b/tests/config-mgmt.test.ts index 4ca5eb32..cb51a12c 100644 --- a/tests/config-mgmt.test.ts +++ b/tests/config-mgmt.test.ts @@ -739,4 +739,433 @@ describe('should handle version specific upgrades', () => { }); }); }); + + describe('should move PTZ elements to live', () => { + it('case with 1 element', () => { + const config = { + type: 'custom:frigate-card', + cameras: [{ camera_entity: 'camera.office' }], + elements: [ + { + type: 'custom:frigate-card-ptz', + orientation: 'vertical', + style: { + right: '20px', + top: '20px', + color: 'white', + }, + actions_up: { + tap_action: { + action: 'call-service', + service: 'notify.persistent_notification', + service_data: { + message: 'Hello 1', + }, + }, + }, + }, + ], + }; + expect(upgradeConfig(config)).toBeTruthy(); + expect(config).toEqual({ + cameras: [{ camera_entity: 'camera.office' }], + live: { + controls: { + ptz: { + actions_up: { + tap_action: { + action: 'call-service', + data: { + message: 'Hello 1', + }, + service: 'notify.persistent_notification', + }, + }, + orientation: 'vertical', + style: { + color: 'white', + right: '20px', + top: '20px', + }, + }, + }, + }, + type: 'custom:frigate-card', + }); + }); + + it('case with >1 element', () => { + const config = { + type: 'custom:frigate-card', + cameras: [{ camera_entity: 'camera.office' }], + elements: [ + { + type: 'custom:frigate-card-ptz', + orientation: 'vertical', + style: { + right: '20px', + top: '20px', + color: 'white', + }, + actions_up: { + tap_action: { + action: 'call-service', + service: 'notify.persistent_notification', + service_data: { + message: 'Hello 1', + }, + }, + }, + }, + { + type: 'service-button', + title: 'title', + service: 'service', + service_data: { + message: "It's a trick", + }, + }, + ], + }; + expect(upgradeConfig(config)).toBeTruthy(); + expect(config).toEqual({ + cameras: [{ camera_entity: 'camera.office' }], + elements: [ + { + service: 'service', + service_data: { + message: "It's a trick", + }, + title: 'title', + type: 'service-button', + }, + ], + live: { + controls: { + ptz: { + actions_up: { + tap_action: { + action: 'call-service', + data: { + message: 'Hello 1', + }, + service: 'notify.persistent_notification', + }, + }, + orientation: 'vertical', + style: { + color: 'white', + right: '20px', + top: '20px', + }, + }, + }, + }, + type: 'custom:frigate-card', + }); + }); + + it('case with custom conditional element with 2 PTZ but nothing else', () => { + const config = { + type: 'custom:frigate-card', + cameras: [{ camera_entity: 'camera.office' }], + elements: [ + { + type: 'custom:frigate-card-conditional', + conditions: { + fullscreen: true, + media_loaded: true, + }, + elements: [ + { + type: 'custom:frigate-card-ptz', + orientation: 'vertical', + style: { + right: '20px', + top: '20px', + color: 'white', + }, + actions_up: { + tap_action: { + action: 'call-service', + service: 'notify.persistent_notification', + service_data: { + message: 'Hello 1', + }, + }, + }, + }, + { + type: 'custom:frigate-card-ptz', + orientation: 'vertical', + style: { + right: '20px', + top: '20px', + color: 'white', + }, + actions_up: { + tap_action: { + action: 'call-service', + service: 'notify.persistent_notification', + service_data: { + message: 'Hello 2', + }, + }, + }, + }, + ], + }, + ], + }; + expect(upgradeConfig(config)).toBeTruthy(); + expect(config).toEqual({ + cameras: [{ camera_entity: 'camera.office' }], + live: { + controls: { + ptz: { + actions_up: { + tap_action: { + action: 'call-service', + data: { + message: 'Hello 1', + }, + service: 'notify.persistent_notification', + }, + }, + orientation: 'vertical', + style: { + color: 'white', + right: '20px', + top: '20px', + }, + }, + }, + }, + type: 'custom:frigate-card', + }); + }); + + it('case with custom conditional element with 1 PTZ and another element', () => { + const config = { + type: 'custom:frigate-card', + cameras: [{ camera_entity: 'camera.office' }], + elements: [ + { + type: 'custom:frigate-card-conditional', + conditions: { + fullscreen: true, + media_loaded: true, + }, + elements: [ + { + type: 'service-button', + title: 'title', + service: 'service', + service_data: { + message: "It's a trick", + }, + }, + { + type: 'custom:frigate-card-ptz', + orientation: 'vertical', + style: { + right: '20px', + top: '20px', + color: 'white', + }, + actions_up: { + tap_action: { + action: 'call-service', + service: 'notify.persistent_notification', + service_data: { + message: 'Hello 1', + }, + }, + }, + }, + ], + }, + ], + }; + expect(upgradeConfig(config)).toBeTruthy(); + expect(config).toEqual({ + cameras: [{ camera_entity: 'camera.office' }], + live: { + controls: { + ptz: { + actions_up: { + tap_action: { + action: 'call-service', + data: { + message: 'Hello 1', + }, + service: 'notify.persistent_notification', + }, + }, + orientation: 'vertical', + style: { + color: 'white', + right: '20px', + top: '20px', + }, + }, + }, + }, + elements: [ + { + type: 'custom:frigate-card-conditional', + conditions: { + fullscreen: true, + media_loaded: true, + }, + elements: [ + { + type: 'service-button', + title: 'title', + service: 'service', + service_data: { + message: "It's a trick", + }, + }, + ], + }, + ], + type: 'custom:frigate-card', + }); + }); + + it('case with stock conditional element with 1 PTZ', () => { + const config = { + type: 'custom:frigate-card', + cameras: [{ camera_entity: 'camera.office' }], + elements: [ + { + type: 'conditional', + conditions: [{ entity: 'light.office', state: 'on' }], + elements: [ + { + type: 'custom:frigate-card-ptz', + orientation: 'vertical', + style: { + right: '20px', + top: '20px', + color: 'white', + }, + actions_up: { + tap_action: { + action: 'call-service', + service: 'notify.persistent_notification', + service_data: { + message: 'Hello 1', + }, + }, + }, + }, + ], + }, + ], + }; + expect(upgradeConfig(config)).toBeTruthy(); + expect(config).toEqual({ + cameras: [{ camera_entity: 'camera.office' }], + live: { + controls: { + ptz: { + actions_up: { + tap_action: { + action: 'call-service', + data: { + message: 'Hello 1', + }, + service: 'notify.persistent_notification', + }, + }, + orientation: 'vertical', + style: { + color: 'white', + right: '20px', + top: '20px', + }, + }, + }, + }, + type: 'custom:frigate-card', + }); + }); + + it('case when live.controls.ptz already exists', () => { + const config = { + type: 'custom:frigate-card', + cameras: [{ camera_entity: 'camera.office' }], + live: { + controls: { + ptz: { + actions_up: { + tap_action: { + action: 'call-service', + data: { + message: 'Original', + }, + service: 'notify.persistent_notification', + }, + }, + orientation: 'vertical', + style: { + color: 'white', + right: '20px', + top: '20px', + }, + }, + }, + }, + elements: [ + { + type: 'custom:frigate-card-ptz', + orientation: 'vertical', + style: { + right: '20px', + top: '20px', + color: 'white', + }, + actions_up: { + tap_action: { + action: 'call-service', + service: 'notify.persistent_notification', + service_data: { + message: 'Replacement that should be ignored', + }, + }, + }, + }, + ], + }; + expect(upgradeConfig(config)).toBeTruthy(); + expect(config).toEqual({ + cameras: [{ camera_entity: 'camera.office' }], + live: { + controls: { + ptz: { + actions_up: { + tap_action: { + action: 'call-service', + data: { + message: 'Original', + }, + service: 'notify.persistent_notification', + }, + }, + orientation: 'vertical', + style: { + color: 'white', + right: '20px', + top: '20px', + }, + }, + }, + }, + type: 'custom:frigate-card', + }); + }); + + }); }); diff --git a/tests/config/types.test.ts b/tests/config/types.test.ts index 6d762c68..0dbd69c3 100644 --- a/tests/config/types.test.ts +++ b/tests/config/types.test.ts @@ -68,6 +68,14 @@ describe('config defaults', () => { size: 48, style: 'chevrons', }, + ptz: { + hide_home: false, + hide_pan_tilt: false, + hide_zoom: false, + mode: 'on', + orientation: 'horizontal', + position: 'bottom-right', + }, thumbnails: { media: 'all', mode: 'right', @@ -208,6 +216,10 @@ describe('config defaults', () => { enabled: false, priority: 50, }, + ptz: { + enabled: false, + priority: 50, + }, recordings: { enabled: false, priority: 50, @@ -343,7 +355,13 @@ describe('should convert webrtc card PTZ to Frigate card PTZ', () => { }, }, service: 'foo', - type: 'custom:frigate-card-ptz', + + hide_home: false, + hide_pan_tilt: false, + hide_zoom: false, + mode: 'on', + orientation: 'horizontal', + position: 'bottom-right', }); }); }); diff --git a/tests/test-utils.ts b/tests/test-utils.ts index 7458701c..8c134c6d 100644 --- a/tests/test-utils.ts +++ b/tests/test-utils.ts @@ -1,27 +1,17 @@ import { HassEntities, HassEntity } from 'home-assistant-js-websocket'; import { vi } from 'vitest'; import { mock } from 'vitest-mock-extended'; +import { Camera } from '../src/camera-manager/camera'; +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'; import { CameraManager } from '../src/camera-manager/manager'; import { CameraManagerStore } from '../src/camera-manager/store'; import { - CameraConfigs, CameraManagerCameraCapabilities, + CameraManagerCapabilities, CameraManagerMediaCapabilities, - QueryType, } from '../src/camera-manager/types'; -import { - CameraConfig, - FrigateCardCondition, - FrigateCardConfig, - PerformanceConfig, - RawFrigateCardConfig, - cameraConfigSchema, - frigateCardConditionSchema, - frigateCardConfigSchema, - performanceConfigSchema, -} from '../src/config/types'; -import { ExtendedHomeAssistant, MediaLoadedInfo } 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'; @@ -44,6 +34,18 @@ import { QueryStringManager } from '../src/card-controller/query-string-manager' import { StyleManager } from '../src/card-controller/style-manager'; import { TriggersManager } from '../src/card-controller/triggers-manager'; import { ViewManager } from '../src/card-controller/view-manager'; +import { + CameraConfig, + cameraConfigSchema, + FrigateCardCondition, + frigateCardConditionSchema, + FrigateCardConfig, + frigateCardConfigSchema, + PerformanceConfig, + performanceConfigSchema, + RawFrigateCardConfig, +} from '../src/config/types'; +import { ExtendedHomeAssistant, MediaLoadedInfo } from '../src/types'; import { Entity } from '../src/utils/ha/entity-registry/types'; import { ViewMedia, ViewMediaType } from '../src/view/media'; import { MediaQueriesResults } from '../src/view/media-queries-results'; @@ -150,55 +152,51 @@ export const createViewWithMedia = (options?: Partial): View => }); }; -export const createCameraManager = (options?: { - store?: CameraManagerStore; - configs?: CameraConfigs; -}): CameraManager => { - const cameraManager = new CameraManager(createCardAPI()); - let store: CameraManagerStore | undefined = options?.store; - if (!store) { - store = mock(); - const configs = options?.configs ?? new Map([['camera', createCameraConfig()]]); - vi.mocked(store.getCameras).mockReturnValue(configs); - vi.mocked(store.getVisibleCameras).mockReturnValue(configs); - vi.mocked(store.getVisibleCameraIDs).mockReturnValue(new Set(configs.keys())); - vi.mocked(store.hasVisibleCameraID).mockImplementation((cameraID: string) => - [...configs.keys()].includes(cameraID), - ); - vi.mocked(store.getCameraConfig).mockImplementation( - (cameraID): CameraConfig | null => { - return configs.get(cameraID) ?? null; - }, +export const createStore = ( + cameras?: { + cameraID: string; + engine?: CameraManagerEngine; + config?: CameraConfig; + capabilities?: CameraManagerCameraCapabilities; + }[], +): CameraManagerStore => { + const store = new CameraManagerStore(); + for (const cameraProps of cameras ?? []) { + const camera = new Camera( + cameraProps.config ?? createCameraConfig(), + cameraProps.engine ?? new GenericCameraManagerEngine(), + cameraProps.capabilities ?? createCameraCapabilities(), ); + camera.setID(cameraProps.cameraID); + store.addCamera(camera); } - vi.mocked(cameraManager.getStore).mockReturnValue(store); - vi.mocked(cameraManager.generateDefaultEventQueries).mockReturnValue([ - { - cameraIDs: new Set(['camera']), - type: QueryType.Event, - }, - ]); - vi.mocked(cameraManager.generateDefaultRecordingQueries).mockReturnValue([ - { - cameraIDs: new Set(['camera']), - type: QueryType.Recording, - }, - ]); - vi.mocked(cameraManager.getCameraCapabilities).mockReturnValue({ - canFavoriteEvents: true, - canFavoriteRecordings: true, - canSeek: true, - supportsClips: true, - supportsRecordings: true, - supportsSnapshots: true, - supportsTimeline: true, - }); + return store; +}; +export const createCameraManager = (): CameraManager => { + const cameraManager = mock(); + vi.mocked(cameraManager.getStore).mockReturnValue(createStore()); 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, + ...capabilities, + }; +}; + export const createCameraCapabilities = ( - options?: Partial, + capabilities?: Partial, ): CameraManagerCameraCapabilities => { return { canFavoriteEvents: false, @@ -208,7 +206,7 @@ export const createCameraCapabilities = ( supportsRecordings: false, supportsSnapshots: false, supportsTimeline: false, - ...options, + ...capabilities, }; }; diff --git a/tests/utils/action.test.ts b/tests/utils/action.test.ts index 1a8c3e69..d75389b6 100644 --- a/tests/utils/action.test.ts +++ b/tests/utils/action.test.ts @@ -4,7 +4,11 @@ import { mock } from 'vitest-mock-extended'; import { actionSchema } from '../../src/config/types'; import { convertActionToFrigateCardCustomAction, - createFrigateCardCustomAction, + createFrigateCardCameraAction, + createFrigateCardDisplayModeAction, + createFrigateCardMediaPlayerAction, + createFrigateCardShowPTZAction, + createFrigateCardSimpleAction, frigateCardHandleAction, frigateCardHandleActionConfig, frigateCardHasAction, @@ -37,30 +41,37 @@ describe('convertActionToFrigateCardCustomAction', () => { }); }); -describe('createFrigateCardCustomAction', () => { - it('should create camera_select', () => { +describe('createFrigateCardSimpleAction', () => { + it('should create general action', () => { expect( - createFrigateCardCustomAction('camera_select', { - camera: 'camera', + createFrigateCardSimpleAction('clips', { cardID: 'card_id', }), ).toEqual({ action: 'fire-dom-event', - camera: 'camera', - frigate_card_action: 'camera_select', + frigate_card_action: 'clips', card_id: 'card_id', }); }); +}); - it('should not create camera_select without camera', () => { - expect(createFrigateCardCustomAction('camera_select')).toBeNull(); +describe('createFrigateCardCameraAction', () => { + it('should create camera_select', () => { + expect( + createFrigateCardCameraAction('camera_select', 'camera', { cardID: 'card_id' }), + ).toEqual({ + action: 'fire-dom-event', + camera: 'camera', + frigate_card_action: 'camera_select', + card_id: 'card_id', + }); }); +}); +describe('createFrigateCardMediaPlayerAction', () => { it('should create media_player', () => { expect( - createFrigateCardCustomAction('media_player', { - media_player: 'device', - media_player_action: 'play', + createFrigateCardMediaPlayerAction('device', 'play', { cardID: 'card_id', }), ).toEqual({ @@ -71,50 +82,36 @@ describe('createFrigateCardCustomAction', () => { card_id: 'card_id', }); }); +}); - it('should not create media_player without player or action', () => { - expect( - createFrigateCardCustomAction('media_player', { - media_player_action: 'play', - }), - ).toBeNull(); - - expect( - createFrigateCardCustomAction('media_player', { - media_player: 'device', - }), - ).toBeNull(); - }); - - it('should create general action', () => { +describe('createFrigateCardDisplayModeAction', () => { + it('should create display mode action', () => { expect( - createFrigateCardCustomAction('clips', { + createFrigateCardDisplayModeAction('grid', { cardID: 'card_id', }), ).toEqual({ action: 'fire-dom-event', - frigate_card_action: 'clips', + frigate_card_action: 'display_mode_select', + display_mode: 'grid', card_id: 'card_id', }); }); +}); - it('should create display mode action', () => { +describe('createFrigateCardShowPTZAction', () => { + it('should create show PTZ action', () => { expect( - createFrigateCardCustomAction('display_mode_select', { - display_mode: 'grid', + createFrigateCardShowPTZAction(true, { cardID: 'card_id', }), ).toEqual({ action: 'fire-dom-event', - frigate_card_action: 'display_mode_select', - display_mode: 'grid', + frigate_card_action: 'show_ptz', + show_ptz: true, card_id: 'card_id', }); }); - - it('should not create display mode action without display mode', () => { - expect(createFrigateCardCustomAction('display_mode_select')).toBeNull(); - }); }); describe('getActionConfigGivenAction', () => { @@ -124,13 +121,13 @@ describe('getActionConfigGivenAction', () => { }); it('should not handle undefined arguments', () => { - expect(getActionConfigGivenAction()).toBeUndefined(); + expect(getActionConfigGivenAction()).toBeNull(); }); it('should not handle unknown interactions', () => { expect( getActionConfigGivenAction('triple_poke', { triple_poke_action: action }), - ).toBeUndefined(); + ).toBeNull(); }); it('should handle tap actions', () => { @@ -178,17 +175,23 @@ describe('frigateCardHandleActionConfig', () => { }); it('should handle simple case', () => { - frigateCardHandleActionConfig(element, createHASS(), {}, 'tap', action); + const hass = createHASS(); + frigateCardHandleActionConfig(element, hass, {}, 'tap', action); expect(handleActionConfig).toBeCalled(); + expect(handleActionConfig).toBeCalledWith(element, hass, {}, action); }); it('should handle array case', () => { - frigateCardHandleActionConfig(element, createHASS(), {}, 'tap', [ - action, - action, - action, - ]); + const hass = createHASS(); + frigateCardHandleActionConfig(element, hass, {}, 'tap', [action, action, action]); expect(handleActionConfig).toBeCalledTimes(3); + expect(handleActionConfig).toBeCalledWith(element, hass, {}, action); + }); + + it('should handle null case', () => { + const hass = createHASS(); + frigateCardHandleActionConfig(element, hass, {}, 'tap', null); + expect(handleActionConfig).toBeCalledWith(element, hass, {}, undefined); }); }); diff --git a/tests/utils/camera.test.ts b/tests/utils/camera.test.ts index a69f5432..c84b2d15 100644 --- a/tests/utils/camera.test.ts +++ b/tests/utils/camera.test.ts @@ -1,11 +1,6 @@ import { describe, expect, it, vi } from 'vitest'; -import { mock } from 'vitest-mock-extended'; -import { CameraManager } from '../../src/camera-manager/manager.js'; -import { CameraConfigs } from '../../src/camera-manager/types.js'; -import { getAllDependentCameras, getCameraID } from '../../src/utils/camera.js'; -import { createCameraConfig, createCameraManager } from '../test-utils.js'; - -vi.mock('../../src/camera-manager/manager.js'); +import { getCameraID } from '../../src/utils/camera.js'; +import { createCameraConfig } from '../test-utils.js'; describe('getCameraID', () => { it('should get camera id with id', () => { @@ -25,7 +20,9 @@ describe('getCameraID', () => { expect(getCameraID(config)).toBe('foo'); }); it('should get camera id with go2rtc url and stream', () => { - const config = createCameraConfig({ go2rtc: { url: 'https://foo', stream: 'office' } }); + const config = createCameraConfig({ + go2rtc: { url: 'https://foo', stream: 'office' }, + }); expect(getCameraID(config)).toBe('https://foo#office'); }); it('should get camera id with frigate camera_name', () => { @@ -39,48 +36,3 @@ describe('getCameraID', () => { expect(getCameraID(config)).toBe(''); }); }); - -describe('getAllDependentCameras', () => { - it('should return null without cameraManager', () => { - expect(getAllDependentCameras()).toBeNull(); - }); - it('should return null without cameraID', () => { - expect(getAllDependentCameras(mock())).toBeNull(); - }); - it('should return dependent cameras', () => { - const cameraConfigs: CameraConfigs = new Map([ - [ - 'one', - createCameraConfig({ - dependencies: { - cameras: ['two', 'three'], - }, - }), - ], - ['two', createCameraConfig({})], - ]); - - const cameraManager = createCameraManager({ configs: cameraConfigs }); - expect(getAllDependentCameras(cameraManager, 'one')).toEqual( - new Set(['one', 'two']), - ); - }); - it('should return all cameras', () => { - const cameraConfigs: CameraConfigs = new Map([ - [ - 'one', - createCameraConfig({ - dependencies: { - all_cameras: true, - }, - }), - ], - ['two', createCameraConfig({})], - ]); - - const cameraManager = createCameraManager({ configs: cameraConfigs }); - expect(getAllDependentCameras(cameraManager, 'one')).toEqual( - new Set(['one', 'two']), - ); - }); -}); diff --git a/tests/utils/diagnostics.test.ts b/tests/utils/diagnostics.test.ts index d56a6080..a68c627e 100644 --- a/tests/utils/diagnostics.test.ts +++ b/tests/utils/diagnostics.test.ts @@ -13,7 +13,6 @@ vi.mock('../../package.json', () => ({ gitDate: 'Wed, 6 Sep 2023 21:27:28 -0700', }, })); -vi.mock('../../src/camera-manager/manager.js'); vi.mock('../../src/utils/ha'); vi.mock('../../src/localize/localize.js'); vi.mock('../../src/utils/ha/device-registry'); diff --git a/tests/utils/download.test.ts b/tests/utils/download.test.ts index c5c9ac0b..dd4ecca9 100644 --- a/tests/utils/download.test.ts +++ b/tests/utils/download.test.ts @@ -1,12 +1,10 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; import { mock } from 'vitest-mock-extended'; -import { CameraManager } from '../../src/camera-manager/manager.js'; import { downloadMedia, downloadURL } from '../../src/utils/download'; import { homeAssistantSignPath } from '../../src/utils/ha'; import { ViewMedia } from '../../src/view/media'; import { createCameraManager, createHASS } from '../test-utils'; -vi.mock('../../src/camera-manager/manager.js'); vi.mock('../../src/utils/ha'); const media = new ViewMedia('clip', 'camera-1'); @@ -73,7 +71,7 @@ describe('downloadMedia', () => { it('should throw error when no media', () => { const cameraManager = createCameraManager(); - mock(cameraManager).getMediaDownloadPath.mockResolvedValue(null); + vi.mocked(cameraManager.getMediaDownloadPath).mockResolvedValue(null); expect(downloadMedia(createHASS(), cameraManager, media)).rejects.toThrow( /No media to download/, @@ -84,7 +82,7 @@ describe('downloadMedia', () => { vi.spyOn(global.console, 'warn').mockReturnValue(undefined); const cameraManager = createCameraManager(); - mock(cameraManager).getMediaDownloadPath.mockResolvedValue({ + vi.mocked(cameraManager).getMediaDownloadPath.mockResolvedValue({ sign: true, endpoint: 'foo', }); @@ -98,7 +96,7 @@ describe('downloadMedia', () => { it('should download media', async () => { const cameraManager = createCameraManager(); - mock(cameraManager).getMediaDownloadPath.mockResolvedValue({ + vi.mocked(cameraManager).getMediaDownloadPath.mockResolvedValue({ sign: true, endpoint: 'foo', }); @@ -111,7 +109,7 @@ describe('downloadMedia', () => { it('should download media without signing', async () => { const cameraManager = createCameraManager(); - mock(cameraManager).getMediaDownloadPath.mockResolvedValue({ + vi.mocked(cameraManager).getMediaDownloadPath.mockResolvedValue({ sign: false, endpoint: 'https://foo/', }); diff --git a/tests/utils/media-to-view.test.ts b/tests/utils/media-to-view.test.ts index 579c594b..9cb614be 100644 --- a/tests/utils/media-to-view.test.ts +++ b/tests/utils/media-to-view.test.ts @@ -1,7 +1,7 @@ import add from 'date-fns/add'; import sub from 'date-fns/sub'; import { beforeEach, describe, expect, it, Mock, vi } from 'vitest'; -import { CameraConfigs } from '../../src/camera-manager/types'; +import { QueryType } from '../../src/camera-manager/types'; import { changeViewToRecentEventsForCameraAndDependents, changeViewToRecentRecordingForCameraAndDependents, @@ -14,12 +14,11 @@ import { EventMediaQueries } from '../../src/view/media-queries'; import { createCameraManager, createPerformanceConfig, + createStore, createView, TestViewMedia, } from '../test-utils'; -vi.mock('../../src/camera-manager/manager.js'); - const createElementListenForView = (): { element: HTMLElement; viewHandler: Mock; @@ -66,11 +65,10 @@ describe('changeViewToRecentEventsForCameraAndDependents', () => { it('should do nothing without camera config for selected camera', async () => { const elementHandler = createElementListenForView(); - const cameraManager = createCameraManager({ configs: new Map() }); await changeViewToRecentEventsForCameraAndDependents( elementHandler.element, - cameraManager, + createCameraManager(), {}, createView(), ); @@ -79,11 +77,10 @@ describe('changeViewToRecentEventsForCameraAndDependents', () => { it('should do nothing without camera configs for all cameras', async () => { const elementHandler = createElementListenForView(); - const cameraManager = createCameraManager({ configs: new Map() }); await changeViewToRecentEventsForCameraAndDependents( elementHandler.element, - cameraManager, + createCameraManager(), {}, createView(), { @@ -113,6 +110,19 @@ describe('changeViewToRecentEventsForCameraAndDependents', () => { it('should dispatch new view on success', async () => { const elementHandler = createElementListenForView(); const cameraManager = createCameraManager(); + vi.mocked(cameraManager.getStore).mockReturnValue( + createStore([ + { + cameraID: 'camera', + }, + ]), + ); + vi.mocked(cameraManager.generateDefaultEventQueries).mockReturnValue([ + { + type: QueryType.Event, + cameraIDs: new Set(['camera']), + }, + ]); const mediaArray = [new ViewMedia('clip', 'camera')]; vi.mocked(cameraManager.executeMediaQueries).mockResolvedValue(mediaArray); @@ -136,6 +146,19 @@ describe('changeViewToRecentEventsForCameraAndDependents', () => { const elementHandler = createElementListenForView(); const cameraManager = createCameraManager(); + vi.mocked(cameraManager.getStore).mockReturnValue( + createStore([ + { + cameraID: 'camera', + }, + ]), + ); + vi.mocked(cameraManager.generateDefaultEventQueries).mockReturnValue([ + { + type: QueryType.Event, + cameraIDs: new Set(['camera']), + }, + ]); vi.mocked(cameraManager.executeMediaQueries).mockRejectedValue(new Error()); await changeViewToRecentEventsForCameraAndDependents( @@ -150,6 +173,13 @@ describe('changeViewToRecentEventsForCameraAndDependents', () => { it('should respect media chunk size', async () => { const cameraManager = createCameraManager(); + vi.mocked(cameraManager.getStore).mockReturnValue( + createStore([ + { + cameraID: 'camera', + }, + ]), + ); await changeViewToRecentEventsForCameraAndDependents( createElementListenForView().element, @@ -178,6 +208,13 @@ describe('changeViewToRecentEventsForCameraAndDependents', () => { ['clips' as const, 'hasClip'], ])('%s', async (mediaType, queryParameter) => { const cameraManager = createCameraManager(); + vi.mocked(cameraManager.getStore).mockReturnValue( + createStore([ + { + cameraID: 'camera', + }, + ]), + ); await changeViewToRecentEventsForCameraAndDependents( createElementListenForView().element, @@ -206,12 +243,9 @@ describe('executeMediaQueryForView', () => { }); it('should not execute empty queries', async () => { - const cameraConfigs: CameraConfigs = new Map(); - const cameraManager = createCameraManager({ configs: cameraConfigs }); - expect( await executeMediaQueryForView( - cameraManager, + createCameraManager(), createView(), new EventMediaQueries(), ), @@ -219,24 +253,25 @@ describe('executeMediaQueryForView', () => { }); it('should throw on failure', async () => { - const cameraConfigs: CameraConfigs = new Map(); - const cameraManager = createCameraManager({ configs: cameraConfigs }); + const cameraManager = createCameraManager(); vi.mocked(cameraManager.executeMediaQueries).mockRejectedValue(new Error()); await expect( executeMediaQueryForView( cameraManager, createView(), - new EventMediaQueries( - cameraManager.generateDefaultEventQueries('camera') ?? undefined, - ), + new EventMediaQueries([ + { + type: QueryType.Event, + cameraIDs: new Set('camera'), + }, + ]), ), ).rejects.toThrowError(); }); it('should select time-based result', async () => { - const cameraConfigs: CameraConfigs = new Map(); - const cameraManager = createCameraManager({ configs: cameraConfigs }); + const cameraManager = createCameraManager(); const now = new Date(); const mediaArray = [ @@ -249,9 +284,12 @@ describe('executeMediaQueryForView', () => { const view = await executeMediaQueryForView( cameraManager, createView(), - new EventMediaQueries( - cameraManager.generateDefaultEventQueries('camera') ?? undefined, - ), + new EventMediaQueries([ + { + type: QueryType.Event, + cameraIDs: new Set('camera'), + }, + ]), { select: 'time', targetTime: add(now, { seconds: 30 }), @@ -264,8 +302,7 @@ describe('executeMediaQueryForView', () => { }); it('should select nothing when time-based selection does not match', async () => { - const cameraConfigs: CameraConfigs = new Map(); - const cameraManager = createCameraManager({ configs: cameraConfigs }); + const cameraManager = createCameraManager(); const now = new Date(); const mediaArray = [ @@ -279,9 +316,12 @@ describe('executeMediaQueryForView', () => { const view = await executeMediaQueryForView( cameraManager, createView(), - new EventMediaQueries( - cameraManager.generateDefaultEventQueries('camera') ?? undefined, - ), + new EventMediaQueries([ + { + type: QueryType.Event, + cameraIDs: new Set('camera'), + }, + ]), { select: 'time', targetTime: sub(now, { seconds: 30 }), @@ -292,6 +332,24 @@ describe('executeMediaQueryForView', () => { expect(view?.queryResults?.getSelectedIndex()).toBe(2); expect(view?.queryResults?.getResults()).toBe(mediaArray); }); + + it('should select nothing when query returns null', async () => { + const cameraManager = createCameraManager(); + vi.mocked(cameraManager.executeMediaQueries).mockResolvedValue(null); + + expect( + await executeMediaQueryForView( + cameraManager, + createView(), + new EventMediaQueries([ + { + type: QueryType.Event, + cameraIDs: new Set('camera'), + }, + ]), + ), + ).toBeNull(); + }); }); // @vitest-environment jsdom @@ -302,11 +360,10 @@ describe('changeViewToRecentRecordingForCameraAndDependents', () => { it('should do nothing without camera config for selected camera', async () => { const elementHandler = createElementListenForView(); - const cameraManager = createCameraManager({ configs: new Map() }); await changeViewToRecentRecordingForCameraAndDependents( elementHandler.element, - cameraManager, + createCameraManager(), {}, createView(), ); @@ -315,11 +372,10 @@ describe('changeViewToRecentRecordingForCameraAndDependents', () => { it('should do nothing without camera configs for all cameras', async () => { const elementHandler = createElementListenForView(); - const cameraManager = createCameraManager({ configs: new Map() }); await changeViewToRecentRecordingForCameraAndDependents( elementHandler.element, - cameraManager, + createCameraManager(), {}, createView(), { @@ -346,9 +402,22 @@ describe('changeViewToRecentRecordingForCameraAndDependents', () => { it('should dispatch new view on success', async () => { const elementHandler = createElementListenForView(); const cameraManager = createCameraManager(); + vi.mocked(cameraManager.getStore).mockReturnValue( + createStore([ + { + cameraID: 'camera', + }, + ]), + ); const mediaArray = [new ViewMedia('recording', 'camera')]; vi.mocked(cameraManager.executeMediaQueries).mockResolvedValue(mediaArray); + vi.mocked(cameraManager.generateDefaultRecordingQueries).mockReturnValue([ + { + type: QueryType.Recording, + cameraIDs: new Set(['camera']), + }, + ]); await changeViewToRecentRecordingForCameraAndDependents( elementHandler.element, @@ -366,6 +435,13 @@ describe('changeViewToRecentRecordingForCameraAndDependents', () => { it('should respect media chunk size', async () => { const cameraManager = createCameraManager(); + vi.mocked(cameraManager.getStore).mockReturnValue( + createStore([ + { + cameraID: 'camera', + }, + ]), + ); await changeViewToRecentRecordingForCameraAndDependents( createElementListenForView().element, @@ -402,9 +478,7 @@ describe('executeMediaQueryForViewWithErrorDispatching', () => { elementHandler.element, cameraManager, createView(), - new EventMediaQueries( - cameraManager.generateDefaultEventQueries('camera') ?? undefined, - ), + new EventMediaQueries([{ type: QueryType.Event, cameraIDs: new Set(['camera']) }]), ); expect(elementHandler.viewHandler).not.toBeCalled(); diff --git a/tests/utils/ptz.test.ts b/tests/utils/ptz.test.ts new file mode 100644 index 00000000..3aefcf1d --- /dev/null +++ b/tests/utils/ptz.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, it } from 'vitest'; +import { FrigateCardPTZConfig, frigateCardPTZSchema } from '../../src/config/types'; +import { hasUsablePTZ } from '../../src/utils/ptz'; +import { createCameraCapabilities } from '../test-utils'; + +const createPTZConfig = ( + config?: Partial, +): FrigateCardPTZConfig => { + return frigateCardPTZSchema.parse(config ?? {}); +}; + +describe('hasUsablePTZ', () => { + it('should return true with manual actions', () => { + expect( + hasUsablePTZ( + createCameraCapabilities(), + createPTZConfig({ + actions_left: {}, + }), + ), + ).toBeTruthy(); + }); + it('should return true with capabilities', () => { + expect( + hasUsablePTZ( + createCameraCapabilities({ + ptz: {}, + }), + createPTZConfig(), + ), + ).toBeTruthy(); + }); + it('should return false with manual actions or capabilities', () => { + expect(hasUsablePTZ(createCameraCapabilities(), createPTZConfig())).toBeFalsy(); + }); +});