diff --git a/src/card-controller/conditions-manager.ts b/src/card-controller/conditions-manager.ts index cfa5d1bb..dcb37349 100644 --- a/src/card-controller/conditions-manager.ts +++ b/src/card-controller/conditions-manager.ts @@ -1,6 +1,6 @@ import { CurrentUser } from '@dermotduffy/custom-card-helpers'; import { HassEntities } from 'home-assistant-js-websocket'; -import { isEqual } from 'lodash-es'; +import isEqual from 'lodash-es/isEqual'; import merge from 'lodash-es/merge'; import { ZodSchema } from 'zod'; import { @@ -16,6 +16,8 @@ import { RawFrigateCardConfig, ViewDisplayMode, } from '../config/types'; +import { localize } from '../localize/localize'; +import { FrigateCardError } from '../types'; import { desparsifyArrays } from '../utils/basic'; import { CardConditionAPI, KeysState } from './types'; @@ -39,6 +41,8 @@ interface ConditionState { keys?: KeysState; } +class OverrideConfigurationError extends FrigateCardError {} + export class ConditionsEvaluateRequestEvent extends Event { public conditions: FrigateCardCondition[]; public evaluation?: boolean; @@ -82,16 +86,15 @@ export function evaluateConditionViaEvent( return evaluateEvent.evaluation ?? false; } -export function getOverriddenConfig( +export function getOverriddenConfig( manager: Readonly, - config: Readonly, + config: Readonly, options?: { configOverrides?: Readonly; stateOverrides?: Partial; schema?: ZodSchema; - logOnParseError?: boolean; }, -): RawFrigateCardConfig { +): RT { let output = copyConfig(config); let overridden = false; if (options?.configOverrides) { @@ -132,14 +135,13 @@ export function getOverriddenConfig( if (options?.schema) { const parseResult = options.schema.safeParse(output); - if (options.logOnParseError && !parseResult.success) { - console.warn( - `Cannot parse overridden configuration`, - output, - parseResult.error.message, + if (!parseResult.success) { + throw new OverrideConfigurationError( + localize('error.invalid_configuration_override'), + [parseResult.error.errors, output], ); } - return parseResult.success ? parseResult.data : config; + return parseResult.data; } return output; } diff --git a/src/card-controller/config/config-manager.ts b/src/card-controller/config/config-manager.ts index 7471f1e6..f8408720 100644 --- a/src/card-controller/config/config-manager.ts +++ b/src/card-controller/config/config-manager.ts @@ -113,11 +113,17 @@ export class ConfigManager { if (!this._config) { return; } - const overriddenConfig = getOverriddenConfig(conditionsManager, this._config, { - configOverrides: this._config.overrides, - schema: frigateCardConfigSchema, - logOnParseError: !!this.getCardWideConfig()?.debug?.logging, - }) as FrigateCardConfig; + + let overriddenConfig: FrigateCardConfig | null = null; + try { + overriddenConfig = getOverriddenConfig(conditionsManager, this._config, { + configOverrides: this._config.overrides, + schema: frigateCardConfigSchema, + }); + } catch (ev) { + this._api.getMessageManager().setErrorIfHigherPriority(ev); + return; + } // Save on Lit re-rendering costs by only updating the configuration if it // actually changes. diff --git a/src/card-controller/default-manager.ts b/src/card-controller/default-manager.ts index 6ba7a380..72070cce 100644 --- a/src/card-controller/default-manager.ts +++ b/src/card-controller/default-manager.ts @@ -1,4 +1,4 @@ -import { isEqual } from 'lodash-es'; +import isEqual from 'lodash-es/isEqual'; import { FrigateCardConfig } from '../config/types'; import { createGeneralAction } from '../utils/action'; import { isActionAllowedBasedOnInteractionState } from '../utils/interaction-mode'; diff --git a/src/card-controller/status-bar-item-manager.ts b/src/card-controller/status-bar-item-manager.ts index 3727c237..4168183f 100644 --- a/src/card-controller/status-bar-item-manager.ts +++ b/src/card-controller/status-bar-item-manager.ts @@ -1,4 +1,4 @@ -import { isEqual } from 'lodash-es'; +import isEqual from 'lodash-es/isEqual'; import { CameraManager } from '../camera-manager/manager'; import { StatusBarConfig, StatusBarItem } from '../config/types'; import { MediaLoadedInfo } from '../types'; diff --git a/src/components-lib/status-bar-controller.ts b/src/components-lib/status-bar-controller.ts index 89e43c02..172e2770 100644 --- a/src/components-lib/status-bar-controller.ts +++ b/src/components-lib/status-bar-controller.ts @@ -1,6 +1,7 @@ import { HASSDomEvent } from '@dermotduffy/custom-card-helpers'; import { LitElement } from 'lit'; -import { isEqual, orderBy } from 'lodash-es'; +import isEqual from 'lodash-es/isEqual'; +import orderBy from 'lodash-es/orderBy'; import { dispatchActionExecutionRequest } from '../card-controller/actions/utils/execution-request'; import { ActionsConfig, diff --git a/src/components/live/live.ts b/src/components/live/live.ts index df5f2144..53b85c7b 100644 --- a/src/components/live/live.ts +++ b/src/components/live/live.ts @@ -21,6 +21,7 @@ import { import { ReadonlyMicrophoneManager } from '../../card-controller/microphone-manager.js'; import { ViewManagerEpoch } from '../../card-controller/view/types.js'; import { LiveController } from '../../components-lib/live/live-controller.js'; +import { MediaActionsController } from '../../components-lib/media-actions-controller.js'; import { MediaGridSelected } from '../../components-lib/media-grid-controller.js'; import { PartialZoomSettings, @@ -56,12 +57,11 @@ import { playMediaMutingIfNecessary } from '../../utils/media.js'; import { getStreamCameraID } from '../../utils/substream.js'; import { View } from '../../view/view.js'; import { EmblaCarouselPlugins } from '../carousel.js'; -import { renderMessage } from '../message.js'; +import { dispatchFrigateCardErrorEvent, renderMessage } from '../message.js'; import '../next-prev-control.js'; import '../ptz.js'; import { FrigateCardPTZ } from '../ptz.js'; import '../surround.js'; -import { MediaActionsController } from '../../components-lib/media-actions-controller.js'; const FRIGATE_CARD_LIVE_PROVIDER = 'frigate-card-live-provider'; @@ -483,20 +483,26 @@ export class FrigateCardLiveCarousel extends LitElement { ) { return; } - // The condition controller object contains the currently live camera, which - // (in the carousel for example) is not necessarily the live camera *this* - // is rendering right now, so we provide a - // stateOverride to evaluate the condition in that context. - const liveConfig = getOverriddenConfig( - this.conditionsManagerEpoch.manager, - { live: this.nonOverriddenLiveConfig }, - { - configOverrides: this.overrides, - stateOverrides: { camera: cameraID }, - schema: liveConfigAbsoluteRootSchema, - logOnParseError: !!this.cardWideConfig?.debug?.logging, - }, - ).live as LiveConfig; + + let liveConfig: LiveConfig | null = null; + + try { + // The condition controller object contains the currently live camera, which + // (in the carousel for example) is not necessarily the live camera *this* + // is rendering right now, so we provide a + // stateOverride to evaluate the condition in that context. + liveConfig = getOverriddenConfig( + this.conditionsManagerEpoch.manager, + { live: this.nonOverriddenLiveConfig }, + { + configOverrides: this.overrides, + stateOverrides: { camera: cameraID }, + schema: liveConfigAbsoluteRootSchema, + }, + ).live; + } catch (ev) { + return dispatchFrigateCardErrorEvent(this, ev); + } const cameraMetadata = this.cameraManager.getCameraMetadata(cameraID); const view = this.viewManagerEpoch?.manager.getView(); diff --git a/src/components/message.ts b/src/components/message.ts index 08d6d352..6b553ede 100644 --- a/src/components/message.ts +++ b/src/components/message.ts @@ -30,6 +30,11 @@ export class FrigateCardMessage extends LitElement { const classes = { dotdotdot: !!this.dotdotdot, }; + + const renderContext = (contextItem: unknown): TemplateResult => { + return html`
${yaml.dump(contextItem)}
`; + }; + return html`
@@ -43,9 +48,11 @@ export class FrigateCardMessage extends LitElement { : ''}` : ''} - ${this.context && typeof this.context === 'object' - ? html`
${yaml.dump(this.context)}
` - : ''} + ${this.context && Array.isArray(this.context) + ? this.context.map((contextItem) => renderContext(contextItem)) + : typeof this.context === 'object' + ? renderContext(this.context) + : ''}
`; diff --git a/src/config/profiles/low-performance.ts b/src/config/profiles/low-performance.ts index 6bd8a49e..8eac77d8 100644 --- a/src/config/profiles/low-performance.ts +++ b/src/config/profiles/low-performance.ts @@ -61,13 +61,13 @@ export const LOW_PERFORMANCE_PROFILE = { [CONF_TIMELINE_SHOW_RECORDINGS]: false, // Take no automatic media actions. - [CONF_LIVE_AUTO_MUTE]: 'never' as const, - [CONF_MEDIA_VIEWER_AUTO_PLAY]: 'never' as const, - [CONF_MEDIA_VIEWER_AUTO_PAUSE]: 'never' as const, - [CONF_MEDIA_VIEWER_AUTO_MUTE]: 'never' as const, + [CONF_LIVE_AUTO_MUTE]: [], + [CONF_MEDIA_VIEWER_AUTO_PLAY]: [], + [CONF_MEDIA_VIEWER_AUTO_PAUSE]: [], + [CONF_MEDIA_VIEWER_AUTO_MUTE]: [], // Always unload resources that are lazily loaded. - [CONF_LIVE_LAZY_UNLOAD]: 'all' as const, + [CONF_LIVE_LAZY_UNLOAD]: ['unselected', 'hidden'], // Media carousels do not drag. [CONF_LIVE_DRAGGABLE]: false, diff --git a/src/localize/languages/ca.json b/src/localize/languages/ca.json index 56c21ac0..0eba4fcc 100644 --- a/src/localize/languages/ca.json +++ b/src/localize/languages/ca.json @@ -586,6 +586,7 @@ "image_load_error": "No s'ha pogut carregar la imatge", "invalid_configuration": "Configuració no vàlida", "invalid_configuration_no_hint": "No hi ha cap indicació d'ubicació disponible (tipus dolent o faltant?)", + "invalid_configuration_override": "", "invalid_elements_config": "La configuració dels elements de la imatge no és vàlida", "invalid_response": "S'ha rebut una resposta no vàlida de Home Assistant per a la sol·licitud", "jsmpeg_no_player": "No s'ha pogut iniciar el reproductor JSMPEG", diff --git a/src/localize/languages/en.json b/src/localize/languages/en.json index 334ad7f6..39ae61c1 100644 --- a/src/localize/languages/en.json +++ b/src/localize/languages/en.json @@ -585,6 +585,7 @@ "failed_sign": "Could not sign Home Assistant URL", "image_load_error": "The image could not be loaded", "invalid_configuration": "Invalid configuration", + "invalid_configuration_override": "Invalid override configuration", "invalid_configuration_no_hint": "No location hint available (bad or missing type?)", "invalid_elements_config": "Invalid picture elements configuration", "invalid_response": "Received invalid response from Home Assistant for request", diff --git a/src/localize/languages/fr.json b/src/localize/languages/fr.json index 4a78b072..ee89a4f2 100644 --- a/src/localize/languages/fr.json +++ b/src/localize/languages/fr.json @@ -586,6 +586,7 @@ "image_load_error": "L'image n'a pas pu être chargée", "invalid_configuration": "Configuration invalide", "invalid_configuration_no_hint": "Aucune indication de localisation disponible (type incorrect ou manquant ?)", + "invalid_configuration_override": "", "invalid_elements_config": "Configuration des éléments d'image invalide", "invalid_response": "Réponse non valide reçue de Home Assistant pour la demande", "jsmpeg_no_player": "Impossible de démarrer le lecteur JSMPEG", diff --git a/src/localize/languages/it.json b/src/localize/languages/it.json index 506b75ed..9c9333c2 100644 --- a/src/localize/languages/it.json +++ b/src/localize/languages/it.json @@ -586,6 +586,7 @@ "image_load_error": "L'immagine non può essere caricata", "invalid_configuration": "Configurazione non valida", "invalid_configuration_no_hint": "Nessun suggerimento di posizione disponibile (tipo difettoso o mancante?)", + "invalid_configuration_override": "", "invalid_elements_config": "Configurazione degli elementi di immagine non valida", "invalid_response": "Ricevuta una risposta non valida da Home Assistant per la richiesta", "jsmpeg_no_player": "Impossibile avviare JSMPEG Player", diff --git a/src/localize/languages/pt-BR.json b/src/localize/languages/pt-BR.json index f06c7c59..617eb10c 100644 --- a/src/localize/languages/pt-BR.json +++ b/src/localize/languages/pt-BR.json @@ -586,6 +586,7 @@ "image_load_error": "A imagem não pôde ser carregada", "invalid_configuration": "Configuração inválida", "invalid_configuration_no_hint": "Nenhuma dica de local disponível (tipo incorreto ou ausente?)", + "invalid_configuration_override": "", "invalid_elements_config": "Configuração de elementos de imagem inválida", "invalid_response": "Resposta inválida recebida do Home Assistant para a solicitação", "jsmpeg_no_player": "Não foi possível iniciar o player JSMPEG", diff --git a/src/localize/languages/pt-PT.json b/src/localize/languages/pt-PT.json index f2015709..3e1a1b9b 100644 --- a/src/localize/languages/pt-PT.json +++ b/src/localize/languages/pt-PT.json @@ -586,6 +586,7 @@ "image_load_error": "A imagem não pôde ser carregada", "invalid_configuration": "Configuração inválida", "invalid_configuration_no_hint": "Nenhuma dica de local disponível (tipo incorreto ou ausente?)", + "invalid_configuration_override": "", "invalid_elements_config": "Configuração de elementos de imagem inválida", "invalid_response": "Resposta inválida recebida do Home Assistant para a solicitação", "jsmpeg_no_player": "Não foi possível iniciar o player JSMPEG", diff --git a/src/scss/message.scss b/src/scss/message.scss index 75d2347d..6d82fe86 100644 --- a/src/scss/message.scss +++ b/src/scss/message.scss @@ -58,4 +58,6 @@ div.message div.icon { .message pre { margin-top: 20px; overflow-x: auto; + border: 1px dotted var(--divider-color); + padding: 1em; } diff --git a/tests/card-controller/conditions-manager.test.ts b/tests/card-controller/conditions-manager.test.ts index 0f3e81a9..6c7fc921 100644 --- a/tests/card-controller/conditions-manager.test.ts +++ b/tests/card-controller/conditions-manager.test.ts @@ -347,7 +347,7 @@ describe('getOverriddenConfig', () => { const manager = new ConditionsManager(createCardAPI()); manager.setState({ fullscreen: true }); - expect( + expect(() => getOverriddenConfig(manager, config, { configOverrides: [ { @@ -364,40 +364,7 @@ describe('getOverriddenConfig', () => { ], schema: testSchema, }), - ).toEqual(config); - }); - - it('failing and logging', () => { - const consoleSpy = vi.spyOn(global.console, 'warn').mockReturnValue(undefined); - - const manager = new ConditionsManager(createCardAPI()); - manager.setState({ fullscreen: true }); - - expect( - getOverriddenConfig(manager, config, { - configOverrides: [ - { - conditions: [ - { - condition: 'fullscreen' as const, - fullscreen: true, - }, - ], - set: { - 'menu.style': 'NOT_A_STYLE', - }, - }, - ], - schema: testSchema, - logOnParseError: true, - }), - ).toEqual(config); - - expect(consoleSpy).toBeCalledWith( - 'Cannot parse overridden configuration', - expect.anything(), - expect.anything(), - ); + ).toThrowError(/Invalid override configuration/); }); }); }); diff --git a/tests/card-controller/config/config-manager.test.ts b/tests/card-controller/config/config-manager.test.ts index 90b1b42b..d527f060 100644 --- a/tests/card-controller/config/config-manager.test.ts +++ b/tests/card-controller/config/config-manager.test.ts @@ -218,6 +218,24 @@ describe('ConfigManager', () => { expect(manager.getConfig()).not.toEqual(manager.getNonOverriddenConfig()); }); + it('should set error on invalid override', () => { + const api = createCardAPI(); + const manager = new ConfigManager(api); + manager.setConfig({ + type: 'custom:frigate-card', + cameras: [{ camera_entity: 'camera.office' }], + }); + + const error = new Error('Invalid override configuration'); + vi.mocked(getOverriddenConfig).mockImplementation(() => { + throw error; + }); + + manager.computeOverrideConfig(); + + expect(api.getMessageManager().setErrorIfHigherPriority).toBeCalledWith(error); + }); + describe('should uninitialize on override', () => { it('cameras', () => { const api = createCardAPI(); diff --git a/tests/config/profiles/low-performance.test.ts b/tests/config/profiles/low-performance.test.ts index b491e186..bad67efa 100644 --- a/tests/config/profiles/low-performance.test.ts +++ b/tests/config/profiles/low-performance.test.ts @@ -1,12 +1,15 @@ import { expect, it } from 'vitest'; +import { setProfiles } from '../../../src/config/profiles'; import { LOW_PERFORMANCE_PROFILE } from '../../../src/config/profiles/low-performance'; +import { frigateCardConfigSchema } from '../../../src/config/types'; +import { createRawConfig } from '../../test-utils'; -it('low performance profile', () => { +it('should contain expected defaults', () => { expect(LOW_PERFORMANCE_PROFILE).toEqual({ 'cameras_global.image.refresh_seconds': 10, 'cameras_global.live_provider': 'image', 'cameras_global.triggers.occupancy': false, - 'live.auto_mute': 'never', + 'live.auto_mute': [], 'live.controls.thumbnails.mode': 'none', 'live.controls.thumbnails.show_details': false, 'live.controls.thumbnails.show_download_control': false, @@ -14,16 +17,16 @@ it('low performance profile', () => { 'live.controls.thumbnails.show_timeline_control': false, 'live.controls.timeline.show_recordings': false, 'live.draggable': false, - 'live.lazy_unload': 'all', + 'live.lazy_unload': ['unselected', 'hidden'], 'live.show_image_during_load': false, 'live.transition_effect': 'none', 'media_gallery.controls.thumbnails.show_details': false, 'media_gallery.controls.thumbnails.show_download_control': false, 'media_gallery.controls.thumbnails.show_favorite_control': false, 'media_gallery.controls.thumbnails.show_timeline_control': false, - 'media_viewer.auto_mute': 'never', - 'media_viewer.auto_pause': 'never', - 'media_viewer.auto_play': 'never', + 'media_viewer.auto_mute': [], + 'media_viewer.auto_pause': [], + 'media_viewer.auto_play': [], 'media_viewer.controls.next_previous.style': 'chevrons', 'media_viewer.controls.thumbnails.mode': 'none', 'media_viewer.controls.thumbnails.show_details': false, @@ -53,3 +56,14 @@ it('low performance profile', () => { 'view.triggers.actions.trigger': 'none', }); }); + +it('should be parseable after application', () => { + const rawInputConfig = createRawConfig(); + const parsedConfig = frigateCardConfigSchema.parse(rawInputConfig); + + setProfiles(rawInputConfig, parsedConfig, ['low-performance']); + + // Reparse the config to ensure the profile did not introduce errors. + const parseResult = frigateCardConfigSchema.safeParse(parsedConfig); + expect(parseResult.success, parseResult.error?.toString()).toBeTruthy(); +}); diff --git a/tests/config/profiles/scrubbing.test.ts b/tests/config/profiles/scrubbing.test.ts index b043dc0e..de2a92b5 100644 --- a/tests/config/profiles/scrubbing.test.ts +++ b/tests/config/profiles/scrubbing.test.ts @@ -1,7 +1,10 @@ import { expect, it } from 'vitest'; import { SCRUBBING_PROFILE } from '../../../src/config/profiles/scrubbing'; +import { setProfiles } from '../../../src/config/profiles'; +import { frigateCardConfigSchema } from '../../../src/config/types'; +import { createRawConfig } from '../../test-utils'; -it('scrubbing profile', () => { +it('should contain expected defaults', () => { expect(SCRUBBING_PROFILE).toEqual({ 'live.controls.timeline.mode': 'below', 'live.controls.timeline.style': 'ribbon', @@ -11,3 +14,14 @@ it('scrubbing profile', () => { 'media_viewer.controls.timeline.pan_mode': 'seek', }); }); + +it('should be parseable after application', () => { + const rawInputConfig = createRawConfig(); + const parsedConfig = frigateCardConfigSchema.parse(rawInputConfig); + + setProfiles(rawInputConfig, parsedConfig, ['low-performance']); + + // Reparse the config to ensure the profile did not introduce errors. + const parseResult = frigateCardConfigSchema.safeParse(parsedConfig); + expect(parseResult.success, parseResult.error?.toString()).toBeTruthy(); +}); diff --git a/tests/test-utils.ts b/tests/test-utils.ts index cbe59207..d35bbfcb 100644 --- a/tests/test-utils.ts +++ b/tests/test-utils.ts @@ -81,12 +81,18 @@ export const createCondition = ( return frigateCardConditionSchema.parse(condition ?? {}); }; -export const createConfig = (config?: RawFrigateCardConfig): FrigateCardConfig => { - return frigateCardConfigSchema.parse({ +export const createRawConfig = ( + config?: Partial, +): RawFrigateCardConfig => { + return { type: 'frigate-hass-card', cameras: [{}], ...config, - }); + }; +}; + +export const createConfig = (config?: RawFrigateCardConfig): FrigateCardConfig => { + return frigateCardConfigSchema.parse(createRawConfig(config)); }; export const createCamera = (