From fb9d5684ec0d0f96cde5c9cff29cffdd4823a934 Mon Sep 17 00:00:00 2001 From: Dermot Duffy Date: Sat, 23 Apr 2022 11:22:04 -0700 Subject: [PATCH 1/9] Add 'dependent camera' support. --- src/browse-media-util.ts | 271 +++++++++++++++++++++----- src/card.ts | 16 +- src/common.ts | 1 + src/components/gallery.ts | 33 ++-- src/components/live.ts | 19 +- src/components/surround-thumbnails.ts | 13 +- src/components/timeline.ts | 99 +++++----- src/components/viewer.ts | 36 ++-- src/localize/languages/en.json | 2 +- src/types.ts | 23 ++- src/view.ts | 14 +- 11 files changed, 363 insertions(+), 164 deletions(-) diff --git a/src/browse-media-util.ts b/src/browse-media-util.ts index 0e8dbd19..0e7b6570 100644 --- a/src/browse-media-util.ts +++ b/src/browse-media-util.ts @@ -1,16 +1,18 @@ import { HomeAssistant } from 'custom-card-helpers'; -import type { - BrowseMediaQueryParametersBase, +import { BrowseMediaQueryParameters, FrigateBrowseMediaSource, CameraConfig, + MEDIA_CLASS_PLAYLIST, + MEDIA_TYPE_PLAYLIST, } from './types.js'; import { View } from './view.js'; import { frigateBrowseMediaSourceSchema } from './types.js'; import { dispatchErrorMessageEvent, dispatchMessageEvent, + getCameraTitle, homeAssistantWSRequest, } from './common.js'; import { localize } from './localize/localize.js'; @@ -73,11 +75,9 @@ export class BrowseMediaUtil { type: 'media_source/browse_media', media_content_id: media_content_id, }; - return homeAssistantWSRequest(hass, frigateBrowseMediaSourceSchema, request); + return await homeAssistantWSRequest(hass, frigateBrowseMediaSourceSchema, request); } - // Browse Frigate media with query parameters. - /** * Browse Frigate media with a media query. May throw. * @param hass The HomeAssistant object. @@ -109,13 +109,100 @@ export class BrowseMediaUtil { ); } + /** + * Browse multiple Frigate media queries. May throw. + * @param hass The HomeAssistant object. + * @param params An array of search parameters to use to search for media. + * @returns A map of FrigateBrowseMediaSource object or null on malformed. + */ + static async multipleBrowseMediaQuery( + hass: HomeAssistant, + params: BrowseMediaQueryParameters | BrowseMediaQueryParameters[], + ): Promise> { + params = Array.isArray(params) ? params : [params]; + const output: Map = new Map(); + await Promise.all( + params.map(async (param: BrowseMediaQueryParameters): Promise => { + output.set(param, await this.browseMediaQuery(hass, param)); + }), + ); + return output; + } + + /** + * Browse multiple Frigate media queries, then merged them. May throw. + * @param hass The HomeAssistant object. + * @param params An array of search parameters to use to search for media. + * @returns A single FrigateBrowseMediaSource object or null on malformed. + */ + static async multipleBrowseMediaQueryMerged( + hass: HomeAssistant, + params: BrowseMediaQueryParameters | BrowseMediaQueryParameters[], + ): Promise { + return this.mergeFrigateBrowseMediaSources( + await this.multipleBrowseMediaQuery(hass, params), + ); + } + + /** + * Merge multiple FrigateBrowseMediaSource into a single. Note that this may + * use information from the query to differentiate results that may otherwise + * be identical. + * @param input A map of query -> result. + * @returns A single FrigateBrowseMediaSource object. + */ + static mergeFrigateBrowseMediaSources( + input: Map, + ): FrigateBrowseMediaSource { + const children: FrigateBrowseMediaSource[] = []; + + for (const [query, result] of input.entries()) { + for (const child of result.children || []) { + if (this.isTrueMedia(child)) { + children.push(child); + } else { + if (query.title) { + children.push({ ...child, title: `[${query.title}] ${child.title}` }); + } else { + children.push(child); + } + } + } + } + + const eventSort = ( + a: FrigateBrowseMediaSource, + b: FrigateBrowseMediaSource, + ): number => { + if ( + !a.frigate?.event || + (b.frigate?.event && b.frigate.event.start_time < a.frigate.event.start_time) + ) { + return 1; + } + + if ( + !b.frigate?.event || + (a.frigate?.event && b.frigate.event.start_time > a.frigate.event.start_time) + ) { + return -1; + } + return 0; + }; + + return this.createEventParentForChildren('Merged events', children.sort(eventSort)); + } + /** * Get the parameters to search for media. * @returns A BrowseMediaQueryParameters object. */ - static getBrowseMediaQueryParametersBase( + static getBrowseMediaQueryParameters( + hass: HomeAssistant, + cameraID: string, cameraConfig?: CameraConfig, - ): BrowseMediaQueryParametersBase | null { + overrides?: Partial, + ): BrowseMediaQueryParameters | null { if (!cameraConfig || !cameraConfig.camera_name) { return null; } @@ -124,84 +211,143 @@ export class BrowseMediaUtil { cameraName: cameraConfig.camera_name, label: cameraConfig.label, zone: cameraConfig.zone, + title: getCameraTitle(hass, cameraConfig), + cameraID: cameraID, + ...overrides, }; } /** - * Set the mediaType parameter from the current view. - * @param browseMediaQueryParametersBase The base media query parameters object. - * @param view The current view. - * @returns A fully populated BrowseMediaQueryParameters or null. + * Apply overrides to multiple query parameters. + * @param parameters An array of query parameters. + * @param overrides The overrides to apply. + * @returns The override query parameters. */ - static setMediaTypeFromView( - browseMediaQueryParametersBase: BrowseMediaQueryParametersBase | null, - view: View, - ): BrowseMediaQueryParameters | null { - if ( - !browseMediaQueryParametersBase || - !(view.isClipRelatedView() || view.isSnapshotRelatedView()) - ) { - return null; + static overrideMultiBrowseMediaQueryParameters( + parameters: BrowseMediaQueryParameters[], + overrides: Partial, + ): BrowseMediaQueryParameters[] { + const output: BrowseMediaQueryParameters[] = []; + parameters.forEach((param) => { + output.push({ ...param, ...overrides }); + }); + return output; + } + + /** + * Get BrowseMediaQueryParameters for a camera (including its dependencies). + * @param hass Home Assistant object. + * @param cameras Cameras map. + * @param camera Name of the current camera. + * @param mediaType Optional media type to include in the parameters. + * @returns An array of query parameters. + */ + static getFullDependentBrowseMediaQueryParameters( + hass: HomeAssistant, + cameras: Map, + camera: string, + mediaType?: 'clips' | 'snapshots', + ): BrowseMediaQueryParameters[] | null { + const cameraIDs: Set = new Set(); + const getDependentCameras = (camera: string): void => { + const cameraConfig = cameras.get(camera); + if (cameraConfig) { + cameraIDs.add(camera); + for (const eventCameraID of cameraConfig.dependent_cameras || []) { + if (!cameraIDs.has(eventCameraID)) { + getDependentCameras(eventCameraID); + } + } + } + }; + getDependentCameras(camera); + + const params: BrowseMediaQueryParameters[] = []; + for (const cameraID of cameraIDs) { + const param = BrowseMediaUtil.getBrowseMediaQueryParameters( + hass, + cameraID, + cameras.get(cameraID), + mediaType ? { mediaType: mediaType } : {}, + ); + // Fail on a single bad camera, as it's almost certainly a user error that + // should be fixed rather than hidden. + if (!param) { + return null; + } + params.push(param); } - return { - ...browseMediaQueryParametersBase, - mediaType: view.isClipRelatedView() ? 'clips' : 'snapshots', - }; + return params.length ? params : null; } /** - * Get the parameters to search for media related to the current view. - * @returns A BrowseMediaQueryParameters object. + * Get BrowseMediaQueryParameters for a camera (including its dependencies) or dispatch an error. + * @param element The element from which to dispatch the error. + * @param hass Home Assistant object. + * @param cameras Cameras map. + * @param camera Name of the current camera. + * @param mediaType Optional media type to include in the parameters. + * @returns An array of query parameters. */ - static getBrowseMediaQueryParametersBaseOrDispatchError( - node: HTMLElement, - cameraConfig: CameraConfig, - ): BrowseMediaQueryParametersBase | null { - // Verify there is a camera name, otherwise getBrowseMediaQueryParametersBase() - // will return undefined. - if (!cameraConfig.camera_name) { + static getFullDependentBrowseMediaQueryParametersOrDispatchError( + element: HTMLElement, + hass: HomeAssistant, + cameras: Map, + camera: string, + mediaType?: 'clips' | 'snapshots', + ): BrowseMediaQueryParameters[] | null { + const params = this.getFullDependentBrowseMediaQueryParameters( + hass, + cameras, + camera, + mediaType, + ); + if (!params) { dispatchErrorMessageEvent( - node, - localize('error.no_camera_name') + `: ${JSON.stringify(cameraConfig)}`, + element, + localize('error.no_camera_name'), + cameras.get(camera), ); return null; } - - return BrowseMediaUtil.getBrowseMediaQueryParametersBase(cameraConfig); + return params; } /** * Fetch the latest media and dispatch a change view event to reflect the * results. If no media is found a suitable message event will be triggered * instead. - * @param node The HTMLElement to dispatch events from. + * @param element The HTMLElement to dispatch events from. * @param hass The Home Assistant object. * @param view The current view to evolve. * @param browseMediaQueryParameters The media parameters to query with. * @returns */ static async fetchLatestMediaAndDispatchViewChange( - node: HTMLElement, + element: HTMLElement, hass: HomeAssistant, view: Readonly, - browseMediaQueryParameters: BrowseMediaQueryParameters, + browseMediaQueryParameters: + | BrowseMediaQueryParameters + | BrowseMediaQueryParameters[], ): Promise { let parent: FrigateBrowseMediaSource | null; try { - parent = await BrowseMediaUtil.browseMediaQuery(hass, browseMediaQueryParameters); + parent = await BrowseMediaUtil.multipleBrowseMediaQueryMerged( + hass, + browseMediaQueryParameters, + ); } catch (e) { - return dispatchErrorMessageEvent(node, (e as Error).message); + return dispatchErrorMessageEvent(element, (e as Error).message); } const childIndex = BrowseMediaUtil.getFirstTrueMediaChildIndex(parent); if (!parent || !parent.children || childIndex == null) { return dispatchMessageEvent( - node, - browseMediaQueryParameters.mediaType == 'clips' + element, + view.isClipRelatedView() ? localize('common.no_clip') : localize('common.no_snapshot'), - browseMediaQueryParameters.mediaType == 'clips' - ? 'mdi:filmstrip-off' - : 'mdi:camera-off', + view.isClipRelatedView() ? 'mdi:filmstrip-off' : 'mdi:camera-off', ); } @@ -210,7 +356,7 @@ export class BrowseMediaUtil { target: parent, childIndex: childIndex, }) - .dispatchChangeEvent(node); + .dispatchChangeEvent(element); } /** @@ -223,7 +369,7 @@ export class BrowseMediaUtil { * @returns */ static async fetchChildMediaAndDispatchViewChange( - node: HTMLElement, + element: HTMLElement, hass: HomeAssistant, view: Readonly, child: Readonly, @@ -232,13 +378,36 @@ export class BrowseMediaUtil { try { parent = await BrowseMediaUtil.browseMedia(hass, child.media_content_id); } catch (e) { - return dispatchErrorMessageEvent(node, (e as Error).message); + return dispatchErrorMessageEvent(element, (e as Error).message); } view .evolve({ target: parent, }) - .dispatchChangeEvent(node); + .dispatchChangeEvent(element); + } + + /** + * Given an array of media children, create a parent for them. + * @param title The title to use for the parent. + * @param children The children media items. + * @returns A single parent containing the children. + */ + static createEventParentForChildren( + title: string, + children: FrigateBrowseMediaSource[], + ): FrigateBrowseMediaSource { + return { + title: title, + media_class: MEDIA_CLASS_PLAYLIST, + media_content_type: MEDIA_TYPE_PLAYLIST, + media_content_id: '', + can_play: false, + can_expand: true, + children_media_class: MEDIA_CLASS_PLAYLIST, + thumbnail: null, + children: children, + }; } } diff --git a/src/card.ts b/src/card.ts index 41f2215d..8e919f83 100644 --- a/src/card.ts +++ b/src/card.ts @@ -8,7 +8,7 @@ import { } from 'lit'; import { HomeAssistant, LovelaceCardEditor, getLovelace } from 'custom-card-helpers'; import { StyleInfo, styleMap } from 'lit/directives/style-map.js'; -import { customElement, property, query, state } from 'lit/decorators.js'; +import { customElement, property, state } from 'lit/decorators.js'; import { classMap } from 'lit/directives/class-map.js'; import { createRef, ref, Ref } from 'lit/directives/ref.js'; import screenfull from 'screenfull'; @@ -328,11 +328,12 @@ export class FrigateCard extends LitElement { const cameraConfig = this._getSelectedCameraConfig(); // Don't show `clips` button if there's no `camera_name` (e.g. non-Frigate - // cameras), or is birdseye. + // cameras), or is birdseye (unless there are dependent cameras). if ( this._getConfig().menu.buttons.clips && cameraConfig?.camera_name && - cameraConfig?.camera_name !== 'birdseye' + (cameraConfig?.camera_name !== 'birdseye' || + cameraConfig?.dependent_cameras?.length) ) { buttons.push({ type: 'custom:frigate-card-menu-icon', @@ -345,11 +346,12 @@ export class FrigateCard extends LitElement { } // Don't show `snapshots` button if there's no `camera_name` (e.g. non-Frigate - // cameras), or is birdseye. + // cameras), or is birdseye (unless there are dependent cameras). if ( this._getConfig().menu.buttons.snapshots && cameraConfig?.camera_name && - cameraConfig?.camera_name !== 'birdseye' + (cameraConfig?.camera_name !== 'birdseye' || + cameraConfig?.dependent_cameras?.length) ) { buttons.push({ type: 'custom:frigate-card-menu-icon', @@ -1299,7 +1301,7 @@ export class FrigateCard extends LitElement { ? html` ` @@ -1308,7 +1310,7 @@ export class FrigateCard extends LitElement { ? html` diff --git a/src/common.ts b/src/common.ts index 68a27adb..c9ca7153 100644 --- a/src/common.ts +++ b/src/common.ts @@ -477,6 +477,7 @@ export function getCameraTitle( config?.title || (config?.camera_entity ? getEntityTitle(hass, config.camera_entity) : '') || (config?.camera_name ? prettifyFrigateName(config.camera_name) : '') || + config?.id || '' ); } diff --git a/src/components/gallery.ts b/src/components/gallery.ts index 9bbc476d..1bdcf2a7 100644 --- a/src/components/gallery.ts +++ b/src/components/gallery.ts @@ -11,11 +11,7 @@ import { import { HomeAssistant } from 'custom-card-helpers'; import { customElement, property } from 'lit/decorators.js'; -import { - CameraConfig, - GalleryConfig, - frigateCardConfigDefaults, -} from '../types.js'; +import { CameraConfig, GalleryConfig, frigateCardConfigDefaults } from '../types.js'; import { BrowseMediaUtil } from '../browse-media-util.js'; import { View } from '../view.js'; import { THUMBNAIL_DETAILS_WIDTH_MIN } from './thumbnail.js'; @@ -35,28 +31,37 @@ export class FrigateCardGallery extends LitElement { protected view?: Readonly; @property({ attribute: false }) - protected cameraConfig?: CameraConfig; + protected galleryConfig?: GalleryConfig; @property({ attribute: false }) - protected galleryConfig?: GalleryConfig; + protected cameras?: Map; /** * Master render method. * @returns A rendered template. */ protected render(): TemplateResult | void { - if (!this.hass || !this.view || !this.cameraConfig || !this.view.isGalleryView()) { + const mediaType = this.view?.getMediaType(); + if ( + !this.hass || + !this.view || + !this.cameras || + !this.view.isGalleryView() || + !mediaType + ) { return; } if (!this.view.target) { - const browseMediaQueryParameters = BrowseMediaUtil.setMediaTypeFromView( - BrowseMediaUtil.getBrowseMediaQueryParametersBaseOrDispatchError( + const browseMediaQueryParameters = + BrowseMediaUtil.getFullDependentBrowseMediaQueryParametersOrDispatchError( this, - this.cameraConfig, - ), - this.view, - ); + this.hass, + this.cameras, + this.view.camera, + mediaType, + ); + if (!browseMediaQueryParameters) { return; } diff --git a/src/components/live.ts b/src/components/live.ts index b3d1593b..42f649be 100644 --- a/src/components/live.ts +++ b/src/components/live.ts @@ -19,7 +19,6 @@ import { LiveProvider, TransitionEffect, frigateCardConfigDefaults, - BrowseMediaQueryParameters, } from '../types.js'; import { EmblaOptionsType, EmblaPluginType } from 'embla-carousel'; import { HomeAssistant } from 'custom-card-helpers'; @@ -127,16 +126,18 @@ export class FrigateCardLive extends LitElement { this.conditionState, ) as LiveConfig; - const browseMediaParamsBase = BrowseMediaUtil.getBrowseMediaQueryParametersBase( - this.cameras.get(this.view.camera), - ); - if (!browseMediaParamsBase) { + const browseMediaParams = + BrowseMediaUtil.getFullDependentBrowseMediaQueryParametersOrDispatchError( + this, + this.hass, + this.cameras, + this.view.camera, + config.controls.thumbnails.media, + ); + + if (!browseMediaParams) { return; } - const browseMediaParams: BrowseMediaQueryParameters = { - ...browseMediaParamsBase, - mediaType: config.controls.thumbnails.media, - } // Note use of liveConfig and not config below -- the carousel will // independently override the liveconfig to reflect the camera in the diff --git a/src/components/surround-thumbnails.ts b/src/components/surround-thumbnails.ts index 60eb18c6..ce708446 100644 --- a/src/components/surround-thumbnails.ts +++ b/src/components/surround-thumbnails.ts @@ -34,7 +34,9 @@ export class FrigateCardSurround extends LitElement { protected targetView?: FrigateCardView; @property({ attribute: false }) - protected browseMediaParams?: BrowseMediaQueryParameters; + protected browseMediaParams?: + | BrowseMediaQueryParameters + | BrowseMediaQueryParameters[]; // A task to await the load of the WebRTC component. protected _browseTask = new Task(this, this._fetchMedia.bind(this), () => [ @@ -53,22 +55,25 @@ export class FrigateCardSurround extends LitElement { | HomeAssistant | Readonly | BrowseMediaQueryParameters + | BrowseMediaQueryParameters[] | undefined )[]): Promise { hass = hass as HomeAssistant; view = view as Readonly; - browseMediaParams = browseMediaParams as BrowseMediaQueryParameters; + browseMediaParams = browseMediaParams as + | BrowseMediaQueryParameters + | BrowseMediaQueryParameters[]; if (!hass || !view || view.target || !browseMediaParams) { return; } let parent: FrigateBrowseMediaSource | null; try { - parent = await BrowseMediaUtil.browseMediaQuery(hass, browseMediaParams); + parent = await BrowseMediaUtil.multipleBrowseMediaQueryMerged(hass, browseMediaParams); } catch (e) { return dispatchErrorMessageEvent(this, (e as Error).message); } - if (BrowseMediaUtil.getFirstTrueMediaChildIndex(parent) != null) { + if (BrowseMediaUtil.getFirstTrueMediaChildIndex(parent) !== null) { this.view ?.evolve({ ...(this.targetView && { view: this.targetView }), diff --git a/src/components/timeline.ts b/src/components/timeline.ts index 1721b68d..7136f50b 100644 --- a/src/components/timeline.ts +++ b/src/components/timeline.ts @@ -25,10 +25,9 @@ import { isEqual } from 'lodash-es'; import { BrowseMediaUtil } from '../browse-media-util'; import { + BrowseMediaQueryParameters, CameraConfig, FrigateBrowseMediaSource, - MEDIA_CLASS_PLAYLIST, - MEDIA_TYPE_PLAYLIST, TimelineConfig, FrigateEvent, frigateCardConfigDefaults, @@ -278,49 +277,47 @@ class TimelineEventManager { } this._dateFetch = new Date(); - const fetchCameraEvents = async ( - camera: string, - fetchMedia: 'clips' | 'snapshots', - ): Promise => { - const cameraConfig = cameras.get(camera); - if (!cameraConfig || !this._dateStart || !this._dateEnd) { - return; - } - const browseMediaQueryParametersBase = - BrowseMediaUtil.getBrowseMediaQueryParametersBase(cameraConfig); - if (!browseMediaQueryParametersBase) { - return; - } - try { - this._addMediaSource( - camera, - media, - await BrowseMediaUtil.browseMediaQuery(hass, { - ...browseMediaQueryParametersBase, + const params: BrowseMediaQueryParameters[] = []; + cameras.forEach((cameraConfig, cameraID) => { + (media === 'all' ? ['clips', 'snapshots'] : [media]).forEach((mediaType) => { + if (this._dateEnd && this._dateStart) { + const param = BrowseMediaUtil.getBrowseMediaQueryParameters( + hass, + cameraID, + cameraConfig, + { + // Events are always fetched for the maximum extent of the managed + // range. This is because events may change at any point in time + // (e.g. a long-running event that ends). + before: this._dateEnd.getTime() / 1000, + after: this._dateStart.getTime() / 1000, + unlimited: true, + mediaType: mediaType as 'clips' | 'snapshots', + }, + ); + if (param) { + params.push(param); + } + } + }); + }); - // Events are always fetched for the maximum extent of the managed - // range. This is because events may change at any point in time - // (e.g. a long-running event that ends). - before: this._dateEnd.getTime() / 1000, - after: this._dateStart.getTime() / 1000, - unlimited: true, - mediaType: fetchMedia, - }), - ); - } catch (e) { - return dispatchErrorMessageEvent(element, (e as Error).message); - } - }; + if (!params.length) { + return; + } - const promises: Promise[] = []; - (media === 'all' ? ['clips', 'snapshots'] : [media]).forEach((mediaType) => - promises.push( - ...Array.from(cameras.keys()).map((camera) => - fetchCameraEvents(camera, mediaType as 'clips' | 'snapshots'), - ), - ), - ); - await Promise.all(promises); + let results: Map; + try { + results = await BrowseMediaUtil.multipleBrowseMediaQuery(hass, params); + } catch (e) { + return dispatchErrorMessageEvent(element, (e as Error).message); + } + + for (const [query, result] of results.entries()) { + if (query.cameraID) { + this._addMediaSource(query.cameraID, media, result); + } + } } } @@ -574,18 +571,10 @@ export class FrigateCardTimelineCore extends LitElement { return null; } - const target = { - title: `Timeline events`, - media_class: MEDIA_CLASS_PLAYLIST, - media_content_type: MEDIA_TYPE_PLAYLIST, - media_content_id: '', - can_play: false, - can_expand: true, - children_media_class: MEDIA_CLASS_PLAYLIST, - thumbnail: null, - children: children, - }; - + const target = BrowseMediaUtil.createEventParentForChildren( + 'Timeline events', + children, + ); return { target: target, childIndex: childIndex < 0 ? null : childIndex, diff --git a/src/components/viewer.ts b/src/components/viewer.ts index c11d65e9..237e7560 100644 --- a/src/components/viewer.ts +++ b/src/components/viewer.ts @@ -17,7 +17,7 @@ import { ref } from 'lit/directives/ref.js'; import { AutoMediaPlugin } from './embla-plugins/automedia.js'; import type { BrowseMediaNeighbors, - BrowseMediaQueryParametersBase, + BrowseMediaQueryParameters, FrigateBrowseMediaSource, CameraConfig, MediaShowInfo, @@ -54,7 +54,7 @@ export class FrigateCardViewer extends LitElement { protected viewerConfig?: ViewerConfig; @property({ attribute: false }) - protected cameraConfig?: CameraConfig; + protected cameras?: Map; @property({ attribute: false }) protected resolvedMediaCache?: ResolvedMediaCache; @@ -64,21 +64,19 @@ export class FrigateCardViewer extends LitElement { * @returns A rendered template. */ protected render(): TemplateResult | void { - if (!this.hass || !this.view || !this.cameraConfig || !this.viewerConfig) { + if (!this.hass || !this.view || !this.cameras || !this.viewerConfig) { return; } - const browseMediaQueryParametersBase = - BrowseMediaUtil.getBrowseMediaQueryParametersBaseOrDispatchError( + const browseMediaQueryParameters = + BrowseMediaUtil.getFullDependentBrowseMediaQueryParametersOrDispatchError( this, - this.cameraConfig, + this.hass, + this.cameras, + this.view.camera, ); if (!this.view.target) { - const browseMediaQueryParameters = BrowseMediaUtil.setMediaTypeFromView( - browseMediaQueryParametersBase, - this.view, - ); if (!browseMediaQueryParameters) { return; } @@ -101,7 +99,7 @@ export class FrigateCardViewer extends LitElement { .hass=${this.hass} .view=${this.view} .viewerConfig=${this.viewerConfig} - .browseMediaQueryParametersBase=${browseMediaQueryParametersBase} + .browseMediaQueryParameters=${browseMediaQueryParameters} .resolvedMediaCache=${this.resolvedMediaCache} > @@ -133,7 +131,7 @@ export class FrigateCardViewerCarousel extends FrigateCardMediaCarousel { protected viewerConfig?: ViewerConfig; @property({ attribute: false }) - protected browseMediaQueryParametersBase?: BrowseMediaQueryParametersBase; + protected browseMediaQueryParameters?: BrowseMediaQueryParameters[] | null; @property({ attribute: false }) protected resolvedMediaCache?: ResolvedMediaCache; @@ -338,7 +336,7 @@ export class FrigateCardViewerCarousel extends FrigateCardMediaCarousel { !this.view.target || !this.view.target.children || !this.view.target.children.length || - !this.browseMediaQueryParametersBase + !this.browseMediaQueryParameters ) { return null; } @@ -379,13 +377,17 @@ export class FrigateCardViewerCarousel extends FrigateCardMediaCarousel { let clips: FrigateBrowseMediaSource | null; - try { - clips = await BrowseMediaUtil.browseMediaQuery(this.hass, { - ...this.browseMediaQueryParametersBase, + const params = BrowseMediaUtil.overrideMultiBrowseMediaQueryParameters( + this.browseMediaQueryParameters, + { mediaType: 'clips', before: latest, after: earliest, - }); + }, + ); + + try { + clips = await BrowseMediaUtil.multipleBrowseMediaQueryMerged(this.hass, params); } catch (e) { // This is best effort. return null; diff --git a/src/localize/languages/en.json b/src/localize/languages/en.json index 1a1a0969..6d3dfa2f 100644 --- a/src/localize/languages/en.json +++ b/src/localize/languages/en.json @@ -276,7 +276,7 @@ "invalid_elements_config": "Invalid picture elements configuration", "jsmpeg_no_sign": "Could not retrieve or sign JSMPEG websocket path", "jsmpeg_no_player": "Could not start JSMPEG player", - "no_camera_name": "Could not determine Frigate camera name for camera, please specify either 'camera_entity' or 'camera_name' for the following camera", + "no_camera_name": "Could not determine a Frigate camera name for camera (or one of its dependents), please specify either 'camera_entity' or 'camera_name'", "download_no_media": "No media to download", "download_no_event_id": "Could not extract Frigate event id from media", "download_sign_failed": "Could not sign media URL for download" diff --git a/src/types.ts b/src/types.ts index e87e198a..00cd0af5 100644 --- a/src/types.ts +++ b/src/types.ts @@ -327,6 +327,9 @@ const cameraConfigSchema = z // Camera identifiers for WebRTC. webrtc_card: webrtcCardCameraConfigSchema.optional(), + + // Set of cameras IDs upon which this camera depends. + dependent_cameras: z.string().array().optional(), }) .default(cameraConfigDefault); export type CameraConfig = z.infer; @@ -936,11 +939,14 @@ export type MenuButton = z.infer; export interface ExtendedHomeAssistant extends HomeAssistant { hassUrl(path?): string; themes: Themes & { - darkMode?: boolean + darkMode?: boolean; }; } -export interface BrowseMediaQueryParametersBase { +export interface BrowseMediaQueryParameters { + // ======================================== + // Parameters used to construct media query + // ======================================== mediaType?: 'clips' | 'snapshots'; clientId: string; cameraName: string; @@ -949,10 +955,17 @@ export interface BrowseMediaQueryParametersBase { before?: number; after?: number; unlimited?: boolean; -} -export interface BrowseMediaQueryParameters extends BrowseMediaQueryParametersBase { - mediaType: 'clips' | 'snapshots'; + // ======================================== + // Parameters used to differentiate results + // ======================================== + // Optional title to be used for separating results when merging multiple + // sets of results. See `mergeFrigateBrowseMediaSources()` . + title?: string; + + // Optional camera-id to which this query is associated. May be used to map + // results to a particular camera within the card. + cameraID?: string; } export interface BrowseMediaNeighbors { diff --git a/src/view.ts b/src/view.ts index 42ad6300..243b4873 100644 --- a/src/view.ts +++ b/src/view.ts @@ -61,7 +61,7 @@ export class View { target: params.target !== undefined ? params.target : this.target, childIndex: params.childIndex !== undefined ? params.childIndex : this.childIndex, context: params.context !== undefined ? params.context : this.context, - + // Special case: Set the previous to this of the evolved view (rather than // the previous of this). previous: params.previous !== undefined ? params.previous : this, @@ -110,6 +110,18 @@ export class View { return ['snapshot', 'snapshots'].includes(this.view); } + /** + * Get the media type for this view if available. + * @returns Whether the media is `clips` or `snapshots` or unknown (`null`) + */ + public getMediaType(): 'clips' | 'snapshots' | null { + return this.isClipRelatedView() + ? 'clips' + : this.isSnapshotRelatedView() + ? 'snapshots' + : null; + } + /** * Get the media item that should be played. **/ From a0dc34f475a26228c0e7a21f44c8164bbb0f5b8a Mon Sep 17 00:00:00 2001 From: Dermot Duffy Date: Sat, 23 Apr 2022 11:27:49 -0700 Subject: [PATCH 2/9] Add README entry for `dependent_cameras`. --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index e19d9c3a..69eb9482 100644 --- a/README.md +++ b/README.md @@ -101,6 +101,7 @@ cameras: | `icon` | Autodetected from `camera_entity` if that is specified. | :heavy_multiplication_x: | The icon to use for this camera in the camera menu and in the next & previous controls when using the `icon` style. | | `webrtc_card` | | :heavy_multiplication_x: | The WebRTC entity/URL to use for this camera with the `webrtc-card` live provider. See below. | | `id` | `camera_entity`, `webrtc_card.entity` or `camera_name` if set (in that preference order). | :heavy_multiplication_x: | An optional identifier to use throughout the card configuration to refer unambiguously to this camera. See [camera IDs](#camera-ids). | +| `dependent_cameras` | | :heavy_multiplication_x: | An optional array of other camera identifiers (see [camera IDs](#camera-ids)). If specified the card will fetch events for this camera and *also* recursively events for the named `dependent_cameras`. All `dependent_cameras` must themselves be a configured camera in the card. This can be useful to group events for cameras that are close together, or to show events for the `birdseye` camera that otherwise would not have events itself.| From 5631850e6d1821d29a369c876a19b8482d4a60f0 Mon Sep 17 00:00:00 2001 From: Dermot Duffy Date: Sat, 23 Apr 2022 11:33:36 -0700 Subject: [PATCH 3/9] Don't include birdseye cameras on the timeline. --- src/components/timeline.ts | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/src/components/timeline.ts b/src/components/timeline.ts index 7136f50b..a473b3c3 100644 --- a/src/components/timeline.ts +++ b/src/components/timeline.ts @@ -280,7 +280,11 @@ class TimelineEventManager { const params: BrowseMediaQueryParameters[] = []; cameras.forEach((cameraConfig, cameraID) => { (media === 'all' ? ['clips', 'snapshots'] : [media]).forEach((mediaType) => { - if (this._dateEnd && this._dateStart) { + if ( + this._dateEnd && + this._dateStart && + cameraConfig.camera_name !== 'birdseye' + ) { const param = BrowseMediaUtil.getBrowseMediaQueryParameters( hass, cameraID, @@ -588,10 +592,12 @@ export class FrigateCardTimelineCore extends LitElement { protected _getGroups(): DataGroupCollectionType { const groups: FrigateCardGroupData[] = []; this.cameras?.forEach((cameraConfig, camera) => { - groups.push({ - id: camera, - content: getCameraTitle(this.hass, cameraConfig), - }); + if (cameraConfig.camera_name !== 'birdseye') { + groups.push({ + id: camera, + content: getCameraTitle(this.hass, cameraConfig), + }); + } }); return new DataSet(groups); } From f8784bfc323a129bffc99188ed44ec54968c6d4f Mon Sep 17 00:00:00 2001 From: Dermot Duffy Date: Sat, 23 Apr 2022 15:15:35 -0700 Subject: [PATCH 4/9] Editor support for dependent cameras. --- src/card.ts | 8 +-- src/common.ts | 45 +++++++++++-- src/const.ts | 2 + src/editor.ts | 112 ++++++++++++++++++++------------- src/localize/languages/en.json | 1 + src/scss/editor.scss | 11 ++-- 6 files changed, 117 insertions(+), 62 deletions(-) diff --git a/src/card.ts b/src/card.ts index 8e919f83..375eb7b9 100644 --- a/src/card.ts +++ b/src/card.ts @@ -48,6 +48,7 @@ import { frigateCardHasAction, getActionConfigGivenAction, getCameraIcon, + getCameraID, getCameraTitle, homeAssistantSignPath, homeAssistantWSRequest, @@ -467,12 +468,7 @@ export class FrigateCard extends LitElement { } } - const id = - config.id || - config.camera_entity || - config.webrtc_card?.entity || - config.camera_name; - + const id = getCameraID(config); if (!id) { this._setMessageAndUpdate({ message: localize('error.no_camera_id'), diff --git a/src/common.ts b/src/common.ts index c9ca7153..de3f9bb8 100644 --- a/src/common.ts +++ b/src/common.ts @@ -28,6 +28,7 @@ import { FrigateEvent, MediaShowInfo, Message, + RawFrigateCardConfig, SignedPath, signedPathSchema, StateParameters, @@ -463,21 +464,53 @@ export function getEntityIcon( return hass && entity ? stateIcon(hass.states[entity]) : undefined; } +/** + * Get a camera id. + * @param config The camera config (either parsed or raw). + * @returns A camera id. + */ +export function getCameraID( + config?: CameraConfig | RawFrigateCardConfig | null, +): string { + return ( + (typeof config?.id === 'string' && config.id) || + (typeof config?.camera_entity === 'string' && config.camera_entity) || + (typeof config?.webrtc_card === 'object' && config.webrtc_card && typeof config.webrtc_card['entity'] === 'string' && config.webrtc_card['entity']) || + (typeof config?.camera_name === 'string' && config.camera_name) || + '' + ); +} + /** * Get a camera text title. * @param hass The Home Assistant object. - * @param config The camera config. + * @param config The camera config (either parsed or raw). * @returns A title string. */ export function getCameraTitle( hass?: HomeAssistant, - config?: CameraConfig | null, + config?: CameraConfig | RawFrigateCardConfig | null, ): string { + // Attempt to render a recognizable name for the camera, + // starting with the most likely to be useful and working our + // ways towards the least useful. Extra type checking here since this is also + // used on raw configuration in the editor. return ( - config?.title || - (config?.camera_entity ? getEntityTitle(hass, config.camera_entity) : '') || - (config?.camera_name ? prettifyFrigateName(config.camera_name) : '') || - config?.id || + (typeof config?.title === 'string' && config.title) || + (typeof config?.id === 'string' && config.id) || + [ + typeof config?.camera_entity === 'string' + ? getEntityTitle(hass, config.camera_entity) + : '', + typeof config?.client_id === 'string' ? config.client_id : '', + typeof config?.camera_name === 'string' + ? prettifyFrigateName(config.camera_name) + : '', + typeof config?.label === 'string' ? prettifyFrigateName(config.label) : '', + typeof config?.zone === 'string' ? prettifyFrigateName(config.zone) : '', + ] + .filter(Boolean) + .join(' / ') || '' ); } diff --git a/src/const.ts b/src/const.ts index 193b9661..aa3915b3 100644 --- a/src/const.ts +++ b/src/const.ts @@ -18,6 +18,8 @@ export const CONF_CAMERAS_ARRAY_WEBRTC_CARD_ENTITY = export const CONF_CAMERAS_ARRAY_WEBRTC_CARD_URL = `${CONF_CAMERAS}.#.webrtc_card.url` as const; export const CONF_CAMERAS_ARRAY_LIVE_PROVIDER = `${CONF_CAMERAS}.#.live_provider` as const; +export const CONF_CAMERAS_ARRAY_DEPENDENT_CAMERAS = + `${CONF_CAMERAS}.#.dependent_cameras` as const; export const CONF_VIEW = 'view' as const; export const CONF_VIEW_CAMERA_SELECT = `${CONF_VIEW}.camera_select` as const; diff --git a/src/editor.ts b/src/editor.ts index 851c2f21..4e708d67 100644 --- a/src/editor.ts +++ b/src/editor.ts @@ -6,6 +6,7 @@ import { HomeAssistant, LovelaceCardEditor, fireEvent } from 'custom-card-helper import { localize } from './localize/localize.js'; import { BUTTON_SIZE_MIN, + CameraConfig, RawFrigateCardConfig, RawFrigateCardConfigArray, THUMBNAIL_WIDTH_MAX, @@ -18,6 +19,7 @@ import { CONF_CAMERAS_ARRAY_CAMERA_ENTITY, CONF_CAMERAS_ARRAY_CAMERA_NAME, CONF_CAMERAS_ARRAY_CLIENT_ID, + CONF_CAMERAS_ARRAY_DEPENDENT_CAMERAS, CONF_CAMERAS_ARRAY_ICON, CONF_CAMERAS_ARRAY_ID, CONF_CAMERAS_ARRAY_LABEL, @@ -88,7 +90,13 @@ import { CONF_VIEW_UPDATE_FORCE, CONF_VIEW_UPDATE_SECONDS, } from './const.js'; -import { arrayMove, getEntityTitle, prettifyFrigateName } from './common.js'; +import { + arrayMove, + getCameraID, + getCameraTitle, + getEntityTitle, + prettifyFrigateName, +} from './common.js'; import { copyConfig, deleteConfigValue, @@ -119,6 +127,11 @@ interface EditorOptionSetTarget { optionSetName: string; } +interface EditorSelectOption { + value: string; + label: string; +} + const options: EditorOptions = { cameras: { icon: 'video', @@ -193,7 +206,7 @@ export class FrigateCardEditor extends LitElement implements LovelaceCardEditor @property({ attribute: false }) protected _expandedCameraIndex: number | null = null; - protected _viewModes = [ + protected _viewModes: EditorSelectOption[] = [ { value: '', label: '' }, { value: 'live', label: localize('config.view.views.live') }, { value: 'clips', label: localize('config.view.views.clips') }, @@ -204,12 +217,12 @@ export class FrigateCardEditor extends LitElement implements LovelaceCardEditor { value: 'timeline', label: localize('config.view.views.timeline') }, ]; - protected _cameraSelectViewModes = [ + protected _cameraSelectViewModes: EditorSelectOption[] = [ ...this._viewModes, { value: 'current', label: localize('config.view.views.current') }, ]; - protected _menuModes = [ + protected _menuModes: EditorSelectOption[] = [ { value: '', label: '' }, { value: 'none', label: localize('config.menu.modes.none') }, { value: 'hidden-top', label: localize('config.menu.modes.hidden-top') }, @@ -228,7 +241,7 @@ export class FrigateCardEditor extends LitElement implements LovelaceCardEditor { value: 'below', label: localize('config.menu.modes.below') }, ]; - protected _eventViewerNextPreviousControlStyles = [ + protected _eventViewerNextPreviousControlStyles: EditorSelectOption[] = [ { value: '', label: '' }, { value: 'thumbnails', @@ -244,7 +257,7 @@ export class FrigateCardEditor extends LitElement implements LovelaceCardEditor }, ]; - protected _liveNextPreviousControlStyles = [ + protected _liveNextPreviousControlStyles: EditorSelectOption[] = [ { value: '', label: '' }, { value: 'chevrons', @@ -257,7 +270,7 @@ export class FrigateCardEditor extends LitElement implements LovelaceCardEditor { value: 'none', label: localize('config.live.controls.next_previous.styles.none') }, ]; - protected _aspectRatioModes = [ + protected _aspectRatioModes: EditorSelectOption[] = [ { value: '', label: '' }, { value: 'dynamic', @@ -270,7 +283,7 @@ export class FrigateCardEditor extends LitElement implements LovelaceCardEditor }, ]; - protected _thumbnailModes = [ + protected _thumbnailModes: EditorSelectOption[] = [ { value: '', label: '' }, { value: 'none', @@ -294,7 +307,7 @@ export class FrigateCardEditor extends LitElement implements LovelaceCardEditor }, ]; - protected _thumbnailMedias = [ + protected _thumbnailMedias: EditorSelectOption[] = [ { value: '', label: '' }, { value: 'clips', label: localize('config.live.controls.thumbnails.medias.clips') }, { @@ -303,7 +316,7 @@ export class FrigateCardEditor extends LitElement implements LovelaceCardEditor }, ]; - protected _titleModes = [ + protected _titleModes: EditorSelectOption[] = [ { value: '', label: '' }, { value: 'none', label: localize('config.event_viewer.controls.title.modes.none') }, { @@ -324,27 +337,27 @@ export class FrigateCardEditor extends LitElement implements LovelaceCardEditor }, ]; - protected _transitionEffects = [ + protected _transitionEffects: EditorSelectOption[] = [ { value: '', label: '' }, { value: 'none', label: localize('config.event_viewer.transition_effects.none') }, { value: 'slide', label: localize('config.event_viewer.transition_effects.slide') }, ]; - protected _imageModes = [ + protected _imageModes: EditorSelectOption[] = [ { value: '', label: '' }, { value: 'camera', label: localize('config.image.modes.camera') }, { value: 'screensaver', label: localize('config.image.modes.screensaver') }, { value: 'url', label: localize('config.image.modes.url') }, ]; - protected _timelineMediaTypes = [ + protected _timelineMediaTypes: EditorSelectOption[] = [ { value: '', label: '' }, { value: 'all', label: localize('config.timeline.medias.all') }, { value: 'clips', label: localize('config.timeline.medias.clips') }, { value: 'snapshots', label: localize('config.timeline.medias.snapshots') }, ]; - protected _darkModes = [ + protected _darkModes: EditorSelectOption[] = [ { value: '', label: '' }, { value: 'on', label: localize('config.view.dark_modes.on') }, { value: 'off', label: localize('config.view.dark_modes.off') }, @@ -456,6 +469,7 @@ export class FrigateCardEditor extends LitElement implements LovelaceCardEditor protected _renderOptionSelector( configPath: string, options: string[] | { value: string; label: string }[], + multiple?: boolean, ): TemplateResult | void { if (!this._config) { return; @@ -464,7 +478,9 @@ export class FrigateCardEditor extends LitElement implements LovelaceCardEditor return html` ${info}`; } + /** + * Get an editor title for the camera. + * @param cameraIndex The index of the camera in the cameras array. + * @param cameraConfig The raw camera configuration object. + * @returns A string title. + */ + protected _getEditorCameraTitle( + cameraIndex: number, + cameraConfig: RawFrigateCardConfig, + ): string { + return ( + getCameraTitle(this.hass, cameraConfig) || + localize('editor.camera') + ' #' + cameraIndex + ); + } + /** * Render a camera header. * @param cameraIndex The index of the camera to edit/add. @@ -540,31 +572,9 @@ export class FrigateCardEditor extends LitElement implements LovelaceCardEditor ? html` [${localize('editor.add_new_camera')}...] ` - : // Attempt to render a recognizable name for the camera, - // starting with the most likely to be useful and working our - // ways towards the least useful. - html` - ${cameraConfig?.title || - cameraConfig?.id || - [ - cameraConfig?.camera_entity - ? getEntityTitle(this.hass, String(cameraConfig.camera_entity)) - : '', - cameraConfig?.client_id, - cameraConfig?.camera_name - ? prettifyFrigateName(String(cameraConfig.camera_name)) - : '', - cameraConfig?.label - ? prettifyFrigateName(String(cameraConfig.label)) - : '', - cameraConfig?.zone - ? prettifyFrigateName(String(cameraConfig.zone)) - : '', - ] - .filter(Boolean) - .join(' / ') || - localize('editor.camera') + ' #' + cameraIndex} - `} + : html`${this._getEditorCameraTitle(cameraIndex, cameraConfig || {})}`} `; @@ -582,7 +592,7 @@ export class FrigateCardEditor extends LitElement implements LovelaceCardEditor cameraIndex: number, addNewCamera?: boolean, ): TemplateResult | void { - const liveProviders = [ + const liveProviders: EditorSelectOption[] = [ { value: '', label: '' }, { value: 'auto', label: localize('config.cameras.live_providers.auto') }, { value: 'ha', label: localize('config.cameras.live_providers.ha') }, @@ -596,6 +606,16 @@ export class FrigateCardEditor extends LitElement implements LovelaceCardEditor }, ]; + const dependentCameras: EditorSelectOption[] = []; + cameras.forEach((camera, index) => { + if (index !== cameraIndex) { + dependentCameras.push({ + value: getCameraID(camera), + label: this._getEditorCameraTitle(index, camera), + }); + } + }); + // Make a new config and update the editor with changes on it, const modifyConfig = (func: (config: RawFrigateCardConfig) => boolean): void => { if (this._config) { @@ -711,6 +731,11 @@ export class FrigateCardEditor extends LitElement implements LovelaceCardEditor ${this._renderStringInput( getArrayConfigPath(CONF_CAMERAS_ARRAY_WEBRTC_CARD_URL, cameraIndex), )} + ${this._renderOptionSelector( + getArrayConfigPath(CONF_CAMERAS_ARRAY_DEPENDENT_CAMERAS, cameraIndex), + dependentCameras, + true, + )} ` : ``} `; @@ -841,10 +866,7 @@ export class FrigateCardEditor extends LitElement implements LovelaceCardEditor CONF_VIEW_CAMERA_SELECT, this._cameraSelectViewModes, )} - ${this._renderOptionSelector( - CONF_VIEW_DARK_MODE, - this._darkModes, - )} + ${this._renderOptionSelector(CONF_VIEW_DARK_MODE, this._darkModes)} ${this._renderNumberInput(CONF_VIEW_TIMEOUT_SECONDS)} ${this._renderNumberInput(CONF_VIEW_UPDATE_SECONDS)} ${this._renderSwitch(CONF_VIEW_UPDATE_FORCE, defaults.view.update_force)} diff --git a/src/localize/languages/en.json b/src/localize/languages/en.json index 6d3dfa2f..662f6694 100644 --- a/src/localize/languages/en.json +++ b/src/localize/languages/en.json @@ -14,6 +14,7 @@ "camera_entity": "Camera Entity", "camera_name": "Frigate camera name (Autodetected from entity)", "client_id": "Frigate client id (For >1 Frigate server)", + "dependent_cameras": "Dependent cameras to also show events for", "id": "Unique id for this camera in this card", "label": "Frigate label/object filter", "frigate_url": "Frigate server URL", diff --git a/src/scss/editor.scss b/src/scss/editor.scss index 7dfc161e..8384b604 100644 --- a/src/scss/editor.scss +++ b/src/scss/editor.scss @@ -28,10 +28,6 @@ display: grid; margin-bottom: 10px; } -ha-formfield { - padding-bottom: 8px; -} - div.upgrade { width: auto; border: 1px dotted var(--primary-color); @@ -78,4 +74,9 @@ div.upgrade span { } span.info { padding: 4px; -} \ No newline at end of file +} + +ha-selector { + padding: 10px; + border: 1px solid var(--divider-color); +} From 36aed76878b8d8d7e0e7c9fd5efa25c1c70a1e8f Mon Sep 17 00:00:00 2001 From: Dermot Duffy Date: Sat, 23 Apr 2022 15:22:33 -0700 Subject: [PATCH 5/9] Improve camera names. --- src/browse-media-util.ts | 4 ++-- src/common.ts | 16 +++------------- 2 files changed, 5 insertions(+), 15 deletions(-) diff --git a/src/browse-media-util.ts b/src/browse-media-util.ts index 0e7b6570..ec182437 100644 --- a/src/browse-media-util.ts +++ b/src/browse-media-util.ts @@ -176,14 +176,14 @@ export class BrowseMediaUtil { ): number => { if ( !a.frigate?.event || - (b.frigate?.event && b.frigate.event.start_time < a.frigate.event.start_time) + (b.frigate?.event && b.frigate.event.start_time > a.frigate.event.start_time) ) { return 1; } if ( !b.frigate?.event || - (a.frigate?.event && b.frigate.event.start_time > a.frigate.event.start_time) + (a.frigate?.event && b.frigate.event.start_time < a.frigate.event.start_time) ) { return -1; } diff --git a/src/common.ts b/src/common.ts index de3f9bb8..3f07ba94 100644 --- a/src/common.ts +++ b/src/common.ts @@ -497,20 +497,10 @@ export function getCameraTitle( // used on raw configuration in the editor. return ( (typeof config?.title === 'string' && config.title) || + (typeof config?.camera_entity === 'string' ? getEntityTitle(hass, config.camera_entity) : '') || + (typeof config?.webrtc_card === 'object' && config.webrtc_card && typeof config.webrtc_card['entity'] === 'string' && config.webrtc_card['entity']) || + (typeof config?.camera_name === 'string' ? prettifyFrigateName(config.camera_name) : '') || (typeof config?.id === 'string' && config.id) || - [ - typeof config?.camera_entity === 'string' - ? getEntityTitle(hass, config.camera_entity) - : '', - typeof config?.client_id === 'string' ? config.client_id : '', - typeof config?.camera_name === 'string' - ? prettifyFrigateName(config.camera_name) - : '', - typeof config?.label === 'string' ? prettifyFrigateName(config.label) : '', - typeof config?.zone === 'string' ? prettifyFrigateName(config.zone) : '', - ] - .filter(Boolean) - .join(' / ') || '' ); } From 7bf824d4e9d6c610136d352869607e3146cf517f Mon Sep 17 00:00:00 2001 From: Dermot Duffy Date: Sat, 23 Apr 2022 15:31:02 -0700 Subject: [PATCH 6/9] Add dependent camera example to README. --- README.md | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/README.md b/README.md index 69eb9482..0e56afd9 100644 --- a/README.md +++ b/README.md @@ -1288,6 +1288,27 @@ card_mod: ``` +### Using a dependent camera + +`dependent_cameras` allows events for other cameras to be shown along with the currently selected camera. For example, this can be used to show events with the `birdseye` camera (since it will not have events of its own). + +
+ Expand: Using dependent cameras with birdseye + +This example shows events for two other cameras when `birdseye` is selected. + +```yaml +[...] +cameras: + - camera_entity: camera.kitchen + - camera_entity: camera.sitting_room + - camera_name: birdseye + dependent_cameras: + - camera.kitchen + - camera.sitting_room +``` +
+ ## Card Refreshes From 7273e699a68bad206136696c4602b61578d5953c Mon Sep 17 00:00:00 2001 From: Dermot Duffy Date: Sat, 23 Apr 2022 16:34:39 -0700 Subject: [PATCH 7/9] Must specify media type in viewer target search. --- src/card.ts | 6 +++--- src/components/timeline.ts | 5 +++-- src/components/viewer.ts | 12 ++++++++++-- src/const.ts | 2 ++ 4 files changed, 18 insertions(+), 7 deletions(-) diff --git a/src/card.ts b/src/card.ts index 375eb7b9..1b57c24d 100644 --- a/src/card.ts +++ b/src/card.ts @@ -35,7 +35,7 @@ import type { Message, } from './types.js'; -import { CARD_VERSION, REPO_URL } from './const.js'; +import { CAMERA_BIRDSEYE, CARD_VERSION, REPO_URL } from './const.js'; import { FrigateCardElements } from './components/elements.js'; import { FrigateCardImage } from './components/image.js'; import { FRIGATE_BUTTON_MENU_ICON, FrigateCardMenu } from './components/menu.js'; @@ -333,7 +333,7 @@ export class FrigateCard extends LitElement { if ( this._getConfig().menu.buttons.clips && cameraConfig?.camera_name && - (cameraConfig?.camera_name !== 'birdseye' || + (cameraConfig?.camera_name !== CAMERA_BIRDSEYE || cameraConfig?.dependent_cameras?.length) ) { buttons.push({ @@ -351,7 +351,7 @@ export class FrigateCard extends LitElement { if ( this._getConfig().menu.buttons.snapshots && cameraConfig?.camera_name && - (cameraConfig?.camera_name !== 'birdseye' || + (cameraConfig?.camera_name !== CAMERA_BIRDSEYE || cameraConfig?.dependent_cameras?.length) ) { buttons.push({ diff --git a/src/components/timeline.ts b/src/components/timeline.ts index a473b3c3..3d8ad9ff 100644 --- a/src/components/timeline.ts +++ b/src/components/timeline.ts @@ -32,6 +32,7 @@ import { FrigateEvent, frigateCardConfigDefaults, } from '../types'; +import { CAMERA_BIRDSEYE } from '../const'; import { View, ViewContext } from '../view'; import { dispatchErrorMessageEvent, @@ -283,7 +284,7 @@ class TimelineEventManager { if ( this._dateEnd && this._dateStart && - cameraConfig.camera_name !== 'birdseye' + cameraConfig.camera_name !== CAMERA_BIRDSEYE ) { const param = BrowseMediaUtil.getBrowseMediaQueryParameters( hass, @@ -592,7 +593,7 @@ export class FrigateCardTimelineCore extends LitElement { protected _getGroups(): DataGroupCollectionType { const groups: FrigateCardGroupData[] = []; this.cameras?.forEach((cameraConfig, camera) => { - if (cameraConfig.camera_name !== 'birdseye') { + if (cameraConfig.camera_name !== CAMERA_BIRDSEYE) { groups.push({ id: camera, content: getCameraTitle(this.hass, cameraConfig), diff --git a/src/components/viewer.ts b/src/components/viewer.ts index 237e7560..e5862b97 100644 --- a/src/components/viewer.ts +++ b/src/components/viewer.ts @@ -77,7 +77,12 @@ export class FrigateCardViewer extends LitElement { ); if (!this.view.target) { - if (!browseMediaQueryParameters) { + // If the target is not specified, the view must tell us which mediaType + // to search for. When the target *is* specified, the view is not required + // to indicate the media type (e.g. the mixed 'events' view from the + // timeline). + const mediaType = this.view.getMediaType(); + if (!browseMediaQueryParameters || !mediaType) { return; } @@ -85,7 +90,10 @@ export class FrigateCardViewer extends LitElement { this, this.hass, this.view, - browseMediaQueryParameters, + BrowseMediaUtil.overrideMultiBrowseMediaQueryParameters( + browseMediaQueryParameters, + { mediaType: mediaType }, + ), ); return renderProgressIndicator(); } diff --git a/src/const.ts b/src/const.ts index aa3915b3..6e18ebfe 100644 --- a/src/const.ts +++ b/src/const.ts @@ -1,4 +1,6 @@ export const CARD_VERSION = '3.0.0' as const; + +export const CAMERA_BIRDSEYE = 'birdseye' as const; export const REPO_URL = 'https://github.com/dermotduffy/frigate-hass-card' as const; export const TROUBLESHOOTING_URL = `${REPO_URL}#troubleshooting` as const; From 0125d329518c25b7335c74f03493e28249fd6272 Mon Sep 17 00:00:00 2001 From: Dermot Duffy Date: Sat, 23 Apr 2022 16:43:59 -0700 Subject: [PATCH 8/9] Address build warning fixes. --- rollup.config.js | 6 ++++++ src/editor.ts | 3 --- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/rollup.config.js b/rollup.config.js index bfaa4b1e..5da89167 100644 --- a/rollup.config.js +++ b/rollup.config.js @@ -54,5 +54,11 @@ export default [ format: 'es', }, plugins: [...plugins], + // These two files use this at the toplevel, which causes rollup warning + // spam on build: `this` has been rewritten to `undefined` + moduleContext: { + './node_modules/@formatjs/intl-utils/lib/src/diff.js': 'window', + './node_modules/@formatjs/intl-utils/lib/src/resolve-locale.js': 'window', + }, }, ]; diff --git a/src/editor.ts b/src/editor.ts index 4e708d67..9e66ae70 100644 --- a/src/editor.ts +++ b/src/editor.ts @@ -6,7 +6,6 @@ import { HomeAssistant, LovelaceCardEditor, fireEvent } from 'custom-card-helper import { localize } from './localize/localize.js'; import { BUTTON_SIZE_MIN, - CameraConfig, RawFrigateCardConfig, RawFrigateCardConfigArray, THUMBNAIL_WIDTH_MAX, @@ -94,8 +93,6 @@ import { arrayMove, getCameraID, getCameraTitle, - getEntityTitle, - prettifyFrigateName, } from './common.js'; import { copyConfig, From c418badc7c8eabe97113c3ec4de0f3787c3ea568 Mon Sep 17 00:00:00 2001 From: Dermot Duffy Date: Sat, 23 Apr 2022 16:55:36 -0700 Subject: [PATCH 9/9] Formatting fixes. --- src/common.ts | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/src/common.ts b/src/common.ts index 3f07ba94..f3308871 100644 --- a/src/common.ts +++ b/src/common.ts @@ -475,7 +475,10 @@ export function getCameraID( return ( (typeof config?.id === 'string' && config.id) || (typeof config?.camera_entity === 'string' && config.camera_entity) || - (typeof config?.webrtc_card === 'object' && config.webrtc_card && typeof config.webrtc_card['entity'] === 'string' && config.webrtc_card['entity']) || + (typeof config?.webrtc_card === 'object' && + config.webrtc_card && + typeof config.webrtc_card['entity'] === 'string' && + config.webrtc_card['entity']) || (typeof config?.camera_name === 'string' && config.camera_name) || '' ); @@ -497,9 +500,16 @@ export function getCameraTitle( // used on raw configuration in the editor. return ( (typeof config?.title === 'string' && config.title) || - (typeof config?.camera_entity === 'string' ? getEntityTitle(hass, config.camera_entity) : '') || - (typeof config?.webrtc_card === 'object' && config.webrtc_card && typeof config.webrtc_card['entity'] === 'string' && config.webrtc_card['entity']) || - (typeof config?.camera_name === 'string' ? prettifyFrigateName(config.camera_name) : '') || + (typeof config?.camera_entity === 'string' + ? getEntityTitle(hass, config.camera_entity) + : '') || + (typeof config?.webrtc_card === 'object' && + config.webrtc_card && + typeof config.webrtc_card['entity'] === 'string' && + config.webrtc_card['entity']) || + (typeof config?.camera_name === 'string' + ? prettifyFrigateName(config.camera_name) + : '') || (typeof config?.id === 'string' && config.id) || '' );