diff --git a/src/camera-manager/browse-media/engine-browse-media.ts b/src/camera-manager/browse-media/engine-browse-media.ts index 751c00b8..55c3fbb8 100644 --- a/src/camera-manager/browse-media/engine-browse-media.ts +++ b/src/camera-manager/browse-media/engine-browse-media.ts @@ -1,22 +1,8 @@ import { HomeAssistant } from 'custom-card-helpers'; -import { CameraConfig, ExtendedHomeAssistant } from '../../types'; -import { ViewMedia } from '../../view/media'; -import { - CameraManagerMediaCapabilities, - DataQuery, - EventQuery, - PartialEventQuery, - CameraConfigs, - CameraManagerCameraCapabilities, - QueryType, - CameraEndpoint, -} from '../types'; -import { EntityRegistryManager } from '../../utils/ha/entity-registry'; -import { CameraManagerEngine } from '../engine'; -import { GenericCameraManagerEngine } from '../generic/engine-generic'; -import { CameraInitializationError } from '../error'; +import { CameraConfig } from '../../config/types'; import { localize } from '../../localize/localize'; -import { Entity } from '../../utils/ha/entity-registry/types'; +import { ExtendedHomeAssistant } from '../../types'; +import { canonicalizeHAURL } from '../../utils/ha'; import { BrowseMediaManager } from '../../utils/ha/browse-media/browse-media-manager'; import { BROWSE_MEDIA_CACHE_SECONDS, @@ -24,12 +10,27 @@ import { MEDIA_CLASS_VIDEO, RichBrowseMedia, } from '../../utils/ha/browse-media/types'; -import { BrowseMediaMetadata } from './types'; -import { rangesOverlap } from '../range'; +import { EntityRegistryManager } from '../../utils/ha/entity-registry'; +import { Entity } from '../../utils/ha/entity-registry/types'; import { ResolvedMediaCache, resolveMedia } from '../../utils/ha/resolved-media'; -import { canonicalizeHAURL } from '../../utils/ha'; +import { ViewMedia } from '../../view/media'; import { RequestCache } from '../cache'; +import { CameraManagerEngine } from '../engine'; +import { CameraInitializationError } from '../error'; +import { GenericCameraManagerEngine } from '../generic/engine-generic'; +import { rangesOverlap } from '../range'; +import { + CameraConfigs, + CameraEndpoint, + CameraManagerCameraCapabilities, + CameraManagerMediaCapabilities, + DataQuery, + EventQuery, + PartialEventQuery, + QueryType, +} from '../types'; import { BrowseMediaViewMediaFactory } from './media'; +import { BrowseMediaMetadata } from './types'; /** * A utility method to determine if a browse media object matches against a diff --git a/src/camera-manager/engine-factory.ts b/src/camera-manager/engine-factory.ts index e7381b1d..1abdbb17 100644 --- a/src/camera-manager/engine-factory.ts +++ b/src/camera-manager/engine-factory.ts @@ -1,6 +1,6 @@ import { HomeAssistant } from 'custom-card-helpers'; +import { CameraConfig } from '../config/types'; import { localize } from '../localize/localize'; -import { CameraConfig } from '../types'; import { BrowseMediaManager } from '../utils/ha/browse-media/browse-media-manager'; import { BrowseMedia } from '../utils/ha/browse-media/types'; import { EntityRegistryManager } from '../utils/ha/entity-registry'; diff --git a/src/camera-manager/engine.ts b/src/camera-manager/engine.ts index 518113c8..89313507 100644 --- a/src/camera-manager/engine.ts +++ b/src/camera-manager/engine.ts @@ -1,11 +1,23 @@ import { HomeAssistant } from 'custom-card-helpers'; -import { CameraConfig, ExtendedHomeAssistant } from '../types'; +import { CameraConfig } from '../config/types'; +import { ExtendedHomeAssistant } from '../types'; import { EntityRegistryManager } from '../utils/ha/entity-registry'; import { ViewMedia } from '../view/media'; import { + CameraConfigs, + CameraEndpoint, + CameraEndpoints, + CameraEndpointsContext, + CameraManagerCameraCapabilities, + CameraManagerCameraMetadata, + CameraManagerMediaCapabilities, DataQuery, + Engine, + EngineOptions, EventQuery, EventQueryResultsMap, + MediaMetadataQuery, + MediaMetadataQueryResultsMap, PartialEventQuery, PartialRecordingQuery, PartialRecordingSegmentsQuery, @@ -14,17 +26,6 @@ import { RecordingQueryResultsMap, RecordingSegmentsQuery, RecordingSegmentsQueryResultsMap, - CameraManagerCameraCapabilities, - CameraManagerMediaCapabilities, - CameraManagerCameraMetadata, - CameraEndpointsContext, - CameraConfigs, - Engine, - CameraEndpoints, - MediaMetadataQuery, - MediaMetadataQueryResultsMap, - EngineOptions, - CameraEndpoint, } from './types'; export const CAMERA_MANAGER_ENGINE_EVENT_LIMIT_DEFAULT = 10000; diff --git a/src/camera-manager/frigate/engine-frigate.ts b/src/camera-manager/frigate/engine-frigate.ts index 8305911b..6301cd93 100644 --- a/src/camera-manager/frigate/engine-frigate.ts +++ b/src/camera-manager/frigate/engine-frigate.ts @@ -9,8 +9,9 @@ import orderBy from 'lodash-es/orderBy'; import throttle from 'lodash-es/throttle'; import uniq from 'lodash-es/uniq'; import uniqWith from 'lodash-es/uniqWith'; +import { CameraConfig } from '../../config/types'; import { localize } from '../../localize/localize'; -import { CameraConfig, ExtendedHomeAssistant } from '../../types'; +import { ExtendedHomeAssistant } from '../../types'; import { allPromises, formatDate, diff --git a/src/camera-manager/frigate/media.ts b/src/camera-manager/frigate/media.ts index f096f40f..2a4c54d9 100644 --- a/src/camera-manager/frigate/media.ts +++ b/src/camera-manager/frigate/media.ts @@ -1,12 +1,12 @@ import fromUnixTime from 'date-fns/fromUnixTime'; import isEqual from 'lodash-es/isEqual'; -import { CameraConfig } from '../../types'; +import { CameraConfig } from '../../config/types'; import { - ViewMedia, EventViewMedia, RecordingViewMedia, - ViewMediaType, VideoContentType, + ViewMedia, + ViewMediaType, } from '../../view/media'; import { FrigateEvent, FrigateRecording } from './types'; import { diff --git a/src/camera-manager/frigate/util.ts b/src/camera-manager/frigate/util.ts index 24c8f532..0f5952a3 100644 --- a/src/camera-manager/frigate/util.ts +++ b/src/camera-manager/frigate/util.ts @@ -1,5 +1,6 @@ import utcToZonedTime from 'date-fns-tz/utcToZonedTime'; -import { CameraConfig, ClipsOrSnapshots } from '../../types'; +import { CameraConfig } from '../../config/types'; +import { ClipsOrSnapshots } from '../../types'; import { formatDateAndTime, prettifyTitle } from '../../utils/basic'; import { FrigateEvent, FrigateRecording } from './types'; diff --git a/src/camera-manager/generic/engine-generic.ts b/src/camera-manager/generic/engine-generic.ts index 9e9207ba..dff7966c 100644 --- a/src/camera-manager/generic/engine-generic.ts +++ b/src/camera-manager/generic/engine-generic.ts @@ -1,35 +1,36 @@ /* eslint-disable @typescript-eslint/no-unused-vars */ import { HomeAssistant } from 'custom-card-helpers'; -import { CameraConfig, ExtendedHomeAssistant } from '../../types'; +import { CameraConfig } from '../../config/types'; +import { ExtendedHomeAssistant } from '../../types'; +import { getEntityIcon, getEntityTitle } from '../../utils/ha'; +import { EntityRegistryManager } from '../../utils/ha/entity-registry'; import { ViewMedia } from '../../view/media'; +import { CameraManagerEngine } from '../engine'; import { + CameraConfigs, + CameraEndpoint, + CameraEndpoints, + CameraEndpointsContext, + CameraManagerCameraCapabilities, CameraManagerCameraMetadata, CameraManagerMediaCapabilities, DataQuery, + Engine, + EngineOptions, EventQuery, EventQueryResultsMap, + MediaMetadataQuery, + MediaMetadataQueryResultsMap, PartialEventQuery, PartialRecordingQuery, PartialRecordingSegmentsQuery, + QueryReturnType, + RecordingQuery, RecordingQueryResultsMap, RecordingSegmentsQuery, RecordingSegmentsQueryResultsMap, - CameraEndpointsContext, - CameraConfigs, - RecordingQuery, - QueryReturnType, - CameraManagerCameraCapabilities, - Engine, - CameraEndpoints, - MediaMetadataQuery, - MediaMetadataQueryResultsMap, - EngineOptions, - CameraEndpoint, } from '../types'; -import { getEntityIcon, getEntityTitle } from '../../utils/ha'; -import { EntityRegistryManager } from '../../utils/ha/entity-registry'; -import { CameraManagerEngine } from '../engine'; export class GenericCameraManagerEngine implements CameraManagerEngine { public getEngineType(): Engine { diff --git a/src/camera-manager/manager.ts b/src/camera-manager/manager.ts index f2c7bc67..b7f6b459 100644 --- a/src/camera-manager/manager.ts +++ b/src/camera-manager/manager.ts @@ -3,9 +3,9 @@ import add from 'date-fns/add'; import cloneDeep from 'lodash-es/cloneDeep'; import merge from 'lodash-es/merge.js'; import sum from 'lodash-es/sum'; +import { CameraConfig, CamerasConfig } from '../config/types.js'; import { MEDIA_CHUNK_SIZE_DEFAULT } from '../const.js'; import { localize } from '../localize/localize.js'; -import { CameraConfig, CamerasConfig } from '../types.js'; import { allPromises, arrayify, setify } from '../utils/basic.js'; import { getCameraID } from '../utils/camera.js'; import { CardCameraAPI } from '../utils/card-controller/types.js'; @@ -17,14 +17,23 @@ import { CameraManagerEngine } from './engine.js'; import { CameraInitializationError } from './error.js'; import { CameraManagerReadOnlyConfigStore, CameraManagerStore } from './store.js'; import { - CameraEndpoint, CameraEndpoints, CameraEndpointsContext, CameraManagerCameraCapabilities, + CameraEndpoint, + CameraEndpoints, + CameraEndpointsContext, + CameraManagerCameraCapabilities, CameraManagerCameraMetadata, CameraManagerCapabilities, - CameraManagerMediaCapabilities, DataQuery, Engine, EngineOptions, EventQuery, + CameraManagerMediaCapabilities, + DataQuery, + Engine, + EngineOptions, + EventQuery, EventQueryResults, EventQueryResultsMap, - MediaMetadata, MediaMetadataQuery, - MediaMetadataQueryResults, MediaQuery, + MediaMetadata, + MediaMetadataQuery, + MediaMetadataQueryResults, + MediaQuery, PartialDataQuery, PartialEventQuery, PartialQueryConcreteType, @@ -40,7 +49,7 @@ import { RecordingSegmentsQuery, RecordingSegmentsQueryResults, RecordingSegmentsQueryResultsMap, - ResultsMap + ResultsMap, } from './types.js'; import { sortMedia } from './util.js'; diff --git a/src/camera-manager/motioneye/engine-motioneye.ts b/src/camera-manager/motioneye/engine-motioneye.ts index badabd70..fbb2e5f8 100644 --- a/src/camera-manager/motioneye/engine-motioneye.ts +++ b/src/camera-manager/motioneye/engine-motioneye.ts @@ -1,6 +1,30 @@ import { HomeAssistant } from 'custom-card-helpers'; -import { CameraConfig } from '../../types'; +import add from 'date-fns/add'; +import endOfDay from 'date-fns/endOfDay'; +import parse from 'date-fns/parse'; +import startOfDay from 'date-fns/startOfDay'; +import orderBy from 'lodash-es/orderBy'; +import { CameraConfig } from '../../config/types'; +import { allPromises, formatDate, isValidDate } from '../../utils/basic'; +import { + BrowseMediaStep, + BrowseMediaTarget, +} from '../../utils/ha/browse-media/browse-media-manager'; +import { + BROWSE_MEDIA_CACHE_SECONDS, + BrowseMedia, + MEDIA_CLASS_IMAGE, + MEDIA_CLASS_VIDEO, + RichBrowseMedia, +} from '../../utils/ha/browse-media/types'; import { ViewMedia } from '../../view/media'; +import { + BrowseMediaCameraManagerEngine, + getViewMediaFromBrowseMediaArray, + isMediaWithinDates, +} from '../browse-media/engine-browse-media'; +import { BrowseMediaMetadata } from '../browse-media/types'; +import { CAMERA_MANAGER_ENGINE_EVENT_LIMIT_DEFAULT } from '../engine'; import { CameraConfigs, CameraEndpoint, @@ -19,32 +43,8 @@ import { QueryResultsType, QueryReturnType, } from '../types'; -import { CAMERA_MANAGER_ENGINE_EVENT_LIMIT_DEFAULT } from '../engine'; -import { - BrowseMediaStep, - BrowseMediaTarget, -} from '../../utils/ha/browse-media/browse-media-manager'; -import { allPromises, formatDate, isValidDate } from '../../utils/basic'; -import endOfDay from 'date-fns/endOfDay'; -import { - BROWSE_MEDIA_CACHE_SECONDS, - BrowseMedia, - MEDIA_CLASS_IMAGE, - MEDIA_CLASS_VIDEO, - RichBrowseMedia, -} from '../../utils/ha/browse-media/types'; -import parse from 'date-fns/parse'; -import { MotionEyeEventQueryResults } from './types'; -import orderBy from 'lodash-es/orderBy'; -import startOfDay from 'date-fns/startOfDay'; -import add from 'date-fns/add'; -import { - BrowseMediaCameraManagerEngine, - getViewMediaFromBrowseMediaArray, - isMediaWithinDates, -} from '../browse-media/engine-browse-media'; -import { BrowseMediaMetadata } from '../browse-media/types'; import motioneyeLogo from './assets/motioneye-logo.svg'; +import { MotionEyeEventQueryResults } from './types'; class MotionEyeQueryResultsClassifier { public static isMotionEyeEventQueryResults( diff --git a/src/camera-manager/store.ts b/src/camera-manager/store.ts index 7d929cb4..4f454234 100644 --- a/src/camera-manager/store.ts +++ b/src/camera-manager/store.ts @@ -1,4 +1,4 @@ -import { CameraConfig } from '../types'; +import { CameraConfig } from '../config/types'; import { ViewMedia } from '../view/media'; import { CameraManagerEngine } from './engine'; import { CameraConfigs, Engine } from './types'; diff --git a/src/camera-manager/types.ts b/src/camera-manager/types.ts index 19b508cc..a9c6906e 100644 --- a/src/camera-manager/types.ts +++ b/src/camera-manager/types.ts @@ -1,4 +1,4 @@ -import { CameraConfig, FrigateCardView } from '../types'; +import { CameraConfig, FrigateCardView } from '../config/types'; import { ViewMedia } from '../view/media'; // ==== diff --git a/src/camera-manager/util.ts b/src/camera-manager/util.ts index c6678b60..d8a5effc 100644 --- a/src/camera-manager/util.ts +++ b/src/camera-manager/util.ts @@ -1,13 +1,13 @@ -import startOfHour from 'date-fns/startOfHour'; -import endOfHour from 'date-fns/endOfHour'; -import startOfDay from 'date-fns/startOfDay'; import endOfDay from 'date-fns/endOfDay'; +import endOfHour from 'date-fns/endOfHour'; import endOfMinute from 'date-fns/endOfMinute'; -import { DateRange } from './range'; +import startOfDay from 'date-fns/startOfDay'; +import startOfHour from 'date-fns/startOfHour'; import orderBy from 'lodash-es/orderBy'; import uniqBy from 'lodash-es/uniqBy'; +import { CameraConfig } from '../config/types'; import { ViewMedia } from '../view/media'; -import { CameraConfig } from '../types'; +import { DateRange } from './range'; export const convertRangeToCacheFriendlyTimes = ( range: DateRange, diff --git a/src/card.ts b/src/card.ts index 975089d9..5579fa1b 100644 --- a/src/card.ts +++ b/src/card.ts @@ -17,16 +17,14 @@ import { renderMessage, renderProgressIndicator } from './components/message.js' import './components/thumbnail-carousel.js'; import './components/views.js'; import { FrigateCardViews } from './components/views.js'; +import { FrigateCardConfig, MenuItem, RawFrigateCardConfig } from './config/types'; import { REPO_URL } from './const.js'; import { localize } from './localize/localize.js'; import cardStyle from './scss/card.scss'; import { ExtendedHomeAssistant, - FrigateCardConfig, MediaLoadedInfo, - MenuButton, Message, - RawFrigateCardConfig, } from './types.js'; import { frigateCardHasAction } from './utils/action.js'; import { ConditionEvaluateRequestEvent } from './utils/card-controller/conditions-manager.js'; @@ -325,11 +323,11 @@ class FrigateCard extends LitElement { .conditionsManagerEpoch=${this._controller .getConditionsManager() ?.getEpoch()} - @frigate-card:menu-add=${(ev: CustomEvent) => { + @frigate-card:menu-add=${(ev: CustomEvent) => { this._menuButtonController.addDynamicMenuButton(ev.detail); this.requestUpdate(); }} - @frigate-card:menu-remove=${(ev: CustomEvent) => { + @frigate-card:menu-remove=${(ev: CustomEvent) => { this._menuButtonController.removeDynamicMenuButton(ev.detail); this.requestUpdate(); }} diff --git a/src/components/carousel.ts b/src/components/carousel.ts index b5a2dc68..e122ea0f 100644 --- a/src/components/carousel.ts +++ b/src/components/carousel.ts @@ -9,8 +9,8 @@ import { } from 'lit'; import { customElement, property } from 'lit/decorators.js'; import { Ref, createRef, ref } from 'lit/directives/ref.js'; +import { TransitionEffect } from '../config/types'; import carouselStyle from '../scss/carousel.scss'; -import { TransitionEffect } from '../types'; import { CarouselController, CarouselDirection, diff --git a/src/components/diagnostics.ts b/src/components/diagnostics.ts index 7e487566..6d908a1c 100644 --- a/src/components/diagnostics.ts +++ b/src/components/diagnostics.ts @@ -1,9 +1,9 @@ import { HomeAssistant } from 'custom-card-helpers'; import { CSSResultGroup, LitElement, TemplateResult, unsafeCSS } from 'lit'; import { customElement, property, state } from 'lit/decorators.js'; +import { RawFrigateCardConfig } from '../config/types'; import { localize } from '../localize/localize'; import basicBlockStyle from '../scss/basic-block.scss'; -import { RawFrigateCardConfig } from '../types'; import { Diagnostics, getDiagnostics } from '../utils/diagnostics'; import { renderMessage } from './message'; diff --git a/src/components/elements.ts b/src/components/elements.ts index dccdc0ea..1cf93617 100644 --- a/src/components/elements.ts +++ b/src/components/elements.ts @@ -1,31 +1,31 @@ import { HASSDomEvent, HomeAssistant } from 'custom-card-helpers'; import { CSSResultGroup, - html, LitElement, PropertyValues, TemplateResult, + html, unsafeCSS, } from 'lit'; import { customElement, property, state } from 'lit/decorators.js'; import { classMap } from 'lit/directives/class-map.js'; import { actionHandler } from '../action-handler-directive.js'; -import { localize } from '../localize/localize.js'; -import ptzStyle from '../scss/elements-ptz.scss'; -import elementsStyle from '../scss/elements.scss'; import { Actions, ActionsConfig, - FrigateCardError, FrigateCardPTZConfig, FrigateConditional, - MenuButton, MenuIcon, + MenuItem, MenuStateIcon, MenuSubmenu, MenuSubmenuSelect, PictureElements, -} from '../types.js'; +} from '../config/types.js'; +import { localize } from '../localize/localize.js'; +import ptzStyle from '../scss/elements-ptz.scss'; +import elementsStyle from '../scss/elements.scss'; +import { FrigateCardError } from '../types.js'; import { frigateCardHandleActionConfig, frigateCardHasAction, @@ -184,11 +184,7 @@ export class FrigateCardElements extends LitElement { protected _menuRemoveHandler(ev: Event): void { // Re-dispatch event from this element (instead of the disconnected one, as // there is no parent of the disconnected element). - dispatchFrigateCardEvent( - this, - 'menu-remove', - (ev as CustomEvent).detail, - ); + dispatchFrigateCardEvent(this, 'menu-remove', (ev as CustomEvent).detail); } /** @@ -196,7 +192,7 @@ export class FrigateCardElements extends LitElement { * @param ev The event. */ protected _menuAddHandler(ev: Event): void { - ev = ev as CustomEvent; + ev = ev as CustomEvent; const path = ev.composedPath(); if (!path.length) { return; diff --git a/src/components/gallery.ts b/src/components/gallery.ts index c53f885d..0cc3fa73 100644 --- a/src/components/gallery.ts +++ b/src/components/gallery.ts @@ -7,37 +7,37 @@ import { unsafeCSS, } from 'lit'; import { customElement, property, state } from 'lit/decorators.js'; -import galleryStyle from '../scss/gallery.scss'; -import galleryCoreStyle from '../scss/gallery-core.scss'; +import { classMap } from 'lit/directives/class-map.js'; +import { createRef, ref, Ref } from 'lit/directives/ref.js'; +import throttle from 'lodash-es/throttle'; +import { CameraManager, ExtendedMediaQueryResult } from '../camera-manager/manager.js'; +import { EventQuery, MediaQuery, RecordingQuery } from '../camera-manager/types'; import { CardWideConfig, - ExtendedHomeAssistant, frigateCardConfigDefaults, GalleryConfig, THUMBNAIL_WIDTH_MAX, -} from '../types.js'; +} from '../config/types'; +import { localize } from '../localize/localize'; +import galleryCoreStyle from '../scss/gallery-core.scss'; +import galleryStyle from '../scss/gallery.scss'; +import { ExtendedHomeAssistant } from '../types.js'; import { stopEventFromActivatingCardWideActions } from '../utils/action.js'; +import { errorToConsole, sleep } from '../utils/basic'; import { changeViewToRecentEventsForCameraAndDependents, changeViewToRecentRecordingForCameraAndDependents, } from '../utils/media-to-view.js'; -import { CameraManager, ExtendedMediaQueryResult } from '../camera-manager/manager.js'; +import { ViewMedia } from '../view/media'; +import { EventMediaQueries, RecordingMediaQueries } from '../view/media-queries'; +import { MediaQueriesClassifier } from '../view/media-queries-classifier'; +import { MediaQueriesResults } from '../view/media-queries-results'; import { View } from '../view/view.js'; +import './media-filter'; import { renderMessage, renderProgressIndicator } from './message.js'; +import './surround-basic'; import './thumbnail.js'; import { THUMBNAIL_DETAILS_WIDTH_MIN } from './thumbnail.js'; -import { createRef, ref, Ref } from 'lit/directives/ref.js'; -import { MediaQueriesClassifier } from '../view/media-queries-classifier'; -import { EventMediaQueries, RecordingMediaQueries } from '../view/media-queries'; -import { EventQuery, MediaQuery, RecordingQuery } from '../camera-manager/types'; -import { MediaQueriesResults } from '../view/media-queries-results'; -import { errorToConsole, sleep } from '../utils/basic'; -import './media-filter'; -import './surround-basic'; -import { ViewMedia } from '../view/media'; -import { localize } from '../localize/localize'; -import throttle from 'lodash-es/throttle'; -import { classMap } from 'lit/directives/class-map.js'; const GALLERY_MEDIA_FILTER_MENU_ICONS = { closed: 'mdi:filter-cog-outline', diff --git a/src/components/image.ts b/src/components/image.ts index 215dadad..e54b91e3 100644 --- a/src/components/image.ts +++ b/src/components/image.ts @@ -6,29 +6,25 @@ import { LitElement, PropertyValues, TemplateResult, - unsafeCSS + unsafeCSS, } from 'lit'; import { customElement, property } from 'lit/decorators.js'; import { live } from 'lit/directives/live.js'; import { createRef, ref, Ref } from 'lit/directives/ref.js'; import isEqual from 'lodash-es/isEqual'; import { CachedValueController } from '../cached-value-controller.js'; +import { CameraConfig, ImageViewConfig } from '../config/types.js'; import defaultImage from '../images/frigate-bird-in-sky.jpg'; import { localize } from '../localize/localize.js'; import imageStyle from '../scss/image.scss'; -import { - CameraConfig, - FrigateCardMediaPlayer, - ImageViewConfig, - MediaLoadedInfo -} from '../types.js'; +import { FrigateCardMediaPlayer, MediaLoadedInfo } from '../types.js'; import { contentsChanged } from '../utils/basic.js'; import { isHassDifferent } from '../utils/ha'; import { createMediaLoadedInfo, dispatchExistingMediaLoadedInfoAsEvent, dispatchMediaPauseEvent, - dispatchMediaPlayEvent + dispatchMediaPlayEvent, } from '../utils/media-info.js'; import { updateElementStyleFromMediaLayoutConfig } from '../utils/media-layout.js'; import { View } from '../view/view.js'; diff --git a/src/components/live/live-go2rtc.ts b/src/components/live/live-go2rtc.ts index ce425716..1602e774 100644 --- a/src/components/live/live-go2rtc.ts +++ b/src/components/live/live-go2rtc.ts @@ -8,14 +8,10 @@ import { } from 'lit'; import { customElement, property } from 'lit/decorators.js'; import { CameraEndpoints } from '../../camera-manager/types.js'; +import { CameraConfig, MicrophoneConfig } from '../../config/types.js'; import { localize } from '../../localize/localize'; import liveMSEStyle from '../../scss/live-go2rtc.scss'; -import { - CameraConfig, - ExtendedHomeAssistant, - FrigateCardMediaPlayer, - MicrophoneConfig, -} from '../../types.js'; +import { ExtendedHomeAssistant, FrigateCardMediaPlayer } from '../../types.js'; import { getEndpointAddressOrDispatchError } from '../../utils/endpoint'; import { setControlsOnVideo } from '../../utils/media.js'; import { screenshotMedia } from '../../utils/screenshot.js'; diff --git a/src/components/live/live-ha.ts b/src/components/live/live-ha.ts index 8fd36b2e..0ce54b49 100644 --- a/src/components/live/live-ha.ts +++ b/src/components/live/live-ha.ts @@ -2,12 +2,13 @@ import { HomeAssistant } from 'custom-card-helpers'; import { CSSResultGroup, html, LitElement, TemplateResult, unsafeCSS } from 'lit'; import { customElement, property } from 'lit/decorators.js'; import { createRef, Ref, ref } from 'lit/directives/ref.js'; -import liveHAStyle from '../../scss/live-ha.scss'; -import { CameraConfig, FrigateCardMediaPlayer } from '../../types.js'; -import { getStateObjOrDispatchError } from './live.js'; +import { CameraConfig } from '../../config/types'; import '../../patches/ha-camera-stream'; import '../../patches/ha-hls-player.js'; import '../../patches/ha-web-rtc-player.ts'; +import liveHAStyle from '../../scss/live-ha.scss'; +import { FrigateCardMediaPlayer } from '../../types.js'; +import { getStateObjOrDispatchError } from './live.js'; @customElement('frigate-card-live-ha') export class FrigateCardLiveHA extends LitElement implements FrigateCardMediaPlayer { @@ -55,7 +56,7 @@ export class FrigateCardLiveHA extends LitElement implements FrigateCardMediaPla } public async getScreenshotURL(): Promise { - return await this._playerRef.value?.getScreenshotURL() ?? null; + return (await this._playerRef.value?.getScreenshotURL()) ?? null; } protected render(): TemplateResult | void { diff --git a/src/components/live/live-image.ts b/src/components/live/live-image.ts index 3aad8aa6..90aa7689 100644 --- a/src/components/live/live-image.ts +++ b/src/components/live/live-image.ts @@ -2,8 +2,9 @@ import { HomeAssistant } from 'custom-card-helpers'; import { CSSResultGroup, html, LitElement, TemplateResult, unsafeCSS } from 'lit'; import { customElement, property } from 'lit/decorators.js'; import { createRef, ref, Ref } from 'lit/directives/ref.js'; +import { CameraConfig } from '../../config/types'; import basicBlockStyle from '../../scss/basic-block.scss'; -import { CameraConfig, FrigateCardMediaPlayer } from '../../types.js'; +import { FrigateCardMediaPlayer } from '../../types.js'; import '../image.js'; import { getStateObjOrDispatchError } from './live.js'; diff --git a/src/components/live/live-jsmpeg.ts b/src/components/live/live-jsmpeg.ts index 16e260dc..8d849d2a 100644 --- a/src/components/live/live-jsmpeg.ts +++ b/src/components/live/live-jsmpeg.ts @@ -4,22 +4,18 @@ import { customElement, property } from 'lit/decorators.js'; import { until } from 'lit/directives/until.js'; import { CameraEndpoints } from '../../camera-manager/types.js'; import { renderProgressIndicator } from '../../components/message.js'; +import { CameraConfig, CardWideConfig } from '../../config/types.js'; import { localize } from '../../localize/localize.js'; import liveJSMPEGStyle from '../../scss/live-jsmpeg.scss'; -import { - CameraConfig, - CardWideConfig, - ExtendedHomeAssistant, - FrigateCardMediaPlayer, -} from '../../types.js'; +import { ExtendedHomeAssistant, FrigateCardMediaPlayer } from '../../types.js'; import { getEndpointAddressOrDispatchError } from '../../utils/endpoint.js'; import { dispatchMediaLoadedEvent, dispatchMediaPauseEvent, dispatchMediaPlayEvent, } from '../../utils/media-info.js'; -import { dispatchErrorMessageEvent } from '../message.js'; import { Timer } from '../../utils/timer.js'; +import { dispatchErrorMessageEvent } from '../message.js'; // Number of seconds a signed URL is valid for. const JSMPEG_URL_SIGN_EXPIRY_SECONDS = 24 * 60 * 60; diff --git a/src/components/live/live-webrtc-card.ts b/src/components/live/live-webrtc-card.ts index 9ed4d42b..00b43bbf 100644 --- a/src/components/live/live-webrtc-card.ts +++ b/src/components/live/live-webrtc-card.ts @@ -3,14 +3,10 @@ import { HomeAssistant } from 'custom-card-helpers'; import { CSSResultGroup, html, LitElement, TemplateResult, unsafeCSS } from 'lit'; import { customElement, property } from 'lit/decorators.js'; import { CameraEndpoints } from '../../camera-manager/types.js'; +import { CameraConfig, CardWideConfig } from '../../config/types.js'; import { localize } from '../../localize/localize.js'; import liveWebRTCCardStyle from '../../scss/live-webrtc-card.scss'; -import { - CameraConfig, - CardWideConfig, - FrigateCardError, - FrigateCardMediaPlayer, -} from '../../types.js'; +import { FrigateCardError, FrigateCardMediaPlayer } from '../../types.js'; import { mayHaveAudio } from '../../utils/audio.js'; import { dispatchMediaLoadedEvent, diff --git a/src/components/live/live.ts b/src/components/live/live.ts index 2df1e729..43c12c66 100644 --- a/src/components/live/live.ts +++ b/src/components/live/live.ts @@ -17,28 +17,30 @@ import { createRef, Ref, ref } from 'lit/directives/ref.js'; import { CameraManager } from '../../camera-manager/manager.js'; import { CameraConfigs, CameraEndpoints } from '../../camera-manager/types.js'; import { - ConditionsManagerEpoch, - getOverriddenConfig, -} from '../../utils/card-controller/conditions-manager.js'; + CameraConfig, + CardWideConfig, + frigateCardConfigDefaults, + LiveConfig, + LiveOverrides, + LiveProvider, + TransitionEffect, +} from '../../config/types.js'; import { localize } from '../../localize/localize.js'; import basicBlockStyle from '../../scss/basic-block.scss'; import liveCarouselStyle from '../../scss/live-carousel.scss'; import liveProviderStyle from '../../scss/live-provider.scss'; import { - CameraConfig, - CardWideConfig, ExtendedHomeAssistant, - frigateCardConfigDefaults, FrigateCardMediaPlayer, - LiveConfig, - LiveOverrides, - LiveProvider, MediaLoadedInfo, Message, - TransitionEffect, } from '../../types.js'; import { stopEventFromActivatingCardWideActions } from '../../utils/action.js'; import { contentsChanged } from '../../utils/basic.js'; +import { + ConditionsManagerEpoch, + getOverriddenConfig, +} from '../../utils/card-controller/conditions-manager.js'; import { CarouselSelected } from '../../utils/embla/carousel-controller.js'; import { AutoLazyLoad } from '../../utils/embla/plugins/auto-lazy-load/auto-lazy-load.js'; import { AutoMediaActions } from '../../utils/embla/plugins/auto-media-actions/auto-media-actions.js'; diff --git a/src/components/media-filter.ts b/src/components/media-filter.ts index f58cab60..f5d71206 100644 --- a/src/components/media-filter.ts +++ b/src/components/media-filter.ts @@ -1,3 +1,15 @@ +import { ScopedRegistryHost } from '@lit-labs/scoped-registry-mixin'; +import { HomeAssistant } from 'custom-card-helpers'; +import endOfDay from 'date-fns/endOfDay'; +import endOfMonth from 'date-fns/endOfMonth'; +import endOfYesterday from 'date-fns/endOfYesterday'; +import endOfToday from 'date-fns/esm/endOfToday'; +import startOfToday from 'date-fns/esm/startOfToday'; +import format from 'date-fns/format'; +import parse from 'date-fns/parse'; +import startOfDay from 'date-fns/startOfDay'; +import startOfYesterday from 'date-fns/startOfYesterday'; +import sub from 'date-fns/sub'; import { CSSResultGroup, html, @@ -10,35 +22,23 @@ import { } from 'lit'; import { customElement, property } from 'lit/decorators.js'; import { createRef, ref, Ref } from 'lit/directives/ref.js'; +import isEqual from 'lodash-es/isEqual'; +import orderBy from 'lodash-es/orderBy'; +import uniqWith from 'lodash-es/uniqWith'; +import { CameraManager } from '../camera-manager/manager'; import { DateRange } from '../camera-manager/range'; +import { DataQuery, MediaMetadata, QueryType } from '../camera-manager/types'; +import { CardWideConfig } from '../config/types'; import { localize } from '../localize/localize'; import mediaFilterStyle from '../scss/media-filter.scss'; -import { executeMediaQueryForViewWithErrorDispatching } from '../utils/media-to-view.js'; import { errorToConsole, formatDate, prettifyTitle } from '../utils/basic'; -import { ScopedRegistryHost } from '@lit-labs/scoped-registry-mixin'; -import './select'; -import { FrigateCardSelect, SelectOption, SelectValues } from './select'; -import uniqWith from 'lodash-es/uniqWith'; -import sub from 'date-fns/sub'; -import endOfDay from 'date-fns/endOfDay'; -import endOfYesterday from 'date-fns/endOfYesterday'; -import endOfToday from 'date-fns/esm/endOfToday'; -import startOfToday from 'date-fns/esm/startOfToday'; -import startOfDay from 'date-fns/startOfDay'; -import startOfYesterday from 'date-fns/startOfYesterday'; -import parse from 'date-fns/parse'; +import { executeMediaQueryForViewWithErrorDispatching } from '../utils/media-to-view.js'; +import { EventMediaQueries, RecordingMediaQueries } from '../view/media-queries'; import { MediaQueriesClassifier } from '../view/media-queries-classifier'; import { View } from '../view/view'; -import { CameraManager } from '../camera-manager/manager'; -import { HomeAssistant } from 'custom-card-helpers'; -import { DataQuery, MediaMetadata, QueryType } from '../camera-manager/types'; -import format from 'date-fns/format'; -import endOfMonth from 'date-fns/endOfMonth'; -import isEqual from 'lodash-es/isEqual'; -import { EventMediaQueries, RecordingMediaQueries } from '../view/media-queries'; +import './select'; +import { FrigateCardSelect, SelectOption, SelectValues } from './select'; import './select.js'; -import orderBy from 'lodash-es/orderBy'; -import { CardWideConfig } from '../types'; interface MediaFilterCoreDefaults { cameraIDs?: string[]; diff --git a/src/components/media-grid.ts b/src/components/media-grid.ts index 913e1aa7..8bbda7f6 100644 --- a/src/components/media-grid.ts +++ b/src/components/media-grid.ts @@ -8,8 +8,8 @@ import { } from 'lit'; import { customElement, property } from 'lit/decorators.js'; import { createRef, ref, Ref } from 'lit/directives/ref.js'; +import { ViewDisplayConfig } from '../config/types'; import mediaGridStyle from '../scss/media-grid.scss'; -import { ViewDisplayConfig } from '../types.js'; import { MediaGridController } from '../utils/media-grid-controller.js'; @customElement('frigate-card-media-grid') diff --git a/src/components/menu.ts b/src/components/menu.ts index eca5fbe3..255fd893 100644 --- a/src/components/menu.ts +++ b/src/components/menu.ts @@ -1,10 +1,10 @@ import { HASSDomEvent, HomeAssistant } from 'custom-card-helpers'; import { CSSResultGroup, - html, LitElement, PropertyValues, TemplateResult, + html, unsafeCSS, } from 'lit'; import { customElement, property, state } from 'lit/decorators.js'; @@ -12,26 +12,25 @@ import { classMap } from 'lit/directives/class-map.js'; import { ifDefined } from 'lit/directives/if-defined.js'; import { styleMap } from 'lit/directives/style-map.js'; import { actionHandler } from '../action-handler-directive.js'; -import menuStyle from '../scss/menu.scss'; +import { FRIGATE_ICON_SVG_PATH } from '../camera-manager/frigate/icon.js'; import type { - ActionsConfig, ActionType, - MenuButton, + ActionsConfig, MenuConfig, MenuItem, - StateParameters, -} from '../types.js'; +} from '../config/types.js'; +import { FRIGATE_BUTTON_MENU_ICON } from '../const.js'; +import menuStyle from '../scss/menu.scss'; +import type { StateParameters } from '../types.js'; import { convertActionToFrigateCardCustomAction, frigateCardHandleActionConfig, frigateCardHasAction, getActionConfigGivenAction, } from '../utils/action.js'; -import { FRIGATE_ICON_SVG_PATH } from '../camera-manager/frigate/icon.js'; import { refreshDynamicStateParameters } from '../utils/ha'; -import './submenu.js'; import { EntityRegistryManager } from '../utils/ha/entity-registry/index.js'; -import { FRIGATE_BUTTON_MENU_ICON } from '../const.js'; +import './submenu.js'; /** * A menu for the FrigateCard. @@ -62,7 +61,7 @@ export class FrigateCardMenu extends LitElement { protected _menuConfig?: MenuConfig; @property({ attribute: false }) - public buttons: MenuButton[] = []; + public buttons: MenuItem[] = []; @property({ attribute: false }) public entityRegistryManager?: EntityRegistryManager; @@ -228,7 +227,7 @@ export class FrigateCardMenu extends LitElement { * @param button The button configuration to render. * @returns A rendered template or void. */ - protected _renderButton(button: MenuButton): TemplateResult | void { + protected _renderButton(button: MenuItem): TemplateResult | void { if (button.type === 'custom:frigate-card-menu-submenu') { return html` { if (stateParameters.icon) { return html` stopEventFromActivatingCardWideActions(ev)} > ; + +const viewDisplaySchema = z + .object({ + mode: viewDisplayModeSchema, + grid_selected_width_factor: z.number().min(0).optional(), + grid_max_columns: z.number().min(0).optional(), + grid_columns: z.number().min(0).optional(), + }) + .optional(); +export type ViewDisplayConfig = z.infer; + +// ************************************************************************* +// Actions +// +// Declare schemas to existing types: +// - https://github.com/colinhacks/zod/issues/372#issuecomment-826380330 +// ************************************************************************* + +const schemaForType = + () => + // eslint-disable-next-line @typescript-eslint/no-explicit-any + >(arg: S) => { + return arg; + }; + +// https://www.home-assistant.io/dashboards/actions/#options-for-confirmation +const actionBaseSchema = z.object({ + confirmation: z + .boolean() + .or( + z.object({ + text: z.string().optional(), + exemptions: z + .object({ + user: z.string(), + }) + .array() + .optional(), + }), + ) + .optional(), +}); + +// HA accepts either a boolean or a ConfirmationRestrictionConfig object. +// `custom-card-helpers` currently only supports the latter. For maximum +// compatibility, this card supports what HA supports. +interface ExtendedConfirmationRestrictionConfig { + confirmation?: boolean | ConfirmationRestrictionConfig; +} + +const toggleActionSchema = schemaForType< + ToggleActionConfig & ExtendedConfirmationRestrictionConfig +>()( + actionBaseSchema.extend({ + action: z.literal('toggle'), + }), +); + +const callServiceActionSchema = schemaForType< + CallServiceActionConfig & ExtendedConfirmationRestrictionConfig +>()( + actionBaseSchema.extend({ + action: z.literal('call-service'), + service: z.string(), + service_data: z.object({}).passthrough().optional(), + }), +); + +const navigateActionSchema = schemaForType< + NavigateActionConfig & ExtendedConfirmationRestrictionConfig +>()( + actionBaseSchema.extend({ + action: z.literal('navigate'), + navigation_path: z.string(), + }), +); + +const urlActionSchema = schemaForType< + UrlActionConfig & ExtendedConfirmationRestrictionConfig +>()( + actionBaseSchema.extend({ + action: z.literal('url'), + url_path: z.string(), + }), +); + +const moreInfoActionSchema = schemaForType< + MoreInfoActionConfig & ExtendedConfirmationRestrictionConfig +>()( + actionBaseSchema.extend({ + action: z.literal('more-info'), + }), +); + +const customActionSchema = actionBaseSchema + .extend({ + action: z.literal('fire-dom-event'), + }) + .passthrough(); + +const noActionSchema = schemaForType< + NoActionConfig & ExtendedConfirmationRestrictionConfig +>()( + actionBaseSchema.extend({ + action: z.literal('none'), + }), +); + +export const frigateCardCustomActionsBaseSchema = customActionSchema.extend({ + action: z + .literal('custom:frigate-card-action') + // Syntactic sugar to avoid 'fire-dom-event' as part of an external API. + .transform((): 'fire-dom-event' => 'fire-dom-event') + .or(z.literal('fire-dom-event')), + + // Card this command is intended for. + card_id: z + .string() + .regex(/^\w+$/, 'card_id parameter can only contain [a-z][A-Z][0-9_]') + .optional(), +}); + +// ************************************************************************* +// Custom Actions +// ************************************************************************* + +const FRIGATE_CARD_GENERAL_ACTIONS = [ + 'camera_ui', + 'default', + 'diagnostics', + 'expand', + 'download', + 'fullscreen', + 'menu_toggle', + 'mute', + 'live_substream_on', + 'live_substream_off', + 'microphone_mute', + 'microphone_unmute', + 'play', + 'pause', + 'screenshot', + 'unmute', +] as const; + +const FRIGATE_CARD_ACTIONS = [ + ...FRIGATE_CARD_VIEWS_USER_SPECIFIED, + ...FRIGATE_CARD_GENERAL_ACTIONS, + 'camera_select', + 'live_substream_select', + 'media_player', + 'display_mode_select', +] as const; +export type FrigateCardAction = (typeof FRIGATE_CARD_ACTIONS)[number]; + +const frigateCardViewActionSchema = frigateCardCustomActionsBaseSchema.extend({ + frigate_card_action: z.enum(FRIGATE_CARD_VIEWS_USER_SPECIFIED), +}); +export type FrigateCardViewAction = z.infer; + +const frigateCardGeneralActionSchema = frigateCardCustomActionsBaseSchema.extend({ + frigate_card_action: z.enum(FRIGATE_CARD_GENERAL_ACTIONS), +}); + +const frigateCardCameraSelectActionSchema = frigateCardCustomActionsBaseSchema.extend({ + frigate_card_action: z.literal('camera_select'), + camera: z.string(), +}); + +const frigateCardLiveDependencySelectActionSchema = + frigateCardCustomActionsBaseSchema.extend({ + frigate_card_action: z.literal('live_substream_select'), + camera: z.string(), + }); + +const frigateCardMediaPlayerActionSchema = frigateCardCustomActionsBaseSchema.extend({ + frigate_card_action: z.literal('media_player'), + media_player: z.string(), + media_player_action: z.enum(['play', 'stop']), +}); + +const frigateCardViewDisplayModeActionSchema = frigateCardCustomActionsBaseSchema.extend( + { + frigate_card_action: z.literal('display_mode_select'), + display_mode: viewDisplayModeSchema, + }, +); + +export const frigateCardCustomActionSchema = z.union([ + frigateCardViewActionSchema, + frigateCardGeneralActionSchema, + frigateCardCameraSelectActionSchema, + frigateCardLiveDependencySelectActionSchema, + frigateCardMediaPlayerActionSchema, + frigateCardViewDisplayModeActionSchema, +]); +export type FrigateCardCustomAction = z.infer; + +// Cannot use discriminatedUnion since frigateCardCustomActionSchema uses a +// transform on the discriminated union key. +export const actionSchema = z.union([ + toggleActionSchema, + callServiceActionSchema, + navigateActionSchema, + urlActionSchema, + moreInfoActionSchema, + noActionSchema, + customActionSchema, + frigateCardCustomActionSchema, +]); +export type ActionType = z.infer; + +const actionsBaseSchema = z + .object({ + tap_action: actionSchema.or(actionSchema.array()).optional(), + hold_action: actionSchema.or(actionSchema.array()).optional(), + double_tap_action: actionSchema.or(actionSchema.array()).optional(), + start_tap_action: actionSchema.or(actionSchema.array()).optional(), + end_tap_action: actionSchema.or(actionSchema.array()).optional(), + }) + // Passthrough to allow (at least) entity/camera_image to go through. This + // card doesn't need these attributes, but handleAction() in + // custom_card_helpers may depending on how the action is configured. + .passthrough(); +export type Actions = z.infer; + +export type ActionsConfig = Actions & { + camera_image?: string; + entity?: string; +}; + +const actionsSchema = z.object({ + actions: actionsBaseSchema.optional(), +}); + +const elementsBaseSchema = actionsBaseSchema.extend({ + style: z.object({}).passthrough().optional(), + title: z.string().nullable().optional(), +}); + +// ************************************************************************* +// Picture Elements +// +// All picture element types are validated (not just the Frigate card custom +// ones) as a convenience to present the user with a consistent error display +// up-front regardless of where they made their error. +// ************************************************************************* + +// https://www.home-assistant.io/lovelace/picture-elements/#state-badge +const stateBadgeIconSchema = elementsBaseSchema.extend({ + type: z.literal('state-badge'), + entity: z.string(), +}); + +// https://www.home-assistant.io/lovelace/picture-elements/#state-icon +const stateIconSchema = elementsBaseSchema.extend({ + type: z.literal('state-icon'), + entity: z.string(), + icon: z.string().optional(), + state_color: z.boolean().default(true), +}); + +// https://www.home-assistant.io/lovelace/picture-elements/#state-label +const stateLabelSchema = elementsBaseSchema.extend({ + type: z.literal('state-label'), + entity: z.string(), + attribute: z.string().optional(), + prefix: z.string().optional(), + suffix: z.string().optional(), +}); + +// https://www.home-assistant.io/lovelace/picture-elements/#service-call-button +const serviceCallButtonSchema = elementsBaseSchema.extend({ + type: z.literal('service-button'), + // Title is required for service button. + title: z.string(), + service: z.string(), + service_data: z.object({}).passthrough().optional(), +}); + +// https://www.home-assistant.io/lovelace/picture-elements/#icon-element +const iconSchema = elementsBaseSchema.extend({ + type: z.literal('icon'), + icon: z.string(), + entity: z.string().optional(), +}); + +// https://www.home-assistant.io/lovelace/picture-elements/#image-element +const imageSchema = elementsBaseSchema.extend({ + type: z.literal('image'), + entity: z.string().optional(), + image: z.string().optional(), + camera_image: z.string().optional(), + camera_view: z.string().optional(), + state_image: z.object({}).passthrough().optional(), + filter: z.string().optional(), + state_filter: z.object({}).passthrough().optional(), + aspect_ratio: z.string().optional(), +}); + +// This state condition is used both for the Picture elements conditional +// schema, and also in frigateCardConditionSchema. +const stateConditions = z + .object({ + entity: z.string(), + state: z.string().optional(), + state_not: z.string().optional(), + }) + .array(); + +// https://www.home-assistant.io/lovelace/picture-elements/#image-element +export const conditionalSchema = z.object({ + type: z.literal('conditional'), + conditions: stateConditions, + elements: z.lazy(() => pictureElementsSchema), +}); + +// https://www.home-assistant.io/lovelace/picture-elements/#custom-elements +export const customSchema = z + .object({ + // Insist that Frigate card custom elements are handled by other schemas. + type: z.string().superRefine((val, ctx) => { + if (!val.match(/^custom:(?!frigate-card).+/)) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: 'Frigate-card custom elements must match specific schemas', + fatal: true, + }); + } + }), + }) + .passthrough(); + +// ************************************************************************* +// Custom Element Configuration: Menu +// ************************************************************************* + +const menuBaseSchema = z.object({ + enabled: z.boolean().default(true).optional(), + priority: z + .number() + .min(0) + .max(FRIGATE_MENU_PRIORITY_MAX) + .default(FRIGATE_MENU_PRIORITY_DEFAULT) + .optional(), + alignment: z.enum(['matching', 'opposing']).default('matching').optional(), + icon: z.string().optional(), +}); + +const menuIconSchema = menuBaseSchema.merge(iconSchema).extend({ + type: z.literal('custom:frigate-card-menu-icon'), +}); +export type MenuIcon = z.infer; + +const menuStateIconSchema = menuBaseSchema + .merge(stateIconSchema) + .extend({ + type: z.literal('custom:frigate-card-menu-state-icon'), + }) + .merge(menuBaseSchema); +export type MenuStateIcon = z.infer; + +const menuSubmenuItemSchema = elementsBaseSchema.extend({ + entity: z.string().optional(), + icon: z.string().optional(), + state_color: z.boolean().default(true), + selected: z.boolean().default(false), + subtitle: z.string().optional(), + enabled: z.boolean().default(true), +}); +export type MenuSubmenuItem = z.infer; + +const menuSubmenuSchema = menuBaseSchema.merge(iconSchema).extend({ + type: z.literal('custom:frigate-card-menu-submenu'), + items: menuSubmenuItemSchema.array(), +}); +export type MenuSubmenu = z.infer; + +const menuSubmenuSelectSchema = menuBaseSchema.merge(stateIconSchema).extend({ + type: z.literal('custom:frigate-card-menu-submenu-select'), + options: z.record(menuSubmenuItemSchema.deepPartial()).optional(), +}); +export type MenuSubmenuSelect = z.infer; +export type MenuItem = MenuIcon | MenuStateIcon | MenuSubmenu | MenuSubmenuSelect; + +// ************************************************************************* +// Custom Element Configuration: Conditions +// ************************************************************************* + +export const frigateCardConditionSchema = z.object({ + view: z.string().array().optional(), + fullscreen: z.boolean().optional(), + expand: z.boolean().optional(), + camera: z.string().array().optional(), + media_loaded: z.boolean().optional(), + state: stateConditions.optional(), + media_query: z.string().optional(), + display_mode: viewDisplayModeSchema.optional(), +}); +export type FrigateCardCondition = z.infer; + +export const frigateConditionalSchema = z.object({ + type: z.literal('custom:frigate-card-conditional'), + conditions: frigateCardConditionSchema, + elements: z.lazy(() => pictureElementsSchema), +}); + +export type FrigateConditional = z.infer; + +// ************************************************************************* +// Custom Element Configuration: PTZ +// ************************************************************************* + +export const frigateCardPTZSchema = z.preprocess( + // To avoid lots of YAML duplication, provide an easy way to just specify the + // service data as actions for each PTZ icon, and it will be preprocessed into + // the full form. This also provides compatability with the AlexIT/WebRTC PTZ + // configuration. + (data) => { + if (!data || typeof data !== 'object' || !data['service']) { + return data; + } + const out = { ...data }; + ['left', 'right', 'up', 'down', 'zoom_in', 'zoom_out', 'home'].forEach((name) => { + if (`data_${name}` in data && !(`actions_${name}` in data)) { + out[`actions_${name}`] = { + tap_action: { + action: 'call-service', + service: data['service'], + service_data: data[`data_${name}`], + }, + }; + delete out[`data_${name}`]; + } + }); + return out; + }, + z.object({ + type: z.literal('custom:frigate-card-ptz'), + style: z.object({}).passthrough().optional(), + orientation: z.enum(['vertical', 'horizontal']).default('vertical').optional(), + service: z.string().optional(), + actions_left: actionsBaseSchema.optional(), + actions_right: actionsBaseSchema.optional(), + actions_up: actionsBaseSchema.optional(), + actions_down: actionsBaseSchema.optional(), + actions_zoom_in: actionsBaseSchema.optional(), + actions_zoom_out: actionsBaseSchema.optional(), + actions_home: actionsBaseSchema.optional(), + }), +); +export type FrigateCardPTZConfig = z.infer; + +// ************************************************************************* +// Custom Element Configuration: Stock Picture Elements + Custom +// ************************************************************************* + +// Cannot use discriminatedUnion since customSchema uses a superRefine, which +// causes false rejections. +const pictureElementSchema = z.union([ + menuStateIconSchema, + menuIconSchema, + menuSubmenuSchema, + menuSubmenuSelectSchema, + frigateConditionalSchema, + frigateCardPTZSchema, + stateBadgeIconSchema, + stateIconSchema, + stateLabelSchema, + serviceCallButtonSchema, + iconSchema, + imageSchema, + conditionalSchema, + customSchema, +]); +const pictureElementsSchema = pictureElementSchema.array().optional(); +export type PictureElements = z.infer; + +// ************************************************************************* +// Media Layout Configuration +// ************************************************************************* + +const mediaLayoutConfigSchema = z.object({ + fit: z.enum(['contain', 'cover', 'fill']).optional(), + position: z + .object({ + x: z.number().min(0).max(100).optional(), + y: z.number().min(0).max(100).optional(), + }) + .optional(), +}); +export type MediaLayoutConfig = z.infer; + +// ************************************************************************* +// Image Configuration +// Image config base options are used both for the `image` live provider and the +// `image` card view. +// ************************************************************************* + +const imageBaseConfigDefault = { + refresh_seconds: 1, +}; + +const imageBaseConfigSchema = z.object({ + url: z.string().optional(), + refresh_seconds: z.number().min(0).default(imageBaseConfigDefault.refresh_seconds), +}); + +const IMAGE_MODES = ['screensaver', 'camera', 'url'] as const; +const imageConfigDefault = { + mode: 'url' as const, + zoomable: true, + ...imageBaseConfigDefault, +}; + +const imageConfigSchema = imageBaseConfigSchema + .extend({ + mode: z.enum(IMAGE_MODES).default(imageConfigDefault.mode), + layout: mediaLayoutConfigSchema.optional(), + zoomable: z.boolean().default(imageConfigDefault.zoomable), + }) + .merge(actionsSchema) + .default(imageConfigDefault); +export type ImageViewConfig = z.infer; + +// ************************************************************************* +// Thumbnail Configuration +// ************************************************************************* + +// The min/max width thumbnail (Frigate returns a maximum of 175px). +export const THUMBNAIL_WIDTH_MAX = 175; +export const THUMBNAIL_WIDTH_MIN = 75; + +const thumbnailControlsBaseDefaults = { + size: 100, + show_details: true, + show_favorite_control: true, + show_timeline_control: true, + show_download_control: true, +}; + +// Configuration for the actual rendered thumbnail. +const thumbnailsControlBaseSchema = z.object({ + size: z + .number() + .min(THUMBNAIL_WIDTH_MIN) + .max(THUMBNAIL_WIDTH_MAX) + .default(thumbnailControlsBaseDefaults.size), + show_details: z.boolean().default(thumbnailControlsBaseDefaults.show_details), + show_favorite_control: z + .boolean() + .default(thumbnailControlsBaseDefaults.show_favorite_control), + show_timeline_control: z + .boolean() + .default(thumbnailControlsBaseDefaults.show_timeline_control), + show_download_control: z + .boolean() + .default(thumbnailControlsBaseDefaults.show_download_control), +}); +export type ThumbnailsControlBaseConfig = z.infer; + +const thumbnailControlsDefaults = { + ...thumbnailControlsBaseDefaults, + mode: 'right' as const, +}; + +const thumbnailsControlSchema = thumbnailsControlBaseSchema.extend({ + mode: z + .enum(['none', 'above', 'below', 'left', 'right']) + .default(thumbnailControlsDefaults.mode), +}); +export type ThumbnailsControlConfig = z.infer; + +// ************************************************************************* +// Timeline Configuration +// ************************************************************************* + +const timelineCoreConfigDefault = { + clustering_threshold: 3, + media: 'all' as const, + window_seconds: 60 * 60, + show_recordings: true, + style: 'stack' as const, +}; + +const timelineMediaSchema = z.enum(['all', 'clips', 'snapshots']); + +const timelineCoreConfigSchema = z.object({ + clustering_threshold: z + .number() + .optional() + .default(timelineCoreConfigDefault.clustering_threshold), + media: timelineMediaSchema.optional().default(timelineCoreConfigDefault.media), + window_seconds: z + .number() + .min(1 * 60) + .max(24 * 60 * 60) + .optional() + .default(timelineCoreConfigDefault.window_seconds), + show_recordings: z + .boolean() + .optional() + .default(timelineCoreConfigDefault.show_recordings), + style: z.enum(['stack', 'ribbon']).optional().default(timelineCoreConfigDefault.style), +}); +export type TimelineCoreConfig = z.infer; + +const miniTimelineConfigDefault = { + ...timelineCoreConfigDefault, + mode: 'none' as const, + + // Mini-timeline defaults to ribbon style. + style: 'ribbon' as const, +}; + +const miniTimelineConfigSchema = timelineCoreConfigSchema.extend({ + mode: z.enum(['none', 'above', 'below']).default(miniTimelineConfigDefault.mode), + style: timelineCoreConfigSchema.shape.style.default(miniTimelineConfigDefault.style), +}); +export type MiniTimelineControlConfig = z.infer; + +const timelineConfigDefault = { + ...timelineCoreConfigDefault, + controls: { + thumbnails: thumbnailControlsDefaults, + }, +}; +const timelineConfigSchema = timelineCoreConfigSchema + .extend({ + controls: z + .object({ + thumbnails: thumbnailsControlSchema.default( + timelineConfigDefault.controls.thumbnails, + ), + }) + .default(timelineConfigDefault.controls), + }) + .default(timelineConfigDefault); +export type TimelineConfig = z.infer; + +// ************************************************************************* +// Next/Previous Control Configuration +// ************************************************************************* +const nextPreviousControlConfigSchema = z.object({ + style: z.enum(['none', 'chevrons', 'icons', 'thumbnails']), + size: z.number().min(BUTTON_SIZE_MIN), +}); +export type NextPreviousControlConfig = z.infer; + +// ************************************************************************* +// Title Control Configuration +// ************************************************************************* + +const titleControlConfigSchema = z.object({ + mode: z.enum([ + 'none', + 'popup-top-right', + 'popup-top-left', + 'popup-bottom-right', + 'popup-bottom-left', + ]), + duration_seconds: z.number().min(0).max(60), +}); +export type TitleControlConfig = z.infer; + +// ************************************************************************* +// Carousel Transition Configuration +// ************************************************************************* + +const transitionEffectConfigSchema = z.enum(['none', 'slide']); +export type TransitionEffect = z.infer; + +// ************************************************************************* +// Live Configuration +// ************************************************************************* + +const LIVE_PROVIDERS = [ + 'auto', + 'image', + 'ha', + 'jsmpeg', + 'go2rtc', + 'webrtc-card', +] as const; +export type LiveProvider = (typeof LIVE_PROVIDERS)[number]; + +const microphoneConfigDefault = { + always_connected: false, + disconnect_seconds: 60, +}; + +const microphoneConfigSchema = z + .object({ + always_connected: z.boolean().default(microphoneConfigDefault.always_connected), + disconnect_seconds: z + .number() + .min(0) + .default(microphoneConfigDefault.disconnect_seconds), + }) + .default(microphoneConfigDefault); +export type MicrophoneConfig = z.infer; + +const go2rtcConfigSchema = z.object({ + modes: z.enum(['webrtc', 'mse', 'mp4', 'mjpeg']).array().optional(), + stream: z.string().optional(), +}); + +const liveImageConfigSchema = imageBaseConfigSchema; + +const webrtcCardConfigSchema = z + .object({ + entity: z.string().optional(), + url: z.string().optional(), + }) + .passthrough(); + +const jsmpegConfigSchema = z.object({ + options: z + .object({ + // https://github.com/phoboslab/jsmpeg#usage + audio: z.boolean().optional(), + video: z.boolean().optional(), + pauseWhenHidden: z.boolean().optional(), + disableGl: z.boolean().optional(), + disableWebAssembly: z.boolean().optional(), + preserveDrawingBuffer: z.boolean().optional(), + progressive: z.boolean().optional(), + throttled: z.boolean().optional(), + chunkSize: z.number().optional(), + maxAudioLag: z.number().optional(), + videoBufferSize: z.number().optional(), + audioBufferSize: z.number().optional(), + }) + .optional(), +}); + +const liveThumbnailControlsDefaults = { + ...thumbnailControlsDefaults, + media: 'all' as const, +}; + +const liveConfigDefault = { + auto_play: 'all' as const, + auto_pause: 'never' as const, + auto_mute: 'all' as const, + auto_unmute: 'never' as const, + preload: false, + lazy_load: true, + lazy_unload: 'never' as const, + draggable: true, + zoomable: true, + transition_effect: 'slide' as const, + show_image_during_load: true, + mode: 'single' as const, + controls: { + builtin: true, + next_previous: { + size: 48, + style: 'chevrons' as const, + }, + thumbnails: liveThumbnailControlsDefaults, + timeline: miniTimelineConfigDefault, + }, + microphone: { + ...microphoneConfigDefault, + }, +}; + +const livethumbnailsControlSchema = thumbnailsControlSchema.extend({ + media: z + .enum(['all', 'clips', 'snapshots']) + .default(liveConfigDefault.controls.thumbnails.media), +}); + +const liveOverridableConfigSchema = z + .object({ + controls: z + .object({ + builtin: z.boolean().default(liveConfigDefault.controls.builtin), + next_previous: nextPreviousControlConfigSchema + .extend({ + // Live cannot show thumbnails, remove that option. + style: z + .enum(['none', 'chevrons', 'icons']) + .default(liveConfigDefault.controls.next_previous.style), + size: nextPreviousControlConfigSchema.shape.size.default( + liveConfigDefault.controls.next_previous.size, + ), + }) + .default(liveConfigDefault.controls.next_previous), + thumbnails: livethumbnailsControlSchema.default( + liveConfigDefault.controls.thumbnails, + ), + timeline: miniTimelineConfigSchema.default(liveConfigDefault.controls.timeline), + title: titleControlConfigSchema.optional(), + }) + .default(liveConfigDefault.controls), + show_image_during_load: z + .boolean() + .default(liveConfigDefault.show_image_during_load), + layout: mediaLayoutConfigSchema.optional(), + microphone: microphoneConfigSchema.default(liveConfigDefault.microphone), + zoomable: z.boolean().default(liveConfigDefault.zoomable), + display: viewDisplaySchema, + }) + .merge(actionsSchema); + +const liveConfigSchema = liveOverridableConfigSchema + .extend({ + // Non-overrideable parameters. + auto_play: z + .enum(MEDIA_ACTION_POSITIVE_CONDITIONS) + .default(liveConfigDefault.auto_play), + auto_pause: z + .enum(MEDIA_ACTION_NEGATIVE_CONDITIONS) + .default(liveConfigDefault.auto_pause), + auto_mute: z + .enum(MEDIA_ACTION_NEGATIVE_CONDITIONS) + .default(liveConfigDefault.auto_mute), + auto_unmute: z + .enum(MEDIA_ACTION_POSITIVE_CONDITIONS) + .default(liveConfigDefault.auto_unmute), + preload: z.boolean().default(liveConfigDefault.preload), + lazy_load: z.boolean().default(liveConfigDefault.lazy_load), + lazy_unload: z + .enum(MEDIA_ACTION_NEGATIVE_CONDITIONS) + .default(liveConfigDefault.lazy_unload), + draggable: z.boolean().default(liveConfigDefault.draggable), + transition_effect: transitionEffectConfigSchema.default( + liveConfigDefault.transition_effect, + ), + }) + .default(liveConfigDefault); +export type LiveConfig = z.infer; + +const liveOverridesSchema = z + .object({ + conditions: frigateCardConditionSchema, + overrides: liveOverridableConfigSchema, + }) + .array() + .optional(); +export type LiveOverrides = z.infer; + +// ************************************************************************* +// Camera Configuration +// ************************************************************************* + +const ENGINES = ['auto', 'frigate', 'generic', 'motioneye'] as const; + +const cameraConfigDefault = { + dependencies: { + all_cameras: false, + cameras: [], + }, + engine: 'auto' as const, + frigate: { + client_id: 'frigate' as const, + }, + hide: false, + image: { + refresh_seconds: 1, + }, + live_provider: 'auto' as const, + motioneye: { + images: { + directory_pattern: '%Y-%m-%d' as const, + file_pattern: '%H-%M-%S' as const, + }, + movies: { + directory_pattern: '%Y-%m-%d' as const, + file_pattern: '%H-%M-%S' as const, + }, + }, + triggers: { + motion: false, + occupancy: true, + entities: [], + }, +}; + +export const cameraConfigSchema = z + .object({ + 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(), + + // Used to hide the camera (e.g. when used only as a dependency). + hide: z.boolean().optional(), + + // Optional identifier to separate different camera configurations used in + // this card. + id: z.string().optional(), + + dependencies: z + .object({ + all_cameras: z.boolean().default(cameraConfigDefault.dependencies.all_cameras), + cameras: z.string().array().default(cameraConfigDefault.dependencies.cameras), + }) + .default(cameraConfigDefault.dependencies), + + triggers: z + .object({ + motion: z.boolean().default(cameraConfigDefault.triggers.motion), + occupancy: z.boolean().default(cameraConfigDefault.triggers.occupancy), + entities: z.string().array().default(cameraConfigDefault.triggers.entities), + }) + .default(cameraConfigDefault.triggers), + + // Engine options. + engine: z.enum(ENGINES).default('auto'), + frigate: z + .object({ + url: z.string().optional(), + client_id: z.string().default(cameraConfigDefault.frigate.client_id), + camera_name: z.string().optional(), + labels: z.string().array().optional(), + zones: z.string().array().optional(), + }) + .default(cameraConfigDefault.frigate), + motioneye: z + .object({ + url: z.string().optional(), + images: z + .object({ + directory_pattern: z + .string() + .includes('%') + .default(cameraConfigDefault.motioneye.images.directory_pattern), + file_pattern: z + .string() + .includes('%') + .default(cameraConfigDefault.motioneye.images.file_pattern), + }) + .default(cameraConfigDefault.motioneye.images), + movies: z + .object({ + directory_pattern: z + .string() + .includes('%') + .default(cameraConfigDefault.motioneye.movies.directory_pattern), + file_pattern: z + .string() + .includes('%') + .default(cameraConfigDefault.motioneye.movies.file_pattern), + }) + .default(cameraConfigDefault.motioneye.movies), + }) + .default(cameraConfigDefault.motioneye), + + // Live provider options. + live_provider: z.enum(LIVE_PROVIDERS).default(cameraConfigDefault.live_provider), + go2rtc: go2rtcConfigSchema.optional(), + image: liveImageConfigSchema.default(cameraConfigDefault.image), + jsmpeg: jsmpegConfigSchema.optional(), + webrtc_card: webrtcCardConfigSchema.optional(), + }) + .default(cameraConfigDefault); +export type CameraConfig = z.infer; + +// Avoid using .nonempty() to avoid changing the inferred type +// (https://github.com/colinhacks/zod#minmaxlength). +const camerasConfigSchema = cameraConfigSchema.array().min(1); +export type CamerasConfig = z.infer; + +// ************************************************************************* +// View Configuration +// ************************************************************************* + +const viewConfigDefault = { + default: FRIGATE_CARD_VIEW_DEFAULT, + camera_select: 'current' as const, + timeout_seconds: 300, + update_seconds: 0, + update_force: false, + update_cycle_camera: false, + dark_mode: 'off' as const, + scan: { + enabled: false, + show_trigger_status: true, + untrigger_seconds: 0, + untrigger_reset: true, + }, +}; + +const scanSchema = z.object({ + enabled: z.boolean().default(viewConfigDefault.scan.enabled), + show_trigger_status: z.boolean().default(viewConfigDefault.scan.show_trigger_status), + untrigger_seconds: z.number().default(viewConfigDefault.scan.untrigger_seconds), + untrigger_reset: z.boolean().default(viewConfigDefault.scan.untrigger_reset), +}); +export type ScanOptions = z.infer; + +const viewConfigSchema = z + .object({ + default: z + .enum(FRIGATE_CARD_VIEWS_USER_SPECIFIED) + .default(viewConfigDefault.default), + camera_select: z + .enum([...FRIGATE_CARD_VIEWS_USER_SPECIFIED, 'current']) + .default(viewConfigDefault.camera_select), + timeout_seconds: z.number().default(viewConfigDefault.timeout_seconds), + update_seconds: z.number().default(viewConfigDefault.update_seconds), + update_force: z.boolean().default(viewConfigDefault.update_force), + update_cycle_camera: z.boolean().default(viewConfigDefault.update_cycle_camera), + update_entities: z.string().array().optional(), + render_entities: z.string().array().optional(), + dark_mode: z.enum(['on', 'off', 'auto']).optional(), + scan: scanSchema.default(viewConfigDefault.scan), + }) + .merge(actionsSchema) + .default(viewConfigDefault); + +// ************************************************************************* +// Menu Configuration +// ************************************************************************* + +const FRIGATE_MENU_STYLES = [ + 'none', + 'hidden', + 'overlay', + 'hover', + 'hover-card', + 'outside', +] as const; +const FRIGATE_MENU_POSITIONS = ['left', 'right', 'top', 'bottom'] as const; +const FRIGATE_MENU_ALIGNMENTS = FRIGATE_MENU_POSITIONS; + +const visibleButtonDefault = { + priority: FRIGATE_MENU_PRIORITY_DEFAULT, + enabled: true, +}; + +const hiddenButtonDefault = { + priority: FRIGATE_MENU_PRIORITY_DEFAULT, + enabled: false, +}; + +const menuConfigDefault = { + style: 'hidden' as const, + position: 'top' as const, + alignment: 'left' as const, + buttons: { + frigate: visibleButtonDefault, + cameras: visibleButtonDefault, + substreams: visibleButtonDefault, + live: visibleButtonDefault, + clips: visibleButtonDefault, + snapshots: visibleButtonDefault, + image: hiddenButtonDefault, + timeline: visibleButtonDefault, + download: visibleButtonDefault, + camera_ui: visibleButtonDefault, + fullscreen: visibleButtonDefault, + expand: hiddenButtonDefault, + media_player: visibleButtonDefault, + microphone: { + ...hiddenButtonDefault, + type: 'momentary' as const, + }, + mute: hiddenButtonDefault, + play: hiddenButtonDefault, + recordings: hiddenButtonDefault, + screenshot: hiddenButtonDefault, + display_mode: visibleButtonDefault, + }, + button_size: 40, +}; + +const visibleButtonSchema = menuBaseSchema.extend({ + enabled: menuBaseSchema.shape.enabled.default(visibleButtonDefault.enabled), + priority: menuBaseSchema.shape.priority.default(visibleButtonDefault.priority), +}); + +const hiddenButtonSchema = menuBaseSchema.extend({ + enabled: menuBaseSchema.shape.enabled.default(hiddenButtonDefault.enabled), + priority: menuBaseSchema.shape.priority.default(hiddenButtonDefault.priority), +}); + +const menuConfigSchema = z + .object({ + style: z.enum(FRIGATE_MENU_STYLES).default(menuConfigDefault.style), + position: z.enum(FRIGATE_MENU_POSITIONS).default(menuConfigDefault.position), + alignment: z.enum(FRIGATE_MENU_ALIGNMENTS).default(menuConfigDefault.alignment), + buttons: z + .object({ + frigate: visibleButtonSchema.default(menuConfigDefault.buttons.frigate), + cameras: visibleButtonSchema.default(menuConfigDefault.buttons.cameras), + substreams: visibleButtonSchema.default(menuConfigDefault.buttons.substreams), + live: visibleButtonSchema.default(menuConfigDefault.buttons.live), + clips: visibleButtonSchema.default(menuConfigDefault.buttons.clips), + snapshots: visibleButtonSchema.default(menuConfigDefault.buttons.snapshots), + image: hiddenButtonSchema.default(menuConfigDefault.buttons.image), + timeline: visibleButtonSchema.default(menuConfigDefault.buttons.timeline), + download: visibleButtonSchema.default(menuConfigDefault.buttons.download), + camera_ui: visibleButtonSchema.default(menuConfigDefault.buttons.camera_ui), + fullscreen: visibleButtonSchema.default(menuConfigDefault.buttons.fullscreen), + expand: hiddenButtonSchema.default(menuConfigDefault.buttons.expand), + media_player: visibleButtonSchema.default( + menuConfigDefault.buttons.media_player, + ), + microphone: hiddenButtonSchema + .extend({ + type: z + .enum(['momentary', 'toggle']) + .default(menuConfigDefault.buttons.microphone.type), + }) + .default(menuConfigDefault.buttons.microphone), + recordings: hiddenButtonSchema.default(menuConfigDefault.buttons.recordings), + mute: hiddenButtonSchema.default(menuConfigDefault.buttons.mute), + play: hiddenButtonSchema.default(menuConfigDefault.buttons.play), + screenshot: hiddenButtonSchema.default(menuConfigDefault.buttons.screenshot), + display_mode: visibleButtonSchema.default( + menuConfigDefault.buttons.display_mode, + ), + }) + .default(menuConfigDefault.buttons), + button_size: z.number().min(BUTTON_SIZE_MIN).default(menuConfigDefault.button_size), + }) + .default(menuConfigDefault); +export type MenuConfig = z.infer; + +// ************************************************************************* +// Event Viewer Configuration +// ************************************************************************* + +const viewerConfigDefault = { + auto_play: 'all' as const, + auto_pause: 'all' as const, + auto_mute: 'all' as const, + auto_unmute: 'never' as const, + lazy_load: true, + draggable: true, + zoomable: true, + transition_effect: 'slide' as const, + snapshot_click_plays_clip: true, + display_mode: 'single' as const, + controls: { + builtin: true, + next_previous: { + size: 48, + style: 'thumbnails' as const, + }, + thumbnails: thumbnailControlsDefaults, + timeline: miniTimelineConfigDefault, + }, +}; + +const viewerNextPreviousControlConfigSchema = nextPreviousControlConfigSchema.extend({ + style: z + .enum(['none', 'thumbnails', 'chevrons']) + .default(viewerConfigDefault.controls.next_previous.style), + size: nextPreviousControlConfigSchema.shape.size.default( + viewerConfigDefault.controls.next_previous.size, + ), +}); + +const viewerConfigSchema = z + .object({ + auto_play: z + .enum(MEDIA_ACTION_POSITIVE_CONDITIONS) + .default(viewerConfigDefault.auto_play), + auto_pause: z + .enum(MEDIA_ACTION_NEGATIVE_CONDITIONS) + .default(viewerConfigDefault.auto_pause), + auto_mute: z + .enum(MEDIA_ACTION_NEGATIVE_CONDITIONS) + .default(viewerConfigDefault.auto_mute), + auto_unmute: z + .enum(MEDIA_ACTION_POSITIVE_CONDITIONS) + .default(viewerConfigDefault.auto_unmute), + lazy_load: z.boolean().default(viewerConfigDefault.lazy_load), + draggable: z.boolean().default(viewerConfigDefault.draggable), + zoomable: z.boolean().default(viewerConfigDefault.zoomable), + transition_effect: transitionEffectConfigSchema.default( + viewerConfigDefault.transition_effect, + ), + snapshot_click_plays_clip: z + .boolean() + .default(viewerConfigDefault.snapshot_click_plays_clip), + display: viewDisplaySchema, + controls: z + .object({ + builtin: z.boolean().default(viewerConfigDefault.controls.builtin), + next_previous: viewerNextPreviousControlConfigSchema.default( + viewerConfigDefault.controls.next_previous, + ), + thumbnails: thumbnailsControlSchema.default( + viewerConfigDefault.controls.thumbnails, + ), + timeline: miniTimelineConfigSchema.default( + viewerConfigDefault.controls.timeline, + ), + title: titleControlConfigSchema.optional(), + }) + .default(viewerConfigDefault.controls), + layout: mediaLayoutConfigSchema.optional(), + }) + .merge(actionsSchema) + .default(viewerConfigDefault); +export type ViewerConfig = z.infer; + +// ************************************************************************* +// Event Gallery Configuration +// ************************************************************************* + +const galleryThumbnailControlsDefaults = { + ...thumbnailControlsDefaults, + show_details: false, +}; + +const galleryConfigDefault = { + controls: { + thumbnails: galleryThumbnailControlsDefaults, + filter: { + mode: 'right' as const, + }, + }, +}; + +const gallerythumbnailsControlSchema = thumbnailsControlSchema.extend({ + show_details: z.boolean().default(galleryThumbnailControlsDefaults.show_details), +}); + +const galleryConfigSchema = z + .object({ + controls: z + .object({ + thumbnails: gallerythumbnailsControlSchema.default( + galleryConfigDefault.controls.thumbnails, + ), + filter: z + .object({ + mode: z + .enum(['none', 'left', 'right']) + .default(galleryConfigDefault.controls.filter.mode), + }) + .default(galleryConfigDefault.controls.filter), + }) + .default(galleryConfigDefault.controls), + }) + .merge(actionsSchema) + .default(galleryConfigDefault); +export type GalleryConfig = z.infer; + +// ************************************************************************* +// Dimensions Configuration +// ************************************************************************* + +const dimensionsConfigDefault = { + aspect_ratio_mode: 'dynamic' as const, + aspect_ratio: [16, 9], + max_height: '100vh', + min_height: '100px', +}; + +export const dimensionsConfigSchema = z + .object({ + aspect_ratio_mode: z + .enum(['dynamic', 'static', 'unconstrained']) + .default(dimensionsConfigDefault.aspect_ratio_mode), + aspect_ratio: z + .number() + .array() + .length(2) + .or( + z + .string() + .regex(/^\s*\d+\s*[:\/]\s*\d+\s*$/) + .transform((input) => input.split(/[:\/]/).map((d) => Number(d))), + ) + .default(dimensionsConfigDefault.aspect_ratio), + max_height: z.string().default(dimensionsConfigDefault.max_height), + min_height: z.string().default(dimensionsConfigDefault.min_height), + }) + .default(dimensionsConfigDefault); + +// ************************************************************************* +// Override Configuration +// ************************************************************************* + +// Strip all defaults from the override schemas, to ensure values are only what +// the user has specified. +const overrideConfigurationSchema = z.object({ + cameras: deepRemoveDefaults(camerasConfigSchema).optional(), + cameras_global: deepRemoveDefaults(cameraConfigSchema).optional(), + live: deepRemoveDefaults(liveOverridableConfigSchema).optional(), + menu: deepRemoveDefaults(menuConfigSchema).optional(), + image: deepRemoveDefaults(imageConfigSchema).optional(), + view: deepRemoveDefaults(viewConfigSchema).optional(), + dimensions: deepRemoveDefaults(dimensionsConfigSchema).optional(), +}); +export type OverrideConfigurationKey = keyof z.infer; + +const overridesSchema = z + .object({ + conditions: frigateCardConditionSchema, + overrides: overrideConfigurationSchema, + }) + .array() + .optional(); + +// ************************************************************************* +// Automation Configuration +// ************************************************************************* + +const automationActionSchema = actionSchema.array().optional(); +export type AutomationActions = z.infer; + +const automationSchema = z.object({ + conditions: frigateCardConditionSchema, + actions: automationActionSchema, + actions_not: automationActionSchema, +}); +export type Automation = z.infer; + +const automationsSchema = automationSchema.array().optional(); +export type Automations = z.infer; + +// ************************************************************************* +// Performance Configuration +// ************************************************************************* + +const performanceConfigDefault = { + profile: 'high' as const, + features: { + animated_progress_indicator: true, + media_chunk_size: MEDIA_CHUNK_SIZE_DEFAULT, + }, + style: { + border_radius: true, + box_shadow: true, + }, +}; + +export const performanceConfigSchema = z + .object({ + profile: z.enum(['low', 'high']).default(performanceConfigDefault.profile), + features: z + .object({ + animated_progress_indicator: z + .boolean() + .default(performanceConfigDefault.features.animated_progress_indicator), + media_chunk_size: z + .number() + .min(0) + .max(MEDIA_CHUNK_SIZE_MAX) + .default(performanceConfigDefault.features.media_chunk_size), + }) + .default(performanceConfigDefault.features), + style: z + .object({ + border_radius: z.boolean().default(performanceConfigDefault.style.border_radius), + box_shadow: z.boolean().default(performanceConfigDefault.style.box_shadow), + }) + .default(performanceConfigDefault.style), + }) + .default(performanceConfigDefault); +export type PerformanceConfig = z.infer; + +const debugConfigDefault = { + logging: false, +}; + +const debugConfigSchema = z + .object({ + logging: z.boolean().default(debugConfigDefault.logging), + }) + .default(debugConfigDefault); +type DebugConfig = z.infer; + +// ************************************************************************* +// CardWideConfig Configuration +// ************************************************************************* + +export interface CardWideConfig { + performance?: PerformanceConfig; + debug?: DebugConfig; +} + +// ************************************************************************* +// *** Card Configuration *** +// ************************************************************************* + +export const frigateCardConfigSchema = z.object({ + // Defaults are stripped out of the individual cameras, since each camera will + // be merged with `cameras_global` which *does* have defaults. If we didn't do + // this, the default values of each individual camera would override the + // intentionally specified values in `cameras_global` during camera + // initialization when the two configs are merged. + cameras: deepRemoveDefaults(camerasConfigSchema), + cameras_global: cameraConfigSchema, + + view: viewConfigSchema, + menu: menuConfigSchema, + live: liveConfigSchema, + media_gallery: galleryConfigSchema, + media_viewer: viewerConfigSchema, + image: imageConfigSchema, + elements: pictureElementsSchema, + dimensions: dimensionsConfigSchema, + timeline: timelineConfigSchema, + performance: performanceConfigSchema, + debug: debugConfigSchema, + automations: automationsSchema, + + // Configuration overrides. + overrides: overridesSchema, + + // Support for card_mod (https://github.com/thomasloven/lovelace-card-mod). + card_mod: z.unknown(), + + // Card ID (used for query string commands). Restrict contents to only values + // that be easily used in a URL. + card_id: z.string().regex(/^\w+$/).optional(), + + // Stock lovelace card config. + type: z.string(), +}); + +export type FrigateCardConfig = z.infer; +export type RawFrigateCardConfig = Record; +export type RawFrigateCardConfigArray = RawFrigateCardConfig[]; + +export const frigateCardConfigDefaults = { + cameras: cameraConfigDefault, + view: viewConfigDefault, + menu: menuConfigDefault, + live: liveConfigDefault, + media_gallery: galleryConfigDefault, + media_viewer: viewerConfigDefault, + image: imageConfigDefault, + timeline: timelineConfigDefault, + performance: performanceConfigDefault, + debug: debugConfigDefault, +}; diff --git a/src/editor.ts b/src/editor.ts index 84416549..fac18b5c 100644 --- a/src/editor.ts +++ b/src/editor.ts @@ -16,6 +16,16 @@ import { setConfigValue, upgradeConfig, } from './config-mgmt.js'; +import { + BUTTON_SIZE_MIN, + FRIGATE_MENU_PRIORITY_MAX, + FrigateCardConfig, + frigateCardConfigDefaults, + RawFrigateCardConfig, + RawFrigateCardConfigArray, + THUMBNAIL_WIDTH_MAX, + THUMBNAIL_WIDTH_MIN, +} from './config/types.js'; import { CONF_CAMERAS, CONF_CAMERAS_ARRAY_CAMERA_ENTITY, @@ -134,8 +144,8 @@ import { CONF_MEDIA_VIEWER_TRANSITION_EFFECT, CONF_MEDIA_VIEWER_ZOOMABLE, CONF_MENU_ALIGNMENT, - CONF_MENU_BUTTONS, CONF_MENU_BUTTON_SIZE, + CONF_MENU_BUTTONS, CONF_MENU_POSITION, CONF_MENU_STYLE, CONF_PERFORMANCE_FEATURES_ANIMATED_PROGRESS_INDICATOR, @@ -171,16 +181,6 @@ import { import { localize } from './localize/localize.js'; import { setLowPerformanceProfile } from './performance.js'; import frigate_card_editor_style from './scss/editor.scss'; -import { - BUTTON_SIZE_MIN, - FrigateCardConfig, - frigateCardConfigDefaults, - FRIGATE_MENU_PRIORITY_MAX, - RawFrigateCardConfig, - RawFrigateCardConfigArray, - THUMBNAIL_WIDTH_MAX, - THUMBNAIL_WIDTH_MIN, -} from './types.js'; import { arrayMove, prettifyTitle } from './utils/basic.js'; import { getCameraID } from './utils/camera.js'; import { diff --git a/src/performance.ts b/src/performance.ts index 62e54dfb..487de6fc 100644 --- a/src/performance.ts +++ b/src/performance.ts @@ -1,10 +1,9 @@ -import { deepRemoveDefaults } from './utils/zod.js'; -import { - frigateCardConfigSchema, - RawFrigateCardConfig, - PerformanceConfig, -} from './types'; import { getConfigValue, setConfigValue } from './config-mgmt.js'; +import { + PerformanceConfig, + RawFrigateCardConfig, + frigateCardConfigSchema, +} from './config/types.js'; import { CONF_CAMERAS_GLOBAL_IMAGE_REFRESH_SECONDS, CONF_CAMERAS_GLOBAL_TRIGGERS_OCCUPANCY, @@ -53,6 +52,7 @@ import { CONF_TIMELINE_CONTROLS_THUMBNAILS_SHOW_TIMELINE_CONTROL, CONF_TIMELINE_SHOW_RECORDINGS, } from './const.js'; +import { deepRemoveDefaults } from './utils/zod.js'; // Caution: These values are applied after parsing (since we cannot know the // performance profile until afterwards), so there is no validation on these diff --git a/src/types.ts b/src/types.ts index f23458b0..8ac565c7 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,102 +1,10 @@ -import { - CallServiceActionConfig, - ConfirmationRestrictionConfig, - HomeAssistant, - LovelaceCardConfig, - MoreInfoActionConfig, - NavigateActionConfig, - NoActionConfig, - Themes, - ToggleActionConfig, - UrlActionConfig, -} from 'custom-card-helpers'; +import { HomeAssistant, LovelaceCardConfig, Themes } from 'custom-card-helpers'; import { StyleInfo } from 'lit/directives/style-map.js'; import { z } from 'zod'; -import { MEDIA_CHUNK_SIZE_DEFAULT, MEDIA_CHUNK_SIZE_MAX } from './const.js'; -import { deepRemoveDefaults } from './utils/zod.js'; - -// The min allowed size of buttons. -export const BUTTON_SIZE_MIN = 20; - -// The min/max width thumbnail (Frigate returns a maximum of 175px). -export const THUMBNAIL_WIDTH_MAX = 175; -export const THUMBNAIL_WIDTH_MIN = 75; - -/** - * Internal types. - */ export type ClipsOrSnapshots = 'clips' | 'snapshots'; export type ClipsOrSnapshotsOrAll = 'clips' | 'snapshots' | 'all'; -export const FRIGATE_CARD_VIEWS_USER_SPECIFIED = [ - 'live', - 'clip', - 'clips', - 'snapshot', - 'snapshots', - 'recording', - 'recordings', - 'image', - 'timeline', -] as const; - -const FRIGATE_CARD_VIEWS = [ - ...FRIGATE_CARD_VIEWS_USER_SPECIFIED, - 'diagnostics', - - // Media: A generic piece of media (could be clip, snapshot, recording). - 'media', -] as const; - -export type FrigateCardView = (typeof FRIGATE_CARD_VIEWS)[number]; -export const FRIGATE_CARD_VIEW_DEFAULT = 'live' as const; - -const FRIGATE_MENU_STYLES = [ - 'none', - 'hidden', - 'overlay', - 'hover', - 'hover-card', - 'outside', -] as const; -const FRIGATE_MENU_POSITIONS = ['left', 'right', 'top', 'bottom'] as const; -const FRIGATE_MENU_ALIGNMENTS = FRIGATE_MENU_POSITIONS; - -const FRIGATE_MENU_PRIORITY_DEFAULT = 50; -export const FRIGATE_MENU_PRIORITY_MAX = 100; - -const LIVE_PROVIDERS = [ - 'auto', - 'image', - 'ha', - 'jsmpeg', - 'go2rtc', - 'webrtc-card', -] as const; -export type LiveProvider = (typeof LIVE_PROVIDERS)[number]; - -const MEDIA_ACTION_NEGATIVE_CONDITIONS = [ - 'all', - 'unselected', - 'hidden', - 'never', -] as const; -export type LazyUnloadCondition = (typeof MEDIA_ACTION_NEGATIVE_CONDITIONS)[number]; -export type AutoMuteCondition = (typeof MEDIA_ACTION_NEGATIVE_CONDITIONS)[number]; -export type AutoPauseCondition = (typeof MEDIA_ACTION_NEGATIVE_CONDITIONS)[number]; - -const MEDIA_ACTION_POSITIVE_CONDITIONS = [ - 'all', - 'selected', - 'visible', - 'never', -] as const; -export type AutoUnmuteCondition = (typeof MEDIA_ACTION_POSITIVE_CONDITIONS)[number]; -export type AutoPlayCondition = (typeof MEDIA_ACTION_POSITIVE_CONDITIONS)[number]; - -const ENGINES = ['auto', 'frigate', 'generic', 'motioneye'] as const; - export class FrigateCardError extends Error { context?: unknown; @@ -106,1383 +14,6 @@ export class FrigateCardError extends Error { } } -const viewDisplayModeSchema = z.enum(['single', 'grid']); -export type ViewDisplayMode = z.infer; - -const viewDisplaySchema = z - .object({ - mode: viewDisplayModeSchema, - grid_selected_width_factor: z.number().min(0).optional(), - grid_max_columns: z.number().min(0).optional(), - grid_columns: z.number().min(0).optional(), - }) - .optional(); -export type ViewDisplayConfig = z.infer; - -/** - * Action Types (for "Picture Elements" / Menu) - */ -// Declare schemas to existing types: -// - https://github.com/colinhacks/zod/issues/372#issuecomment-826380330 -const schemaForType = - () => - // eslint-disable-next-line @typescript-eslint/no-explicit-any - >(arg: S) => { - return arg; - }; - -// https://www.home-assistant.io/dashboards/actions/#options-for-confirmation -const actionBaseSchema = z.object({ - confirmation: z - .boolean() - .or( - z.object({ - text: z.string().optional(), - exemptions: z - .object({ - user: z.string(), - }) - .array() - .optional(), - }), - ) - .optional(), -}); - -// HA accepts either a boolean or a ConfirmationRestrictionConfig object. -// `custom-card-helpers` currently only supports the latter. For maximum -// compatibility, this card supports what HA supports. -interface ExtendedConfirmationRestrictionConfig { - confirmation?: boolean | ConfirmationRestrictionConfig; -} - -const toggleActionSchema = schemaForType< - ToggleActionConfig & ExtendedConfirmationRestrictionConfig ->()( - actionBaseSchema.extend({ - action: z.literal('toggle'), - }), -); -const callServiceActionSchema = schemaForType< - CallServiceActionConfig & ExtendedConfirmationRestrictionConfig ->()( - actionBaseSchema.extend({ - action: z.literal('call-service'), - service: z.string(), - service_data: z.object({}).passthrough().optional(), - }), -); -const navigateActionSchema = schemaForType< - NavigateActionConfig & ExtendedConfirmationRestrictionConfig ->()( - actionBaseSchema.extend({ - action: z.literal('navigate'), - navigation_path: z.string(), - }), -); -const urlActionSchema = schemaForType< - UrlActionConfig & ExtendedConfirmationRestrictionConfig ->()( - actionBaseSchema.extend({ - action: z.literal('url'), - url_path: z.string(), - }), -); -const moreInfoActionSchema = schemaForType< - MoreInfoActionConfig & ExtendedConfirmationRestrictionConfig ->()( - actionBaseSchema.extend({ - action: z.literal('more-info'), - }), -); - -const customActionSchema = actionBaseSchema - .extend({ - action: z.literal('fire-dom-event'), - }) - .passthrough(); - -const noActionSchema = schemaForType< - NoActionConfig & ExtendedConfirmationRestrictionConfig ->()( - actionBaseSchema.extend({ - action: z.literal('none'), - }), -); - -const frigateCardCustomActionsBaseSchema = customActionSchema.extend({ - action: z - .literal('custom:frigate-card-action') - // Syntactic sugar to avoid 'fire-dom-event' as part of an external API. - .transform((): 'fire-dom-event' => 'fire-dom-event') - .or(z.literal('fire-dom-event')), - - // Card this command is intended for. - card_id: z - .string() - .regex(/^\w+$/, 'card_id parameter can only contain [a-z][A-Z][0-9_]') - .optional(), -}); - -const FRIGATE_CARD_GENERAL_ACTIONS = [ - 'camera_ui', - 'default', - 'diagnostics', - 'expand', - 'download', - 'fullscreen', - 'menu_toggle', - 'mute', - 'live_substream_on', - 'live_substream_off', - 'microphone_mute', - 'microphone_unmute', - 'play', - 'pause', - 'screenshot', - 'unmute', -] as const; -const FRIGATE_CARD_ACTIONS = [ - ...FRIGATE_CARD_VIEWS_USER_SPECIFIED, - ...FRIGATE_CARD_GENERAL_ACTIONS, - 'camera_select', - 'live_substream_select', - 'media_player', - 'display_mode_select', -] as const; -export type FrigateCardAction = (typeof FRIGATE_CARD_ACTIONS)[number]; - -const frigateCardViewActionSchema = frigateCardCustomActionsBaseSchema.extend({ - frigate_card_action: z.enum(FRIGATE_CARD_VIEWS_USER_SPECIFIED), -}); -export type FrigateCardViewAction = z.infer; - -const frigateCardGeneralActionSchema = frigateCardCustomActionsBaseSchema.extend({ - frigate_card_action: z.enum(FRIGATE_CARD_GENERAL_ACTIONS), -}); -const frigateCardCameraSelectActionSchema = frigateCardCustomActionsBaseSchema.extend({ - frigate_card_action: z.literal('camera_select'), - camera: z.string(), -}); -const frigateCardLiveDependencySelectActionSchema = - frigateCardCustomActionsBaseSchema.extend({ - frigate_card_action: z.literal('live_substream_select'), - camera: z.string(), - }); -const frigateCardMediaPlayerActionSchema = frigateCardCustomActionsBaseSchema.extend({ - frigate_card_action: z.literal('media_player'), - media_player: z.string(), - media_player_action: z.enum(['play', 'stop']), -}); -const frigateCardViewDisplayModeActionSchema = frigateCardCustomActionsBaseSchema.extend( - { - frigate_card_action: z.literal('display_mode_select'), - display_mode: viewDisplayModeSchema, - }, -); - -export const frigateCardCustomActionSchema = z.union([ - frigateCardViewActionSchema, - frigateCardGeneralActionSchema, - frigateCardCameraSelectActionSchema, - frigateCardLiveDependencySelectActionSchema, - frigateCardMediaPlayerActionSchema, - frigateCardViewDisplayModeActionSchema, -]); -export type FrigateCardCustomAction = z.infer; - -// Cannot use discriminatedUnion since frigateCardCustomActionSchema uses a -// transform on the discriminated union key. -export const actionSchema = z.union([ - toggleActionSchema, - callServiceActionSchema, - navigateActionSchema, - urlActionSchema, - moreInfoActionSchema, - noActionSchema, - customActionSchema, - frigateCardCustomActionSchema, -]); -export type ActionType = z.infer; - -const actionsBaseSchema = z - .object({ - tap_action: actionSchema.or(actionSchema.array()).optional(), - hold_action: actionSchema.or(actionSchema.array()).optional(), - double_tap_action: actionSchema.or(actionSchema.array()).optional(), - start_tap_action: actionSchema.or(actionSchema.array()).optional(), - end_tap_action: actionSchema.or(actionSchema.array()).optional(), - }) - // Passthrough to allow (at least) entity/camera_image to go through. This - // card doesn't need these attributes, but handleAction() in - // custom_card_helpers may depending on how the action is configured. - .passthrough(); -export type Actions = z.infer; -export type ActionsConfig = Actions & { - camera_image?: string; - entity?: string; -}; - -const actionsSchema = z.object({ - actions: actionsBaseSchema.optional(), -}); - -const elementsBaseSchema = actionsBaseSchema.extend({ - style: z.object({}).passthrough().optional(), - title: z.string().nullable().optional(), -}); - -/** - * Picture Element Configuration. - * - * All picture element types are validated (not just the Frigate card custom - * ones) as a convenience to present the user with a consistent error display - * up-front regardless of where they made their error. - */ - -// https://www.home-assistant.io/lovelace/picture-elements/#state-badge -const stateBadgeIconSchema = elementsBaseSchema.extend({ - type: z.literal('state-badge'), - entity: z.string(), -}); - -// https://www.home-assistant.io/lovelace/picture-elements/#state-icon -const stateIconSchema = elementsBaseSchema.extend({ - type: z.literal('state-icon'), - entity: z.string(), - icon: z.string().optional(), - state_color: z.boolean().default(true), -}); - -// https://www.home-assistant.io/lovelace/picture-elements/#state-label -const stateLabelSchema = elementsBaseSchema.extend({ - type: z.literal('state-label'), - entity: z.string(), - attribute: z.string().optional(), - prefix: z.string().optional(), - suffix: z.string().optional(), -}); - -// https://www.home-assistant.io/lovelace/picture-elements/#service-call-button -const serviceCallButtonSchema = elementsBaseSchema.extend({ - type: z.literal('service-button'), - // Title is required for service button. - title: z.string(), - service: z.string(), - service_data: z.object({}).passthrough().optional(), -}); - -// https://www.home-assistant.io/lovelace/picture-elements/#icon-element -const iconSchema = elementsBaseSchema.extend({ - type: z.literal('icon'), - icon: z.string(), - entity: z.string().optional(), -}); - -// https://www.home-assistant.io/lovelace/picture-elements/#image-element -const imageSchema = elementsBaseSchema.extend({ - type: z.literal('image'), - entity: z.string().optional(), - image: z.string().optional(), - camera_image: z.string().optional(), - camera_view: z.string().optional(), - state_image: z.object({}).passthrough().optional(), - filter: z.string().optional(), - state_filter: z.object({}).passthrough().optional(), - aspect_ratio: z.string().optional(), -}); - -// This state condition is used both for the Picture elements conditional -// schema, and also in frigateCardConditionSchema. -const stateConditions = z - .object({ - entity: z.string(), - state: z.string().optional(), - state_not: z.string().optional(), - }) - .array(); - -// https://www.home-assistant.io/lovelace/picture-elements/#image-element -const conditionalSchema = z.object({ - type: z.literal('conditional'), - conditions: stateConditions, - elements: z.lazy(() => pictureElementsSchema), -}); - -// https://www.home-assistant.io/lovelace/picture-elements/#custom-elements -const customSchema = z - .object({ - // Insist that Frigate card custom elements are handled by other schemas. - type: z.string().superRefine((val, ctx) => { - if (!val.match(/^custom:(?!frigate-card).+/)) { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - message: 'Frigate-card custom elements must match specific schemas', - fatal: true, - }); - } - }), - }) - .passthrough(); - -/** - * Image config base options. Image config schema base is used both for the - * `image` live provider and the `image` card view. - */ -const imageBaseConfigDefault = { - refresh_seconds: 1, -}; -const imageBaseConfigSchema = z.object({ - url: z.string().optional(), - refresh_seconds: z.number().min(0).default(imageBaseConfigDefault.refresh_seconds), -}); - -/** - * Live provider options - */ - -const microphoneConfigDefault = { - always_connected: false, - disconnect_seconds: 60, -}; - -const microphoneConfigSchema = z - .object({ - always_connected: z.boolean().default(microphoneConfigDefault.always_connected), - disconnect_seconds: z - .number() - .min(0) - .default(microphoneConfigDefault.disconnect_seconds), - }) - .default(microphoneConfigDefault); -export type MicrophoneConfig = z.infer; - -const go2rtcConfigSchema = z.object({ - modes: z.enum(['webrtc', 'mse', 'mp4', 'mjpeg']).array().optional(), - stream: z.string().optional(), -}); - -const liveImageConfigSchema = imageBaseConfigSchema; - -const webrtcCardConfigSchema = z - .object({ - entity: z.string().optional(), - url: z.string().optional(), - }) - .passthrough(); - -const jsmpegConfigSchema = z.object({ - options: z - .object({ - // https://github.com/phoboslab/jsmpeg#usage - audio: z.boolean().optional(), - video: z.boolean().optional(), - pauseWhenHidden: z.boolean().optional(), - disableGl: z.boolean().optional(), - disableWebAssembly: z.boolean().optional(), - preserveDrawingBuffer: z.boolean().optional(), - progressive: z.boolean().optional(), - throttled: z.boolean().optional(), - chunkSize: z.number().optional(), - maxAudioLag: z.number().optional(), - videoBufferSize: z.number().optional(), - audioBufferSize: z.number().optional(), - }) - .optional(), -}); - -/** - * Camera configuration section - */ -const cameraConfigDefault = { - dependencies: { - all_cameras: false, - cameras: [], - }, - engine: 'auto' as const, - frigate: { - client_id: 'frigate' as const, - }, - hide: false, - image: { - refresh_seconds: 1, - }, - live_provider: 'auto' as const, - motioneye: { - images: { - directory_pattern: '%Y-%m-%d' as const, - file_pattern: '%H-%M-%S' as const, - }, - movies: { - directory_pattern: '%Y-%m-%d' as const, - file_pattern: '%H-%M-%S' as const, - }, - }, - triggers: { - motion: false, - occupancy: true, - entities: [], - }, -}; - -export const cameraConfigSchema = z - .object({ - 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(), - - // Used to hide the camera (e.g. when used only as a dependency). - hide: z.boolean().optional(), - - // Optional identifier to separate different camera configurations used in - // this card. - id: z.string().optional(), - - dependencies: z - .object({ - all_cameras: z.boolean().default(cameraConfigDefault.dependencies.all_cameras), - cameras: z.string().array().default(cameraConfigDefault.dependencies.cameras), - }) - .default(cameraConfigDefault.dependencies), - - triggers: z - .object({ - motion: z.boolean().default(cameraConfigDefault.triggers.motion), - occupancy: z.boolean().default(cameraConfigDefault.triggers.occupancy), - entities: z.string().array().default(cameraConfigDefault.triggers.entities), - }) - .default(cameraConfigDefault.triggers), - - // Engine options. - engine: z.enum(ENGINES).default('auto'), - frigate: z - .object({ - url: z.string().optional(), - client_id: z.string().default(cameraConfigDefault.frigate.client_id), - camera_name: z.string().optional(), - labels: z.string().array().optional(), - zones: z.string().array().optional(), - }) - .default(cameraConfigDefault.frigate), - motioneye: z - .object({ - url: z.string().optional(), - images: z - .object({ - directory_pattern: z - .string() - .includes('%') - .default(cameraConfigDefault.motioneye.images.directory_pattern), - file_pattern: z - .string() - .includes('%') - .default(cameraConfigDefault.motioneye.images.file_pattern), - }) - .default(cameraConfigDefault.motioneye.images), - movies: z - .object({ - directory_pattern: z - .string() - .includes('%') - .default(cameraConfigDefault.motioneye.movies.directory_pattern), - file_pattern: z - .string() - .includes('%') - .default(cameraConfigDefault.motioneye.movies.file_pattern), - }) - .default(cameraConfigDefault.motioneye.movies), - }) - .default(cameraConfigDefault.motioneye), - - // Live provider options. - live_provider: z.enum(LIVE_PROVIDERS).default(cameraConfigDefault.live_provider), - go2rtc: go2rtcConfigSchema.optional(), - image: liveImageConfigSchema.default(cameraConfigDefault.image), - jsmpeg: jsmpegConfigSchema.optional(), - webrtc_card: webrtcCardConfigSchema.optional(), - }) - .default(cameraConfigDefault); -export type CameraConfig = z.infer; - -// Avoid using .nonempty() to avoid changing the inferred type -// (https://github.com/colinhacks/zod#minmaxlength). -const camerasConfigSchema = cameraConfigSchema.array().min(1); -export type CamerasConfig = z.infer; - -/** - * Custom Element Types. - */ -const menuBaseSchema = z.object({ - enabled: z.boolean().default(true).optional(), - priority: z - .number() - .min(0) - .max(FRIGATE_MENU_PRIORITY_MAX) - .default(FRIGATE_MENU_PRIORITY_DEFAULT) - .optional(), - alignment: z.enum(['matching', 'opposing']).default('matching').optional(), - icon: z.string().optional(), -}); - -const menuIconSchema = menuBaseSchema.merge(iconSchema).extend({ - type: z.literal('custom:frigate-card-menu-icon'), -}); -export type MenuIcon = z.infer; - -const menuStateIconSchema = menuBaseSchema - .merge(stateIconSchema) - .extend({ - type: z.literal('custom:frigate-card-menu-state-icon'), - }) - .merge(menuBaseSchema); -export type MenuStateIcon = z.infer; - -const menuSubmenuItemSchema = elementsBaseSchema.extend({ - entity: z.string().optional(), - icon: z.string().optional(), - state_color: z.boolean().default(true), - selected: z.boolean().default(false), - subtitle: z.string().optional(), - enabled: z.boolean().default(true), -}); -export type MenuSubmenuItem = z.infer; - -const menuSubmenuSchema = menuBaseSchema.merge(iconSchema).extend({ - type: z.literal('custom:frigate-card-menu-submenu'), - items: menuSubmenuItemSchema.array(), -}); -export type MenuSubmenu = z.infer; - -const menuSubmenuSelectSchema = menuBaseSchema.merge(stateIconSchema).extend({ - type: z.literal('custom:frigate-card-menu-submenu-select'), - options: z.record(menuSubmenuItemSchema.deepPartial()).optional(), -}); -export type MenuSubmenuSelect = z.infer; - -export type MenuItem = MenuIcon | MenuStateIcon | MenuSubmenu | MenuSubmenuSelect; - -export const frigateCardConditionSchema = z.object({ - view: z.string().array().optional(), - fullscreen: z.boolean().optional(), - expand: z.boolean().optional(), - camera: z.string().array().optional(), - media_loaded: z.boolean().optional(), - state: stateConditions.optional(), - media_query: z.string().optional(), - display_mode: viewDisplayModeSchema.optional(), -}); -export type FrigateCardCondition = z.infer; - -export const frigateConditionalSchema = z.object({ - type: z.literal('custom:frigate-card-conditional'), - conditions: frigateCardConditionSchema, - elements: z.lazy(() => pictureElementsSchema), -}); -export type FrigateConditional = z.infer; - -const frigateCardPTZSchema = z.preprocess( - // To avoid lots of YAML duplication, provide an easy way to just specify the - // service data as actions for each PTZ icon, and it will be preprocessed into - // the full form. This also provides compatability with the AlexIT/WebRTC PTZ - // configuration. - (data) => { - if (!data || typeof data !== 'object' || !data['service']) { - return data; - } - const out = { ...data }; - ['left', 'right', 'up', 'down', 'zoom_in', 'zoom_out', 'home'].forEach((name) => { - if (`data_${name}` in data && !(`actions_${name}` in data)) { - out[`actions_${name}`] = { - tap_action: { - action: 'call-service', - service: data['service'], - service_data: data[`data_${name}`], - }, - }; - delete out[`data_${name}`]; - } - }); - return out; - }, - z.object({ - type: z.literal('custom:frigate-card-ptz'), - style: z.object({}).passthrough().optional(), - orientation: z.enum(['vertical', 'horizontal']).default('vertical').optional(), - service: z.string().optional(), - actions_left: actionsBaseSchema.optional(), - actions_right: actionsBaseSchema.optional(), - actions_up: actionsBaseSchema.optional(), - actions_down: actionsBaseSchema.optional(), - actions_zoom_in: actionsBaseSchema.optional(), - actions_zoom_out: actionsBaseSchema.optional(), - actions_home: actionsBaseSchema.optional(), - }), -); - -export type FrigateCardPTZConfig = z.infer; - -// Cannot use discriminatedUnion since customSchema uses a superRefine, which -// causes false rejections. -const pictureElementSchema = z.union([ - menuStateIconSchema, - menuIconSchema, - menuSubmenuSchema, - menuSubmenuSelectSchema, - frigateConditionalSchema, - frigateCardPTZSchema, - stateBadgeIconSchema, - stateIconSchema, - stateLabelSchema, - serviceCallButtonSchema, - iconSchema, - imageSchema, - conditionalSchema, - customSchema, -]); - -const pictureElementsSchema = pictureElementSchema.array().optional(); -export type PictureElements = z.infer; - -/** - * Media layout configuration section. - */ -const mediaLayoutConfigSchema = z.object({ - fit: z.enum(['contain', 'cover', 'fill']).optional(), - position: z - .object({ - x: z.number().min(0).max(100).optional(), - y: z.number().min(0).max(100).optional(), - }) - .optional(), -}); -export type MediaLayoutConfig = z.infer; - -/** - * View configuration section. - */ -const viewConfigDefault = { - default: FRIGATE_CARD_VIEW_DEFAULT, - camera_select: 'current' as const, - timeout_seconds: 300, - update_seconds: 0, - update_force: false, - update_cycle_camera: false, - dark_mode: 'off' as const, - scan: { - enabled: false, - show_trigger_status: true, - untrigger_seconds: 0, - untrigger_reset: true, - }, -}; -const scanSchema = z.object({ - enabled: z.boolean().default(viewConfigDefault.scan.enabled), - show_trigger_status: z.boolean().default(viewConfigDefault.scan.show_trigger_status), - untrigger_seconds: z.number().default(viewConfigDefault.scan.untrigger_seconds), - untrigger_reset: z.boolean().default(viewConfigDefault.scan.untrigger_reset), -}); -export type ScanOptions = z.infer; - -const viewConfigSchema = z - .object({ - default: z - .enum(FRIGATE_CARD_VIEWS_USER_SPECIFIED) - .default(viewConfigDefault.default), - camera_select: z - .enum([...FRIGATE_CARD_VIEWS_USER_SPECIFIED, 'current']) - .default(viewConfigDefault.camera_select), - timeout_seconds: z.number().default(viewConfigDefault.timeout_seconds), - update_seconds: z.number().default(viewConfigDefault.update_seconds), - update_force: z.boolean().default(viewConfigDefault.update_force), - update_cycle_camera: z.boolean().default(viewConfigDefault.update_cycle_camera), - update_entities: z.string().array().optional(), - render_entities: z.string().array().optional(), - dark_mode: z.enum(['on', 'off', 'auto']).optional(), - scan: scanSchema.default(viewConfigDefault.scan), - }) - .merge(actionsSchema) - .default(viewConfigDefault); - -/** - * Image view configuration section. - */ - -const IMAGE_MODES = ['screensaver', 'camera', 'url'] as const; -const imageConfigDefault = { - mode: 'url' as const, - zoomable: true, - ...imageBaseConfigDefault, -}; -const imageConfigSchema = imageBaseConfigSchema - .extend({ - mode: z.enum(IMAGE_MODES).default(imageConfigDefault.mode), - layout: mediaLayoutConfigSchema.optional(), - zoomable: z.boolean().default(imageConfigDefault.zoomable), - }) - .merge(actionsSchema) - .default(imageConfigDefault); -export type ImageViewConfig = z.infer; - -/** - * Thumbnail controls configuration section. - */ - -const thumbnailControlsBaseDefaults = { - size: 100, - show_details: true, - show_favorite_control: true, - show_timeline_control: true, - show_download_control: true, -}; - -// Configuration for the actual rendered thumbnail. -const thumbnailsControlBaseSchema = z.object({ - size: z - .number() - .min(THUMBNAIL_WIDTH_MIN) - .max(THUMBNAIL_WIDTH_MAX) - .default(thumbnailControlsBaseDefaults.size), - show_details: z.boolean().default(thumbnailControlsBaseDefaults.show_details), - show_favorite_control: z - .boolean() - .default(thumbnailControlsBaseDefaults.show_favorite_control), - show_timeline_control: z - .boolean() - .default(thumbnailControlsBaseDefaults.show_timeline_control), - show_download_control: z - .boolean() - .default(thumbnailControlsBaseDefaults.show_download_control), -}); - -export type ThumbnailsControlBaseConfig = z.infer; - -// Configuration that may control the placement of the thumbnail. -const thumbnailControlsDefaults = { - ...thumbnailControlsBaseDefaults, - mode: 'right' as const, -}; - -const thumbnailsControlSchema = thumbnailsControlBaseSchema.extend({ - mode: z - .enum(['none', 'above', 'below', 'left', 'right']) - .default(thumbnailControlsDefaults.mode), -}); - -export type ThumbnailsControlConfig = z.infer; - -/** - * Core/Mini timeline controls configuration section. - */ - -const timelineCoreConfigDefault = { - clustering_threshold: 3, - media: 'all' as const, - window_seconds: 60 * 60, - show_recordings: true, - style: 'stack' as const, -}; - -const timelineMediaSchema = z.enum(['all', 'clips', 'snapshots']); -const timelineCoreConfigSchema = z.object({ - clustering_threshold: z - .number() - .optional() - .default(timelineCoreConfigDefault.clustering_threshold), - media: timelineMediaSchema.optional().default(timelineCoreConfigDefault.media), - window_seconds: z - .number() - .min(1 * 60) - .max(24 * 60 * 60) - .optional() - .default(timelineCoreConfigDefault.window_seconds), - show_recordings: z - .boolean() - .optional() - .default(timelineCoreConfigDefault.show_recordings), - style: z.enum(['stack', 'ribbon']).optional().default(timelineCoreConfigDefault.style), -}); -export type TimelineCoreConfig = z.infer; - -const miniTimelineConfigDefault = { - ...timelineCoreConfigDefault, - mode: 'none' as const, - - // Mini-timeline defaults to ribbon style. - style: 'ribbon' as const, -}; -const miniTimelineConfigSchema = timelineCoreConfigSchema.extend({ - mode: z.enum(['none', 'above', 'below']).default(miniTimelineConfigDefault.mode), - style: timelineCoreConfigSchema.shape.style.default(miniTimelineConfigDefault.style), -}); -export type MiniTimelineControlConfig = z.infer; - -/** - * Next/Previous Control configuration section. - */ - -const nextPreviousControlConfigSchema = z.object({ - style: z.enum(['none', 'chevrons', 'icons', 'thumbnails']), - size: z.number().min(BUTTON_SIZE_MIN), -}); -export type NextPreviousControlConfig = z.infer; - -/** - * Carousel transition effect configuration. - */ -const transitionEffectConfigSchema = z.enum(['none', 'slide']); -export type TransitionEffect = z.infer; - -/** - * Title Control configuration section. - */ -const titleControlConfigSchema = z.object({ - mode: z.enum([ - 'none', - 'popup-top-right', - 'popup-top-left', - 'popup-bottom-right', - 'popup-bottom-left', - ]), - duration_seconds: z.number().min(0).max(60), -}); -export type TitleControlConfig = z.infer; - -/** - * Live view configuration section. - */ - -const liveThumbnailControlsDefaults = { - ...thumbnailControlsDefaults, - media: 'all' as const, -}; - -const liveConfigDefault = { - auto_play: 'all' as const, - auto_pause: 'never' as const, - auto_mute: 'all' as const, - auto_unmute: 'never' as const, - preload: false, - lazy_load: true, - lazy_unload: 'never' as const, - draggable: true, - zoomable: true, - transition_effect: 'slide' as const, - show_image_during_load: true, - mode: 'single' as const, - controls: { - builtin: true, - next_previous: { - size: 48, - style: 'chevrons' as const, - }, - thumbnails: liveThumbnailControlsDefaults, - timeline: miniTimelineConfigDefault, - }, - microphone: { - ...microphoneConfigDefault, - }, -}; - -const livethumbnailsControlSchema = thumbnailsControlSchema.extend({ - media: z - .enum(['all', 'clips', 'snapshots']) - .default(liveConfigDefault.controls.thumbnails.media), -}); - -const liveOverridableConfigSchema = z - .object({ - controls: z - .object({ - builtin: z.boolean().default(liveConfigDefault.controls.builtin), - next_previous: nextPreviousControlConfigSchema - .extend({ - // Live cannot show thumbnails, remove that option. - style: z - .enum(['none', 'chevrons', 'icons']) - .default(liveConfigDefault.controls.next_previous.style), - size: nextPreviousControlConfigSchema.shape.size.default( - liveConfigDefault.controls.next_previous.size, - ), - }) - .default(liveConfigDefault.controls.next_previous), - thumbnails: livethumbnailsControlSchema.default( - liveConfigDefault.controls.thumbnails, - ), - timeline: miniTimelineConfigSchema.default(liveConfigDefault.controls.timeline), - title: titleControlConfigSchema.optional(), - }) - .default(liveConfigDefault.controls), - show_image_during_load: z - .boolean() - .default(liveConfigDefault.show_image_during_load), - layout: mediaLayoutConfigSchema.optional(), - microphone: microphoneConfigSchema.default(liveConfigDefault.microphone), - zoomable: z.boolean().default(liveConfigDefault.zoomable), - display: viewDisplaySchema, - }) - .merge(actionsSchema); - -const liveConfigSchema = liveOverridableConfigSchema - .extend({ - // Non-overrideable parameters. - auto_play: z - .enum(MEDIA_ACTION_POSITIVE_CONDITIONS) - .default(liveConfigDefault.auto_play), - auto_pause: z - .enum(MEDIA_ACTION_NEGATIVE_CONDITIONS) - .default(liveConfigDefault.auto_pause), - auto_mute: z - .enum(MEDIA_ACTION_NEGATIVE_CONDITIONS) - .default(liveConfigDefault.auto_mute), - auto_unmute: z - .enum(MEDIA_ACTION_POSITIVE_CONDITIONS) - .default(liveConfigDefault.auto_unmute), - preload: z.boolean().default(liveConfigDefault.preload), - lazy_load: z.boolean().default(liveConfigDefault.lazy_load), - lazy_unload: z - .enum(MEDIA_ACTION_NEGATIVE_CONDITIONS) - .default(liveConfigDefault.lazy_unload), - draggable: z.boolean().default(liveConfigDefault.draggable), - transition_effect: transitionEffectConfigSchema.default( - liveConfigDefault.transition_effect, - ), - }) - .default(liveConfigDefault); -export type LiveConfig = z.infer; - -/** - * Menu configuration section. - */ - -const visibleButtonDefault = { - priority: FRIGATE_MENU_PRIORITY_DEFAULT, - enabled: true, -}; -const hiddenButtonDefault = { - priority: FRIGATE_MENU_PRIORITY_DEFAULT, - enabled: false, -}; - -const menuConfigDefault = { - style: 'hidden' as const, - position: 'top' as const, - alignment: 'left' as const, - buttons: { - frigate: visibleButtonDefault, - cameras: visibleButtonDefault, - substreams: visibleButtonDefault, - live: visibleButtonDefault, - clips: visibleButtonDefault, - snapshots: visibleButtonDefault, - image: hiddenButtonDefault, - timeline: visibleButtonDefault, - download: visibleButtonDefault, - camera_ui: visibleButtonDefault, - fullscreen: visibleButtonDefault, - expand: hiddenButtonDefault, - media_player: visibleButtonDefault, - microphone: { - ...hiddenButtonDefault, - type: 'momentary' as const, - }, - mute: hiddenButtonDefault, - play: hiddenButtonDefault, - recordings: hiddenButtonDefault, - screenshot: hiddenButtonDefault, - display_mode: visibleButtonDefault, - }, - button_size: 40, -}; - -const visibleButtonSchema = menuBaseSchema.extend({ - enabled: menuBaseSchema.shape.enabled.default(visibleButtonDefault.enabled), - priority: menuBaseSchema.shape.priority.default(visibleButtonDefault.priority), -}); -const hiddenButtonSchema = menuBaseSchema.extend({ - enabled: menuBaseSchema.shape.enabled.default(hiddenButtonDefault.enabled), - priority: menuBaseSchema.shape.priority.default(hiddenButtonDefault.priority), -}); - -const menuConfigSchema = z - .object({ - style: z.enum(FRIGATE_MENU_STYLES).default(menuConfigDefault.style), - position: z.enum(FRIGATE_MENU_POSITIONS).default(menuConfigDefault.position), - alignment: z.enum(FRIGATE_MENU_ALIGNMENTS).default(menuConfigDefault.alignment), - buttons: z - .object({ - frigate: visibleButtonSchema.default(menuConfigDefault.buttons.frigate), - cameras: visibleButtonSchema.default(menuConfigDefault.buttons.cameras), - substreams: visibleButtonSchema.default(menuConfigDefault.buttons.substreams), - live: visibleButtonSchema.default(menuConfigDefault.buttons.live), - clips: visibleButtonSchema.default(menuConfigDefault.buttons.clips), - snapshots: visibleButtonSchema.default(menuConfigDefault.buttons.snapshots), - image: hiddenButtonSchema.default(menuConfigDefault.buttons.image), - timeline: visibleButtonSchema.default(menuConfigDefault.buttons.timeline), - download: visibleButtonSchema.default(menuConfigDefault.buttons.download), - camera_ui: visibleButtonSchema.default(menuConfigDefault.buttons.camera_ui), - fullscreen: visibleButtonSchema.default(menuConfigDefault.buttons.fullscreen), - expand: hiddenButtonSchema.default(menuConfigDefault.buttons.expand), - media_player: visibleButtonSchema.default( - menuConfigDefault.buttons.media_player, - ), - microphone: hiddenButtonSchema - .extend({ - type: z - .enum(['momentary', 'toggle']) - .default(menuConfigDefault.buttons.microphone.type), - }) - .default(menuConfigDefault.buttons.microphone), - recordings: hiddenButtonSchema.default(menuConfigDefault.buttons.recordings), - mute: hiddenButtonSchema.default(menuConfigDefault.buttons.mute), - play: hiddenButtonSchema.default(menuConfigDefault.buttons.play), - screenshot: hiddenButtonSchema.default(menuConfigDefault.buttons.screenshot), - display_mode: visibleButtonSchema.default( - menuConfigDefault.buttons.display_mode, - ), - }) - .default(menuConfigDefault.buttons), - button_size: z.number().min(BUTTON_SIZE_MIN).default(menuConfigDefault.button_size), - }) - .default(menuConfigDefault); -export type MenuConfig = z.infer; - -/** - * Event viewer configuration section (clip, snapshot). - */ -const viewerConfigDefault = { - auto_play: 'all' as const, - auto_pause: 'all' as const, - auto_mute: 'all' as const, - auto_unmute: 'never' as const, - lazy_load: true, - draggable: true, - zoomable: true, - transition_effect: 'slide' as const, - snapshot_click_plays_clip: true, - display_mode: 'single' as const, - controls: { - builtin: true, - next_previous: { - size: 48, - style: 'thumbnails' as const, - }, - thumbnails: thumbnailControlsDefaults, - timeline: miniTimelineConfigDefault, - }, -}; -const viewerNextPreviousControlConfigSchema = nextPreviousControlConfigSchema.extend({ - style: z - .enum(['none', 'thumbnails', 'chevrons']) - .default(viewerConfigDefault.controls.next_previous.style), - size: nextPreviousControlConfigSchema.shape.size.default( - viewerConfigDefault.controls.next_previous.size, - ), -}); - -const viewerConfigSchema = z - .object({ - auto_play: z - .enum(MEDIA_ACTION_POSITIVE_CONDITIONS) - .default(viewerConfigDefault.auto_play), - auto_pause: z - .enum(MEDIA_ACTION_NEGATIVE_CONDITIONS) - .default(viewerConfigDefault.auto_pause), - auto_mute: z - .enum(MEDIA_ACTION_NEGATIVE_CONDITIONS) - .default(viewerConfigDefault.auto_mute), - auto_unmute: z - .enum(MEDIA_ACTION_POSITIVE_CONDITIONS) - .default(viewerConfigDefault.auto_unmute), - lazy_load: z.boolean().default(viewerConfigDefault.lazy_load), - draggable: z.boolean().default(viewerConfigDefault.draggable), - zoomable: z.boolean().default(viewerConfigDefault.zoomable), - transition_effect: transitionEffectConfigSchema.default( - viewerConfigDefault.transition_effect, - ), - snapshot_click_plays_clip: z - .boolean() - .default(viewerConfigDefault.snapshot_click_plays_clip), - display: viewDisplaySchema, - controls: z - .object({ - builtin: z.boolean().default(viewerConfigDefault.controls.builtin), - next_previous: viewerNextPreviousControlConfigSchema.default( - viewerConfigDefault.controls.next_previous, - ), - thumbnails: thumbnailsControlSchema.default( - viewerConfigDefault.controls.thumbnails, - ), - timeline: miniTimelineConfigSchema.default( - viewerConfigDefault.controls.timeline, - ), - title: titleControlConfigSchema.optional(), - }) - .default(viewerConfigDefault.controls), - layout: mediaLayoutConfigSchema.optional(), - }) - .merge(actionsSchema) - .default(viewerConfigDefault); -export type ViewerConfig = z.infer; - -/** - * Event gallery configuration section (clips, snapshots). - */ -const galleryThumbnailControlsDefaults = { - ...thumbnailControlsDefaults, - show_details: false, -}; - -const galleryConfigDefault = { - controls: { - thumbnails: galleryThumbnailControlsDefaults, - filter: { - mode: 'right' as const, - }, - }, -}; - -const gallerythumbnailsControlSchema = thumbnailsControlSchema.extend({ - show_details: z.boolean().default(galleryThumbnailControlsDefaults.show_details), -}); - -const galleryConfigSchema = z - .object({ - controls: z - .object({ - thumbnails: gallerythumbnailsControlSchema.default( - galleryConfigDefault.controls.thumbnails, - ), - filter: z - .object({ - mode: z - .enum(['none', 'left', 'right']) - .default(galleryConfigDefault.controls.filter.mode), - }) - .default(galleryConfigDefault.controls.filter), - }) - .default(galleryConfigDefault.controls), - }) - .merge(actionsSchema) - .default(galleryConfigDefault); -export type GalleryConfig = z.infer; - -/** - * Dimensions configuration section. - */ -const dimensionsConfigDefault = { - aspect_ratio_mode: 'dynamic' as const, - aspect_ratio: [16, 9], - max_height: '100vh', - min_height: '100px', -}; -const dimensionsConfigSchema = z - .object({ - aspect_ratio_mode: z - .enum(['dynamic', 'static', 'unconstrained']) - .default(dimensionsConfigDefault.aspect_ratio_mode), - aspect_ratio: z - .number() - .array() - .length(2) - .or( - z - .string() - .regex(/^\s*\d+\s*[:\/]\s*\d+\s*$/) - .transform((input) => input.split(/[:\/]/).map((d) => Number(d))), - ) - .default(dimensionsConfigDefault.aspect_ratio), - max_height: z.string().default(dimensionsConfigDefault.max_height), - min_height: z.string().default(dimensionsConfigDefault.min_height), - }) - .default(dimensionsConfigDefault); - -/** - * Timeline configuration section. - */ -const timelineConfigDefault = { - ...timelineCoreConfigDefault, - controls: { - thumbnails: thumbnailControlsDefaults, - }, -}; - -const timelineConfigSchema = timelineCoreConfigSchema - .extend({ - controls: z - .object({ - thumbnails: thumbnailsControlSchema.default( - timelineConfigDefault.controls.thumbnails, - ), - }) - .default(timelineConfigDefault.controls), - }) - .default(timelineConfigDefault); -export type TimelineConfig = z.infer; - -/** - * Configuration overrides - */ -// Strip all defaults from the override schemas, to ensure values are only what -// the user has specified. -const overrideConfigurationSchema = z.object({ - cameras: deepRemoveDefaults(camerasConfigSchema).optional(), - cameras_global: deepRemoveDefaults(cameraConfigSchema).optional(), - live: deepRemoveDefaults(liveOverridableConfigSchema).optional(), - menu: deepRemoveDefaults(menuConfigSchema).optional(), - image: deepRemoveDefaults(imageConfigSchema).optional(), - view: deepRemoveDefaults(viewConfigSchema).optional(), - dimensions: deepRemoveDefaults(dimensionsConfigSchema).optional(), -}); -export type OverrideConfigurationKey = keyof z.infer; - -const overridesSchema = z - .object({ - conditions: frigateCardConditionSchema, - overrides: overrideConfigurationSchema, - }) - .array() - .optional(); - -const liveOverridesSchema = z - .object({ - conditions: frigateCardConditionSchema, - overrides: liveOverridableConfigSchema, - }) - .array() - .optional(); -export type LiveOverrides = z.infer; - -const automationActionSchema = actionSchema.array().optional(); -export type AutomationActions = z.infer; - -const automationSchema = z.object({ - conditions: frigateCardConditionSchema, - actions: automationActionSchema, - actions_not: automationActionSchema, -}); -export type Automation = z.infer; - -const automationsSchema = automationSchema.array().optional(); -export type Automations = z.infer; - -const performanceConfigDefault = { - profile: 'high' as const, - features: { - animated_progress_indicator: true, - media_chunk_size: MEDIA_CHUNK_SIZE_DEFAULT, - }, - style: { - border_radius: true, - box_shadow: true, - }, -}; - -export const performanceConfigSchema = z - .object({ - profile: z.enum(['low', 'high']).default(performanceConfigDefault.profile), - features: z - .object({ - animated_progress_indicator: z - .boolean() - .default(performanceConfigDefault.features.animated_progress_indicator), - media_chunk_size: z - .number() - .min(0) - .max(MEDIA_CHUNK_SIZE_MAX) - .default(performanceConfigDefault.features.media_chunk_size), - }) - .default(performanceConfigDefault.features), - style: z - .object({ - border_radius: z.boolean().default(performanceConfigDefault.style.border_radius), - box_shadow: z.boolean().default(performanceConfigDefault.style.box_shadow), - }) - .default(performanceConfigDefault.style), - }) - .default(performanceConfigDefault); -export type PerformanceConfig = z.infer; - -const debugConfigDefault = { - logging: false, -}; - -const debugConfigSchema = z - .object({ - logging: z.boolean().default(debugConfigDefault.logging), - }) - .default(debugConfigDefault); -type DebugConfig = z.infer; - -export interface CardWideConfig { - performance?: PerformanceConfig; - debug?: DebugConfig; -} - -/** - * Main card config. - */ -export const frigateCardConfigSchema = z.object({ - // Defaults are stripped out of the individual cameras, since each camera will - // be merged with `cameras_global` which *does* have defaults. If we didn't do - // this, the default values of each individual camera would override the - // intentionally specified values in `cameras_global` during camera - // initialization when the two configs are merged. - cameras: deepRemoveDefaults(camerasConfigSchema), - cameras_global: cameraConfigSchema, - - view: viewConfigSchema, - menu: menuConfigSchema, - live: liveConfigSchema, - media_gallery: galleryConfigSchema, - media_viewer: viewerConfigSchema, - image: imageConfigSchema, - elements: pictureElementsSchema, - dimensions: dimensionsConfigSchema, - timeline: timelineConfigSchema, - performance: performanceConfigSchema, - debug: debugConfigSchema, - automations: automationsSchema, - - // Configuration overrides. - overrides: overridesSchema, - - // Support for card_mod (https://github.com/thomasloven/lovelace-card-mod). - card_mod: z.unknown(), - - // Card ID (used for query string commands). Restrict contents to only values - // that be easily used in a URL. - card_id: z.string().regex(/^\w+$/).optional(), - - // Stock lovelace card config. - type: z.string(), -}); -export type FrigateCardConfig = z.infer; -export type RawFrigateCardConfig = Record; -export type RawFrigateCardConfigArray = RawFrigateCardConfig[]; - -export const frigateCardConfigDefaults = { - cameras: cameraConfigDefault, - view: viewConfigDefault, - menu: menuConfigDefault, - live: liveConfigDefault, - media_gallery: galleryConfigDefault, - media_viewer: viewerConfigDefault, - image: imageConfigDefault, - timeline: timelineConfigDefault, - performance: performanceConfigDefault, - debug: debugConfigDefault, -}; - -const menuButtonSchema = z.discriminatedUnion('type', [ - menuIconSchema, - menuStateIconSchema, - menuSubmenuSchema, - menuSubmenuSelectSchema, -]); -export type MenuButton = z.infer; export interface ExtendedHomeAssistant extends HomeAssistant { hassUrl(path?): string; themes: Themes & { @@ -1555,9 +86,9 @@ export interface CardHelpers { }>; } -/** - * Home Assistant API types. - */ +// ************************************************************************* +// Home Assistant API types. +// ************************************************************************* // Server side data-type defined here: https://github.com/home-assistant/core/blob/dev/homeassistant/components/media_source/models.py export const resolvedMediaSchema = z.object({ diff --git a/src/utils/action.ts b/src/utils/action.ts index ea228c57..fcf055ec 100644 --- a/src/utils/action.ts +++ b/src/utils/action.ts @@ -11,7 +11,7 @@ import { FrigateCardCustomAction, frigateCardCustomActionSchema, ViewDisplayMode, -} from '../types.js'; +} from '../config/types.js'; /** * Convert a generic Action to a FrigateCardCustomAction if it parses correctly. diff --git a/src/utils/camera.ts b/src/utils/camera.ts index 497d9355..cd824e05 100644 --- a/src/utils/camera.ts +++ b/src/utils/camera.ts @@ -1,5 +1,5 @@ import { CameraManager } from '../camera-manager/manager.js'; -import { CameraConfig, RawFrigateCardConfig } from '../types.js'; +import { CameraConfig, RawFrigateCardConfig } from '../config/types.js'; /** * Get a camera id. diff --git a/src/utils/card-controller/actions-manager.ts b/src/utils/card-controller/actions-manager.ts index 6ca97cb3..54475396 100644 --- a/src/utils/card-controller/actions-manager.ts +++ b/src/utils/card-controller/actions-manager.ts @@ -1,7 +1,9 @@ import { Actions, - ActionsConfig, FrigateCardCustomAction, FRIGATE_CARD_VIEW_DEFAULT -} from '../../types.js'; + ActionsConfig, + FRIGATE_CARD_VIEW_DEFAULT, + FrigateCardCustomAction +} from '../../config/types.js'; import { convertActionToFrigateCardCustomAction, frigateCardHandleActionConfig, @@ -9,6 +11,8 @@ import { } from '../action.js'; import { getStreamCameraID } from '../substream.js'; import { CardActionsManagerAPI } from './types.js'; +import { getStreamCameraID } from '../substream.js'; +import { CardActionsManagerAPI } from './types.js'; export class ActionsManager { protected _api: CardActionsManagerAPI; diff --git a/src/utils/card-controller/automations-manager.ts b/src/utils/card-controller/automations-manager.ts index 35251d61..dd145502 100644 --- a/src/utils/card-controller/automations-manager.ts +++ b/src/utils/card-controller/automations-manager.ts @@ -1,9 +1,5 @@ +import { Automation, AutomationActions, Automations } from '../../config/types.js'; import { localize } from '../../localize/localize.js'; -import { - Automation, - AutomationActions, - Automations, -} from '../../types.js'; import { frigateCardHandleAction } from '../action.js'; import { CardAutomationsAPI } from './types.js'; diff --git a/src/utils/card-controller/conditions-manager.ts b/src/utils/card-controller/conditions-manager.ts index 8857ba39..45703118 100644 --- a/src/utils/card-controller/conditions-manager.ts +++ b/src/utils/card-controller/conditions-manager.ts @@ -6,8 +6,8 @@ import { frigateConditionalSchema, OverrideConfigurationKey, RawFrigateCardConfig, - ViewDisplayMode, -} from '../../types'; + ViewDisplayMode +} from '../../config/types'; import { CardConditionAPI } from './types'; interface ConditionState { diff --git a/src/utils/card-controller/config-manager.ts b/src/utils/card-controller/config-manager.ts index da146b9e..70af9921 100644 --- a/src/utils/card-controller/config-manager.ts +++ b/src/utils/card-controller/config-manager.ts @@ -1,13 +1,13 @@ import isEqual from 'lodash-es/isEqual'; import { isConfigUpgradeable } from '../../config-mgmt'; -import { localize } from '../../localize/localize'; -import { setLowPerformanceProfile } from '../../performance.js'; import { CardWideConfig, FrigateCardConfig, frigateCardConfigSchema, - RawFrigateCardConfig, -} from '../../types'; + RawFrigateCardConfig +} from '../../config/types'; +import { localize } from '../../localize/localize'; +import { setLowPerformanceProfile } from '../../performance.js'; import { getParseErrorPaths } from '../zod.js'; import { getOverriddenConfig } from './conditions-manager'; import { InitializationAspect } from './initialization-manager'; diff --git a/src/utils/card-controller/controller.ts b/src/utils/card-controller/controller.ts index 264cf91e..0b684fd3 100644 --- a/src/utils/card-controller/controller.ts +++ b/src/utils/card-controller/controller.ts @@ -1,10 +1,7 @@ -// TODO: executeMediaQueryForView should not need a HTMLElement host parameter see the view-manager.ts call in particular. -// TODO: Split out zod schema and add tests for config parsing (e.g. view.scan.show_status argument was missing) - import { LovelaceCardEditor } from 'custom-card-helpers'; import { ReactiveController } from 'lit'; import { CameraManager } from '../../camera-manager/manager'; -import { FrigateCardConfig } from '../../types'; +import { FrigateCardConfig } from '../../config/types'; import { EntityRegistryManager } from '../ha/entity-registry'; import { EntityCache } from '../ha/entity-registry/cache'; import { ResolvedMediaCache } from '../ha/resolved-media'; @@ -35,8 +32,8 @@ import { StyleManager } from './style-manager'; import { TriggersManager } from './triggers-manager'; import { CardActionsManagerAPI, - CardAutomationsAPI, CardAutoRefreshAPI, + CardAutomationsAPI, CardCameraAPI, CardCameraURLAPI, CardConditionAPI, diff --git a/src/utils/card-controller/hass-manager.ts b/src/utils/card-controller/hass-manager.ts index ac682583..ebbb55bd 100644 --- a/src/utils/card-controller/hass-manager.ts +++ b/src/utils/card-controller/hass-manager.ts @@ -1,5 +1,6 @@ +import { CameraConfig } from '../../config/types'; import { localize } from '../../localize/localize'; -import { CameraConfig, ExtendedHomeAssistant } from '../../types'; +import { ExtendedHomeAssistant } from '../../types'; import { hasHAConnectionStateChanged, isHassDifferent } from '../ha'; import { CardHASSAPI } from './types'; diff --git a/src/utils/card-controller/query-string-manager.ts b/src/utils/card-controller/query-string-manager.ts index ded00c7a..81420f88 100644 --- a/src/utils/card-controller/query-string-manager.ts +++ b/src/utils/card-controller/query-string-manager.ts @@ -1,4 +1,4 @@ -import { FrigateCardCustomAction, FrigateCardViewAction } from '../../types'; +import { FrigateCardCustomAction, FrigateCardViewAction } from '../../config/types'; import { createFrigateCardCustomAction } from '../action.js'; import { CardQueryStringAPI } from './types'; import { ViewManagerSetViewParameters } from './view-manager'; diff --git a/src/utils/card-controller/style-manager.ts b/src/utils/card-controller/style-manager.ts index b7eccccd..3376288e 100644 --- a/src/utils/card-controller/style-manager.ts +++ b/src/utils/card-controller/style-manager.ts @@ -1,5 +1,5 @@ +import { FrigateCardConfig } from '../../config/types'; import { setPerformanceCSSStyles } from '../../performance'; -import { FrigateCardConfig } from '../../types'; import { View } from '../../view/view'; import { setOrRemoveAttribute } from '../basic'; import { CardStyleAPI } from './types'; diff --git a/src/utils/card-controller/view-manager.ts b/src/utils/card-controller/view-manager.ts index 568d65f8..3e42a786 100644 --- a/src/utils/card-controller/view-manager.ts +++ b/src/utils/card-controller/view-manager.ts @@ -1,5 +1,5 @@ import { ViewContext } from 'view'; -import { FrigateCardConfig, FrigateCardView, ViewDisplayMode } from '../../types'; +import { FrigateCardConfig, FrigateCardView, ViewDisplayMode } from '../../config/types'; import { View } from '../../view/view'; import { getAllDependentCameras } from '../camera'; import { log } from '../debug'; diff --git a/src/utils/debug.ts b/src/utils/debug.ts index dcc9e704..f89f819c 100644 --- a/src/utils/debug.ts +++ b/src/utils/debug.ts @@ -1,4 +1,4 @@ -import { CardWideConfig } from '../types'; +import { CardWideConfig } from '../config/types'; export const log = (cardWideConfig?: CardWideConfig | null, ...args: unknown[]) => { if (cardWideConfig?.debug?.logging) { diff --git a/src/utils/diagnostics.ts b/src/utils/diagnostics.ts index 1ec18a5a..32c97bf7 100644 --- a/src/utils/diagnostics.ts +++ b/src/utils/diagnostics.ts @@ -1,7 +1,7 @@ import { HomeAssistant } from 'custom-card-helpers'; import pkg from '../../package.json'; +import { RawFrigateCardConfig } from '../config/types'; import { getLanguage } from '../localize/localize'; -import { RawFrigateCardConfig } from '../types'; import { DeviceList, getAllDevices } from './ha/device-registry'; type FrigateVersions = Record; diff --git a/src/utils/embla/carousel-controller.ts b/src/utils/embla/carousel-controller.ts index b80f13c8..d89e9c5a 100644 --- a/src/utils/embla/carousel-controller.ts +++ b/src/utils/embla/carousel-controller.ts @@ -2,7 +2,7 @@ import EmblaCarousel, { EmblaCarouselType } from 'embla-carousel'; import { WheelGesturesPlugin } from 'embla-carousel-wheel-gestures'; import { CreatePluginType, LoosePluginType } from 'embla-carousel/components/Plugins'; import isEqual from 'lodash-es/isEqual'; -import { TransitionEffect } from '../../types'; +import { TransitionEffect } from '../../config/types'; import { dispatchFrigateCardEvent, getChildrenFromElement } from '../basic.js'; export interface CarouselSelected { diff --git a/src/utils/embla/plugins/auto-lazy-load/auto-lazy-load.ts b/src/utils/embla/plugins/auto-lazy-load/auto-lazy-load.ts index 2941f983..efd4f2f4 100644 --- a/src/utils/embla/plugins/auto-lazy-load/auto-lazy-load.ts +++ b/src/utils/embla/plugins/auto-lazy-load/auto-lazy-load.ts @@ -2,7 +2,7 @@ import { EmblaCarouselType, EmblaEventType } from 'embla-carousel'; import { CreateOptionsType } from 'embla-carousel/components/Options'; import { OptionsHandlerType } from 'embla-carousel/components/OptionsHandler'; import { CreatePluginType, LoosePluginType } from 'embla-carousel/components/Plugins'; -import { LazyUnloadCondition } from '../../../../types'; +import { LazyUnloadCondition } from '../../../../config/types'; declare module 'embla-carousel/components/Plugins' { interface EmblaPluginsType { diff --git a/src/utils/embla/plugins/auto-media-actions/auto-media-actions.ts b/src/utils/embla/plugins/auto-media-actions/auto-media-actions.ts index fbb3f3cf..cb47af69 100644 --- a/src/utils/embla/plugins/auto-media-actions/auto-media-actions.ts +++ b/src/utils/embla/plugins/auto-media-actions/auto-media-actions.ts @@ -7,8 +7,8 @@ import { AutoPauseCondition, AutoPlayCondition, AutoUnmuteCondition, - FrigateCardMediaPlayer, -} from '../../../../types.js'; +} from '../../../../config/types.js'; +import { FrigateCardMediaPlayer } from '../../../../types.js'; declare module 'embla-carousel/components/Plugins' { interface EmblaPluginsType { diff --git a/src/utils/media-grid-controller.ts b/src/utils/media-grid-controller.ts index 4ce713fa..cdf0bba3 100644 --- a/src/utils/media-grid-controller.ts +++ b/src/utils/media-grid-controller.ts @@ -1,7 +1,8 @@ import isEqual from 'lodash-es/isEqual'; import throttle from 'lodash-es/throttle'; import Masonry from 'masonry-layout'; -import { MediaLoadedInfo, ViewDisplayConfig } from '../types'; +import { ViewDisplayConfig } from '../config/types'; +import { MediaLoadedInfo } from '../types'; import { dispatchFrigateCardEvent, getChildrenFromElement, diff --git a/src/utils/media-layout.ts b/src/utils/media-layout.ts index bf5ff24f..017c1efe 100644 --- a/src/utils/media-layout.ts +++ b/src/utils/media-layout.ts @@ -1,4 +1,4 @@ -import { MediaLayoutConfig } from '../types'; +import { MediaLayoutConfig } from '../config/types'; /** * Update element style from a media configuration. diff --git a/src/utils/media-to-view.ts b/src/utils/media-to-view.ts index 0fba9008..ac3bc44c 100644 --- a/src/utils/media-to-view.ts +++ b/src/utils/media-to-view.ts @@ -2,8 +2,9 @@ import { ViewContext } from 'view'; import { CameraManager } from '../camera-manager/manager'; import { MediaQuery } from '../camera-manager/types'; import { dispatchFrigateCardErrorEvent } from '../components/message'; +import { CardWideConfig, FrigateCardView } from '../config/types'; import { MEDIA_CHUNK_SIZE_DEFAULT } from '../const'; -import { CardWideConfig, ClipsOrSnapshotsOrAll, FrigateCardView } from '../types'; +import { ClipsOrSnapshotsOrAll } from '../types'; import { ViewMedia } from '../view/media'; import { EventMediaQueries, diff --git a/src/utils/menu-controller.ts b/src/utils/menu-controller.ts index 45a61097..906fbce2 100644 --- a/src/utils/menu-controller.ts +++ b/src/utils/menu-controller.ts @@ -1,23 +1,24 @@ import { HomeAssistant } from 'custom-card-helpers'; import { StyleInfo } from 'lit/directives/style-map'; import { CameraManager } from '../camera-manager/manager'; +import { + FRIGATE_CARD_VIEWS_USER_SPECIFIED, + FrigateCardConfig, + FrigateCardCustomAction, + MenuItem +} from '../config/types'; import { FRIGATE_BUTTON_MENU_ICON } from '../const'; import { localize } from '../localize/localize.js'; import { - FrigateCardConfig, - FrigateCardCustomAction, - FRIGATE_CARD_VIEWS_USER_SPECIFIED, MediaLoadedInfo, - MenuButton, } from '../types'; import { View } from '../view/view'; import { createFrigateCardCustomAction } from './action'; import { getAllDependentCameras } from './camera'; -import { getEntityIcon, getEntityTitle } from './ha'; import { MediaPlayerManager } from './card-controller/media-player-manager'; import { MicrophoneManager } from './card-controller/microphone-manager'; +import { getEntityIcon, getEntityTitle } from './ha'; import { hasSubstream } from './substream'; - export interface MenuButtonControllerOptions { currentMediaLoadedInfo?: MediaLoadedInfo | null; showCameraUIButton?: boolean; @@ -29,15 +30,15 @@ export interface MenuButtonControllerOptions { export class MenuButtonController { // Array of dynamic menu buttons to be added to menu. - protected _dynamicMenuButtons: MenuButton[] = []; + protected _dynamicMenuButtons: MenuItem[] = []; - public addDynamicMenuButton(button: MenuButton): void { + public addDynamicMenuButton(button: MenuItem): void { if (!this._dynamicMenuButtons.includes(button)) { this._dynamicMenuButtons.push(button); } } - public removeDynamicMenuButton(button: MenuButton): void { + public removeDynamicMenuButton(button: MenuItem): void { this._dynamicMenuButtons = this._dynamicMenuButtons.filter( (existingButton) => existingButton != button, ); @@ -53,7 +54,7 @@ export class MenuButtonController { cameraManager: CameraManager, view: View, options?: MenuButtonControllerOptions, - ): MenuButton[] { + ): MenuItem[] { const visibleCameras = cameraManager.getStore().getVisibleCameras(); const selectedCameraID = view.camera; const selectedCameraConfig = cameraManager @@ -68,7 +69,7 @@ export class MenuButtonController { ? cameraManager?.getMediaCapabilities(selectedMedia) : null; - const buttons: MenuButton[] = []; + const buttons: MenuItem[] = []; buttons.push({ // Use a magic icon value that the menu will use to render the custom // Frigate icon. @@ -451,7 +452,7 @@ export class MenuButtonController { protected _getStyleFromActions( config: FrigateCardConfig, view: View, - button: MenuButton, + button: MenuItem, options?: MenuButtonControllerOptions, ): StyleInfo { for (const actionSet of [ diff --git a/src/utils/task.ts b/src/utils/task.ts index 155958b6..2d249d3c 100644 --- a/src/utils/task.ts +++ b/src/utils/task.ts @@ -4,7 +4,7 @@ import { dispatchFrigateCardErrorEvent, renderProgressIndicator, } from '../components/message'; -import { CardWideConfig } from '../types'; +import { CardWideConfig } from '../config/types'; import { errorToConsole } from './basic'; /** diff --git a/src/view/view.ts b/src/view/view.ts index 444c5405..e2a1de01 100644 --- a/src/view/view.ts +++ b/src/view/view.ts @@ -1,5 +1,6 @@ import { ViewContext } from 'view'; -import { ClipsOrSnapshots, FrigateCardView, ViewDisplayMode } from '../types.js'; +import { FrigateCardView, ViewDisplayMode } from '../config/types.js'; +import { ClipsOrSnapshots } from '../types.js'; import { dispatchFrigateCardEvent } from '../utils/basic.js'; import { MediaQueries } from './media-queries'; import { MediaQueriesClassifier } from './media-queries-classifier.js'; diff --git a/tests/camera-manager/engine-factory.test.ts b/tests/camera-manager/engine-factory.test.ts index 340eff08..95e1db69 100644 --- a/tests/camera-manager/engine-factory.test.ts +++ b/tests/camera-manager/engine-factory.test.ts @@ -4,7 +4,7 @@ import { FrigateCameraManagerEngine } from '../../src/camera-manager/frigate/eng import { GenericCameraManagerEngine } from '../../src/camera-manager/generic/engine-generic'; import { MotionEyeCameraManagerEngine } from '../../src/camera-manager/motioneye/engine-motioneye'; import { Engine } from '../../src/camera-manager/types.js'; -import { CardWideConfig } from '../../src/types.js'; +import { CardWideConfig } from '../../src/config/types.js'; import { EntityRegistryManager } from '../../src/utils/ha/entity-registry'; import { EntityCache } from '../../src/utils/ha/entity-registry/cache'; import { ResolvedMediaCache } from '../../src/utils/ha/resolved-media'; diff --git a/tests/camera-manager/frigate/engine-frigate.test.ts b/tests/camera-manager/frigate/engine-frigate.test.ts index 0ae26b5d..da11bcb7 100644 --- a/tests/camera-manager/frigate/engine-frigate.test.ts +++ b/tests/camera-manager/frigate/engine-frigate.test.ts @@ -6,7 +6,7 @@ import { FrigateRecordingViewMedia, } from '../../../src/camera-manager/frigate/media'; import { FrigateEvent, eventSchema } from '../../../src/camera-manager/frigate/types.js'; -import { CameraConfig, RawFrigateCardConfig } from '../../../src/types'; +import { CameraConfig, RawFrigateCardConfig } from '../../../src/config/types'; import { ViewMedia } from '../../../src/view/media'; import { createCameraConfig, createHASS } from '../../test-utils'; diff --git a/tests/camera-manager/frigate/util.test.ts b/tests/camera-manager/frigate/util.test.ts index b47557b5..38e32482 100644 --- a/tests/camera-manager/frigate/util.test.ts +++ b/tests/camera-manager/frigate/util.test.ts @@ -9,7 +9,7 @@ import { getRecordingMediaContentID, getRecordingTitle, } from '../../../src/camera-manager/frigate/util'; -import { CameraConfig } from '../../../src/types'; +import { CameraConfig } from '../../../src/config/types'; import { createCameraConfig, createFrigateEvent, diff --git a/tests/camera-manager/utils.test.ts b/tests/camera-manager/utils.test.ts index 913ad754..a4efec66 100644 --- a/tests/camera-manager/utils.test.ts +++ b/tests/camera-manager/utils.test.ts @@ -5,8 +5,7 @@ import { getCameraEntityFromConfig, sortMedia, } from '../../src/camera-manager/util.js'; -import { CameraConfig, cameraConfigSchema } from '../../src/types.js'; -import { ViewMedia, ViewMediaType } from '../../src/view/media.js'; +import { CameraConfig, cameraConfigSchema } from '../../src/config/types.js'; import { TestViewMedia } from '../test-utils.js'; describe('convertRangeToCacheFriendlyTimes', () => { diff --git a/tests/config/types.test.ts b/tests/config/types.test.ts new file mode 100644 index 00000000..7882536f --- /dev/null +++ b/tests/config/types.test.ts @@ -0,0 +1,421 @@ +import { describe, expect, it } from 'vitest'; +import { + conditionalSchema, + customSchema, + dimensionsConfigSchema, + frigateCardCustomActionsBaseSchema, + frigateCardPTZSchema +} from '../../src/config/types'; +import { createConfig } from '../test-utils'; + +// @vitest-environment jsdom +describe('config defaults', () => { + it('should be as expected', () => { + expect(createConfig()).toEqual({ + cameras: [], + cameras_global: { + dependencies: { + all_cameras: false, + cameras: [], + }, + engine: 'auto', + frigate: { + client_id: 'frigate', + }, + hide: false, + image: { + refresh_seconds: 1, + }, + live_provider: 'auto', + motioneye: { + images: { + directory_pattern: '%Y-%m-%d', + file_pattern: '%H-%M-%S', + }, + movies: { + directory_pattern: '%Y-%m-%d', + file_pattern: '%H-%M-%S', + }, + }, + triggers: { + entities: [], + motion: false, + occupancy: true, + }, + }, + debug: { + logging: false, + }, + dimensions: { + aspect_ratio: [16, 9], + aspect_ratio_mode: 'dynamic', + max_height: '100vh', + min_height: '100px', + }, + image: { + mode: 'url', + refresh_seconds: 1, + zoomable: true, + }, + live: { + auto_mute: 'all', + auto_pause: 'never', + auto_play: 'all', + auto_unmute: 'never', + controls: { + builtin: true, + next_previous: { + size: 48, + style: 'chevrons', + }, + thumbnails: { + media: 'all', + mode: 'right', + show_details: true, + show_download_control: true, + show_favorite_control: true, + show_timeline_control: true, + size: 100, + }, + timeline: { + clustering_threshold: 3, + media: 'all', + mode: 'none', + show_recordings: true, + style: 'ribbon', + window_seconds: 3600, + }, + }, + draggable: true, + lazy_load: true, + lazy_unload: 'never', + microphone: { + always_connected: false, + disconnect_seconds: 60, + }, + preload: false, + show_image_during_load: true, + transition_effect: 'slide', + zoomable: true, + }, + media_gallery: { + controls: { + filter: { + mode: 'right', + }, + thumbnails: { + mode: 'right', + show_details: false, + show_download_control: true, + show_favorite_control: true, + show_timeline_control: true, + size: 100, + }, + }, + }, + media_viewer: { + auto_mute: 'all', + auto_pause: 'all', + auto_play: 'all', + auto_unmute: 'never', + controls: { + builtin: true, + next_previous: { + size: 48, + style: 'thumbnails', + }, + thumbnails: { + mode: 'right', + show_details: true, + show_download_control: true, + show_favorite_control: true, + show_timeline_control: true, + size: 100, + }, + timeline: { + clustering_threshold: 3, + media: 'all', + mode: 'none', + show_recordings: true, + style: 'ribbon', + window_seconds: 3600, + }, + }, + draggable: true, + lazy_load: true, + snapshot_click_plays_clip: true, + transition_effect: 'slide', + zoomable: true, + }, + menu: { + alignment: 'left', + button_size: 40, + buttons: { + camera_ui: { + enabled: true, + priority: 50, + }, + cameras: { + enabled: true, + priority: 50, + }, + clips: { + enabled: true, + priority: 50, + }, + display_mode: { + enabled: true, + priority: 50, + }, + download: { + enabled: true, + priority: 50, + }, + expand: { + enabled: false, + priority: 50, + }, + frigate: { + enabled: true, + priority: 50, + }, + fullscreen: { + enabled: true, + priority: 50, + }, + image: { + enabled: false, + priority: 50, + }, + live: { + enabled: true, + priority: 50, + }, + media_player: { + enabled: true, + priority: 50, + }, + microphone: { + enabled: false, + priority: 50, + type: 'momentary', + }, + mute: { + enabled: false, + priority: 50, + }, + play: { + enabled: false, + priority: 50, + }, + recordings: { + enabled: false, + priority: 50, + }, + screenshot: { + enabled: false, + priority: 50, + }, + snapshots: { + enabled: true, + priority: 50, + }, + substreams: { + enabled: true, + priority: 50, + }, + timeline: { + enabled: true, + priority: 50, + }, + }, + position: 'top', + style: 'hidden', + }, + performance: { + features: { + animated_progress_indicator: true, + media_chunk_size: 50, + }, + profile: 'high', + style: { + border_radius: true, + box_shadow: true, + }, + }, + timeline: { + clustering_threshold: 3, + controls: { + thumbnails: { + mode: 'right', + show_details: true, + show_download_control: true, + show_favorite_control: true, + show_timeline_control: true, + size: 100, + }, + }, + media: 'all', + show_recordings: true, + style: 'stack', + window_seconds: 3600, + }, + type: 'frigate-hass-card', + view: { + camera_select: 'current', + dark_mode: 'off', + default: 'live', + scan: { + enabled: false, + show_trigger_status: true, + untrigger_reset: true, + untrigger_seconds: 0, + }, + timeout_seconds: 300, + update_cycle_camera: false, + update_force: false, + update_seconds: 0, + }, + }); + }); +}); + +it('should transform dimensions.aspect_ratio', () => { + expect( + dimensionsConfigSchema.parse({ + aspect_ratio: '16 / 9', + }), + ).toEqual(expect.objectContaining({ aspect_ratio: [16, 9] })); + + expect( + dimensionsConfigSchema.parse({ + aspect_ratio: '16 : 9', + }), + ).toEqual(expect.objectContaining({ aspect_ratio: [16, 9] })); + + expect( + dimensionsConfigSchema.parse({ + aspect_ratio: [16, 9], + }), + ).toEqual(expect.objectContaining({ aspect_ratio: [16, 9] })); +}); + +it('should transform action', () => { + expect( + frigateCardCustomActionsBaseSchema.parse({ + action: 'custom:frigate-card-action', + }), + ).toEqual({ + action: 'fire-dom-event', + }); +}); + +describe('should convert webrtc card PTZ to Frigate card PTZ', () => { + it.each([ + ['left' as const], + ['right' as const], + ['up' as const], + ['down' as const], + ['zoom_in' as const], + ['zoom_out' as const], + ['home' as const], + ])('%s', (action: string) => { + expect( + frigateCardPTZSchema.parse({ + type: 'custom:frigate-card-ptz', + service: 'foo', + [`data_${action}`]: { + tap_action: { + action: 'none', + }, + }, + }), + ).toEqual({ + [`actions_${action}`]: { + tap_action: { + action: 'call-service', + service: 'foo', + service_data: { + tap_action: { + action: 'none', + }, + }, + }, + }, + service: 'foo', + type: 'custom:frigate-card-ptz', + }); + }); +}); + +describe('should lazy evaluate', () => { + it('conditional picture element', () => { + expect( + conditionalSchema.parse({ + type: 'conditional', + conditions: [ + { + entity: 'light.office_main_lights', + state: 'on', + state_not: 'off', + }, + ], + elements: [ + { + type: 'icon', + icon: 'mdi:dog', + title: 'Woof', + style: { + left: '100px', + top: '400px', + }, + }, + ], + }), + ).toEqual({ + conditions: [ + { + entity: 'light.office_main_lights', + state: 'on', + state_not: 'off', + }, + ], + elements: [ + { + icon: 'mdi:dog', + style: { + left: '100px', + top: '400px', + }, + title: 'Woof', + type: 'icon', + }, + ], + type: 'conditional', + }); + }); +}); + +describe('should handle custom frigate elements', () => { + it('should add custom error on frigate entry', () => { + const result = customSchema.safeParse({ + type: 'custom:frigate-card-foo', + }); + expect(result.success).toBeFalsy(); + if (!result.success) { + expect(result.error.errors[0]).toEqual({ + code: 'custom', + message: 'Frigate-card custom elements must match specific schemas', + fatal: true, + path: ['type'], + }); + } + }); + + it('should not add custom error on valid entry', () => { + const result = customSchema.safeParse({ + type: 'custom:foo', + }); + expect(result.success).toBeTruthy(); + }); +}); diff --git a/tests/test-utils.ts b/tests/test-utils.ts index f7d83369..c73926e7 100644 --- a/tests/test-utils.ts +++ b/tests/test-utils.ts @@ -12,17 +12,16 @@ import { } from '../src/camera-manager/types'; import { CameraConfig, - ExtendedHomeAssistant, FrigateCardCondition, FrigateCardConfig, - MediaLoadedInfo, PerformanceConfig, RawFrigateCardConfig, cameraConfigSchema, frigateCardConditionSchema, frigateCardConfigSchema, performanceConfigSchema, -} from '../src/types'; +} from '../src/config/types'; +import { ExtendedHomeAssistant, MediaLoadedInfo } from '../src/types'; import { ActionsManager } from '../src/utils/card-controller/actions-manager'; import { AutoUpdateManager } from '../src/utils/card-controller/auto-update-manager'; import { AutomationsManager } from '../src/utils/card-controller/automations-manager'; diff --git a/tests/utils/action.test.ts b/tests/utils/action.test.ts index 7bb9b74a..e03bfa7c 100644 --- a/tests/utils/action.test.ts +++ b/tests/utils/action.test.ts @@ -1,12 +1,7 @@ import { handleActionConfig, hasAction } from 'custom-card-helpers'; import { afterEach, describe, expect, it, vi } from 'vitest'; import { mock } from 'vitest-mock-extended'; -import { - actionSchema, - FrigateCardAction, - FrigateCardCustomAction, - frigateCardCustomActionSchema, -} from '../../src/types'; +import { actionSchema } from '../../src/config/types'; import { convertActionToFrigateCardCustomAction, createFrigateCardCustomAction, diff --git a/tests/utils/card-controller/actions-manager.test.ts b/tests/utils/card-controller/actions-manager.test.ts index 93d00887..8985a534 100644 --- a/tests/utils/card-controller/actions-manager.test.ts +++ b/tests/utils/card-controller/actions-manager.test.ts @@ -3,10 +3,10 @@ import { mock } from 'vitest-mock-extended'; import { ActionType, FrigateCardCustomAction, - FrigateCardMediaPlayer, FrigateCardView, frigateCardCustomActionSchema, -} from '../../../src/types'; +} from '../../../src/config/types'; +import { FrigateCardMediaPlayer } from '../../../src/types'; import { convertActionToFrigateCardCustomAction, frigateCardHandleActionConfig, @@ -139,9 +139,7 @@ describe('ActionsManager.getMergedActions', () => { vi.mocked(api.getViewManager().getView).mockReturnValue( createView({ view: viewName }), ); - vi.mocked(api.getConfigManager().getConfig).mockReturnValue( - createConfig(config), - ); + vi.mocked(api.getConfigManager().getConfig).mockReturnValue(createConfig(config)); const manager = new ActionsManager(api); diff --git a/tests/utils/card-controller/conditions-manager.test.ts b/tests/utils/card-controller/conditions-manager.test.ts index d7799030..e0c65618 100644 --- a/tests/utils/card-controller/conditions-manager.test.ts +++ b/tests/utils/card-controller/conditions-manager.test.ts @@ -1,12 +1,12 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; +import { FrigateCardCondition } from '../../../src/config/types'; import { - ConditionsManager, ConditionEvaluateRequestEvent, + ConditionsManager, evaluateConditionViaEvent, getOverriddenConfig, getOverridesByKey, } from '../../../src/utils/card-controller/conditions-manager'; -import { FrigateCardCondition } from '../../../src/types'; import { createCardAPI, createCondition, diff --git a/tests/utils/card-controller/config-manager.test.ts b/tests/utils/card-controller/config-manager.test.ts index 3e515871..dcabf1c3 100644 --- a/tests/utils/card-controller/config-manager.test.ts +++ b/tests/utils/card-controller/config-manager.test.ts @@ -1,6 +1,6 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; import { ZodError } from 'zod'; -import { frigateCardConfigSchema } from '../../../src/types'; +import { frigateCardConfigSchema } from '../../../src/config/types'; import { getOverriddenConfig } from '../../../src/utils/card-controller/conditions-manager'; import { ConfigManager } from '../../../src/utils/card-controller/config-manager'; import { InitializationAspect } from '../../../src/utils/card-controller/initialization-manager'; diff --git a/tests/utils/card-controller/style-manager.test.ts b/tests/utils/card-controller/style-manager.test.ts index 963b0c6b..0c25273e 100644 --- a/tests/utils/card-controller/style-manager.test.ts +++ b/tests/utils/card-controller/style-manager.test.ts @@ -1,6 +1,6 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { FrigateCardView } from '../../../src/config/types'; import { setPerformanceCSSStyles } from '../../../src/performance'; -import { FrigateCardView } from '../../../src/types'; import { StyleManager } from '../../../src/utils/card-controller/style-manager'; import { createCardAPI, createConfig, createHASS, createView } from '../../test-utils'; diff --git a/tests/utils/card-controller/triggers-manager.test.ts b/tests/utils/card-controller/triggers-manager.test.ts index 69067757..2bd6beb3 100644 --- a/tests/utils/card-controller/triggers-manager.test.ts +++ b/tests/utils/card-controller/triggers-manager.test.ts @@ -1,7 +1,7 @@ import add from 'date-fns/add'; import { HassEntities } from 'home-assistant-js-websocket'; import { beforeEach, describe, expect, it, vi } from 'vitest'; -import { ScanOptions } from '../../../src/types'; +import { ScanOptions } from '../../../src/config/types'; import { TriggersManager } from '../../../src/utils/card-controller/triggers-manager'; import { createCameraConfig, diff --git a/tests/utils/card-controller/view-manager.test.ts b/tests/utils/card-controller/view-manager.test.ts index 9d498389..a8616725 100644 --- a/tests/utils/card-controller/view-manager.test.ts +++ b/tests/utils/card-controller/view-manager.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it, vi } from 'vitest'; import { QueryType } from '../../../src/camera-manager/types'; -import { FrigateCardView } from '../../../src/types'; +import { FrigateCardView } from '../../../src/config/types'; import { getAllDependentCameras } from '../../../src/utils/camera'; import { ViewManager } from '../../../src/utils/card-controller/view-manager'; import { EventMediaQueries } from '../../../src/view/media-queries'; diff --git a/tests/utils/menu-controller.test.ts b/tests/utils/menu-controller.test.ts index 7b9a215f..43f2e629 100644 --- a/tests/utils/menu-controller.test.ts +++ b/tests/utils/menu-controller.test.ts @@ -4,16 +4,15 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; import { mock } from 'vitest-mock-extended'; import { CameraManager } from '../../src/camera-manager/manager'; import { CameraManagerCameraMetadata } from '../../src/camera-manager/types'; -import { - FrigateCardConfig, - FrigateCardMediaPlayer, - MenuButton, - ViewDisplayMode, -} from '../../src/types'; +import { FrigateCardConfig, MenuItem, ViewDisplayMode } from '../../src/config/types'; +import { FrigateCardMediaPlayer } from '../../src/types'; import { createFrigateCardCustomAction } from '../../src/utils/action'; import { MediaPlayerManager } from '../../src/utils/card-controller/media-player-manager'; import { MicrophoneManager } from '../../src/utils/card-controller/microphone-manager'; -import { MenuButtonController, MenuButtonControllerOptions } from '../../src/utils/menu-controller'; +import { + MenuButtonController, + MenuButtonControllerOptions, +} from '../../src/utils/menu-controller'; import { ViewMedia } from '../../src/view/media'; import { MediaQueriesResults } from '../../src/view/media-queries-results'; import { View } from '../../src/view/view'; @@ -42,7 +41,7 @@ const calculateButtons = ( cameraManager?: CameraManager; view?: View; }, -): MenuButton[] => { +): MenuItem[] => { return controller.calculateButtons( options?.hass ?? createHASS(), options?.config ?? createConfig(), @@ -61,7 +60,7 @@ const calculateButtons = ( // @vitest-environment jsdom describe('MenuButtonController', () => { let controller: MenuButtonController; - const dynamicButton: MenuButton = { + const dynamicButton: MenuItem = { type: 'custom:frigate-card-menu-icon', icon: 'mdi:alpha-a-circle', title: 'Dynamic button', @@ -1130,7 +1129,7 @@ describe('MenuButtonController', () => { }); it('should handle dynamic buttons', () => { - const button: MenuButton = { + const button: MenuItem = { ...dynamicButton, style: {}, }; @@ -1152,7 +1151,7 @@ describe('MenuButtonController', () => { }); it('should not set style for dynamic button with stock action', () => { - const button: MenuButton = { + const button: MenuItem = { ...dynamicButton, tap_action: { action: 'navigate', navigation_path: 'foo' }, }; @@ -1165,7 +1164,7 @@ describe('MenuButtonController', () => { }); it('should not set style for dynamic button with non-Frigate fire-dom-event action', () => { - const button: MenuButton = { + const button: MenuItem = { ...dynamicButton, tap_action: { action: 'fire-dom-event' }, }; @@ -1179,7 +1178,7 @@ describe('MenuButtonController', () => { }); it('should set style for dynamic button with Frigate view action', () => { - const button: MenuButton = { + const button: MenuItem = { ...dynamicButton, tap_action: { action: 'fire-dom-event', frigate_card_action: 'clips' }, }; @@ -1193,7 +1192,7 @@ describe('MenuButtonController', () => { }); it('should set style for dynamic button with Frigate default action', () => { - const button: MenuButton = { + const button: MenuItem = { ...dynamicButton, tap_action: { action: 'fire-dom-event', frigate_card_action: 'default' }, }; @@ -1206,7 +1205,7 @@ describe('MenuButtonController', () => { }); it('should set style for dynamic button with fullscreen action', () => { - const button: MenuButton = { + const button: MenuItem = { ...dynamicButton, tap_action: { action: 'fire-dom-event', frigate_card_action: 'fullscreen' }, }; @@ -1219,7 +1218,7 @@ describe('MenuButtonController', () => { }); it('should set style for dynamic button with camera_select action', () => { - const button: MenuButton = { + const button: MenuItem = { ...dynamicButton, tap_action: { action: 'fire-dom-event', @@ -1237,7 +1236,7 @@ describe('MenuButtonController', () => { }); it('should set style for dynamic button with array of actions', () => { - const button: MenuButton = { + const button: MenuItem = { ...dynamicButton, tap_action: [ { action: 'fire-dom-event' },