diff --git a/package.json b/package.json index 34dc814b..00f06483 100644 --- a/package.json +++ b/package.json @@ -19,6 +19,8 @@ "@material/image-list": "^12.0.0", "custom-card-helpers": "^1.8.0", "dayjs": "^1.10.7", + "dlv": "github:developit/dlv", + "dset": "^3.1.1", "embla-carousel": "^5.0.1", "home-assistant-js-websocket": "^5.11.1", "lit": "^2.0.2", diff --git a/src/card.ts b/src/card.ts index c15199cc..46ff25a2 100644 --- a/src/card.ts +++ b/src/card.ts @@ -20,7 +20,7 @@ import { import screenfull from 'screenfull'; import { z } from 'zod'; -import { entitySchema, frigateCardConfigSchema, MenuInteraction } from './types.js'; +import { entitySchema, frigateCardConfigSchema, MenuInteraction, RawFrigateCardConfig } from './types.js'; import type { BrowseMediaQueryParameters, Entity, @@ -58,6 +58,7 @@ import './patches/ha-hls-player.js'; import cardStyle from './scss/card.scss'; import { ResolvedMediaCache } from './resolved-media.js'; import { BrowseMediaUtil } from './browse-media-util.js'; +import { isConfigUpgradeable } from './config-mgmt.js'; /** A note on media callbacks: * @@ -401,19 +402,25 @@ export class FrigateCard extends LitElement { * Set the card configuration. * @param inputConfig The card configuration. */ - public setConfig(inputConfig: FrigateCardConfig): void { + public setConfig(inputConfig: RawFrigateCardConfig): void { if (!inputConfig) { - throw new Error(localize('error.invalid_configuration:')); + throw new Error(localize('error.invalid_configuration')); } + const configUpgradeable = isConfigUpgradeable(inputConfig); const parseResult = frigateCardConfigSchema.safeParse(inputConfig); if (!parseResult.success) { const hint = this._getParseErrorPaths(parseResult.error); + let upgradeMessage = ''; + if (configUpgradeable && getLovelace().mode !== 'yaml') { + upgradeMessage = `${localize('editor.upgrade_available_in_editor')}. `; + } throw new Error( + upgradeMessage + `${localize('error.invalid_configuration')}: ` + (hint.length ? JSON.stringify(hint, null, ' ') - : localize('error.invalid_configuration_no_hint')), + : localize('error.invalid_configuration_no_hint')) ); } const config = parseResult.data; diff --git a/src/config-mgmt.ts b/src/config-mgmt.ts new file mode 100644 index 00000000..88055dfc --- /dev/null +++ b/src/config-mgmt.ts @@ -0,0 +1,152 @@ +import delve from 'dlv'; +import { dset } from 'dset'; +import { RawFrigateCardConfig } from './types'; + +/** + * Set a configuration value. + * @param obj The configuration. + * @param key The key to the property to set. + * @param value The value to set. + */ +export const setConfigValue = ( + obj: RawFrigateCardConfig, + key: string, + value: unknown, +): void => { + dset(obj, key, value); +}; + +/** + * Get a configuration value. + * @param obj The configuration. + * @param key The key to the property to retrieve. + * @returns The property or undefined if not found. + */ +export const getConfigValue = (obj: RawFrigateCardConfig, key: string, def?: unknown): unknown => { + return delve(obj, key, def); +}; + +/** + * Delete a configuration value. + * @param obj The configuration. + * @param key The key to the property to delete. + */ +export const deleteConfigValue = (obj: RawFrigateCardConfig, key: string): void => { + let id = key; + let targetObj: unknown = obj; + if (key && key.split && key.includes('.')) { + const keys = key.split('.'); + id = keys[keys.length - 1]; + targetObj = getConfigValue(obj, keys.slice(0, -1).join('.')); + } + if (targetObj && typeof targetObj === 'object') { + delete targetObj[id]; + } +}; + +/** + * Upgrade a configuration. + * @param obj The configuration to upgrade. + * @returns `true` if the configuration is modified. + */ +export const upgradeConfig = function (obj: RawFrigateCardConfig): boolean { + let upgraded = false; + for (let i = 0; i < UPGRADES.length; i++) { + upgraded = UPGRADES[i](obj) || upgraded; + } + trimConfig(obj); + return upgraded; +}; + +/** + * Determine if a configuration is automatically upgradeable. + * @param obj The configuration. It is not modified. + * @returns `true` if the configuration is upgradeable. + */ +export const isConfigUpgradeable = function (obj: RawFrigateCardConfig): boolean { + const newObj = JSON.parse(JSON.stringify(obj)); + return upgradeConfig(newObj); +}; + +/** + * Remove empty sections from a configuration. + * @param obj Configuration object. + */ +export const trimConfig = function (obj: RawFrigateCardConfig): void { + const keys = Object.keys(obj); + for (let i = 0; i < keys.length; i++) { + const key = keys[i]; + if (typeof obj[key] === 'object' && obj[key] != null) { + trimConfig(obj[key] as RawFrigateCardConfig); + + if (!Object.keys(obj[key] as RawFrigateCardConfig).length) { + delete obj[key]; + } + } + } +}; + +/** + * Copy a configuration. + * @param obj Configuration to copy. + * @returns A new deeply-copied configuration. + */ +export const copyConfig = function (obj: RawFrigateCardConfig): RawFrigateCardConfig { + return JSON.parse(JSON.stringify(obj)); +} + +/** + * Determines if a property is not an object. + * @param value The property. + * @returns `true` is the value is not an object. + */ +const isNotObject = function (value: unknown) { + return typeof value !== 'object' ? value : undefined; +}; + +/** + * Move a property from one location to another. + * @param oldPath The old property path. + * @param newPath The new property path. + * @param transform An optional transform for the value. + * @returns `true` if the configuration was modified. + */ +const upgradeMoveTo = function ( + oldPath: string, + newPath: string, + transform?: (valueIn: unknown) => unknown, +): (obj: RawFrigateCardConfig) => boolean { + return function (obj: RawFrigateCardConfig): boolean { + let value = getConfigValue(obj, oldPath); + if (transform) { + value = transform(value); + } + if (typeof value !== 'undefined') { + deleteConfigValue(obj, oldPath); + setConfigValue(obj, newPath, value); + return true; + } + return false; + }; +}; + +const UPGRADES = [ + // v1.2.1 -> v2.0.0 + upgradeMoveTo('frigate_url', 'frigate.url'), + upgradeMoveTo('frigate_client_id', 'frigate.client_id'), + upgradeMoveTo('frigate_camera_name', 'frigate.camera_name'), + upgradeMoveTo('label', 'frigate.label'), + upgradeMoveTo('zone', 'frigate.zone'), + upgradeMoveTo('view_default', 'view.default'), + upgradeMoveTo('view_timeout', 'view.timeout'), + upgradeMoveTo('live_provider', 'live.provider'), + upgradeMoveTo('live_preload', 'live.preload'), + upgradeMoveTo('webrtc', 'live.webrtc'), + upgradeMoveTo('autoplay_clip', 'event_viewer.autoplay_clip'), + upgradeMoveTo('controls.nextprev', 'event_viewer.controls.next_previous.style'), + upgradeMoveTo('controls.nextprev_size', 'event_viewer.controls.next_previous.size'), + upgradeMoveTo('menu_mode', 'menu.mode'), + upgradeMoveTo('menu_buttons', 'menu.buttons'), + upgradeMoveTo('menu_button_size', 'menu.button_size'), + upgradeMoveTo('image', 'image.src', isNotObject), +]; diff --git a/src/editor.ts b/src/editor.ts index f5235978..50c720a4 100644 --- a/src/editor.ts +++ b/src/editor.ts @@ -4,15 +4,40 @@ import { customElement, property, state } from 'lit/decorators.js'; import { HomeAssistant, LovelaceCardEditor, fireEvent } from 'custom-card-helpers'; import { localize } from './localize/localize.js'; -import { - FrigateCardConfig, - frigateCardConfigDefaults, - frigateCardConfigSchema, -} from './types.js'; +import { frigateCardConfigDefaults, RawFrigateCardConfig } from './types.js'; import frigate_card_editor_style from './scss/editor.scss'; +import { + copyConfig, + deleteConfigValue, + getConfigValue, + isConfigUpgradeable, + setConfigValue, + trimConfig, + upgradeConfig, +} from './config-mgmt.js'; -const options = { +interface EditorOptionsSet { + icon: string; + name: string; + secondary: string; + show: boolean; +} +interface EditorOptions { + [setName: string]: EditorOptionsSet; +} + +interface EditorOptionTarget { + configValue: string; + checked?: boolean; + value?: string; +} + +interface EditorOptionSetTarget { + optionSetName: string; +} + +const options: EditorOptions = { basic: { icon: 'cog', name: localize('editor.basic'), @@ -58,7 +83,7 @@ const options = { dimensions: { icon: 'aspect-ratio', name: localize('editor.dimensions'), - secondary: localize('editor.dimensions'), + secondary: localize('editor.dimensions_secondary'), show: false, }, }; @@ -66,16 +91,18 @@ const options = { @customElement('frigate-card-editor') export class FrigateCardEditor extends LitElement implements LovelaceCardEditor { @property({ attribute: false }) public hass?: HomeAssistant; - @state() private _config?: FrigateCardConfig; - @state() private _toggle?: boolean; - @state() private _helpers?: any; - private _initialized = false; + @state() protected _config?: RawFrigateCardConfig; + @state() protected _helpers?: any; + protected _initialized = false; + protected _configUpgradeable = false; - public setConfig(config: FrigateCardConfig): void { + public setConfig(config: RawFrigateCardConfig): void { // Note: This does not use Zod to parse the configuration, so it may be // partially or completely invalid. It's more useful to have a partially - // valid configuration here, to allow the user to fix the broken parts. + // valid configuration here, to allow the user to fix the broken parts. As + // such, RawFrigateCardConfig is used as the type. this._config = config; + this._configUpgradeable = isConfigUpgradeable(config); this.loadCardHelpers(); } @@ -102,7 +129,7 @@ export class FrigateCardEditor extends LitElement implements LovelaceCardEditor } protected render(): TemplateResult | void { - if (!this.hass || !this._helpers) { + if (!this.hass || !this._helpers || !this._config) { return html``; } @@ -111,11 +138,9 @@ export class FrigateCardEditor extends LitElement implements LovelaceCardEditor this._helpers.importMoreInfoControl('climate'); const cameraEntities = this._getEntities('camera'); - - const webrtcCameraEntity = - this._config?.live?.webrtc && (this._config?.live.webrtc as any).entity - ? (this._config?.live.webrtc as any).entity - : ''; + const webrtcCameraEntity = String( + getConfigValue(this._config, 'live.webrtc.entity', ''), + ); const viewModes = { '': '', @@ -172,8 +197,35 @@ export class FrigateCardEditor extends LitElement implements LovelaceCardEditor const defaults = frigateCardConfigDefaults; return html` + ${this._configUpgradeable + ? html`
+ ${localize('editor.upgrade_available')} + + { + if (this._config) { + const upgradedConfig = copyConfig(this._config); + upgradeConfig(upgradedConfig); + this._config = upgradedConfig; + + fireEvent(this, 'config-changed', { config: this._config }); + this.requestUpdate(); + } + }} + > + + +
+
` + : html``}
-
+
${options.basic.name}
@@ -185,13 +237,13 @@ export class FrigateCardEditor extends LitElement implements LovelaceCardEditor
${cameraEntities.map((entity) => { @@ -202,7 +254,11 @@ export class FrigateCardEditor extends LitElement implements LovelaceCardEditor
` : ''} -
+
${options.frigate.name}
@@ -214,38 +270,38 @@ export class FrigateCardEditor extends LitElement implements LovelaceCardEditor
` : ''} -
+
${options.view.name}
@@ -257,13 +313,13 @@ export class FrigateCardEditor extends LitElement implements LovelaceCardEditor
${Object.keys(viewModes).map((key) => { @@ -277,16 +333,14 @@ export class FrigateCardEditor extends LitElement implements LovelaceCardEditor label=${localize('config.view.timeout')} prevent-invalid-input allowed-pattern="[0-9]" - .value=${this._config?.view?.timeout - ? String(this._config?.view?.timeout) - : ''} + .value=${getConfigValue(this._config, 'view.timeout', '')} .configValue=${'view.timeout'} - @value-changed=${this._valueChanged} + @value-changed=${this._valueChangedHandler} >
` : ''} -
+
${options.menu.name}
@@ -298,13 +352,13 @@ export class FrigateCardEditor extends LitElement implements LovelaceCardEditor
${Object.keys(menuModes).map((key) => { @@ -316,9 +370,9 @@ export class FrigateCardEditor extends LitElement implements LovelaceCardEditor
` : ''} -
+
${options.live.name}
@@ -432,20 +510,24 @@ export class FrigateCardEditor extends LitElement implements LovelaceCardEditor
${Object.keys(liveProviders).map((key) => { @@ -457,7 +539,7 @@ export class FrigateCardEditor extends LitElement implements LovelaceCardEditor
` : ''} -
+
${options.event_viewer.name}
@@ -490,29 +576,41 @@ export class FrigateCardEditor extends LitElement implements LovelaceCardEditor
${Object.keys(eventViewerNextPreviousControlStyles).map((key) => { @@ -526,13 +624,21 @@ export class FrigateCardEditor extends LitElement implements LovelaceCardEditor
` : ''} -
+
${options.image.name}
@@ -544,15 +650,17 @@ export class FrigateCardEditor extends LitElement implements LovelaceCardEditor
` : ''} -
+
${options.dimensions.name}
@@ -563,13 +671,15 @@ export class FrigateCardEditor extends LitElement implements LovelaceCardEditor ? html`
${Object.keys(aspectRatioModes).map((key) => { @@ -582,11 +692,9 @@ export class FrigateCardEditor extends LitElement implements LovelaceCardEditor
` : ''} @@ -594,69 +702,95 @@ export class FrigateCardEditor extends LitElement implements LovelaceCardEditor `; } - private _initialize(): void { + /** + * Verify editor is initialized. + */ + protected _initialize(): void { if (this.hass === undefined) return; if (this._config === undefined) return; if (this._helpers === undefined) return; this._initialized = true; } - private async loadCardHelpers(): Promise { + /** + * Load card helpers. + */ + protected async loadCardHelpers(): Promise { this._helpers = await (window as any).loadCardHelpers(); } - private _toggleOption(ev): void { - this._toggleThing(ev, options); + /** + * Handle a toggled set of options. + * @param ev The event triggering the change. + */ + protected _toggleOptionHandler(ev: { target: EditorOptionSetTarget | null }): void { + this._toggleOptionSet(ev, options); } - private _toggleThing(ev, optionList): void { - const show = !optionList[ev.target.option].show; - for (const [key] of Object.entries(optionList)) { - optionList[key].show = false; + /** + * Toggle display of a set of options (e.g. 'Live') + * @param ev The event triggering the change. + * @param options The EditorOptions object. + */ + protected _toggleOptionSet( + ev: { target: EditorOptionSetTarget | null }, + options: EditorOptions, + ): void { + if (ev && ev.target) { + const show = !options[ev.target.optionSetName].show; + for (const [key] of Object.entries(options)) { + options[key].show = false; + } + options[ev.target.optionSetName].show = show; + this.requestUpdate(); } - optionList[ev.target.option].show = show; - this._toggle = !this._toggle; } - private _valueChanged(ev): void { - if (!this._config || !this.hass) { + /** + * Handle a changed option value. + * @param ev Event triggering the change. + * @returns + */ + protected _valueChangedHandler(ev: { + target: (EditorOptionTarget & HTMLElement) | null; + }): void { + const target = ev.target; + if (!this._config || !this.hass || !target) { return; } - const target = ev.target; - const value = target.value?.trim(); - let key: string = target.configValue; + let value; + if ('checked' in target) { + value = target.checked; + } else { + value = target.value?.trim(); + } + const key: string = target.configValue; if (!key) { return; } - // Need to deep copy the config so cannot use Object.assign. - const newConfig = JSON.parse(JSON.stringify(this._config)); - let objectTarget = newConfig; - - if (key.includes('.')) { - const parts = key.split('.'); - objectTarget = parts.slice(0, -1).reduce((obj, key) => { - if (!(key in obj)) { - obj[key] = {}; - } - return obj[key]; - }, newConfig); - key = parts[parts.length - 1]; - } - - if (value !== undefined && objectTarget[key] === value) { - return; - } else if (value === '') { - delete objectTarget[key]; + const newConfig = copyConfig(this._config); + if (value === '' || typeof value === 'undefined') { + // Don't delete empty properties that are from a dropdown menu. An empty + // property in that context may just be a user-entered value that is not + // in the valid choices in the dropdown. This probably won't end well for + // the user anyway, but having the whole property deleted the moment they + // press a key is very jarring. + if (target.tagName != 'PAPER-DROPDOWN-MENU') { + deleteConfigValue(newConfig, key); + } } else { - objectTarget[key] = target.checked !== undefined ? target.checked : value; + setConfigValue(newConfig, key, value); } + trimConfig(newConfig); this._config = newConfig; fireEvent(this, 'config-changed', { config: this._config }); } - // Return compiled CSS styles (thus safe to use with unsafeCSS). + /** + * Return compiled CSS styles. + */ static get styles(): CSSResultGroup { return unsafeCSS(frigate_card_editor_style); } diff --git a/src/localize/languages/en.json b/src/localize/languages/en.json index 7c43d9bf..5527f039 100644 --- a/src/localize/languages/en.json +++ b/src/localize/languages/en.json @@ -101,22 +101,25 @@ }, "editor": { "basic": "Basic", - "basic_secondary": "Basic options for most users", + "basic_secondary": "Options for most users", "frigate": "Frigate", "frigate_secondary": "Frigate server options", "view": "View", - "view_secondary": "Card view options", + "view_secondary": "What the card should show and how to show it", "menu": "Menu", - "menu_secondary": "Menu options", + "menu_secondary": "Menu look & feel options", "live": "Live", - "live_secondary": "Live view options", + "live_secondary": "Live camera view options", "event_viewer": "Event viewer", - "event_viewer_secondary": "Event viewer options", + "event_viewer_secondary": "Snapshots & clips gallery options", "image": "Image", "image_secondary": "Static image view options", "dimensions": "Dimensions", - "dimensions_secondary": "Card dimensions options", - "show_button": "Show button" + "dimensions_secondary": "Dimensions & shape options", + "show_button": "Show button", + "upgrade": "Upgrade", + "upgrade_available": "An automatic card configuration upgrade is available", + "upgrade_available_in_editor": "An automatic card configuration upgrade is available in the editor" }, "error": { "empty_response": "Received empty response from Home Assistant for request", diff --git a/src/scss/editor.scss b/src/scss/editor.scss index c1d167e6..76d0be76 100644 --- a/src/scss/editor.scss +++ b/src/scss/editor.scss @@ -25,3 +25,15 @@ ha-formfield { padding-bottom: 8px; } + +div.upgrade { + width: auto; + border: 1px dotted var(--primary-color); + margin: 10px; + display: flex; + justify-content: space-between; + align-items: center; +} +div.upgrade span { + padding: 10px; +} \ No newline at end of file diff --git a/src/scss/live.scss b/src/scss/live.scss index f55ab496..13a89892 100644 --- a/src/scss/live.scss +++ b/src/scss/live.scss @@ -1,3 +1,7 @@ +:host { + --video-max-height: none; +} + canvas { width: 100%; display: block; diff --git a/src/types.ts b/src/types.ts index 7e1e09e5..d3be276a 100644 --- a/src/types.ts +++ b/src/types.ts @@ -466,6 +466,7 @@ export const frigateCardConfigSchema = z.object({ test_gui: z.boolean().optional(), }); export type FrigateCardConfig = z.infer; +export type RawFrigateCardConfig = Record; export const frigateCardConfigDefaults = { frigate: frigateConfigDefault,