From 7d4f32ce73e25cfb3ae0aedd57c00bbcfc0459f6 Mon Sep 17 00:00:00 2001 From: Dermot Duffy Date: Fri, 7 Jan 2022 22:28:38 -0800 Subject: [PATCH] Support overriding live configuration per camera. --- package.json | 2 + src/card-condition.ts | 87 +++++++++++-- src/card.ts | 21 +++- src/components/live.ts | 91 +++++++++++--- src/components/next-prev-control.ts | 5 +- src/config-mgmt.ts | 2 +- src/const.ts | 118 ++++++++++-------- src/types.ts | 183 ++++++++++++++++++---------- 8 files changed, 358 insertions(+), 151 deletions(-) diff --git a/package.json b/package.json index 24b47921..c4d8646d 100644 --- a/package.json +++ b/package.json @@ -27,6 +27,7 @@ "embla-carousel": "^5.0.1", "home-assistant-js-websocket": "^5.11.1", "lit": "^2.0.2", + "lodash-es": "^4.17.21", "quick-lru": "github:sindresorhus/quick-lru", "screenfull": "^5.1.0", "zod": "^3.11.6" @@ -37,6 +38,7 @@ "@babel/plugin-proposal-decorators": "^7.15.8", "@rollup/plugin-image": "^2.1.1", "@rollup/plugin-json": "^4.1.0", + "@types/lodash-es": "^4.17.5", "@typescript-eslint/eslint-plugin": "^4.33.0", "@typescript-eslint/parser": "^4.33.0", "eslint": "^7.32.0", diff --git a/src/card-condition.ts b/src/card-condition.ts index d72e6a54..fbe69b27 100644 --- a/src/card-condition.ts +++ b/src/card-condition.ts @@ -1,10 +1,16 @@ -import type { FrigateCardCondition } from './types'; +import type { + CameraConfig, + FrigateCardCondition, + RawFrigateCardConfig, +} from './types'; +import { merge, cloneDeep } from 'lodash-es'; + import { View } from './view'; export interface ConditionState { view?: Readonly; fullscreen?: boolean; - camera?: string; + camera?: CameraConfig; } class ConditionStateRequestEvent extends Event { @@ -12,18 +18,49 @@ class ConditionStateRequestEvent extends Event { } export function evaluateCondition( - condition?: FrigateCardCondition, - state?: ConditionState, + condition?: Readonly, + state?: Readonly, ): boolean { + if (!state) { + return false; + } + let result = true; - if (condition?.view?.length && state?.view) { - result &&= condition?.view.includes(state?.view.view); + if (condition?.view?.length && state.view) { + result &&= condition?.view.includes(state.view.view); } - if (condition?.fullscreen !== undefined && state?.fullscreen !== undefined) { - result &&= condition?.fullscreen == state?.fullscreen; + if (condition?.fullscreen !== undefined && state.fullscreen !== undefined) { + result &&= condition?.fullscreen == state.fullscreen; } - if (condition?.camera?.length && state?.camera) { - result &&= condition?.camera.includes(state?.camera); + + const evaluateNested = ( + input: Readonly, + condition: Readonly, + ): boolean => { + let result = true; + + for (const key of Object.keys(condition)) { + if (typeof condition[key] === 'string') { + // If the test is a literal, it must exactly match. + result &&= input[key] === condition[key]; + } else if (Array.isArray(condition[key])) { + // If the test is an array, it's a list of acceptable values. + result &&= (condition[key] as unknown[]).includes(input[key]); + } else if (typeof condition[key] === 'object' && typeof input[key] === 'object') { + // If the test is an object, recursively navigate downwards. + result &&= evaluateNested( + input[key] as RawFrigateCardConfig, + condition[key] as RawFrigateCardConfig, + ); + } else if (input[key] === undefined) { + return false; + } + } + return result; + }; + + if (condition?.camera) { + result &&= state.camera ? evaluateNested(state.camera, condition.camera) : false; } return result; } @@ -70,3 +107,33 @@ export function conditionStateRequestHandler( ): void { ev.conditionState = conditionState; } + +type Overrides = { + conditions: FrigateCardCondition; + overrides: RawFrigateCardConfig; +}[]; + +export function getOverriddenConfig( + config: Readonly, + conditionState?: Readonly, + overrides?: Readonly, +): RawFrigateCardConfig { + const overridesSource = + overrides || (config['overrides'] as Readonly | undefined); + if (!overridesSource) { + return config; + } + + const output = cloneDeep(config); + let overridden = false; + + for (const override of overridesSource) { + if (evaluateCondition(override.conditions, conditionState)) { + merge(output, override.overrides); + overridden = true; + } + } + // Attempt to return the same configuration object if it has not been + // overridden (to reduce re-renders for a configuration that has not changed). + return overridden ? output : config; +} \ No newline at end of file diff --git a/src/card.ts b/src/card.ts index 1828e098..b5cada03 100644 --- a/src/card.ts +++ b/src/card.ts @@ -22,13 +22,14 @@ import screenfull from 'screenfull'; import { z } from 'zod'; import { + Actions, ActionType, + CameraConfig, GetFrigateCardMenuButtonParameters, + LiveConfig, RawFrigateCardConfig, entitySchema, frigateCardConfigSchema, - Actions, - CameraConfig, } from './types.js'; import type { Entity, @@ -74,7 +75,11 @@ import { ResolvedMediaCache } from './resolved-media.js'; import { BrowseMediaUtil } from './browse-media-util.js'; import { isConfigUpgradeable } from './config-mgmt.js'; import { actionHandler } from './action-handler-directive.js'; -import { ConditionState, conditionStateRequestHandler } from './card-condition.js'; +import { + ConditionState, + conditionStateRequestHandler, + getOverriddenConfig, +} from './card-condition.js'; /** A note on media callbacks: * @@ -219,7 +224,8 @@ export class FrigateCard extends LitElement { this._conditionState = { view: this._view, fullscreen: screenfull.isEnabled && screenfull.isFullscreen, - camera: this._view?.camera, + camera: + this._cameras && this._view ? this._cameras.get(this._view.camera) : undefined, }; } @@ -1035,7 +1041,11 @@ export class FrigateCard extends LitElement { let specificActions: Actions | undefined = undefined; if (this._view?.is('live')) { - specificActions = this.config.live.actions; + const config = getOverriddenConfig( + this.config.live, + this._conditionState, + ) as LiveConfig; + specificActions = config.actions; } else if (this._view?.isGalleryView()) { specificActions = this.config.event_gallery?.actions; } else if (this._view?.isViewerView()) { @@ -1198,6 +1208,7 @@ export class FrigateCard extends LitElement { .hass=${this._hass} .view=${this._view} .liveConfig=${this.config.live} + .conditionState=${this._conditionState} .cameras=${this._cameras} .preload=${this.config.live.preload && !this._view.is('live')} class="${classMap(liveClasses)}" diff --git a/src/components/live.ts b/src/components/live.ts index 0c96d6bd..f3eb3378 100644 --- a/src/components/live.ts +++ b/src/components/live.ts @@ -1,5 +1,9 @@ -// TODO update_entities should reload view rather than be involved in rendering // TODO different live configs per camera +// TODO replace dset/dlv with lodash +// TODO evaluate single override section instead of per-subcomponent +// TODO fix adaptive height in fullscreen +// TODO Remove id as a concept for cameras and use an array instead? +// TODO Convert menu condition to use overrides // TODO Remove media load event console message // TODO Remove view change console message // TODO readme @@ -26,10 +30,12 @@ import { import { EmblaOptionsType } from 'embla-carousel'; import { HomeAssistant } from 'custom-card-helpers'; import { customElement, property, state } from 'lit/decorators.js'; +import { isEqual } from 'lodash-es'; import { ref } from 'lit/directives/ref'; import { until } from 'lit/directives/until.js'; import { BrowseMediaUtil } from '../browse-media-util.js'; +import { ConditionState, getOverriddenConfig } from '../card-condition.js'; import { FrigateCardMediaCarousel } from './media-carousel.js'; import { FrigateCardNextPreviousControl } from './next-prev-control.js'; import { ThumbnailCarouselTap } from './thumbnail-carousel.js'; @@ -75,6 +81,9 @@ export class FrigateCardLive extends LitElement { @property({ attribute: false }) protected liveConfig?: LiveConfig; + @property({ attribute: false }) + protected conditionState?: ConditionState; + set preload(preload: boolean) { this._preload = preload; @@ -108,17 +117,17 @@ export class FrigateCardLive extends LitElement { * Render thumbnails carousel. * @returns A rendered template or void. */ - protected renderThumbnails(): TemplateResult | void { + protected renderThumbnails(config: LiveConfig): TemplateResult | void { if (!this.liveConfig || !this.view) { return; } const fetchThumbnailsThenRender = async (): Promise => { - if (!this.hass || !this.cameras || !this.view || !this.liveConfig) { + if (!this.hass || !this.cameras || !this.view) { return; } const browseMediaParams = BrowseMediaUtil.getBrowseMediaQueryParameters( - this.liveConfig.controls.thumbnails.media, + config.controls.thumbnails.media, this.cameras.get(this.view.camera), ); if (!browseMediaParams) { @@ -135,7 +144,7 @@ export class FrigateCardLive extends LitElement { return html` ) => { const mediaType = browseMediaParams.mediaType; @@ -165,16 +174,23 @@ export class FrigateCardLive extends LitElement { return; } + const config = getOverriddenConfig( + this.liveConfig, + this.conditionState, + ) as LiveConfig; + + // Note use of liveConfig and not config below -- the carousel will + // independently override the liveconfig to reflect the camera in the + // carousel (not necessarily the selected camera). return html` - ${this.liveConfig.controls.thumbnails.mode === 'above' - ? this.renderThumbnails() - : ''} + ${config.controls.thumbnails.mode === 'above' ? this.renderThumbnails(config) : ''} { // Re-rendering the component will cause the thumbnails to be @@ -183,9 +199,7 @@ export class FrigateCardLive extends LitElement { }} > - ${this.liveConfig.controls.thumbnails.mode === 'below' - ? this.renderThumbnails() - : ''} + ${config.controls.thumbnails.mode === 'below' ? this.renderThumbnails(config) : ''} `; } @@ -214,6 +228,9 @@ export class FrigateCardLiveCarousel extends FrigateCardMediaCarousel { @property({ attribute: false }) protected preload?: boolean; + @property({ attribute: false }) + protected conditionState?: ConditionState; + // Index between camera name and slide number. protected _cameraToSlide: Record = {}; @@ -332,13 +349,29 @@ export class FrigateCardLiveCarousel extends FrigateCardMediaCarousel { } } - protected _renderLive(cameraConfig: CameraConfig, slideIndex: number): TemplateResult { + protected _renderLive( + cameraConfig: CameraConfig, + slideIndex: number, + ): TemplateResult | void { + if (!this.liveConfig) { + return; + } + // The conditionState object contains the currently live camera, which (in + // the carousel for example) is not necessarily the live camera this + // is rendering right now. + const conditionState = Object.assign({ + ...this.conditionState, + camera: cameraConfig, + }); + + const config = getOverriddenConfig(this.liveConfig, conditionState) as LiveConfig; + return html`
) => this._mediaShowEventHandler(slideIndex, e)} @@ -400,17 +433,22 @@ export class FrigateCardLiveCarousel extends FrigateCardMediaCarousel { protected render(): TemplateResult | void { const [slides, cameraToSlide] = this._getSlides(); this._cameraToSlide = cameraToSlide; - if (!slides) { + if (!slides || !this.liveConfig) { return; } + const config = getOverriddenConfig( + this.liveConfig, + this.conditionState, + ) as LiveConfig; + const [prev, next] = this._getCameraNeighbors(); return html`
; + /** * Custom Element Types. */ @@ -314,7 +349,9 @@ export type MenuSubmenu = z.infer; const frigateCardConditionSchema = z.object({ view: z.string().array().optional(), fullscreen: z.boolean().optional(), - camera: z.string().array().optional(), + + // Allow matching any field of cameraConfig. + camera: z.record(z.any()), }); export type FrigateCardCondition = z.infer; @@ -345,39 +382,16 @@ const pictureElementsSchema = pictureElementSchema.array().optional(); export type PictureElements = z.infer; /** - * Camera configuration section + * Configuration overrides */ -export const cameraConfigDefault = { - client_id: 'frigate' as const, -}; -const webrtcCameraConfigSchema = z.object({ - entity: z.string().optional(), - url: z.string().optional(), -}); -const cameraConfigDefaultSchema = z - .object({ - // No URL validation to allow relative URLs within HA (e.g. Frigate addon). - frigate_url: z.string().optional(), - 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(), - - // Optional identifier to separate different camera configurations used in - // this card. - id: z.string().optional(), - - // Camera identifiers for WebRTC. - webrtc: webrtcCameraConfigSchema.optional(), - }) - .default(cameraConfigDefault); -export type CameraConfig = z.infer; + const overridesSchema = z + .object({ + conditions: frigateCardConditionSchema, + overrides: z.record(z.unknown()), + }) + .array() + .optional(); +export type Overrides = z.infer; /** * View configuration section. @@ -424,12 +438,8 @@ export type ImageViewConfig = z.infer; * Thumbnail controls configuration section. */ -const thumbnailsControlDefault = { - mode: 'none' as const, -}; - const thumbnailsControlSchema = z.object({ - mode: z.enum(['none', 'above', 'below']).default(thumbnailsControlDefault.mode), + mode: z.enum(['none', 'above', 'below']), size: z.string().optional(), }); export type ThumbnailsControlConfig = z.infer; @@ -459,6 +469,8 @@ const liveConfigDefault = { }, thumbnails: { media: 'clips' as const, + size: '100px', + mode: 'none' as const, }, }, }; @@ -489,44 +501,77 @@ const jsmpegConfigSchema = z .optional(); export type JSMPEGConfig = z.infer; -const liveNextPreviousControlConfigSchema = nextPreviousControlConfigSchema.merge( - z.object({ - style: z - .enum(['none', 'chevrons', 'icons']) - .default(liveConfigDefault.controls.next_previous.style), - size: z.string().default(liveConfigDefault.controls.next_previous.size), - }), -); -export type LiveNextPreviousControlConfig = z.infer< - typeof liveNextPreviousControlConfigSchema ->; +const liveNextPreviousControlConfigSchema = nextPreviousControlConfigSchema.extend({ + // Live cannot show thumbnails, remove that option. + style: z.enum(['none', 'chevrons', 'icons']), +}); -const liveConfigSchema = z +const liveOverridableConfigSchema = z .object({ - provider: z.enum(LIVE_PROVIDERS).default(liveConfigDefault.provider), - preload: z.boolean().default(liveConfigDefault.preload), + provider: z.enum(LIVE_PROVIDERS).optional(), webrtc: webrtcConfigSchema, jsmpeg: jsmpegConfigSchema, - lazy_load: z.boolean().default(liveConfigDefault.lazy_load), - draggable: z.boolean().default(liveConfigDefault.draggable), controls: z .object({ - next_previous: liveNextPreviousControlConfigSchema.default( - liveConfigDefault.controls.next_previous, - ), + next_previous: liveNextPreviousControlConfigSchema.optional(), thumbnails: thumbnailsControlSchema .merge( z.object({ - media: z - .enum(['clips', 'snapshots']) - .default(liveConfigDefault.controls.thumbnails.media), + media: z.enum(['clips', 'snapshots']), }), ) + .optional(), + }) + .optional(), + }) + .merge(actionsSchema); + +const liveConfigSchema = liveOverridableConfigSchema + .extend({ + // Replace attributes from the overridable schema with those with defaults. + provider: liveOverridableConfigSchema.shape.provider.default( + liveConfigDefault.provider, + ), + controls: z + .object({ + next_previous: liveNextPreviousControlConfigSchema + .extend({ + size: liveNextPreviousControlConfigSchema.shape.size.default( + liveConfigDefault.controls.next_previous.size, + ), + style: liveNextPreviousControlConfigSchema.shape.style.default( + liveConfigDefault.controls.next_previous.style, + ), + }) + .default(liveConfigDefault.controls.next_previous), + thumbnails: thumbnailsControlSchema + .extend({ + mode: thumbnailsControlSchema.shape.mode.default( + liveConfigDefault.controls.thumbnails.mode, + ), + size: thumbnailsControlSchema.shape.size.default( + liveConfigDefault.controls.thumbnails.size, + ), + media: z + .enum(['clips', 'snapshots']) + .default(liveConfigDefault.controls.thumbnails.media), + }) .default(liveConfigDefault.controls.thumbnails), }) .default(liveConfigDefault.controls), + + // Non-overrideable parameters. + preload: z.boolean().default(liveConfigDefault.preload), + lazy_load: z.boolean().default(liveConfigDefault.lazy_load), + draggable: z.boolean().default(liveConfigDefault.draggable), + overrides: z + .object({ + conditions: frigateCardConditionSchema, + overrides: liveOverridableConfigSchema, + }) + .array() + .optional(), }) - .merge(actionsSchema) .default(liveConfigDefault); export type LiveConfig = z.infer; @@ -583,6 +628,7 @@ const viewerConfigDefault = { style: 'thumbnails' as const, }, thumbnails: { + size: '100px', mode: 'none' as const, }, }, @@ -609,9 +655,16 @@ const viewerConfigSchema = z next_previous: viewerNextPreviousControlConfigSchema.default( viewerConfigDefault.controls.next_previous, ), - thumbnails: thumbnailsControlSchema.default( - viewerConfigDefault.controls.thumbnails, - ), + thumbnails: thumbnailsControlSchema + .extend({ + mode: thumbnailsControlSchema.shape.mode.default( + viewerConfigDefault.controls.thumbnails.mode, + ), + size: thumbnailsControlSchema.shape.size.default( + viewerConfigDefault.controls.thumbnails.size, + ), + }) + .default(viewerConfigDefault.controls.thumbnails), }) .default(viewerConfigDefault.controls), }) @@ -656,7 +709,7 @@ const dimensionsConfigSchema = z */ export const frigateCardConfigSchema = z.object({ // Main configuration sections. - cameras: cameraConfigDefaultSchema.array().nonempty(), + cameras: cameraConfigSchema.array().nonempty(), view: viewConfigSchema, menu: menuConfigSchema, live: liveConfigSchema,