From 812c3e101829b5ae4dbd17ad5f4916d392db835b Mon Sep 17 00:00:00 2001 From: Dermot Duffy Date: Fri, 24 Feb 2023 20:41:11 -0800 Subject: [PATCH] Initial version of substream support. --- src/camera-manager/engine.ts | 1 - src/camera-manager/frigate/engine-frigate.ts | 1 - src/camera-manager/manager.ts | 54 ++++----- src/camera-manager/store.ts | 53 +++++++-- src/card.ts | 117 +++++++++++++++---- src/components/live/live.ts | 63 ++++++---- src/components/media-filter.ts | 17 ++- src/components/surround.ts | 13 ++- src/components/timeline-core.ts | 20 +--- src/const.ts | 1 + src/editor.ts | 9 ++ src/localize/languages/en.json | 5 +- src/localize/languages/it.json | 5 +- src/localize/languages/pt-BR.json | 5 +- src/types.ts | 21 +++- src/utils/action.ts | 22 ++-- src/utils/camera.ts | 14 ++- src/utils/ha/entity-registry/index.ts | 2 +- src/utils/media-to-view.ts | 12 +- src/view/view.ts | 7 +- 20 files changed, 295 insertions(+), 147 deletions(-) diff --git a/src/camera-manager/engine.ts b/src/camera-manager/engine.ts index baaf2161..2a41e97a 100644 --- a/src/camera-manager/engine.ts +++ b/src/camera-manager/engine.ts @@ -6,7 +6,6 @@ import { DataQuery, EventQuery, EventQueryResultsMap, - MediaMetadata, PartialEventQuery, PartialRecordingQuery, PartialRecordingSegmentsQuery, diff --git a/src/camera-manager/frigate/engine-frigate.ts b/src/camera-manager/frigate/engine-frigate.ts index 7db8c105..1711bcf7 100644 --- a/src/camera-manager/frigate/engine-frigate.ts +++ b/src/camera-manager/frigate/engine-frigate.ts @@ -19,7 +19,6 @@ import { EventQuery, EventQueryResults, EventQueryResultsMap, - MediaMetadata, PartialEventQuery, PartialRecordingQuery, PartialRecordingSegmentsQuery, diff --git a/src/camera-manager/manager.ts b/src/camera-manager/manager.ts index 70be592c..f923ab1d 100644 --- a/src/camera-manager/manager.ts +++ b/src/camera-manager/manager.ts @@ -1,5 +1,9 @@ import { HomeAssistant } from 'custom-card-helpers'; -import { CameraConfig, CamerasConfig, CardWideConfig } from '../types.js'; +import { + CameraConfig, + CamerasConfig, + CardWideConfig, +} from '../types.js'; import { allPromises, arrayify, setify } from '../utils/basic.js'; import { CameraManagerCameraCapabilities, @@ -46,7 +50,7 @@ import { EntityRegistryManager } from '../utils/ha/entity-registry/index.js'; import { getCameraID } from '../utils/camera.js'; import { localize } from '../localize/localize.js'; import { CameraInitializationError } from './error.js'; -import { CameraManagerStore } from './store.js'; +import { CameraManagerReadOnlyConfigStore, CameraManagerStore } from './store.js'; import cloneDeep from 'lodash-es/cloneDeep'; import { MEDIA_CHUNK_SIZE_DEFAULT } from '../const.js'; @@ -126,14 +130,18 @@ export class CameraManager { const output: Map = new Map(); const engines: Map = new Map(); - for (const cameraConfig of camerasConfig) { - const engineType = await this._engineFactory.getEngineForCamera( - hass, - cameraConfig, + const getEngineTypes = async (configs: CameraConfig[]) => { + return await allPromises(configs, (config) => + this._engineFactory.getEngineForCamera(hass, config), ); + }; + + const engineTypes = await getEngineTypes(camerasConfig); + for (const [index, cameraConfig] of camerasConfig.entries()) { + const engineType = engineTypes[index]; const engine = engineType - ? engines.get(engineType) ?? this._engineFactory.createEngine(engineType) - : null; + ? engines.get(engineType) ?? this._engineFactory.createEngine(engineType) + : null; if (!engine || !engineType) { throw new CameraInitializationError( localize('error.no_camera_engine'), @@ -218,8 +226,8 @@ export class CameraManager { this._store.addCamera(id, result.initializedConfig, result.engine); }); - if (!this._store.getCameraCount()) { - throw new CameraInitializationError(localize('error.no_cameras')); + if (!this._store.getVisibleCameraCount()) { + throw new CameraInitializationError(localize('error.no_visible_cameras')); } } @@ -227,22 +235,8 @@ export class CameraManager { return this._store.getCameraCount() > 0; } - public getCameras(): Map | null { - return this._store.getCameras(); - } - - public getCameraConfig(cameraID: string): CameraConfig | null { - return this._store.getCameraConfig(cameraID); - } - - public getCameraIDs(): Set | null { - return this._store.getCameraCount() - ? new Set(this._store.getCameras().keys()) - : null; - } - - public hasCameraID(cameraID: string): boolean { - return this._store.hasCameraID(cameraID); + public getStore(): CameraManagerReadOnlyConfigStore { + return this._store; } public generateDefaultEventQueries( @@ -287,7 +281,7 @@ export class CameraManager { const results = await this._handleQuery(hass, query); - for (const [query, result] of results?.entries() ?? []) { + for (const result of results?.values() ?? []) { if (result.metadata.what) { result.metadata.what.forEach(what.add, what); } @@ -325,19 +319,19 @@ export class CameraManager { let queries: DataQuery[] | null = null; if (QueryClassifier.isEventQuery(partialQuery)) { queries = engine.generateDefaultEventQuery( - this._store.getCameras(), + this._store.getVisibleCameras(), cameraIDs, partialQuery, ); } else if (QueryClassifier.isRecordingQuery(partialQuery)) { queries = engine.generateDefaultRecordingQuery( - this._store.getCameras(), + this._store.getVisibleCameras(), cameraIDs, partialQuery, ); } else if (QueryClassifier.isRecordingSegmentsQuery(partialQuery)) { queries = engine.generateDefaultRecordingSegmentsQuery( - this._store.getCameras(), + this._store.getVisibleCameras(), cameraIDs, partialQuery, ); diff --git a/src/camera-manager/store.ts b/src/camera-manager/store.ts index c6203a20..7d929cb4 100644 --- a/src/camera-manager/store.ts +++ b/src/camera-manager/store.ts @@ -5,8 +5,26 @@ import { CameraConfigs, Engine } from './types'; type CameraManagerEngineCameraIDMap = Map>; -export class CameraManagerStore { - protected _configs: Map = new Map(); +export interface CameraManagerReadOnlyConfigStore { + getCameraConfig(cameraID: string): CameraConfig | null; + getCameraConfigForMedia(media: ViewMedia): CameraConfig | null; + + hasCameraID(cameraID: string): boolean; + hasVisibleCameraID(cameraID: string): boolean; + + getCameraCount(): number; + getVisibleCameraCount(): number; + + getCameras(): CameraConfigs; + getVisibleCameras(): CameraConfigs; + + getCameraIDs(): Set; + getVisibleCameraIDs(): Set; +} + +export class CameraManagerStore implements CameraManagerReadOnlyConfigStore { + protected _allConfigs: Map = new Map(); + protected _visibleConfigs: Map = new Map(); protected _enginesByCamera: Map = new Map(); protected _enginesByType: Map = new Map(); @@ -15,29 +33,44 @@ export class CameraManagerStore { cameraConfig: CameraConfig, engine: CameraManagerEngine, ): void { - this._configs.set(cameraID, cameraConfig); + if (!cameraConfig.hide) { + this._visibleConfigs.set(cameraID, cameraConfig); + } + this._allConfigs.set(cameraID, cameraConfig); this._enginesByCamera.set(cameraID, engine); this._enginesByType.set(engine.getEngineType(), engine); } - public getCameraCount(): number { - return this._configs.size; + public getCameraConfig(cameraID: string): CameraConfig | null { + return this._allConfigs.get(cameraID) ?? null; } public hasCameraID(cameraID: string): boolean { - return this._configs.has(cameraID); + return this._allConfigs.has(cameraID); + } + public hasVisibleCameraID(cameraID: string): boolean { + return this._visibleConfigs.has(cameraID); } - public getCameraConfig(cameraID: string): CameraConfig | null { - return this._configs.get(cameraID) ?? null; + public getCameraCount(): number { + return this._allConfigs.size; + } + public getVisibleCameraCount(): number { + return this._visibleConfigs.size; } public getCameras(): CameraConfigs { - return this._configs; + return this._allConfigs; + } + public getVisibleCameras(): CameraConfigs { + return this._visibleConfigs; } public getCameraIDs(): Set { - return new Set(this._configs.keys()); + return new Set(this._allConfigs.keys()); + } + public getVisibleCameraIDs(): Set { + return new Set(this._visibleConfigs.keys()); } public getCameraConfigForMedia(media: ViewMedia): CameraConfig | null { diff --git a/src/card.ts b/src/card.ts index f055b4c2..e13b3df5 100644 --- a/src/card.ts +++ b/src/card.ts @@ -384,13 +384,14 @@ class FrigateCard extends LitElement { protected _getMenuButtons(): MenuButton[] { const buttons: MenuButton[] = []; - const cameras = this._cameraManager?.getCameras(); + const visibleCameras = this._cameraManager?.getStore().getVisibleCameras(); const selectedCameraID = this._view?.camera; const selectedCameraConfig = this._getSelectedCameraConfig(); - const allSelectedCameraIDs = - cameras && selectedCameraID - ? getAllDependentCameras(cameras, selectedCameraID) - : null; + const allSelectedCameraIDs = getAllDependentCameras( + this._cameraManager, + selectedCameraID, + ); + const cameraCapabilities = allSelectedCameraIDs ? this._cameraManager?.getAggregateCameraCapabilities(allSelectedCameraIDs) : null; @@ -410,8 +411,8 @@ class FrigateCard extends LitElement { ) as FrigateCardCustomAction, }); - if (cameras) { - const menuItems = Array.from(cameras, ([cameraID, config]) => { + if (visibleCameras) { + const menuItems = Array.from(visibleCameras, ([cameraID, config]) => { const action = createFrigateCardCustomAction('camera_select', { camera: cameraID, }); @@ -439,6 +440,61 @@ class FrigateCard extends LitElement { }); } + if (selectedCameraID && allSelectedCameraIDs && this._view?.is('live')) { + const dependencies = [...allSelectedCameraIDs]; + const override = this._view?.context?.live?.overrides?.get(selectedCameraID); + + if (dependencies.length === 2) { + // If there are only two dependencies (the main camera, and 1 other) + // then use a button not a menu to toggle. + buttons.push({ + icon: 'mdi:video-input-component', + style: + override && override !== selectedCameraID ? this._getEmphasizedStyle() : {}, + title: localize('config.menu.buttons.substreams'), + ...this._getConfig().menu.buttons.substreams, + type: 'custom:frigate-card-menu-icon', + tap_action: createFrigateCardCustomAction('live_substream_select', { + camera: + override === undefined || override === dependencies[0] + ? dependencies[1] + : dependencies[0], + }) as FrigateCardCustomAction, + }); + } else if (dependencies.length > 2) { + const menuItems = Array.from(dependencies, (cameraID) => { + const action = createFrigateCardCustomAction('live_substream_select', { + camera: cameraID, + }); + const metadata = this._hass + ? this._cameraManager?.getCameraMetadata(this._hass, cameraID) ?? undefined + : undefined; + const cameraConfig = this._cameraManager?.getStore().getCameraConfig(cameraID); + return { + enabled: true, + icon: metadata?.icon, + entity: cameraConfig?.camera_entity, + state_color: true, + title: metadata?.title, + selected: + (this._view?.context?.live?.overrides?.get(selectedCameraID) ?? + selectedCameraID) === cameraID, + ...(action && { tap_action: action }), + }; + }); + + buttons.push({ + icon: 'mdi:video-input-component', + title: localize('config.menu.buttons.substreams'), + style: + override && override !== selectedCameraID ? this._getEmphasizedStyle() : {}, + ...this._getConfig().menu.buttons.substreams, + type: 'custom:frigate-card-menu-submenu', + items: menuItems, + }); + } + } + buttons.push({ icon: 'mdi:cctv', ...this._getConfig().menu.buttons.live, @@ -634,7 +690,7 @@ class FrigateCard extends LitElement { if (!this._view || !this._cameraManager) { return null; } - return this._cameraManager.getCameraConfig(this._view.camera); + return this._cameraManager.getStore().getCameraConfig(this._view.camera); } /** @@ -767,7 +823,7 @@ class FrigateCard extends LitElement { protected _changeView(args?: { view?: View; resetMessage?: boolean }): void { log(this._cardWideConfig, `Frigate Card view change: `, args?.view ?? '[default]'); const changeView = (view: View): void => { - if (View.isMediaChange(this._view, view)) { + if (View.isMajorMediaChange(this._view, view)) { this._currentMediaLoadedInfo = null; } if (this._view?.view !== view.view) { @@ -785,7 +841,7 @@ class FrigateCard extends LitElement { // Load the default view. let cameraID: string | null = null; if (this._cameraManager) { - const cameras = this._cameraManager.getCameras(); + const cameras = this._cameraManager.getStore().getVisibleCameras(); if (cameras) { if (this._view?.camera && this._getConfig().view.update_cycle_camera) { const keys = Array.from(cameras.keys()); @@ -1007,7 +1063,7 @@ class FrigateCard extends LitElement { let changedCamera = false; let triggerChanges = false; - const cameras = this._cameraManager?.getCameras(); + const cameras = this._cameraManager?.getStore().getVisibleCameras(); for (const [cameraID, config] of cameras?.entries() ?? []) { const triggerEntities = config.triggers.entities ?? []; const diffs = getHassDifferences(this._hass, oldHass, triggerEntities, { @@ -1307,7 +1363,7 @@ class FrigateCard extends LitElement { */ protected _cardActionHandler(ev: CustomEvent): void { const frigateCardAction = convertActionToFrigateCardCustomAction(ev.detail); - if (!frigateCardAction) { + if (!this._view || !frigateCardAction) { return; } const action = frigateCardAction.frigate_card_action; @@ -1325,14 +1381,12 @@ class FrigateCard extends LitElement { case 'snapshot': case 'snapshots': case 'timeline': - if (this._view) { - this._changeView({ - view: new View({ - view: action, - camera: this._view.camera, - }), - }); - } + this._changeView({ + view: new View({ + view: action, + camera: this._view.camera, + }), + }); break; case 'download': this._downloadViewerMedia(); @@ -1355,17 +1409,32 @@ class FrigateCard extends LitElement { this._refMenu.value?.toggleMenu(); break; case 'camera_select': - const cameraID = frigateCardAction.camera; - if (this._cameraManager?.hasCameraID(cameraID) && this._view) { + const selectCameraID = frigateCardAction.camera; + if ( + this._view && + this._cameraManager?.getStore().hasVisibleCameraID(selectCameraID) + ) { const viewOnCameraSelect = this._getConfig().view.camera_select; const targetView = viewOnCameraSelect === 'current' ? this._view.view : viewOnCameraSelect; - const actualView = this.isViewSupportedByCamera(cameraID, targetView) + const actualView = this.isViewSupportedByCamera(selectCameraID, targetView) ? targetView : FRIGATE_CARD_VIEW_DEFAULT; - this._changeView({ view: new View({ view: actualView, camera: cameraID }) }); + this._changeView({ + view: new View({ view: actualView, camera: selectCameraID }), + }); } break; + case 'live_substream_select': + const overrides: Map = + this._view.context?.live?.overrides ?? new Map(); + overrides.set(this._view.camera, frigateCardAction.camera); + this._changeView({ + view: this._view.clone().mergeInContext({ + live: { overrides: overrides }, + }), + }); + break; case 'media_player': this._mediaPlayerAction( frigateCardAction.media_player, diff --git a/src/components/live/live.ts b/src/components/live/live.ts index 1dcd6aee..34248e2b 100644 --- a/src/components/live/live.ts +++ b/src/components/live/live.ts @@ -56,6 +56,18 @@ import { dispatchMessageEvent, dispatchErrorMessageEvent } from '../message.js'; import { HassEntity } from 'home-assistant-js-websocket'; import { CameraEndpoints } from '../../camera-manager/types.js'; +interface LiveViewContext { + // A cameraID override (used for dependencies/substreams to force a different + // camera to be live rather than the camera selected in the view). + overrides?: Map; +} + +declare module 'view' { + interface ViewContext { + live?: LiveViewContext; + } +} + /** * Get the state object or dispatch an error. Used in `ha` and `image` live * providers. @@ -339,7 +351,7 @@ export class FrigateCardLiveCarousel extends LitElement { } protected _getSelectedCameraIndex(): number { - const cameraIDs = this.cameraManager?.getCameraIDs(); + const cameraIDs = this.cameraManager?.getStore().getVisibleCameraIDs(); if (!cameraIDs || !this.view) { return 0; } @@ -362,7 +374,7 @@ export class FrigateCardLiveCarousel extends LitElement { * @returns A list of EmblaOptionsTypes. */ protected _getPlugins(): EmblaCarouselPlugins { - const cameras = this.cameraManager?.getCameraIDs(); + const cameras = this.cameraManager?.getStore().getVisibleCameraIDs(); return [ // Only enable wheel plugin if there is more than one camera. ...(cameras && cameras.size > 1 @@ -420,18 +432,27 @@ export class FrigateCardLiveCarousel extends LitElement { * name to slide number. */ protected _getSlides(): [TemplateResult[], Record] { - const cameras = this.cameraManager?.getCameras(); - if (!cameras) { + const visibleCameras = this.cameraManager?.getStore().getVisibleCameras(); + if (!visibleCameras) { return [[], {}]; } const slides: TemplateResult[] = []; const cameraToSlide: Record = {}; - for (const [camera, cameraConfig] of cameras) { - const slide = this._renderLive(camera, cameraConfig, slides.length); + for (const [cameraID, cameraConfig] of visibleCameras) { + const liveCameraID = + this.view?.context?.live?.overrides?.get(cameraID) ?? cameraID; + const liveCameraConfig = + cameraID === liveCameraID + ? cameraConfig + : this.cameraManager?.getStore().getCameraConfig(liveCameraID); + + const slide = liveCameraConfig + ? this._renderLive(liveCameraID, liveCameraConfig, slides.length) + : null; if (slide) { - cameraToSlide[camera] = slides.length; + cameraToSlide[cameraID] = slides.length; slides.push(slide); } } @@ -442,7 +463,7 @@ export class FrigateCardLiveCarousel extends LitElement { * Handle the user selecting a new slide in the carousel. */ protected _setViewHandler(ev: CustomEvent): void { - const cameras = this.cameraManager?.getCameras(); + const cameras = this.cameraManager?.getStore().getVisibleCameras(); if (cameras && ev.detail.index !== this._getSelectedCameraIndex()) { this._setViewCameraID(Array.from(cameras.keys())[ev.detail.index]); } @@ -515,7 +536,7 @@ export class FrigateCardLiveCarousel extends LitElement { + .cameraEndpoints=${guard([this.cameraManager, cameraID], () => this.cameraManager?.getCameraEndpoints(cameraID), )} .label=${cameraMetadata?.title ?? ''} @@ -535,7 +556,7 @@ export class FrigateCardLiveCarousel extends LitElement { } protected _getCameraIDsOfNeighbors(): [string | null, string | null] { - const cameras = this.cameraManager?.getCameras(); + const cameras = this.cameraManager?.getStore().getVisibleCameras(); if (!cameras || !this.view || !this.hass) { return [null, null]; } @@ -557,15 +578,13 @@ export class FrigateCardLiveCarousel extends LitElement { * @returns A template to display to the user. */ protected render(): TemplateResult | void { + if (!this.liveConfig || !this.view || !this.hass || !this.cameraManager) { + return; + } + const [slides, cameraToSlide] = this._getSlides(); this._cameraToSlide = cameraToSlide; - if ( - !slides.length || - !this.liveConfig || - !this.view || - !this.hass || - !this.cameraManager - ) { + if (!slides.length) { return; } @@ -577,15 +596,19 @@ export class FrigateCardLiveCarousel extends LitElement { const [prevID, nextID] = this._getCameraIDsOfNeighbors(); + const overrideCameraID = (cameraID: string): string => { + return this.view?.context?.live?.overrides?.get(cameraID) ?? cameraID; + }; + const cameraMetadataPrevious = prevID - ? this.cameraManager.getCameraMetadata(this.hass, prevID) + ? this.cameraManager.getCameraMetadata(this.hass, overrideCameraID(prevID)) : null; const cameraMetadataCurrent = this.cameraManager.getCameraMetadata( this.hass, - this.view.camera, + overrideCameraID(this.view.camera), ); const cameraMetadataNext = nextID - ? this.cameraManager.getCameraMetadata(this.hass, nextID) + ? this.cameraManager.getCameraMetadata(this.hass, overrideCameraID(nextID)) : null; // Notes on the below: diff --git a/src/components/media-filter.ts b/src/components/media-filter.ts index ee9bed19..255390e0 100644 --- a/src/components/media-filter.ts +++ b/src/components/media-filter.ts @@ -31,10 +31,7 @@ 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 { - MediaMetadata, - QueryType, -} from '../camera-manager/types'; +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'; @@ -168,7 +165,7 @@ class FrigateCardMediaFilter extends ScopedRegistryHost(LitElement) { // eslint-disable-next-line @typescript-eslint/no-unused-vars _ev: CustomEvent<{ value: unknown }>, ): Promise { - const cameras = this.cameraManager?.getCameras(); + const cameras = this.cameraManager?.getStore().getVisibleCameras(); if (!this.hass || !cameras || !this.cameraManager || !this.view) { return; } @@ -269,7 +266,7 @@ class FrigateCardMediaFilter extends ScopedRegistryHost(LitElement) { protected willUpdate(changedProps: PropertyValues): void { if (changedProps.has('cameraManager')) { - const cameras = this.cameraManager?.getCameras(); + const cameras = this.cameraManager?.getStore().getVisibleCameras(); if (cameras) { this._cameraOptions = Array.from(cameras.keys()).map((cameraID) => ({ value: cameraID, @@ -321,7 +318,7 @@ class FrigateCardMediaFilter extends ScopedRegistryHost(LitElement) { protected _getDefaultsFromView(): MediaFilterCoreDefaults | null { const queries = this.view?.query?.getQueries(); - const cameras = this.cameraManager?.getCameras(); + const cameras = this.cameraManager?.getStore().getVisibleCameras(); if (!this.view || !queries || !cameras) { return null; } @@ -333,12 +330,12 @@ class FrigateCardMediaFilter extends ScopedRegistryHost(LitElement) { let favorite: MediaFilterCoreFavoriteSelection | undefined; const cameraIDSets = uniqWith( - queries.map((query) => query.cameraIDs), + queries.map((query: DataQuery) => query.cameraIDs), isEqual, ); - // Special note: If all cameras are selected, this is the same as no + // Special note: If all visible cameras are selected, this is the same as no // selector at all. - if (cameraIDSets.length === 1 && queries[0].cameraIDs.size !== cameras.size) { + if (cameraIDSets.length === 1 && isEqual(queries[0].cameraIDs, cameras)) { cameraIDs = [...queries[0].cameraIDs]; } diff --git a/src/components/surround.ts b/src/components/surround.ts index 10706196..ddfede4d 100644 --- a/src/components/surround.ts +++ b/src/components/surround.ts @@ -22,6 +22,7 @@ import { ThumbnailCarouselTap } from './thumbnail-carousel.js'; import './surround-basic.js'; import { changeViewToRecentEventsForCameraAndDependents } from '../utils/media-to-view'; import { getAllDependentCameras } from '../utils/camera.js'; +import type { DataQuery } from '../camera-manager/types'; interface ThumbnailViewContext { // Whether or not to fetch thumbnails. @@ -120,7 +121,7 @@ export class FrigateCardSurround extends LitElement { // user is scrubbing video). if ( changedProperties.has('view') && - View.isMediaChange(changedProperties.get('view'), this.view) + View.isMajorMediaChange(changedProperties.get('view'), this.view) ) { this._cameraIDsForTimeline = this._getCameraIDsForTimeline() ?? undefined; } @@ -138,18 +139,20 @@ export class FrigateCardSurround extends LitElement { } protected _getCameraIDsForTimeline(): Set | null { - const cameras = this.cameraManager?.getCameras(); - if (!this.view || !cameras) { + if (!this.view) { return null; } if (this.view?.is('live')) { - return getAllDependentCameras(cameras, this.view.camera); + return getAllDependentCameras( + this.cameraManager, + this.view.camera, + ); } if (this.view.isViewerView()) { return new Set( this.view.query ?.getQueries() - ?.map((query) => [...query.cameraIDs]) + ?.map((query: DataQuery) => [...query.cameraIDs]) .flat(), ); } diff --git a/src/components/timeline-core.ts b/src/components/timeline-core.ts index 36ac813e..02520857 100644 --- a/src/components/timeline-core.ts +++ b/src/components/timeline-core.ts @@ -238,7 +238,7 @@ export class FrigateCardTimelineCore extends LitElement { const item = request.detail.item; const media = this._timelineSource?.dataset.get(item)?.media; const cameraConfig = media - ? this.cameraManager?.getCameraConfig(media.getCameraID()) ?? undefined + ? this.cameraManager?.getStore().getCameraConfigForMedia(media) ?? undefined : undefined; request.detail.hass = this.hass; @@ -291,15 +291,9 @@ export class FrigateCardTimelineCore extends LitElement { * @returns A set of camera ids (may be empty). */ protected _getTimelineCameraIDs(): Set | null { - return this.cameraIDs ?? this._getAllCameraIDs(); - } - - /** - * Get all the keys of all cameras. - * @returns A set of camera ids (may be empty). - */ - protected _getAllCameraIDs(): Set | null { - return this.cameraManager?.getCameraIDs() ?? null; + return ( + this.cameraIDs ?? this.cameraManager?.getStore().getVisibleCameraIDs() ?? null + ); } /** @@ -1042,11 +1036,7 @@ export class FrigateCardTimelineCore extends LitElement { changedProps.has('cameraIDs') ) { const cameraIDs = this._getTimelineCameraIDs(); - if ( - cameraIDs && - this.cameraManager && - this.timelineConfig - ) { + if (cameraIDs && this.cameraManager && this.timelineConfig) { this._timelineSource = new TimelineDataSource( this.cameraManager, cameraIDs, diff --git a/src/const.ts b/src/const.ts index 0194dc92..47cecc94 100644 --- a/src/const.ts +++ b/src/const.ts @@ -13,6 +13,7 @@ export const CONF_CAMERAS_ARRAY_FRIGATE_LABEL = export const CONF_CAMERAS_ARRAY_FRIGATE_URL = `${CONF_CAMERAS}.#.frigate.url` as const; export const CONF_CAMERAS_ARRAY_FRIGATE_ZONE = `${CONF_CAMERAS}.#.frigate.zone` as const; export const CONF_CAMERAS_ARRAY_GO2RTC_MODES = `${CONF_CAMERAS}.#.go2rtc.modes` as const; +export const CONF_CAMERAS_ARRAY_HIDE = `${CONF_CAMERAS}.#.hide` as const; export const CONF_CAMERAS_ARRAY_ID = `${CONF_CAMERAS}.#.id` as const; export const CONF_CAMERAS_ARRAY_TITLE = `${CONF_CAMERAS}.#.title` as const; export const CONF_CAMERAS_ARRAY_ICON = `${CONF_CAMERAS}.#.icon` as const; diff --git a/src/editor.ts b/src/editor.ts index 58637fb0..ca8d11db 100644 --- a/src/editor.ts +++ b/src/editor.ts @@ -130,6 +130,7 @@ import { CONF_PERFORMANCE_FEATURES_MEDIA_CHUNK_SIZE, MEDIA_CHUNK_SIZE_MAX, CONF_CAMERAS_ARRAY_GO2RTC_MODES, + CONF_CAMERAS_ARRAY_HIDE, } from './const.js'; import { localize } from './localize/localize.js'; import frigate_card_editor_style from './scss/editor.scss'; @@ -1341,6 +1342,13 @@ export class FrigateCardEditor extends LitElement implements LovelaceCardEditor ${this._renderStringInput( getArrayConfigPath(CONF_CAMERAS_ARRAY_ID, cameraIndex), )} + ${this._renderSwitch( + getArrayConfigPath( + CONF_CAMERAS_ARRAY_HIDE, + cameraIndex, + ), + this._defaults.cameras.hide, + )} ${this._putInSubmenu( MENU_CAMERAS_FRIGATE, cameraIndex, @@ -1604,6 +1612,7 @@ export class FrigateCardEditor extends LitElement implements LovelaceCardEditor })} ${this._renderMenuButton('frigate') /* */} ${this._renderMenuButton('cameras') /* */} + ${this._renderMenuButton('substreams') /* */} ${this._renderMenuButton('live') /* */} ${this._renderMenuButton('clips') /* */} ${this._renderMenuButton('snapshots')} diff --git a/src/localize/languages/en.json b/src/localize/languages/en.json index d99ec54d..2f6508ff 100644 --- a/src/localize/languages/en.json +++ b/src/localize/languages/en.json @@ -33,6 +33,7 @@ "mjpeg": "Motion JPEG (MJPEG)" } }, + "hide": "Hide camera from UI", "icon": "Icon for this camera (Autodetected from entity)", "id": "Unique id for this camera in this card", "live_provider": "Live view provider for this camera", @@ -239,6 +240,7 @@ "media_player": "Send to media player", "priority": "Priority", "snapshots": "Snapshots", + "substreams": "Substream(s)", "timeline": "Timeline", "recordings": "Recordings" }, @@ -368,10 +370,11 @@ "live_camera_unavailable": "Camera unavailable", "no_camera_engine": "Could not determine suitable engine for camera", "no_camera_entity": "Could not find camera entity", + "no_camera_entity_for_triggers": "A camera entity is required in order to autodetect triggers", "no_camera_id": "Could not determine camera id for the following camera, may need to set 'id' parameter manually", "no_camera_name": "Could not determine a Frigate camera name for camera (or one of its dependents), please specify either 'camera_entity' or 'camera_name'", - "no_cameras": "No valid cameras found, you must configure at least one camera entry", "no_live_camera": "The camera_entity parameter must be set and valid for this live provider", + "no_visible_cameras": "No visible cameras found, you must configure at least one non-hidden camera", "reconnecting": "Reconnecting", "timeline_no_cameras": "No Frigate cameras to show in timeline", "troubleshooting": "Check troubleshooting", diff --git a/src/localize/languages/it.json b/src/localize/languages/it.json index 613c7779..39a6a1ab 100644 --- a/src/localize/languages/it.json +++ b/src/localize/languages/it.json @@ -23,6 +23,7 @@ "url": "Frigate URL del server", "zone": "Frigate zona" }, + "hide": "", "icon": "Icona per questa telecamera (Autoidentificato dall'entità)", "id": "ID univoco per questa telecamera in questa carta", "live_provider": "Provider di visualizzazione dal vivo per questa telecamera", @@ -223,6 +224,7 @@ "live": "Abitare", "media_player": "Invia a Media Player", "priority": "Priorità", + "substreams": "", "snapshots": "Istantanee", "timeline": "Timeline" }, @@ -347,10 +349,11 @@ "live_camera_unavailable": "Telecamera non disponibile", "no_camera_engine": "", "no_camera_entity": "", + "no_camera_entity_for_triggers": "", "no_camera_id": "Impossibile determinare l'ID della telecamera , potrebbe essere necessario impostare manualmente il parametro 'ID'", "no_camera_name": "Impossibile determinare un nome della telecamera in Frigate, si prega di specificare 'camera_enty' o 'camera_name'", - "no_cameras": "Nessuna telecamera valida trovata, è necessario configurare almeno una voce della telecamera", "no_live_camera": "Il parametro fotocamera_enty deve essere impostato e valido per questo provider live", + "no_visible_cameras": "", "reconnecting": "Riconnessione", "timeline_no_cameras": "Nessuna telecamera damostrare in Frigate nella timeline", "troubleshooting": "Controllare la risoluzione dei problemi", diff --git a/src/localize/languages/pt-BR.json b/src/localize/languages/pt-BR.json index 5db86277..fec51f53 100644 --- a/src/localize/languages/pt-BR.json +++ b/src/localize/languages/pt-BR.json @@ -23,6 +23,7 @@ "url": "URL do servidor Frigate", "zone": "Zona do Frigate" }, + "hide": "", "icon": "Ícone para esta câmera (detectado automaticamente pela entidade)", "id": "ID exclusivo para esta câmera nesse cartão", "live_provider": "Provedor de visualização ao vivo para esta câmera", @@ -223,6 +224,7 @@ "live": "Ao vivo", "media_player": "Enviar para o reprodutor de mídia", "priority": "Prioridade", + "substreams": "", "snapshots": "Instantâneos", "timeline": "Linha do tempo" }, @@ -347,10 +349,11 @@ "live_camera_unavailable": "", "no_camera_engine": "", "no_camera_entity": "", + "no_camera_entity_for_triggers": "", "no_camera_id": "Não foi possível determinar o ID da câmera para a câmera a seguir, pode ser necessário definir o parâmetro 'id' manualmente", "no_camera_name": "Não foi possível determinar o nome da câmera da Frigate, especifique 'camera_entity' ou 'camera_name' para a câmera a seguir", - "no_cameras": "Nenhuma câmera válida encontrada, você deve configurar pelo menos uma câmera", "no_live_camera": "O parâmetro camera_entity deve ser definido e válido para este provedor ativo", + "no_visible_cameras": "", "reconnecting": "Reconectando", "timeline_no_cameras": "Nenhuma câmera do Frigate para mostrar na linha do tempo", "troubleshooting": "Verifique a solução de problemas", diff --git a/src/types.ts b/src/types.ts index 635ea628..3cd6ee68 100644 --- a/src/types.ts +++ b/src/types.ts @@ -199,7 +199,7 @@ const noActionSchema = schemaForType< }), ); -const frigateCardCustomactionsBaseSchema = customActionSchema.extend({ +const frigateCardCustomActionsBaseSchema = customActionSchema.extend({ action: z .literal('custom:frigate-card-action') // Syntactic sugar to avoid 'fire-dom-event' as part of an external API. @@ -227,18 +227,24 @@ const FRIGATE_CARD_GENERAL_ACTIONS = [ const FRIGATE_CARD_ACTIONS = [ ...FRIGATE_CARD_GENERAL_ACTIONS, 'camera_select', + 'live_substream_select', 'media_player', ] as const; export type FrigateCardAction = (typeof FRIGATE_CARD_ACTIONS)[number]; -const frigateCardGeneralActionSchema = frigateCardCustomactionsBaseSchema.extend({ +const frigateCardGeneralActionSchema = frigateCardCustomActionsBaseSchema.extend({ frigate_card_action: z.enum(FRIGATE_CARD_GENERAL_ACTIONS), }); -const frigateCardCameraSelectActionSchema = frigateCardCustomactionsBaseSchema.extend({ +const frigateCardCameraSelectActionSchema = frigateCardCustomActionsBaseSchema.extend({ frigate_card_action: z.literal('camera_select'), camera: z.string(), }); -const frigateCarMediaPlayerActionSchema = frigateCardCustomactionsBaseSchema.extend({ +const frigateCardLiveDependencySelectActionSchema = + frigateCardCustomActionsBaseSchema.extend({ + frigate_card_action: z.literal('live_substream_select'), + camera: z.string(), + }); +const frigateCarMediaPlayerActionSchema = frigateCardCustomActionsBaseSchema.extend({ frigate_card_action: z.literal('media_player'), media_player: z.string(), media_player_action: z.enum(['play', 'stop']), @@ -247,6 +253,7 @@ const frigateCarMediaPlayerActionSchema = frigateCardCustomactionsBaseSchema.ext export const frigateCardCustomActionSchema = z.union([ frigateCardGeneralActionSchema, frigateCardCameraSelectActionSchema, + frigateCardLiveDependencySelectActionSchema, frigateCarMediaPlayerActionSchema, ]); export type FrigateCardCustomAction = z.infer; @@ -398,6 +405,7 @@ const cameraConfigDefault = { all_cameras: false, cameras: [], }, + hide: false, triggers: { motion: false, occupancy: true, @@ -418,6 +426,9 @@ const cameraConfigSchema = z 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(), @@ -944,6 +955,7 @@ const menuConfigDefault = { buttons: { frigate: visibleButtonDefault, cameras: visibleButtonDefault, + substreams: visibleButtonDefault, live: visibleButtonDefault, clips: visibleButtonDefault, snapshots: visibleButtonDefault, @@ -976,6 +988,7 @@ const menuConfigSchema = 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), diff --git a/src/utils/action.ts b/src/utils/action.ts index e7b6d5f5..e6449d45 100644 --- a/src/utils/action.ts +++ b/src/utils/action.ts @@ -1,16 +1,16 @@ import { - ActionConfig, - handleActionConfig, - hasAction, - HomeAssistant + ActionConfig, + handleActionConfig, + hasAction, + HomeAssistant, } from 'custom-card-helpers'; import { - Actions, - ActionsConfig, - ActionType, - FrigateCardAction, - FrigateCardCustomAction, - frigateCardCustomActionSchema + Actions, + ActionsConfig, + ActionType, + FrigateCardAction, + FrigateCardCustomAction, + frigateCardCustomActionSchema, } from '../types.js'; /** @@ -43,7 +43,7 @@ export function createFrigateCardCustomAction( media_player_action?: 'play' | 'stop'; }, ): FrigateCardCustomAction | null { - if (action === 'camera_select') { + if (action === 'camera_select' || action === 'live_substream_select') { if (!args?.camera) { return null; } diff --git a/src/utils/camera.ts b/src/utils/camera.ts index 71af9945..2982c501 100644 --- a/src/utils/camera.ts +++ b/src/utils/camera.ts @@ -1,3 +1,4 @@ +import { CameraManager } from '../camera-manager/manager.js'; import { CameraConfig, RawFrigateCardConfig } from '../types.js'; /** @@ -25,14 +26,19 @@ export function getCameraID( /** * Get all cameras that depend on a given camera. - * @param cameras Cameras map. + * @param cameraManager The camera manager. * @param cameraID ID of the target camera. - * @returns A set of query parameters. + * @returns A set of dependent cameraIDs or null. */ export const getAllDependentCameras = ( - cameras: Map, + cameraManager?: CameraManager, cameraID?: string, -): Set => { +): Set | null => { + if (!cameraManager || !cameraID) { + return null; + } + const cameras = cameraManager.getStore().getCameras(); + const cameraIDs: Set = new Set(); const getDependentCameras = (cameraID: string): void => { const cameraConfig = cameras.get(cameraID); diff --git a/src/utils/ha/entity-registry/index.ts b/src/utils/ha/entity-registry/index.ts index e11a1964..c7d6582d 100644 --- a/src/utils/ha/entity-registry/index.ts +++ b/src/utils/ha/entity-registry/index.ts @@ -53,7 +53,7 @@ export class EntityRegistryManager { public async getExtendedEntity( hass: HomeAssistant, entityID: string, - ): Promise { + ): Promise { const cachedValue = this._extendedCache.get(entityID); if (cachedValue) { return cachedValue; diff --git a/src/utils/media-to-view.ts b/src/utils/media-to-view.ts index 180a5569..e93ea145 100644 --- a/src/utils/media-to-view.ts +++ b/src/utils/media-to-view.ts @@ -27,11 +27,11 @@ export const changeViewToRecentEventsForCameraAndDependents = async ( targetView?: FrigateCardView; }, ): Promise => { - const cameras = cameraManager.getCameras(); - if (!cameras) { + const cameraIDs = getAllDependentCameras(cameraManager, view.camera); + if (!cameraIDs) { return; } - const cameraIDs = new Set(getAllDependentCameras(cameras, view.camera)); + const queries = createQueriesForEventsView(cameraManager, cardWideConfig, cameraIDs, { mediaType: options?.mediaType, }); @@ -83,18 +83,16 @@ export const changeViewToRecentRecordingForCameraAndDependents = async ( targetView?: 'recording' | 'recordings'; }, ): Promise => { - const cameras = cameraManager.getCameras(); - if (!cameras) { + const cameraIDs = getAllDependentCameras(cameraManager, view.camera); + if (!cameraIDs) { return; } - const cameraIDs = new Set(getAllDependentCameras(cameras, view.camera)); const queries = createQueriesForRecordingsView( cameraManager, cardWideConfig, cameraIDs, ); - if (!queries) { return; } diff --git a/src/view/view.ts b/src/view/view.ts index 07c91e1c..ac5a7847 100644 --- a/src/view/view.ts +++ b/src/view/view.ts @@ -39,12 +39,17 @@ export class View { * @param curr The current view. * @returns True if the view change is a real media change. */ - public static isMediaChange(prev?: View, curr?: View): boolean { + public static isMajorMediaChange(prev?: View, curr?: View): boolean { return ( !prev || !curr || prev.view !== curr.view || prev.camera !== curr.camera || + // When in live mode, take overrides into account in deciding if this is a + // major media change. + (curr.view === 'live' && + prev.context?.live?.overrides?.get(prev.camera) !== + curr.context?.live?.overrides?.get(curr.camera)) || // When in the live view, the queryResults contain the events that // happened in the past -- not reflective of the actual live media viewer // the user is seeing.