From ca581c507867cdd4d9ee4d93b64b8716d254a361 Mon Sep 17 00:00:00 2001 From: Dermot Duffy Date: Mon, 20 Dec 2021 15:36:03 -0800 Subject: [PATCH] Initial skeleton of multiple camera support. --- README.md | 7 +- package.json | 1 + src/card.ts | 306 +++++++++++++++++++++++---------- src/common.ts | 17 +- src/components/gallery.ts | 2 + src/components/live.ts | 77 +++++---- src/components/menu.ts | 9 + src/components/submenu.ts | 8 +- src/components/viewer.ts | 1 + src/localize/languages/en.json | 6 +- src/scss/button.scss | 2 - src/scss/menu.scss | 11 +- src/scss/submenu.scss | 5 +- src/types.ts | 65 +++++-- src/view.ts | 9 +- 15 files changed, 366 insertions(+), 160 deletions(-) diff --git a/README.md b/README.md index 16682aa8..383c7bd4 100644 --- a/README.md +++ b/README.md @@ -139,6 +139,7 @@ menu: | Option | Default | Description | | - | - | - | | `frigate` | `true` | Whether to show the `Frigate` menu button: brings the user to the default configured view (`view.default`), or collapses/expands the menu if the `menu.mode` is `hidden-*` . | +| `cameras` | `true` | Whether to show the camera selection submenu. Will only appear if multiple cameras are configured. | | `live` | `true` | Whether to show the `live` view menu button: brings the user to the `live` view. See [views](#views) below.| | `clips` | `true` | Whether to show the `clips` view menu button: brings the user to the `clips` view on tap and the most-recent `clip` view on hold. See [views](#views) below.| | `snapshots` | `true` | Whether to show the `snapshots` view menu button: brings the user to the `clips` view on tap and the most-recent `snapshot` view on hold. See [views](#views) below.| @@ -889,7 +890,7 @@ The following table describes the behavior these 3 flags have. ### Card Update Truth Table -| `view.timeout` | `view.update_force` | `view.update_entities` & `camera_entity` | Behavior | +| `view.timeout` | `view.update_force` | `view.update_entities` | Behavior | | :-: | :-: | :-: | - | | Unset or `0` | *(Any value)* | Unset | Card will not automatically re-render. | | Unset or `0` | `false` | *(Any entity)* | Card will reload **current** view when entity state changes, unless media is playing. | @@ -910,9 +911,7 @@ view: ``` * Using `clip` or `snapshot` as the default view (for the most recent clip or snapshot respectively) and having the card automatically refresh (to fetch a - newer clip/snapshot) when an entity state changes. A Frigate `camera_entity` - is generally not sufficient for this since the Home Assistant state for - Frigate camera entities does not change often. Instead, use the Frigate + newer clip/snapshot) when an entity state changes. Use the Frigate binary_sensor for that camera (or any other entity at your discretion) to trigger the update: ```yaml diff --git a/package.json b/package.json index 5a077cf0..79b82e46 100644 --- a/package.json +++ b/package.json @@ -17,6 +17,7 @@ "dependencies": { "@cycjimmy/jsmpeg-player": "^5.0.1", "@material/image-list": "^12.0.0", + "@material/mwc-menu": "^0.25.3", "@material/rtl": "^13.0.0", "custom-card-helpers": "^1.8.0", "dayjs": "^1.10.7", diff --git a/src/card.ts b/src/card.ts index d8068f44..d457cbac 100644 --- a/src/card.ts +++ b/src/card.ts @@ -1,3 +1,5 @@ +// TODO change url to frigate_url? + /* eslint-disable @typescript-eslint/no-explicit-any */ import { CSSResultGroup, @@ -28,6 +30,7 @@ import { entitySchema, frigateCardConfigSchema, Actions, + CameraConfig, } from './types.js'; import type { BrowseMediaQueryParameters, @@ -131,7 +134,7 @@ export class FrigateCard extends LitElement { protected _interactionTimerID: number | null = null; @property({ attribute: false }) - protected _view: View = new View(); + protected _view?: View; @state() protected _conditionState?: ConditionState; @@ -155,13 +158,8 @@ export class FrigateCard extends LitElement { // Array of dynamic menu buttons to be added to menu. protected _dynamicMenuButtons: MenuButton[] = []; - // The frigate camera name to use (may be manually specified or automatically - // derived). - // Values: - // - string: Camera name on the Frigate backend. - // - null: Attempted to find name, but failed. - // - undefined: Have not yet attempted to find name. - protected _frigateCameraName?: string | null; + @state() + protected _cameras?: Map; // Error/info message to render. protected _message: Message | null = null; @@ -208,8 +206,15 @@ export class FrigateCard extends LitElement { ): FrigateCardConfig { const cameraEntity = entities.find((element) => element.startsWith('camera.')); return { - camera_entity: cameraEntity, - } as FrigateCardConfig; + frigate: { + camera: { + camera_entity: cameraEntity, + }, + }, + // Need to use 'as unknown' to convince Typescript that this really isn't a + // mistake, despite the miniscule size of the configuration vs the full type + // description. + } as unknown as FrigateCardConfig; } /** @@ -273,13 +278,31 @@ export class FrigateCard extends LitElement { }), ); } + + if (this.config.menu.buttons.cameras && this._cameras && this._cameras.size > 1) { + const menuItems = Array.from(this._cameras, ([camera, config]) => ({ + icon: config.icon || 'mdi:cctv', + entity: config.camera_entity, + state_color: true, + title: config.title, + tap_action: createFrigateCardCustomAction('camera_select', camera), + })); + + buttons.push({ + type: 'custom:frigate-card-menu-submenu', + title: localize('config.menu.buttons.cameras'), + icon: 'mdi:camera-switch', + items: menuItems, + }); + } + if (this.config.menu.buttons.live) { buttons.push( this._getFrigateCardMenuButton({ tap_action: 'live', title: localize('config.view.views.live'), icon: 'mdi:cctv', - emphasize: this._view.is('live'), + emphasize: this._view?.is('live'), }), ); } @@ -291,7 +314,7 @@ export class FrigateCard extends LitElement { hold_action: 'clip', title: localize('config.view.views.clips'), icon: 'mdi:filmstrip', - emphasize: this._view.is('clips'), + emphasize: this._view?.is('clips'), }), ); } @@ -302,7 +325,7 @@ export class FrigateCard extends LitElement { hold_action: 'snapshot', title: localize('config.view.views.snapshots'), icon: 'mdi:camera', - emphasize: this._view.is('snapshots'), + emphasize: this._view?.is('snapshots'), }), ); } @@ -312,11 +335,11 @@ export class FrigateCard extends LitElement { tap_action: 'image', title: localize('config.view.views.image'), icon: 'mdi:image', - emphasize: this._view.is('image'), + emphasize: this._view?.is('image'), }), ); } - if (this.config.menu.buttons.download && this._view.isViewerView()) { + if (this.config.menu.buttons.download && this._view?.isViewerView()) { buttons.push( this._getFrigateCardMenuButton({ tap_action: 'download', @@ -325,7 +348,9 @@ export class FrigateCard extends LitElement { }), ); } - if (this.config.menu.buttons.frigate_ui && this.config.frigate.url) { + + const cameraConfig = this._getSelectedCameraConfig(); + if (this.config.menu.buttons.frigate_ui && cameraConfig && cameraConfig.url) { buttons.push( this._getFrigateCardMenuButton({ tap_action: 'frigate_ui', @@ -369,49 +394,104 @@ export class FrigateCard extends LitElement { } /** - * Get the Frigate camera name through a variety of means. + * Fully load the configured cameras. + */ + protected async _loadCameras(): Promise { + const cameras: Map = new Map(); + + const addCameraConfig = async (config: CameraConfig) => { + if (!config.camera_name && config.camera_entity) { + const resolvedName = await this._getFrigateCameraNameFromEntity( + config.camera_entity, + ); + if (resolvedName) { + config.camera_name = resolvedName; + } + } + + if (config.camera_name) { + const id = config.id || config.camera_name; + if (cameras.has(id)) { + this._setMessageAndUpdate( + { + message: localize('error.duplicate_frigate_camera_name'), + type: 'error', + }, + true, + ); + } else { + cameras.set(config.id || config.camera_name, config); + } + } + }; + + if (this.config.camera) { + if (Array.isArray(this.config.camera)) { + await Promise.all(this.config.camera.map(addCameraConfig.bind(this))); + } else { + await addCameraConfig(this.config.camera); + } + } + + if (!cameras.size) { + return this._setMessageAndUpdate( + { + message: localize('error.no_cameras'), + type: 'error', + }, + true, + ); + } + + this._cameras = cameras; + } + + /** + * Get the camera configuration for the selected camera. + * @returns The CameraConfig object or null if not found. + */ + protected _getSelectedCameraConfig(): CameraConfig | null { + if (!this._cameras || !this._cameras.size || !this._view?.camera) { + return null; + } + return this._cameras.get(this._view.camera) || null; + } + + /** + * Get the Frigate camera name from an entity name. * @returns The Frigate camera name or null if unavailable. */ - protected async _getFrigateCameraName(): Promise { - // No camera name specified, apply two heuristics in this order: - // - Get the entity information and pull out the camera name from the unique_id. - // - Apply basic entity name guesswork. - - if (!this._hass || !this.config) { + protected async _getFrigateCameraNameFromEntity( + entity: string, + ): Promise { + if (!this._hass) { return null; } - // Option 1: Name specified in config -> done! - if (this.config.frigate.camera_name) { - return this.config.frigate.camera_name; + // Find entity unique_id in registry. + const request = { + type: 'config/entity_registry/get', + entity_id: entity, + }; + try { + const entityResult = await homeAssistantWSRequest( + this._hass, + entitySchema, + request, + ); + if (entityResult && entityResult.platform == 'frigate') { + const match = entityResult.unique_id.match(/:camera:(?[^:]+)$/); + if (match && match.groups) { + return match.groups['camera']; + } + } + } catch (e: any) { + // Pass. } - if (this.config.camera_entity) { - // Option 2: Find entity unique_id in registry. - const request = { - type: 'config/entity_registry/get', - entity_id: this.config.camera_entity, - }; - try { - const entityResult = await homeAssistantWSRequest( - this._hass, - entitySchema, - request, - ); - if (entityResult && entityResult.platform == 'frigate') { - const match = entityResult.unique_id.match(/:camera:(?[^:]+)$/); - if (match && match.groups) { - return match.groups['camera']; - } - } - } catch (e: any) { - // Pass. - } - - // Option 3: Guess from the entity_id. - if (this.config.camera_entity.includes('.')) { - return this.config.camera_entity.split('.', 2)[1]; - } + // Fallback: Guess from the entity_id. + if (entity.includes('.')) { + return entity.split('.', 2)[1]; } return null; @@ -510,13 +590,11 @@ export class FrigateCard extends LitElement { getLovelace().setEditMode(true); } - this._frigateCameraName = undefined; this.config = config; + this._cameras = undefined; + this._view = undefined; this._entitiesToMonitor = this.config.view.update_entities || []; - if (this.config.camera_entity) { - this._entitiesToMonitor.push(this.config.camera_entity); - } if (this.config.view.update_force) { // If update force is enabled, start a timer right away. this._resetInteractionTimer(); @@ -528,7 +606,17 @@ export class FrigateCard extends LitElement { this._message = null; if (view === undefined) { - this._view = new View({ view: this.config.view.default }); + let camera = this._view?.camera; + if (!camera && this._cameras?.size) { + camera = this._cameras.keys().next().value; + } + + if (camera) { + this._view = new View({ + view: this.config.view.default, + camera: camera, + }); + } } else { this._view = view; } @@ -565,7 +653,10 @@ export class FrigateCard extends LitElement { // are browsing the mini-gallery). Do not allow re-rendering from a Home // Assistant update if there's been recent interaction (e.g. clicks on the // card) or if there is media active playing. - if (!this.config.view.update_force && (this._interactionTimerID || this._mediaPlaying)) { + if ( + !this.config.view.update_force && + (this._interactionTimerID || this._mediaPlaying) + ) { return false; } return shouldUpdateBasedOnHass(this._hass, oldHass, this._entitiesToMonitor); @@ -577,7 +668,7 @@ export class FrigateCard extends LitElement { * Download media being displayed in the viewer. */ protected async _downloadViewerMedia(): Promise { - if (!this._hass || !this._view.isViewerView()) { + if (!this._hass || !this._view?.isViewerView()) { // Should not occur. return; } @@ -598,8 +689,13 @@ export class FrigateCard extends LitElement { return; } + const cameraConfig = this._getSelectedCameraConfig(); + if (!cameraConfig) { + return; + } + const path = - `/api/frigate/${this.config.frigate.client_id}` + + `/api/frigate/${cameraConfig.client_id}` + `/notifications/${event_id}/` + `${this._view.isClipRelatedView() ? 'clip.mp4' : 'snapshot.jpg'}` + `?download=true`; @@ -618,7 +714,10 @@ export class FrigateCard extends LitElement { return; } - if (navigator.userAgent.startsWith("Home Assistant/") || navigator.userAgent.startsWith("HomeAssistant/")) { + if ( + navigator.userAgent.startsWith('Home Assistant/') || + navigator.userAgent.startsWith('HomeAssistant/') + ) { // Home Assistant companion apps cannot download files without opening a // new browser window. // @@ -662,7 +761,14 @@ export class FrigateCard extends LitElement { case 'live': case 'snapshot': case 'snapshots': - this._changeView(new View({ view: action })); + if (this._view) { + this._changeView( + new View({ + view: action, + camera: this._view.camera, + }), + ); + } break; case 'download': this._downloadViewerMedia(); @@ -678,6 +784,23 @@ export class FrigateCard extends LitElement { screenfull.toggle(this); } break; + case 'camera_select': + const camera = frigateCardAction.camera; + if (this._cameras?.has(camera) && this._view) { + this._changeView( + new View({ + view: this._view.view, + camera: camera, + }), + ); + } + break; + // case 'next_camera': + // this._changeCamera({ next: true }); + // break; + // case 'previous_camera': + // this._changeCamera({ previous: true }); + // break; default: console.warn(`Frigate card received unknown card action: ${action}`); } @@ -688,15 +811,14 @@ export class FrigateCard extends LitElement { * @returns The URL or null if unavailable. */ protected _getFrigateURLFromContext(): string | null { - if (!this.config.frigate.url) { + const cameraConfig = this._getSelectedCameraConfig(); + if (!cameraConfig || !cameraConfig.url || !this._view) { return null; } - if (!this._frigateCameraName) { - return this.config.frigate.url; - } else if (this._view.is('live')) { - return `${this.config.frigate.url}/cameras/${this._frigateCameraName}`; + if (this._view.isViewerView() || this._view.isGalleryView()) { + return `${cameraConfig.url}/events?camera=${cameraConfig.camera_name}`; } - return `${this.config.frigate.url}/events?camera=${this._frigateCameraName}`; + return `${cameraConfig.url}/cameras/${cameraConfig.camera_name}`; } /** @@ -769,8 +891,12 @@ export class FrigateCard extends LitElement { protected _getBrowseMediaQueryParameters( mediaType?: 'clips' | 'snapshots', ): BrowseMediaQueryParameters | undefined { + const cameraConfig = this._getSelectedCameraConfig(); + if ( - !this._frigateCameraName || + !cameraConfig || + !cameraConfig.camera_name || + !this._view || !( this._view.isClipRelatedView() || this._view.isSnapshotRelatedView() || @@ -781,10 +907,10 @@ export class FrigateCard extends LitElement { } return { mediaType: mediaType || (this._view.isClipRelatedView() ? 'clips' : 'snapshots'), - clientId: this.config.frigate.client_id, - cameraName: this._frigateCameraName, - label: this.config.frigate.label, - zone: this.config.frigate.zone, + clientId: cameraConfig.client_id, + cameraName: cameraConfig.camera_name, + label: cameraConfig.label, + zone: cameraConfig.zone, }; } @@ -838,7 +964,7 @@ export class FrigateCard extends LitElement { } let requestRefresh = false; if ( - this._view.isGalleryView() && + this._view?.isGalleryView() && (mediaShowInfo.width != this._mediaShowInfo?.width || mediaShowInfo.height != this._mediaShowInfo?.height) ) { @@ -897,7 +1023,7 @@ export class FrigateCard extends LitElement { return !( (screenfull.isEnabled && screenfull.isFullscreen) || aspectRatioMode == 'unconstrained' || - (aspectRatioMode == 'dynamic' && this._view.isMediaView()) + (aspectRatioMode == 'dynamic' && this._view?.isMediaView()) ); } @@ -931,13 +1057,13 @@ export class FrigateCard extends LitElement { protected _getMergedActions(): Actions { let specificActions: Actions | undefined = undefined; - if (this._view.is('live')) { + if (this._view?.is('live')) { specificActions = this.config.live.actions; - } else if (this._view.isGalleryView()) { + } else if (this._view?.isGalleryView()) { specificActions = this.config.event_gallery?.actions; - } else if (this._view.isViewerView()) { + } else if (this._view?.isViewerView()) { specificActions = this.config.event_viewer.actions; - } else if (this._view.is('image')) { + } else if (this._view?.is('image')) { specificActions = this.config.image?.actions; } return { ...this.config.view.actions, ...specificActions }; @@ -973,10 +1099,11 @@ export class FrigateCard extends LitElement { ${this.config.menu.mode == 'above' ? this._renderMenu() : ''}
- ${this._frigateCameraName == undefined + ${this._cameras === undefined ? until( (async () => { - this._frigateCameraName = await this._getFrigateCameraName(); + await this._loadCameras(); + this._changeView(); return this._render(); })(), renderProgressIndicator(), @@ -992,18 +1119,11 @@ export class FrigateCard extends LitElement { * Sub-render method for the card. */ protected _render(): TemplateResult | void { - if (!this._hass) { + const cameraConfig = this._getSelectedCameraConfig(); + + if (!this._hass || !this._view || !cameraConfig) { return html``; } - if (!this._frigateCameraName) { - this._setMessageAndUpdate( - { - message: localize('error.no_frigate_camera_name'), - type: 'error', - }, - true, - ); - } const pictureElementsClasses = { 'picture-elements': true, @@ -1068,10 +1188,12 @@ export class FrigateCard extends LitElement { ? html` ) => { const mediaType = this.browseMediaQueryParameters?.mediaType; - if (mediaType && ['snapshots', 'clips'].includes(mediaType)) { + if (mediaType && this.view && ['snapshots', 'clips'].includes(mediaType)) { new View({ view: mediaType === 'clips' ? 'clip-specific' : 'snapshot-specific', + camera: this.view.camera, target: ev.detail.target, childIndex: ev.detail.childIndex, }).dispatchChangeEvent(this); @@ -131,37 +139,37 @@ export class FrigateCardLive extends LitElement { * @returns A rendered template. */ protected render(): TemplateResult | void { - if (!this.hass || !this.config) { + if (!this.hass || !this.liveConfig || !this.cameraConfig) { return; } return html` - ${this.config.live.controls.thumbnails.mode === 'above' + ${this.liveConfig.controls.thumbnails.mode === 'above' ? this.renderThumbnails() : ''} - ${this.config.live.provider == 'frigate' + ${this.liveConfig.provider == 'frigate' ? html` ` - : this.config.live.provider == 'webrtc' + : this.liveConfig.provider == 'webrtc' ? html` ` : html` `} - ${this.config.live.controls.thumbnails.mode === 'below' + ${this.liveConfig.controls.thumbnails.mode === 'below' ? this.renderThumbnails() : ''} `; @@ -320,10 +328,10 @@ export class FrigateCardLiveJSMPEG extends LitElement { @property({ attribute: false }) protected jsmpegConfig?: JSMPEGConfig; + @property({ attribute: false }) protected hass?: HomeAssistant & ExtendedHomeAssistant; protected _jsmpegCanvasElement?: HTMLCanvasElement; protected _jsmpegVideoPlayer?: JSMpeg.VideoElement; - protected _jsmpegURL?: string | null; protected _refreshPlayerTimerID?: number; /** @@ -356,7 +364,7 @@ export class FrigateCardLiveJSMPEG extends LitElement { * Create a JSMPEG player. * @returns A JSMPEG player. */ - protected _createJSMPEGPlayer(): JSMpeg.VideoElement { + protected _createJSMPEGPlayer(url: string): JSMpeg.VideoElement { let videoDecoded = false; const jsmpegOptions = { @@ -380,7 +388,7 @@ export class FrigateCardLiveJSMPEG extends LitElement { return new JSMpeg.VideoElement( this, - this._jsmpegURL, + url, { canvas: this._jsmpegCanvasElement, hooks: { @@ -416,7 +424,6 @@ export class FrigateCardLiveJSMPEG extends LitElement { this._jsmpegCanvasElement.remove(); this._jsmpegCanvasElement = undefined; } - this._jsmpegURL = undefined; } /** @@ -448,35 +455,31 @@ export class FrigateCardLiveJSMPEG extends LitElement { this._jsmpegCanvasElement = document.createElement('canvas'); this._jsmpegCanvasElement.className = 'media'; - this._jsmpegURL = await this._getURL(); - if (this._jsmpegURL) { - this._jsmpegVideoPlayer = this._createJSMPEGPlayer(); + const url = await this._getURL(); + if (url) { + this._jsmpegVideoPlayer = this._createJSMPEGPlayer(url); this._refreshPlayerTimerID = window.setTimeout(() => { - this._refreshPlayer(); + this.requestUpdate(); }, (URL_SIGN_EXPIRY_SECONDS - URL_SIGN_REFRESH_THRESHOLD_SECONDS) * 1000); + } else { + dispatchErrorMessageEvent(this, localize('error.jsmpeg_no_sign')); } - this.requestUpdate(); } /** * Master render method. */ protected render(): TemplateResult | void { - if ( - this._jsmpegURL === undefined || - !this._jsmpegVideoPlayer || - !this._jsmpegCanvasElement - ) { - return html`${until(this._refreshPlayer(), renderProgressIndicator())}`; + const _render = async (): Promise => { + await this._refreshPlayer(); + + if (!this._jsmpegVideoPlayer || !this._jsmpegCanvasElement) { + return dispatchErrorMessageEvent(this, localize('error.jsmpeg_no_player')); + } + return html`${this._jsmpegCanvasElement}`; } - if (!this._jsmpegURL) { - return dispatchErrorMessageEvent(this, localize('error.jsmpeg_no_sign')); - } - if (!this._jsmpegVideoPlayer || !this._jsmpegCanvasElement) { - return dispatchErrorMessageEvent(this, localize('error.jsmpeg_no_player')); - } - return html`${this._jsmpegCanvasElement}`; + return html`${until(_render(), renderProgressIndicator())}`; } /** diff --git a/src/components/menu.ts b/src/components/menu.ts index 787ea5d6..d2dda060 100644 --- a/src/components/menu.ts +++ b/src/components/menu.ts @@ -29,6 +29,7 @@ import { import menuStyle from '../scss/menu.scss'; import { ConditionState, evaluateCondition } from '../card-condition.js'; +import { Corner } from '@material/mwc-menu'; export const FRIGATE_BUTTON_MENU_ICON = 'frigate'; @@ -115,7 +116,15 @@ export class FrigateCardMenu extends LitElement { */ protected _renderButton(button: MenuButton): TemplateResult | void { if (button.type == 'custom:frigate-card-menu-submenu') { + let corner: Corner | undefined; + if (this._menuConfig?.mode.endsWith("-left")) { + // Minor nicety: Start the menu to the right of the menu itself is on + // the left, otherwise use the default. + corner = "BOTTOM_RIGHT"; + } + return html` + ()( action: z.literal('fire-dom-event'), }), ); -export const frigateCardCustomActionSchema = customActionSchema.merge( +const frigateCardCustomActionBaseSchema = customActionSchema.merge( z.object({ // Syntactic sugar to avoid 'fire-dom-event' as part of an external API. action: z .literal('custom:frigate-card-action') .transform((): 'fire-dom-event' => 'fire-dom-event') .or(z.literal('fire-dom-event')), - frigate_card_action: z.string(), }), ); + +const FRIGATE_CARD_GENERAL_ACTIONS = [ + 'frigate', + 'clip', + 'clips', + 'image', + 'live', + 'snapshot', + 'snapshots', + 'download', + 'frigate_ui', + 'fullscreen', +] as const; +const FRIGATE_CARD_ACTIONS = [...FRIGATE_CARD_GENERAL_ACTIONS, 'camera_select'] as const; +export type FrigateCardAction = typeof FRIGATE_CARD_ACTIONS[number]; + +const frigateCardGeneralActionSchema = frigateCardCustomActionBaseSchema.merge( + z.object({ + frigate_card_action: z.enum(FRIGATE_CARD_GENERAL_ACTIONS), + }), +); +const frigateCardCameraSelectActionSchema = frigateCardCustomActionBaseSchema.merge( + z.object({ + frigate_card_action: z.literal('camera_select'), + camera: z.string(), + }), +); +export const frigateCardCustomActionSchema = z.union([ + frigateCardGeneralActionSchema, + frigateCardCameraSelectActionSchema, +]); export type FrigateCardCustomAction = z.infer; const actionSchema = z.union([ @@ -322,19 +352,28 @@ export type PictureElements = z.infer; /** * Frigate configuration section. */ -const frigateConfigDefault = { +export const cameraConfigDefault = { client_id: 'frigate' as const, }; -const frigateConfigDefaultSchema = z +const cameraConfigDefaultSchema = z .object({ // No URL validation to allow relative URLs within HA (e.g. addons). url: z.string().optional(), - client_id: z.string().optional().default(frigateConfigDefault.client_id), + client_id: z.string().optional().default(cameraConfigDefault.client_id), camera_name: z.string().optional(), label: z.string().optional(), zone: z.string().optional(), + camera_entity: z.string().optional(), + + // Used for presentation in the UI (autodetected from the entity if + // specified). + icon: z.string().optional(), + title: z.string().optional(), + + id: z.string().optional(), }) - .default(frigateConfigDefault); + .default(cameraConfigDefault); +export type CameraConfig = z.infer; /** * View configuration section. @@ -457,6 +496,7 @@ const liveConfigSchema = z }) .merge(actionsSchema) .default(liveConfigDefault); +export type LiveConfig = z.infer; /** * Menu configuration section. @@ -465,6 +505,7 @@ const menuConfigDefault = { mode: 'hidden-top' as const, buttons: { frigate: true, + cameras: true, live: true, clips: true, snapshots: true, @@ -481,6 +522,7 @@ const menuConfigSchema = z buttons: z .object({ frigate: z.boolean().default(menuConfigDefault.buttons.frigate), + cameras: z.boolean().default(menuConfigDefault.buttons.cameras), live: z.boolean().default(menuConfigDefault.buttons.live), clips: z.boolean().default(menuConfigDefault.buttons.clips), snapshots: z.boolean().default(menuConfigDefault.buttons.snapshots), @@ -577,10 +619,8 @@ const dimensionsConfigSchema = z * Main card config. */ export const frigateCardConfigSchema = z.object({ - camera_entity: z.string().optional(), - // Main configuration sections. - frigate: frigateConfigDefaultSchema, + camera: cameraConfigDefaultSchema.or(cameraConfigDefaultSchema.array().nonempty()), view: viewConfigSchema, menu: menuConfigSchema, live: liveConfigSchema, @@ -598,7 +638,7 @@ export type FrigateCardConfig = z.infer; export type RawFrigateCardConfig = Record; export const frigateCardConfigDefaults = { - frigate: frigateConfigDefault, + cameras: cameraConfigDefault, view: viewConfigDefault, menu: menuConfigDefault, live: liveConfigDefault, @@ -628,9 +668,8 @@ export interface BrowseMediaQueryParameters { export interface GetFrigateCardMenuButtonParameters { icon: string; title: string; - tap_action: string; - - hold_action?: string; + tap_action: FrigateCardAction; + hold_action?: FrigateCardAction; emphasize?: boolean; } diff --git a/src/view.ts b/src/view.ts index 548ef84e..313bf0c7 100644 --- a/src/view.ts +++ b/src/view.ts @@ -2,7 +2,8 @@ import type { BrowseMediaSource, FrigateCardView } from './types.js'; import { dispatchFrigateCardEvent } from './common.js'; export interface ViewParameters { - view?: FrigateCardView; + view: FrigateCardView; + camera: string; target?: BrowseMediaSource; childIndex?: number; previous?: View; @@ -10,12 +11,14 @@ export interface ViewParameters { export class View { view: FrigateCardView; + camera: string; target?: BrowseMediaSource; childIndex?: number; previous?: View; - constructor(params?: ViewParameters) { - this.view = params?.view || 'live'; + constructor(params: ViewParameters) { + this.view = params?.view; + this.camera = params?.camera; this.target = params?.target; this.childIndex = params?.childIndex; this.previous = params?.previous;