Merge pull request #936 from dermotduffy/refactor-initialization

Refactor camera initialization logic into the camera engines
This commit is contained in:
Dermot Duffy
2023-02-12 16:21:07 -08:00
committed by GitHub
30 changed files with 1249 additions and 842 deletions
+12
View File
@@ -104,6 +104,7 @@ See the [fully expanded cameras configuration example](#config-expanded-cameras)
| `title` | Autodetected from `camera_entity` if that is specified. | :heavy_multiplication_x: | A friendly name for this camera to use in the card. | | `title` | Autodetected from `camera_entity` if that is specified. | :heavy_multiplication_x: | A friendly name for this camera to use in the card. |
| `icon` | Autodetected from `camera_entity` if that is specified. | :heavy_multiplication_x: | The icon to use for this camera in the camera menu and in the next & previous controls when using the `icon` style. | | `icon` | Autodetected from `camera_entity` if that is specified. | :heavy_multiplication_x: | The icon to use for this camera in the camera menu and in the next & previous controls when using the `icon` style. |
| `id` | `camera_entity`, `webrtc_card.entity` or `frigate.camera_name` if set (in that preference order). | :heavy_multiplication_x: | An optional identifier to use throughout the card configuration to refer unambiguously to this camera. See [camera IDs](#camera-ids). | | `id` | `camera_entity`, `webrtc_card.entity` or `frigate.camera_name` if set (in that preference order). | :heavy_multiplication_x: | An optional identifier to use throughout the card configuration to refer unambiguously to this camera. See [camera IDs](#camera-ids). |
| `engine` | `auto` | :heavy_multiplication_x: | Which camera engine to use for this camera. If `auto` the card will attempt to choose the correct engine from the specified options. See [engines](#engines) below for valid options.|
| `frigate` | | :heavy_multiplication_x: | Options for a Frigate camera. See [Frigate configuration](#camera-frigate-configuration) below. | | `frigate` | | :heavy_multiplication_x: | Options for a Frigate camera. See [Frigate configuration](#camera-frigate-configuration) below. |
| `dependencies` | | :heavy_multiplication_x: | Other cameras that this camera should depend upon. See [camera dependencies](#camera-dependencies-configuration) below. | | `dependencies` | | :heavy_multiplication_x: | Other cameras that this camera should depend upon. See [camera dependencies](#camera-dependencies-configuration) below. |
| `triggers` | | :heavy_multiplication_x: | Define what should cause this camera to update/trigger. See [camera triggers](#camera-trigger-configuration) below. | | `triggers` | | :heavy_multiplication_x: | Define what should cause this camera to update/trigger. See [camera triggers](#camera-trigger-configuration) below. |
@@ -122,6 +123,15 @@ See the [fully expanded cameras configuration example](#config-expanded-cameras)
|`frigate-jsmpeg`|Better|Low|Poor|Builtin|Stream the JSMPEG stream from Frigate (proxied via the Frigate integration). See [note below on the required integration version](#jsmpeg-troubleshooting) for this live provider to function. This is the only live provider that can view the Frigate `birdseye` view.| |`frigate-jsmpeg`|Better|Low|Poor|Builtin|Stream the JSMPEG stream from Frigate (proxied via the Frigate integration). See [note below on the required integration version](#jsmpeg-troubleshooting) for this live provider to function. This is the only live provider that can view the Frigate `birdseye` view.|
|`webrtc-card`|Best|High|Better|Separate installation required|Embed's [AlexxIT's WebRTC Card](https://github.com/AlexxIT/WebRTC) to stream live feed, requires manual extra setup, see [below](#webrtc). Not to be confused with native Home Assistant WebRTC (use `ha` provider above).| |`webrtc-card`|Best|High|Better|Separate installation required|Embed's [AlexxIT's WebRTC Card](https://github.com/AlexxIT/WebRTC) to stream live feed, requires manual extra setup, see [below](#webrtc). Not to be confused with native Home Assistant WebRTC (use `ha` provider above).|
<a name="engines"></a>
#### Available Camera Engines
|Engine|Live|Supports clips|Supports Snapshots|Supports Recordings|Supports Timeline|Favorite events|Favorite recordings|
| - | - | - | - | - | - | - | - |
|`frigate`| :white_check_mark: | :white_check_mark: | :white_check_mark: | :white_check_mark: | :white_check_mark: | :white_check_mark: | :heavy_multiplication_x: |
|`generic`| :white_check_mark: | :heavy_multiplication_x: | :heavy_multiplication_x: | :heavy_multiplication_x: | :heavy_multiplication_x: | :heavy_multiplication_x: | :heavy_multiplication_x: |
<a name="camera-frigate-configuration"></a> <a name="camera-frigate-configuration"></a>
#### Camera Frigate configuration #### Camera Frigate configuration
@@ -1304,6 +1314,7 @@ Reference: [Camera Options](#camera-options).
cameras: cameras:
- camera_entity: camera.front_Door - camera_entity: camera.front_Door
live_provider: ha live_provider: ha
engine: auto
frigate: frigate:
url: http://my.frigate.local url: http://my.frigate.local
client_id: frigate client_id: frigate
@@ -1322,6 +1333,7 @@ cameras:
- binary_sensor.front_door_sensor - binary_sensor.front_door_sensor
- camera_entity: camera.entrance - camera_entity: camera.entrance
live_provider: webrtc-card live_provider: webrtc-card
engine: auto
frigate: frigate:
url: http://my-other.frigate.local url: http://my-other.frigate.local
client_id: frigate-other client_id: frigate-other
+34 -56
View File
@@ -1,27 +1,30 @@
import { HomeAssistant } from 'custom-card-helpers';
import { CameraConfig, CardWideConfig } from '../types'; import { CameraConfig, CardWideConfig } from '../types';
import { ViewMedia } from '../view/media'; import { EntityRegistryManager } from '../utils/ha/entity-registry';
import { RecordingSegmentsCache, RequestCache } from './cache'; import { RecordingSegmentsCache, RequestCache } from './cache';
import { CameraManagerEngine } from './engine'; import { CameraManagerEngine } from './engine';
import { FrigateCameraManagerEngine } from './frigate/engine-frigate'; import { FrigateCameraManagerEngine } from './frigate/engine-frigate';
import { GenericCameraManagerEngine } from './generic/engine-generic';
import { Engine } from './types'; import { Engine } from './types';
type CameraManagerEngineCameraIDMap = Map<CameraManagerEngine, Set<string>>;
export class CameraManagerEngineFactory { export class CameraManagerEngineFactory {
protected _engines: Map<Engine, CameraManagerEngine> = new Map(); protected _entityRegistryManager: EntityRegistryManager;
protected _cardWideConfig: CardWideConfig; protected _cardWideConfig: CardWideConfig;
constructor(cardWideConfig: CardWideConfig) { constructor(
entityRegistryManager: EntityRegistryManager,
cardWideConfig: CardWideConfig,
) {
this._entityRegistryManager = entityRegistryManager;
this._cardWideConfig = cardWideConfig; this._cardWideConfig = cardWideConfig;
} }
public getEngine(engine: Engine): CameraManagerEngine | null { public async createEngine(engine: Engine): Promise<CameraManagerEngine | null> {
const cachedEngine = this._engines.get(engine);
if (cachedEngine) {
return cachedEngine;
}
let cameraManagerEngine: CameraManagerEngine | null = null; let cameraManagerEngine: CameraManagerEngine | null = null;
switch (engine) { switch (engine) {
case Engine.Generic:
cameraManagerEngine = new GenericCameraManagerEngine();
break;
case Engine.Frigate: case Engine.Frigate:
cameraManagerEngine = new FrigateCameraManagerEngine( cameraManagerEngine = new FrigateCameraManagerEngine(
this._cardWideConfig, this._cardWideConfig,
@@ -30,64 +33,39 @@ export class CameraManagerEngineFactory {
); );
break; break;
} }
if (cameraManagerEngine) {
this._engines.set(engine, cameraManagerEngine);
}
return cameraManagerEngine; return cameraManagerEngine;
} }
public getEngineForCamera(cameraConfig?: CameraConfig): CameraManagerEngine | null { public async getEngineForCamera(
hass: HomeAssistant,
cameraConfig: CameraConfig,
): Promise<Engine | null> {
if (!cameraConfig) { if (!cameraConfig) {
return null; return null;
} }
let engine: Engine | null = null; let engine: Engine | null = null;
if (cameraConfig.frigate.camera_name) { if (cameraConfig.engine === 'frigate') {
engine = Engine.Frigate; engine = Engine.Frigate;
} } else if (cameraConfig.engine === 'auto') {
return engine ? this.getEngine(engine) : null; const cameraEntity = cameraConfig.camera_entity;
}
public getEnginesForCameraIDs( if (cameraEntity) {
cameras: Map<string, CameraConfig>, const entity = await this._entityRegistryManager.getEntity(hass, cameraEntity);
cameraIDs: Set<string>, switch (entity?.platform) {
): CameraManagerEngineCameraIDMap | null { case 'frigate':
const output: CameraManagerEngineCameraIDMap = new Map(); engine = Engine.Frigate;
break;
for (const cameraID of cameraIDs) { default:
const cameraConfig = cameras.get(cameraID); engine = Engine.Generic;
if (!cameraConfig) { }
continue; } else if (cameraConfig.frigate.camera_name) {
// Frigate technically does not need an entity, if the camera name is
// manually set the camera is assumed to be Frigate.
engine = Engine.Frigate;
} }
const engine = this.getEngineForCamera(cameraConfig);
if (!engine) {
continue;
}
if (!output.has(engine)) {
output.set(engine, new Set());
}
output.get(engine)?.add(cameraID);
} }
return output.size ? output : null;
}
public getEngineForMedia( return engine;
cameras: Map<string, CameraConfig>,
media: ViewMedia,
): CameraManagerEngine | null {
const cameraID = media.getCameraID();
if (!cameraID) {
return null;
}
const engines = this.getEnginesForCameraIDs(cameras, new Set([cameraID]));
return engines ? ([...engines.keys()][0] ?? null) : null;
}
public getAllEngines(
cameras: Map<string, CameraConfig>,
): CameraManagerEngine[] | null {
const engines = this.getEnginesForCameraIDs(cameras, new Set(cameras.keys()));
return engines ? [...engines.keys()] : null;
} }
} }
+27 -10
View File
@@ -1,5 +1,6 @@
import { HomeAssistant } from 'custom-card-helpers'; import { HomeAssistant } from 'custom-card-helpers';
import { CameraConfig } from '../types'; import { CameraConfig } from '../types';
import { EntityRegistryManager } from '../utils/ha/entity-registry';
import { ViewMedia } from '../view/media'; import { ViewMedia } from '../view/media';
import { import {
DataQuery, DataQuery,
@@ -17,57 +18,68 @@ import {
CameraManagerCameraCapabilities, CameraManagerCameraCapabilities,
CameraManagerMediaCapabilities, CameraManagerMediaCapabilities,
CameraManagerCameraMetadata, CameraManagerCameraMetadata,
CameraURLContext,
CameraConfigs,
Engine,
} from './types'; } from './types';
export const CAMERA_MANAGER_ENGINE_EVENT_LIMIT_DEFAULT = 10000; export const CAMERA_MANAGER_ENGINE_EVENT_LIMIT_DEFAULT = 10000;
export interface CameraManagerEngine { export interface CameraManagerEngine {
getEngineType(): Engine;
initializeCamera(
hass: HomeAssistant,
entityRegistryManager: EntityRegistryManager,
cameraConfig: CameraConfig,
): Promise<CameraConfig>;
generateDefaultEventQuery( generateDefaultEventQuery(
cameras: Map<string, CameraConfig>, cameras: CameraConfigs,
cameraIDs: Set<string>, cameraIDs: Set<string>,
query: PartialEventQuery, query: PartialEventQuery,
): EventQuery[] | null; ): EventQuery[] | null;
generateDefaultRecordingQuery( generateDefaultRecordingQuery(
cameras: Map<string, CameraConfig>, cameras: CameraConfigs,
cameraIDs: Set<string>, cameraIDs: Set<string>,
query: PartialRecordingQuery, query: PartialRecordingQuery,
): RecordingQuery[] | null; ): RecordingQuery[] | null;
generateDefaultRecordingSegmentsQuery( generateDefaultRecordingSegmentsQuery(
cameras: Map<string, CameraConfig>, cameras: CameraConfigs,
cameraIDs: Set<string>, cameraIDs: Set<string>,
query: PartialRecordingSegmentsQuery, query: PartialRecordingSegmentsQuery,
): RecordingSegmentsQuery[] | null; ): RecordingSegmentsQuery[] | null;
getEvents( getEvents(
hass: HomeAssistant, hass: HomeAssistant,
cameras: Map<string, CameraConfig>, cameras: CameraConfigs,
query: EventQuery, query: EventQuery,
): Promise<EventQueryResultsMap | null>; ): Promise<EventQueryResultsMap | null>;
getRecordings( getRecordings(
hass: HomeAssistant, hass: HomeAssistant,
cameras: Map<string, CameraConfig>, cameras: CameraConfigs,
query: RecordingQuery, query: RecordingQuery,
): Promise<RecordingQueryResultsMap | null>; ): Promise<RecordingQueryResultsMap | null>;
getRecordingSegments( getRecordingSegments(
hass: HomeAssistant, hass: HomeAssistant,
cameras: Map<string, CameraConfig>, cameras: CameraConfigs,
query: RecordingSegmentsQuery, query: RecordingSegmentsQuery,
): Promise<RecordingSegmentsQueryResultsMap | null>; ): Promise<RecordingSegmentsQueryResultsMap | null>;
generateMediaFromEvents( generateMediaFromEvents(
hass: HomeAssistant, hass: HomeAssistant,
cameras: Map<string, CameraConfig>, cameras: CameraConfigs,
query: EventQuery, query: EventQuery,
results: QueryReturnType<EventQuery>, results: QueryReturnType<EventQuery>,
): ViewMedia[] | null; ): ViewMedia[] | null;
generateMediaFromRecordings( generateMediaFromRecordings(
hass: HomeAssistant, hass: HomeAssistant,
cameras: Map<string, CameraConfig>, cameras: CameraConfigs,
query: RecordingQuery, query: RecordingQuery,
results: QueryReturnType<RecordingQuery>, results: QueryReturnType<RecordingQuery>,
): ViewMedia[] | null; ): ViewMedia[] | null;
@@ -85,14 +97,14 @@ export interface CameraManagerEngine {
getMediaSeekTime( getMediaSeekTime(
hass: HomeAssistant, hass: HomeAssistant,
cameras: Map<string, CameraConfig>, cameras: CameraConfigs,
media: ViewMedia, media: ViewMedia,
target: Date, target: Date,
): Promise<number | null>; ): Promise<number | null>;
getMediaMetadata( getMediaMetadata(
hass: HomeAssistant, hass: HomeAssistant,
cameras: Map<string, CameraConfig>, cameras: CameraConfigs,
): Promise<MediaMetadata | null>; ): Promise<MediaMetadata | null>;
getCameraMetadata( getCameraMetadata(
@@ -105,4 +117,9 @@ export interface CameraManagerEngine {
): CameraManagerCameraCapabilities | null; ): CameraManagerCameraCapabilities | null;
getMediaCapabilities(media: ViewMedia): CameraManagerMediaCapabilities | null; getMediaCapabilities(media: ViewMedia): CameraManagerMediaCapabilities | null;
getCameraURL(
cameraConfig: CameraConfig,
context?: CameraURLContext,
): string | null;
} }
+3
View File
@@ -0,0 +1,3 @@
import { FrigateCardError } from '../types.js';
export class CameraInitializationError extends FrigateCardError {}
+229 -21
View File
@@ -37,6 +37,8 @@ import {
RecordingSegment, RecordingSegment,
RecordingSegmentsQuery, RecordingSegmentsQuery,
RecordingSegmentsQueryResultsMap, RecordingSegmentsQueryResultsMap,
CameraURLContext,
CameraConfigs,
} from '../types'; } from '../types';
import { FrigateRecording } from './types'; import { FrigateRecording } from './types';
import { import {
@@ -62,7 +64,14 @@ import { FrigateViewMediaClassifier } from './media-classifier';
import { ViewMediaClassifier } from '../../view/media-classifier'; import { ViewMediaClassifier } from '../../view/media-classifier';
import { FrigateViewMediaFactory } from './media'; import { FrigateViewMediaFactory } from './media';
import { log } from '../../utils/debug'; import { log } from '../../utils/debug';
import { getEntityIcon, getEntityTitle } from '../../utils/ha'; import { getEntityTitle } from '../../utils/ha';
import { EntityRegistryManager } from '../../utils/ha/entity-registry';
import { ExtendedEntity } from '../../utils/ha/entity-registry/types';
import { CameraInitializationError } from '../error';
import { localize } from '../../localize/localize';
import uniq from 'lodash-es/uniq';
import format from 'date-fns/format';
import { GenericCameraManagerEngine } from '../generic/engine-generic';
const EVENT_REQUEST_CACHE_MAX_AGE_SECONDS = 60; const EVENT_REQUEST_CACHE_MAX_AGE_SECONDS = 60;
const RECORDING_SUMMARY_REQUEST_CACHE_MAX_AGE_SECONDS = 60; const RECORDING_SUMMARY_REQUEST_CACHE_MAX_AGE_SECONDS = 60;
@@ -92,7 +101,10 @@ class FrigateQueryResultsClassifier {
} }
} }
export class FrigateCameraManagerEngine implements CameraManagerEngine { export class FrigateCameraManagerEngine
extends GenericCameraManagerEngine
implements CameraManagerEngine
{
protected _recordingSegmentsCache: RecordingSegmentsCache; protected _recordingSegmentsCache: RecordingSegmentsCache;
protected _requestCache: RequestCache; protected _requestCache: RequestCache;
protected _cardWideConfig: CardWideConfig; protected _cardWideConfig: CardWideConfig;
@@ -109,11 +121,162 @@ export class FrigateCameraManagerEngine implements CameraManagerEngine {
recordingSegmentsCache: RecordingSegmentsCache, recordingSegmentsCache: RecordingSegmentsCache,
requestCache: RequestCache, requestCache: RequestCache,
) { ) {
super();
this._cardWideConfig = cardWideConfig; this._cardWideConfig = cardWideConfig;
this._recordingSegmentsCache = recordingSegmentsCache; this._recordingSegmentsCache = recordingSegmentsCache;
this._requestCache = requestCache; this._requestCache = requestCache;
} }
public getEngineType(): Engine {
return Engine.Frigate;
}
public async initializeCamera(
hass: HomeAssistant,
entityRegistryManager: EntityRegistryManager,
cameraConfig: CameraConfig,
): Promise<CameraConfig> {
const hasCameraName = !!cameraConfig.frigate?.camera_name;
const hasAutoTriggers =
cameraConfig.triggers.motion || cameraConfig.triggers.occupancy;
let entity: ExtendedEntity | null = null;
// Extended entity information is required if the Frigate camera name is
// missing, or if the entity requires automatic resolution of
// motion/occupancy sensors.
if (cameraConfig.camera_entity && (!hasCameraName || hasAutoTriggers)) {
try {
entity = await entityRegistryManager.getExtendedEntity(
hass,
cameraConfig.camera_entity,
);
} catch (e) {
throw new CameraInitializationError(
localize('error.no_camera_entity'),
cameraConfig,
);
}
}
if (entity && !hasCameraName) {
const resolvedName = this._getFrigateCameraNameFromEntity(entity);
if (resolvedName) {
cameraConfig.frigate.camera_name = resolvedName;
}
}
if (hasAutoTriggers) {
// Try to find the correct entities for the motion & occupancy sensors.
// We know they are binary_sensors, and that they'll have the same
// config entry ID as the camera. Searching via unique_id ensures this
// search still works if the user renames the entity_id.
const binarySensorEntities = await entityRegistryManager.getMatchingEntities(
hass,
(ent) =>
ent.config_entry_id === entity?.config_entry_id &&
!ent.disabled_by &&
ent.entity_id.startsWith('binary_sensor.'),
);
const extendedEntities = await entityRegistryManager.getExtendedEntities(
hass,
binarySensorEntities.map((entity) => entity.entity_id),
);
if (cameraConfig.triggers.motion) {
const motionEntity = this._getMotionSensor(cameraConfig, [
...extendedEntities.values(),
]);
if (motionEntity) {
cameraConfig.triggers.entities.push(motionEntity);
}
}
if (cameraConfig.triggers.occupancy) {
const occupancyEntity = this._getOccupancySensor(cameraConfig, [
...extendedEntities.values(),
]);
if (occupancyEntity) {
cameraConfig.triggers.entities.push(occupancyEntity);
}
}
// De-duplicate triggering entities.
cameraConfig.triggers.entities = uniq(cameraConfig.triggers.entities);
}
return cameraConfig;
}
/**
* Get the Frigate camera name from an entity.
* @returns The Frigate camera name or null if unavailable.
*/
protected _getFrigateCameraNameFromEntity(entity: ExtendedEntity): string | null {
if (entity.unique_id && entity.platform === 'frigate') {
const match = entity.unique_id.match(/:camera:(?<camera>[^:]+)$/);
if (match && match.groups) {
return match.groups['camera'];
}
}
return null;
}
/**
* Get the motion sensor entity for a given camera.
* @param cache The ExtendedEntityCache of entity registry information.
* @param cameraConfig The camera config in question.
* @returns The entity id of the motion sensor or null.
*/
protected _getMotionSensor(
cameraConfig: CameraConfig,
extendedEntities: ExtendedEntity[],
): string | null {
if (cameraConfig.frigate.camera_name) {
return (
extendedEntities.find(
(ent) =>
!!ent.unique_id?.match(
new RegExp(
`:motion_sensor:${
cameraConfig.frigate.zone || cameraConfig.frigate.camera_name
}`,
),
),
)?.entity_id ?? null
);
}
return null;
}
/**
* Get the occupancy sensor entity for a given camera.
* @param cache The ExtendedEntityCache of entity registry information.
* @param cameraConfig The camera config in question.
* @returns The entity id of the occupancy sensor or null.
*/
protected _getOccupancySensor(
cameraConfig: CameraConfig,
extendedEntities: ExtendedEntity[],
): string | null {
if (cameraConfig.frigate.camera_name) {
return (
extendedEntities.find(
(ent) =>
!!ent.unique_id?.match(
new RegExp(
`:occupancy_sensor:${
cameraConfig.frigate.zone || cameraConfig.frigate.camera_name
}_${cameraConfig.frigate.label || 'all'}`,
),
),
)?.entity_id ?? null
);
}
return null;
}
public getMediaDownloadPath( public getMediaDownloadPath(
cameraConfig: CameraConfig, cameraConfig: CameraConfig,
media: ViewMedia, media: ViewMedia,
@@ -137,7 +300,7 @@ export class FrigateCameraManagerEngine implements CameraManagerEngine {
} }
public generateDefaultEventQuery( public generateDefaultEventQuery(
cameras: Map<string, CameraConfig>, cameras: CameraConfigs,
cameraIDs: Set<string>, cameraIDs: Set<string>,
query?: PartialEventQuery, query?: PartialEventQuery,
): EventQuery[] | null { ): EventQuery[] | null {
@@ -182,7 +345,7 @@ export class FrigateCameraManagerEngine implements CameraManagerEngine {
} }
public generateDefaultRecordingQuery( public generateDefaultRecordingQuery(
_cameras: Map<string, CameraConfig>, _cameras: CameraConfigs,
cameraIDs: Set<string>, cameraIDs: Set<string>,
query?: PartialRecordingQuery, query?: PartialRecordingQuery,
): RecordingQuery[] { ): RecordingQuery[] {
@@ -196,7 +359,7 @@ export class FrigateCameraManagerEngine implements CameraManagerEngine {
} }
public generateDefaultRecordingSegmentsQuery( public generateDefaultRecordingSegmentsQuery(
_cameras: Map<string, CameraConfig>, _cameras: CameraConfigs,
cameraIDs: Set<string>, cameraIDs: Set<string>,
query: PartialRecordingSegmentsQuery, query: PartialRecordingSegmentsQuery,
): RecordingSegmentsQuery[] | null { ): RecordingSegmentsQuery[] | null {
@@ -229,7 +392,7 @@ export class FrigateCameraManagerEngine implements CameraManagerEngine {
} }
protected _buildInstanceToCameraIDMapFromQuery( protected _buildInstanceToCameraIDMapFromQuery(
cameras: Map<string, CameraConfig>, cameras: CameraConfigs,
cameraIDs: Set<string>, cameraIDs: Set<string>,
): Map<string, Set<string>> { ): Map<string, Set<string>> {
const output: Map<string, Set<string>> = new Map(); const output: Map<string, Set<string>> = new Map();
@@ -247,7 +410,7 @@ export class FrigateCameraManagerEngine implements CameraManagerEngine {
} }
protected _getFrigateCameraNamesForCameraIDs( protected _getFrigateCameraNamesForCameraIDs(
cameras: Map<string, CameraConfig>, cameras: CameraConfigs,
cameraIDs: Set<string>, cameraIDs: Set<string>,
): Set<string> { ): Set<string> {
const output = new Set<string>(); const output = new Set<string>();
@@ -262,7 +425,7 @@ export class FrigateCameraManagerEngine implements CameraManagerEngine {
public async getEvents( public async getEvents(
hass: HomeAssistant, hass: HomeAssistant,
cameras: Map<string, CameraConfig>, cameras: CameraConfigs,
query: EventQuery, query: EventQuery,
): Promise<EventQueryResultsMap | null> { ): Promise<EventQueryResultsMap | null> {
const output: EventQueryResultsMap = new Map(); const output: EventQueryResultsMap = new Map();
@@ -326,7 +489,7 @@ export class FrigateCameraManagerEngine implements CameraManagerEngine {
public async getRecordings( public async getRecordings(
hass: HomeAssistant, hass: HomeAssistant,
cameras: Map<string, CameraConfig>, cameras: CameraConfigs,
query: RecordingQuery, query: RecordingQuery,
): Promise<RecordingQueryResultsMap | null> { ): Promise<RecordingQueryResultsMap | null> {
const output: RecordingQueryResultsMap = new Map(); const output: RecordingQueryResultsMap = new Map();
@@ -408,7 +571,7 @@ export class FrigateCameraManagerEngine implements CameraManagerEngine {
public async getRecordingSegments( public async getRecordingSegments(
hass: HomeAssistant, hass: HomeAssistant,
cameras: Map<string, CameraConfig>, cameras: CameraConfigs,
query: RecordingSegmentsQuery, query: RecordingSegmentsQuery,
): Promise<RecordingSegmentsQueryResultsMap | null> { ): Promise<RecordingSegmentsQueryResultsMap | null> {
const output: RecordingSegmentsQueryResultsMap = new Map(); const output: RecordingSegmentsQueryResultsMap = new Map();
@@ -474,7 +637,7 @@ export class FrigateCameraManagerEngine implements CameraManagerEngine {
} }
protected _getCameraIDMatch( protected _getCameraIDMatch(
cameras: Map<string, CameraConfig>, cameras: CameraConfigs,
query: DataQuery, query: DataQuery,
instanceID: string, instanceID: string,
cameraName: string, cameraName: string,
@@ -499,7 +662,7 @@ export class FrigateCameraManagerEngine implements CameraManagerEngine {
public generateMediaFromEvents( public generateMediaFromEvents(
_hass: HomeAssistant, _hass: HomeAssistant,
cameras: Map<string, CameraConfig>, cameras: CameraConfigs,
query: EventQuery, query: EventQuery,
results: QueryReturnType<EventQuery>, results: QueryReturnType<EventQuery>,
): ViewMedia[] | null { ): ViewMedia[] | null {
@@ -552,7 +715,7 @@ export class FrigateCameraManagerEngine implements CameraManagerEngine {
public generateMediaFromRecordings( public generateMediaFromRecordings(
hass: HomeAssistant, hass: HomeAssistant,
cameras: Map<string, CameraConfig>, cameras: CameraConfigs,
_query: RecordingQuery, _query: RecordingQuery,
results: QueryReturnType<RecordingQuery>, results: QueryReturnType<RecordingQuery>,
): ViewMedia[] | null { ): ViewMedia[] | null {
@@ -590,7 +753,7 @@ export class FrigateCameraManagerEngine implements CameraManagerEngine {
public async getMediaSeekTime( public async getMediaSeekTime(
hass: HomeAssistant, hass: HomeAssistant,
cameras: Map<string, CameraConfig>, cameras: CameraConfigs,
media: ViewMedia, media: ViewMedia,
target: Date, target: Date,
): Promise<number | null> { ): Promise<number | null> {
@@ -623,7 +786,7 @@ export class FrigateCameraManagerEngine implements CameraManagerEngine {
} }
protected _getQueryableCameraConfig( protected _getQueryableCameraConfig(
cameras: Map<string, CameraConfig>, cameras: CameraConfigs,
cameraID: string, cameraID: string,
): CameraConfig | null { ): CameraConfig | null {
const cameraConfig = cameras.get(cameraID); const cameraConfig = cameras.get(cameraID);
@@ -635,7 +798,7 @@ export class FrigateCameraManagerEngine implements CameraManagerEngine {
public async getMediaMetadata( public async getMediaMetadata(
hass: HomeAssistant, hass: HomeAssistant,
cameras: Map<string, CameraConfig>, cameras: CameraConfigs,
): Promise<MediaMetadata | null> { ): Promise<MediaMetadata | null> {
const what: Set<string> = new Set(); const what: Set<string> = new Set();
const where: Set<string> = new Set(); const where: Set<string> = new Set();
@@ -715,7 +878,7 @@ export class FrigateCameraManagerEngine implements CameraManagerEngine {
*/ */
protected async _garbageCollectSegments( protected async _garbageCollectSegments(
hass: HomeAssistant, hass: HomeAssistant,
cameras: Map<string, CameraConfig>, cameras: CameraConfigs,
): Promise<void> { ): Promise<void> {
const cameraIDs = this._recordingSegmentsCache.getCameraIDs(); const cameraIDs = this._recordingSegmentsCache.getCameraIDs();
const recordingQuery: RecordingQuery = { const recordingQuery: RecordingQuery = {
@@ -812,6 +975,9 @@ export class FrigateCameraManagerEngine implements CameraManagerEngine {
return { return {
canFavoriteEvents: !isBirdseye, canFavoriteEvents: !isBirdseye,
canFavoriteRecordings: !isBirdseye, canFavoriteRecordings: !isBirdseye,
supportsClips: !isBirdseye,
supportsSnapshots: !isBirdseye,
supportsRecordings: !isBirdseye,
supportsTimeline: !isBirdseye, supportsTimeline: !isBirdseye,
}; };
} }
@@ -826,6 +992,7 @@ export class FrigateCameraManagerEngine implements CameraManagerEngine {
hass: HomeAssistant, hass: HomeAssistant,
cameraConfig: CameraConfig, cameraConfig: CameraConfig,
): CameraManagerCameraMetadata { ): CameraManagerCameraMetadata {
const metadata = super.getCameraMetadata(hass, cameraConfig);
return { return {
title: title:
cameraConfig.title ?? cameraConfig.title ??
@@ -834,10 +1001,51 @@ export class FrigateCameraManagerEngine implements CameraManagerEngine {
prettifyTitle(cameraConfig.frigate?.camera_name) ?? prettifyTitle(cameraConfig.frigate?.camera_name) ??
cameraConfig.id ?? cameraConfig.id ??
'', '',
icon: icon: metadata.icon,
cameraConfig?.icon ??
getEntityIcon(hass, cameraConfig.camera_entity) ??
'mdi:video',
}; };
} }
public getCameraURL(
cameraConfig: CameraConfig,
context?: CameraURLContext,
): string | null {
if (!cameraConfig.frigate.url) {
return null;
}
if (!cameraConfig.frigate.camera_name) {
return cameraConfig.frigate.url;
}
const eventsURL =
`${cameraConfig.frigate.url}/events?camera=` + cameraConfig.frigate.camera_name;
const recordingsURL =
`${cameraConfig.frigate.url}/recording/` + cameraConfig.frigate.camera_name;
// If media is available, use it since it may result in a more precisely
// correct URL.
switch (context?.media?.getMediaType()) {
case 'clip':
case 'snapshot':
return eventsURL;
case 'recording':
const startTime = context.media.getStartTime();
if (startTime) {
return recordingsURL + format(startTime, 'yyyy-MM-dd/HH');
}
}
// Otherwise, fall back to just using the view if we have that.
switch (context?.view) {
case 'clip':
case 'clips':
case 'snapshots':
case 'snapshot':
return eventsURL;
case 'recording':
case 'recordings':
return recordingsURL;
}
return `${cameraConfig.frigate.url}/cameras/${cameraConfig.frigate.camera_name}`;
}
} }
@@ -0,0 +1,186 @@
/* eslint-disable @typescript-eslint/no-unused-vars */
import { HomeAssistant } from 'custom-card-helpers';
import { CameraConfig } from '../../types';
import { ViewMedia } from '../../view/media';
import {
CameraManagerCameraMetadata,
CameraManagerMediaCapabilities,
DataQuery,
EventQuery,
EventQueryResultsMap,
MediaMetadata,
PartialEventQuery,
PartialRecordingQuery,
PartialRecordingSegmentsQuery,
RecordingQueryResultsMap,
RecordingSegmentsQuery,
RecordingSegmentsQueryResultsMap,
CameraURLContext,
CameraConfigs,
RecordingQuery,
QueryReturnType,
CameraManagerCameraCapabilities,
Engine,
} 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 {
return Engine.Generic;
}
public async initializeCamera(
_hass: HomeAssistant,
_entityRegistryManager: EntityRegistryManager,
cameraConfig: CameraConfig,
): Promise<CameraConfig> {
return cameraConfig;
}
public generateDefaultEventQuery(
_cameras: CameraConfigs,
_cameraIDs: Set<string>,
_query: PartialEventQuery,
): EventQuery[] | null {
return null;
}
public generateDefaultRecordingQuery(
_cameras: CameraConfigs,
_cameraIDs: Set<string>,
_query: PartialRecordingQuery,
): RecordingQuery[] | null {
return null;
}
public generateDefaultRecordingSegmentsQuery(
_cameras: CameraConfigs,
_cameraIDs: Set<string>,
_query: PartialRecordingSegmentsQuery,
): RecordingSegmentsQuery[] | null {
return null;
}
public async getEvents(
_hass: HomeAssistant,
_cameras: CameraConfigs,
_query: EventQuery,
): Promise<EventQueryResultsMap | null> {
return null;
}
public async getRecordings(
_hass: HomeAssistant,
_cameras: CameraConfigs,
_query: RecordingQuery,
): Promise<RecordingQueryResultsMap | null> {
return null;
}
public async getRecordingSegments(
_hass: HomeAssistant,
_cameras: CameraConfigs,
_query: RecordingSegmentsQuery,
): Promise<RecordingSegmentsQueryResultsMap | null> {
return null;
}
public generateMediaFromEvents(
_hass: HomeAssistant,
_cameras: CameraConfigs,
_query: EventQuery,
_results: QueryReturnType<EventQuery>,
): ViewMedia[] | null {
return null;
}
public generateMediaFromRecordings(
_hass: HomeAssistant,
_cameras: CameraConfigs,
_query: RecordingQuery,
_results: QueryReturnType<RecordingQuery>,
): ViewMedia[] | null {
return null;
}
public getMediaDownloadPath(
_cameraConfig: CameraConfig,
_media: ViewMedia,
): string | null {
return null;
}
public async favoriteMedia(
_hass: HomeAssistant,
_cameraConfig: CameraConfig,
_media: ViewMedia,
_favorite: boolean,
): Promise<void> {
return;
}
public getQueryResultMaxAge(_query: DataQuery): number | null {
return null;
}
public async getMediaSeekTime(
_hass: HomeAssistant,
_cameras: CameraConfigs,
_media: ViewMedia,
_target: Date,
): Promise<number | null> {
return null;
}
public async getMediaMetadata(
_hass: HomeAssistant,
_cameras: CameraConfigs,
): Promise<MediaMetadata | null> {
return null;
}
public getCameraMetadata(
hass: HomeAssistant,
cameraConfig: CameraConfig,
): CameraManagerCameraMetadata {
return {
title:
cameraConfig.title ??
getEntityTitle(hass, cameraConfig.camera_entity) ??
getEntityTitle(hass, cameraConfig.webrtc_card?.entity) ??
cameraConfig.id ??
'',
icon:
cameraConfig?.icon ??
getEntityIcon(hass, cameraConfig.camera_entity) ??
'mdi:video',
};
}
public getCameraCapabilities(
_cameraConfig: CameraConfig,
): CameraManagerCameraCapabilities | null {
return {
canFavoriteEvents: false,
canFavoriteRecordings:false,
supportsClips: false,
supportsRecordings: false,
supportsSnapshots: false,
supportsTimeline: false,
}
}
public getMediaCapabilities(_media: ViewMedia): CameraManagerMediaCapabilities | null {
return null;
}
public getCameraURL(
_cameraConfig: CameraConfig,
_context?: CameraURLContext,
): string | null {
return null;
}
}
+206 -76
View File
@@ -1,11 +1,12 @@
import { HomeAssistant } from 'custom-card-helpers'; import { HomeAssistant } from 'custom-card-helpers';
import { CameraConfig, CardWideConfig } from '../types.js'; import { CameraConfig, CamerasConfig, CardWideConfig } from '../types.js';
import { allPromises, arrayify, setify } from '../utils/basic.js'; import { allPromises, arrayify, setify } from '../utils/basic.js';
import { import {
CameraManagerCameraCapabilities, CameraManagerCameraCapabilities,
CameraManagerCameraMetadata, CameraManagerCameraMetadata,
CameraManagerCapabilities, CameraManagerCapabilities,
CameraManagerMediaCapabilities, CameraManagerMediaCapabilities,
CameraURLContext,
DataQuery, DataQuery,
EventQuery, EventQuery,
EventQueryResults, EventQueryResults,
@@ -37,6 +38,12 @@ import { CameraManagerEngine } from './engine.js';
import sum from 'lodash-es/sum'; import sum from 'lodash-es/sum';
import add from 'date-fns/add'; import add from 'date-fns/add';
import { log } from '../utils/debug.js'; import { log } from '../utils/debug.js';
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 { cloneDeep } from 'lodash-es';
class QueryClassifier { class QueryClassifier {
public static isEventQuery(query: DataQuery | PartialDataQuery): query is EventQuery { public static isEventQuery(query: DataQuery | PartialDataQuery): query is EventQuery {
@@ -77,19 +84,132 @@ export interface ExtendedMediaQueryResult<T extends MediaQuery> {
results: ViewMedia[]; results: ViewMedia[];
} }
interface InitializedCamera {
inputConfig: CameraConfig;
initializedConfig: CameraConfig;
engine: CameraManagerEngine;
}
export class CameraManager { export class CameraManager {
protected _engineFactory: CameraManagerEngineFactory; protected _engineFactory: CameraManagerEngineFactory;
protected _cameras: Map<string, CameraConfig>;
protected _cardWideConfig?: CardWideConfig; protected _cardWideConfig?: CardWideConfig;
protected _store: CameraManagerStore;
constructor( constructor(
engineFactory: CameraManagerEngineFactory, engineFactory: CameraManagerEngineFactory,
cameras: Map<string, CameraConfig>,
cardWideConfig?: CardWideConfig, cardWideConfig?: CardWideConfig,
) { ) {
this._engineFactory = engineFactory; this._engineFactory = engineFactory;
this._cameras = cameras;
this._cardWideConfig = cardWideConfig; this._cardWideConfig = cardWideConfig;
this._store = new CameraManagerStore();
}
protected async _initializeCamera(
hass: HomeAssistant,
entityRegistryManager: EntityRegistryManager,
inputCameraConfig: CameraConfig,
): Promise<InitializedCamera> {
const engineType = await this._engineFactory.getEngineForCamera(
hass,
inputCameraConfig,
);
const engine = engineType
? this._store.getEngineOfType(engineType) ??
(await this._engineFactory.createEngine(engineType))
: null;
if (!engine) {
throw new CameraInitializationError(
localize('error.no_camera_engine'),
inputCameraConfig,
);
}
const initializedConfig = await engine.initializeCamera(
hass,
entityRegistryManager,
// Camera initialization may modify the configuration. Keep the original
// for display in error messages to avoid user confusion.
cloneDeep(inputCameraConfig),
);
return {
inputConfig: inputCameraConfig,
initializedConfig: initializedConfig,
engine: engine,
};
}
public async initializeCameras(
hass: HomeAssistant,
entityRegistryManager: EntityRegistryManager,
camerasConfig: CamerasConfig,
): Promise<void> {
const hasAutoTriggers = (config: CameraConfig): boolean => {
return config.triggers.motion || config.triggers.occupancy;
};
if (
// If any camera requires automatic trigger detection ...
camerasConfig.some((config) => hasAutoTriggers(config))
) {
// ... then we need to populate the entity cache by fetching all entities
// from Home Assistant.
await entityRegistryManager.fetchEntityList(hass);
}
const results = await allPromises(
camerasConfig,
async (cameraConfig) =>
await this._initializeCamera(hass, entityRegistryManager, cameraConfig),
);
// Do the additions based off the result-order, to ensure the map order is
// preserved.
results.forEach((result) => {
const id = getCameraID(result.initializedConfig);
if (!id) {
throw new CameraInitializationError(
localize('error.no_camera_id'),
result.inputConfig,
);
}
if (this._store.hasCameraID(id)) {
throw new CameraInitializationError(
localize('error.duplicate_camera_id'),
result.inputConfig,
);
}
this._store.addCamera(id, result.initializedConfig, result.engine);
});
if (!this._store.getCameraCount()) {
throw new CameraInitializationError(localize('error.no_cameras'));
}
}
public isInitialized(): boolean {
return this._store.getCameraCount() > 0;
}
public getCameras(): Map<string, CameraConfig> | null {
return this._store.getCameras();
}
public getCameraConfig(cameraID: string): CameraConfig | null {
return this._store.getCameraConfig(cameraID);
}
public getCameraIDs(): Set<string> | null {
return this._store.getCameraCount()
? new Set(this._store.getCameras().keys())
: null;
}
public hasCameraID(cameraID: string): boolean {
return this._store.hasCameraID(cameraID);
} }
public generateDefaultEventQueries( public generateDefaultEventQueries(
@@ -127,13 +247,13 @@ export class CameraManager {
const where: Set<string> = new Set(); const where: Set<string> = new Set();
const days: Set<string> = new Set(); const days: Set<string> = new Set();
const engines = this._engineFactory.getAllEngines(this._cameras); const engines = this._store.getAllEngines();
if (!engines) {
return null;
}
const processMetadata = async (engine: CameraManagerEngine): Promise<void> => { const processMetadata = async (engine: CameraManagerEngine): Promise<void> => {
const engineMetadata = await engine.getMediaMetadata(hass, this._cameras); const engineMetadata = await engine.getMediaMetadata(
hass,
this._store.getCameras(),
);
if (engineMetadata) { if (engineMetadata) {
if (engineMetadata.what) { if (engineMetadata.what) {
engineMetadata.what.forEach(what.add, what); engineMetadata.what.forEach(what.add, what);
@@ -147,7 +267,7 @@ export class CameraManager {
} }
}; };
await allPromises(engines, (engine) => processMetadata(engine)); await allPromises(engines, processMetadata);
if (!what.size && !where.size && !days.size) { if (!what.size && !where.size && !days.size) {
return null; return null;
@@ -165,11 +285,7 @@ export class CameraManager {
): PartialQueryConcreteType<PQT>[] | null { ): PartialQueryConcreteType<PQT>[] | null {
const concreteQueries: PartialQueryConcreteType<PQT>[] = []; const concreteQueries: PartialQueryConcreteType<PQT>[] = [];
const _cameraIDs = setify(cameraIDs); const _cameraIDs = setify(cameraIDs);
const engines = this._store.getEnginesForCameraIDs(_cameraIDs);
const engines = this._engineFactory.getEnginesForCameraIDs(
this._cameras,
_cameraIDs,
);
if (!engines) { if (!engines) {
return null; return null;
@@ -179,19 +295,19 @@ export class CameraManager {
let queries: DataQuery[] | null = null; let queries: DataQuery[] | null = null;
if (QueryClassifier.isEventQuery(partialQuery)) { if (QueryClassifier.isEventQuery(partialQuery)) {
queries = engine.generateDefaultEventQuery( queries = engine.generateDefaultEventQuery(
this._cameras, this._store.getCameras(),
cameraIDs, cameraIDs,
partialQuery, partialQuery,
); );
} else if (QueryClassifier.isRecordingQuery(partialQuery)) { } else if (QueryClassifier.isRecordingQuery(partialQuery)) {
queries = engine.generateDefaultRecordingQuery( queries = engine.generateDefaultRecordingQuery(
this._cameras, this._store.getCameras(),
cameraIDs, cameraIDs,
partialQuery, partialQuery,
); );
} else if (QueryClassifier.isRecordingSegmentsQuery(partialQuery)) { } else if (QueryClassifier.isRecordingSegmentsQuery(partialQuery)) {
queries = engine.generateDefaultRecordingSegmentsQuery( queries = engine.generateDefaultRecordingSegmentsQuery(
this._cameras, this._store.getCameras(),
cameraIDs, cameraIDs,
partialQuery, partialQuery,
); );
@@ -303,10 +419,9 @@ export class CameraManager {
} }
public getMediaDownloadPath(media: ViewMedia): string | null { public getMediaDownloadPath(media: ViewMedia): string | null {
const cameraConfig = this._cameras.get(media.getCameraID()); const cameraConfig = this._store.getCameraConfigForMedia(media);
const engine = cameraConfig const engine = this._store.getEngineForMedia(media);
? this._engineFactory.getEngineForCamera(cameraConfig)
: null;
if (!cameraConfig || !engine) { if (!cameraConfig || !engine) {
return null; return null;
} }
@@ -314,7 +429,7 @@ export class CameraManager {
} }
public getMediaCapabilities(media: ViewMedia): CameraManagerMediaCapabilities | null { public getMediaCapabilities(media: ViewMedia): CameraManagerMediaCapabilities | null {
const engine = this._engineFactory.getEngineForMedia(this._cameras, media); const engine = this._store.getEngineForMedia(media);
if (!engine) { if (!engine) {
return null; return null;
} }
@@ -326,26 +441,26 @@ export class CameraManager {
media: ViewMedia, media: ViewMedia,
favorite: boolean, favorite: boolean,
): Promise<void> { ): Promise<void> {
const cameraConfig = this._cameras.get(media.getCameraID()); const cameraConfig = this._store.getCameraConfigForMedia(media);
if (!cameraConfig) { const engine = this._store.getEngineForMedia(media);
if (!cameraConfig || !engine) {
return; return;
} }
const engine = this._engineFactory.getEngineForCamera(cameraConfig);
if (engine) {
const queryStartTime = new Date();
await engine.favoriteMedia(hass, cameraConfig, media, favorite);
log( const queryStartTime = new Date();
this._cardWideConfig, await engine.favoriteMedia(hass, cameraConfig, media, favorite);
'Frigate Card CameraManager favorite request (',
`Duration: ${(new Date().getTime() - queryStartTime.getTime()) / 1000}s,`, log(
'Media:', this._cardWideConfig,
media.getID(), 'Frigate Card CameraManager favorite request (',
', Favorite:', `Duration: ${(new Date().getTime() - queryStartTime.getTime()) / 1000}s,`,
favorite, 'Media:',
')', media.getID(),
); ', Favorite:',
} favorite,
')',
);
} }
public areMediaQueriesResultsFresh<T extends MediaQuery>( public areMediaQueriesResultsFresh<T extends MediaQuery>(
@@ -355,10 +470,7 @@ export class CameraManager {
const now = new Date(); const now = new Date();
for (const query of queries) { for (const query of queries) {
const engines = this._engineFactory.getEnginesForCameraIDs( const engines = this._store.getEnginesForCameraIDs(query.cameraIDs);
this._cameras,
query.cameraIDs,
);
for (const [engine, cameraIDs] of engines ?? []) { for (const [engine, cameraIDs] of engines ?? []) {
const maxAgeSeconds = engine.getQueryResultMaxAge({ const maxAgeSeconds = engine.getQueryResultMaxAge({
...query, ...query,
@@ -382,9 +494,11 @@ export class CameraManager {
): Promise<number | null> { ): Promise<number | null> {
const startTime = media.getStartTime(); const startTime = media.getStartTime();
const endTime = media.getEndTime(); const endTime = media.getEndTime();
const cameraConfig = this._cameras.get(media.getCameraID()); const cameraConfig = this._store.getCameraConfigForMedia(media);
const engine = this._store.getEngineForMedia(media);
if ( if (
!cameraConfig || !cameraConfig ||
!engine ||
!startTime || !startTime ||
!endTime || !endTime ||
target < startTime || target < startTime ||
@@ -393,8 +507,7 @@ export class CameraManager {
return null; return null;
} }
const engine = this._engineFactory.getEngineForCamera(cameraConfig); return await engine.getMediaSeekTime(hass, this._store.getCameras(), media, target);
return (await engine?.getMediaSeekTime(hass, this._cameras, media, target)) ?? null;
} }
protected async _handleQuery<QT extends DataQuery>( protected async _handleQuery<QT extends DataQuery>(
@@ -415,19 +528,21 @@ export class CameraManager {
let engineResult: Map<QT, QueryReturnType<QT>> | null = null; let engineResult: Map<QT, QueryReturnType<QT>> | null = null;
if (QueryClassifier.isEventQuery(query)) { if (QueryClassifier.isEventQuery(query)) {
engineResult = (await engine.getEvents(hass, this._cameras, query)) as Map< engineResult = (await engine.getEvents(
QT, hass,
QueryReturnType<QT> this._store.getCameras(),
> | null; query,
)) as Map<QT, QueryReturnType<QT>> | null;
} else if (QueryClassifier.isRecordingQuery(query)) { } else if (QueryClassifier.isRecordingQuery(query)) {
engineResult = (await engine.getRecordings(hass, this._cameras, query)) as Map< engineResult = (await engine.getRecordings(
QT, hass,
QueryReturnType<QT> this._store.getCameras(),
> | null; query,
)) as Map<QT, QueryReturnType<QT>> | null;
} else if (QueryClassifier.isRecordingSegmentsQuery(query)) { } else if (QueryClassifier.isRecordingSegmentsQuery(query)) {
engineResult = (await engine.getRecordingSegments( engineResult = (await engine.getRecordingSegments(
hass, hass,
this._cameras, this._store.getCameras(),
query, query,
)) as Map<QT, QueryReturnType<QT>> | null; )) as Map<QT, QueryReturnType<QT>> | null;
} }
@@ -436,10 +551,7 @@ export class CameraManager {
}; };
const processQuery = async (query: QT): Promise<void> => { const processQuery = async (query: QT): Promise<void> => {
const engines = this._engineFactory.getEnginesForCameraIDs( const engines = this._store.getEnginesForCameraIDs(query.cameraIDs);
this._cameras,
query.cameraIDs,
);
if (!engines) { if (!engines) {
return; return;
} }
@@ -481,7 +593,7 @@ export class CameraManager {
): ViewMedia[] { ): ViewMedia[] {
const mediaArray: ViewMedia[] = []; const mediaArray: ViewMedia[] = [];
for (const [query, result] of results.entries()) { for (const [query, result] of results.entries()) {
const engine = this._engineFactory.getEngine(result.engine); const engine = this._store.getEngineOfType(result.engine);
if (engine) { if (engine) {
let media: ViewMedia[] | null = null; let media: ViewMedia[] | null = null;
@@ -489,12 +601,22 @@ export class CameraManager {
QueryClassifier.isEventQuery(query) && QueryClassifier.isEventQuery(query) &&
QueryResultClassifier.isEventQueryResult(result) QueryResultClassifier.isEventQueryResult(result)
) { ) {
media = engine.generateMediaFromEvents(hass, this._cameras, query, result); media = engine.generateMediaFromEvents(
hass,
this._store.getCameras(),
query,
result,
);
} else if ( } else if (
QueryClassifier.isRecordingQuery(query) && QueryClassifier.isRecordingQuery(query) &&
QueryResultClassifier.isRecordingQuery(result) QueryResultClassifier.isRecordingQuery(result)
) { ) {
media = engine.generateMediaFromRecordings(hass, this._cameras, query, result); media = engine.generateMediaFromRecordings(
hass,
this._store.getCameras(),
query,
result,
);
} }
if (media) { if (media) {
mediaArray.push(...media); mediaArray.push(...media);
@@ -517,13 +639,22 @@ export class CameraManager {
); );
} }
public getCameraURL(cameraID: string, context?: CameraURLContext): string | null {
const cameraConfig = this._store.getCameraConfig(cameraID);
const engine = this._store.getEngineForCameraID(cameraID);
if (!cameraConfig || !engine) {
return null;
}
return engine.getCameraURL(cameraConfig, context);
}
public getCameraMetadata( public getCameraMetadata(
hass: HomeAssistant, hass: HomeAssistant,
cameraID: string, cameraID: string,
): CameraManagerCameraMetadata | null { ): CameraManagerCameraMetadata | null {
const cameraConfig = this._cameras.get(cameraID); const cameraConfig = this._store.getCameraConfig(cameraID);
const engine = this._engineFactory.getEngineForCamera(cameraConfig); const engine = this._store.getEngineForCameraID(cameraID);
if (!engine || !cameraConfig) { if (!cameraConfig || !engine) {
return null; return null;
} }
return engine.getCameraMetadata(hass, cameraConfig); return engine.getCameraMetadata(hass, cameraConfig);
@@ -532,23 +663,18 @@ export class CameraManager {
public getCameraCapabilities( public getCameraCapabilities(
cameraID: string, cameraID: string,
): CameraManagerCameraCapabilities | null { ): CameraManagerCameraCapabilities | null {
const cameraConfig = this._cameras.get(cameraID); const cameraConfig = this._store.getCameraConfig(cameraID);
if (!cameraConfig) { const engine = this._store.getEngineForCameraID(cameraID);
if (!cameraConfig || !engine) {
return null; return null;
} }
const engine = this._engineFactory.getEngineForCamera(cameraConfig);
if (!engine) {
return null;
}
return engine.getCameraCapabilities(cameraConfig); return engine.getCameraCapabilities(cameraConfig);
} }
public getAggregateCameraCapabilities( public getAggregateCameraCapabilities(
cameraIDs?: Set<string>, cameraIDs?: Set<string>,
): CameraManagerCapabilities { ): CameraManagerCapabilities | null {
const perCameraCapabilities = [...(cameraIDs ?? this._cameras.keys())].map( const perCameraCapabilities = [...(cameraIDs ?? this._store.getCameraIDs())].map(
(cameraID) => this.getCameraCapabilities(cameraID), (cameraID) => this.getCameraCapabilities(cameraID),
); );
@@ -557,6 +683,10 @@ export class CameraManager {
canFavoriteRecordings: perCameraCapabilities.some( canFavoriteRecordings: perCameraCapabilities.some(
(cap) => cap?.canFavoriteRecordings, (cap) => cap?.canFavoriteRecordings,
), ),
supportsClips: perCameraCapabilities.some((cap) => cap?.supportsClips),
supportsRecordings: perCameraCapabilities.some((cap) => cap?.supportsRecordings),
supportsSnapshots: perCameraCapabilities.some((cap) => cap?.supportsSnapshots),
supportsTimeline: perCameraCapabilities.some((cap) => cap?.supportsTimeline), supportsTimeline: perCameraCapabilities.some((cap) => cap?.supportsTimeline),
}; };
} }
+89
View File
@@ -0,0 +1,89 @@
import uniq from 'lodash-es/uniq';
import { CameraConfig } from '../types';
import { ViewMedia } from '../view/media';
import { CameraManagerEngine } from './engine';
import { CameraConfigs, Engine } from './types';
type CameraManagerEngineCameraIDMap = Map<CameraManagerEngine, Set<string>>;
export class CameraManagerStore {
protected _configs: Map<string, CameraConfig> = new Map();
protected _engines: Map<string, CameraManagerEngine> = new Map();
protected _enginesByType: Map<Engine, CameraManagerEngine> = new Map();
public addCamera(
cameraID: string,
cameraConfig: CameraConfig,
engine: CameraManagerEngine,
): void {
this._configs.set(cameraID, cameraConfig);
this._engines.set(cameraID, engine);
this._enginesByType.set(engine.getEngineType(), engine);
}
public getCameraCount(): number {
return this._configs.size;
}
public hasCameraID(cameraID: string): boolean {
return this._configs.has(cameraID);
}
public getCameraConfig(cameraID: string): CameraConfig | null {
return this._configs.get(cameraID) ?? null;
}
public getCameras(): CameraConfigs {
return this._configs;
}
public getCameraIDs(): Set<string> {
return new Set(this._configs.keys());
}
public getCameraConfigForMedia(media: ViewMedia): CameraConfig | null {
const cameraID = media.getCameraID();
if (!cameraID) {
return null;
}
return this.getCameraConfig(cameraID);
}
public getEngineOfType(engine: Engine): CameraManagerEngine | null {
return this._enginesByType.get(engine) ?? null;
}
public getEngineForCameraID(cameraID: string): CameraManagerEngine | null {
return this._engines.get(cameraID) ?? null;
}
public getEnginesForCameraIDs(
cameraIDs: Set<string>,
): CameraManagerEngineCameraIDMap | null {
const output: CameraManagerEngineCameraIDMap = new Map();
for (const cameraID of cameraIDs) {
const engine = this.getEngineForCameraID(cameraID);
if (!engine) {
continue;
}
if (!output.has(engine)) {
output.set(engine, new Set());
}
output.get(engine)?.add(cameraID);
}
return output.size ? output : null;
}
public getEngineForMedia(media: ViewMedia): CameraManagerEngine | null {
const cameraID = media.getCameraID();
if (!cameraID) {
return null;
}
return this.getEngineForCameraID(cameraID);
}
public getAllEngines(): CameraManagerEngine[] {
return uniq([...this._engines.values()]);
}
}
+14
View File
@@ -1,3 +1,5 @@
import { CameraConfig, FrigateCardView } from '../types';
import { ViewMedia } from '../view/media';
import { FrigateEvent, FrigateRecording } from './frigate/types'; import { FrigateEvent, FrigateRecording } from './frigate/types';
// ==== // ====
@@ -18,6 +20,7 @@ export enum QueryResultsType {
export enum Engine { export enum Engine {
Frigate = 'frigate', Frigate = 'frigate',
Generic = 'generic',
} }
export interface DataQuery { export interface DataQuery {
@@ -85,6 +88,10 @@ export interface MediaMetadata {
interface BaseCapabilities { interface BaseCapabilities {
canFavoriteEvents: boolean; canFavoriteEvents: boolean;
canFavoriteRecordings: boolean; canFavoriteRecordings: boolean;
supportsClips: boolean;
supportsRecordings: boolean;
supportsSnapshots: boolean;
supportsTimeline: boolean; supportsTimeline: boolean;
} }
@@ -99,6 +106,13 @@ export interface CameraManagerCameraMetadata {
icon: string; icon: string;
} }
export interface CameraURLContext {
media?: ViewMedia;
view?: FrigateCardView;
}
export type CameraConfigs = Map<string, CameraConfig>;
// =========== // ===========
// Event Query // Event Query
// =========== // ===========
+150 -351
View File
@@ -11,7 +11,6 @@ import { customElement, property, state } from 'lit/decorators.js';
import { classMap } from 'lit/directives/class-map.js'; import { classMap } from 'lit/directives/class-map.js';
import { createRef, ref, Ref } from 'lit/directives/ref.js'; import { createRef, ref, Ref } from 'lit/directives/ref.js';
import { StyleInfo, styleMap } from 'lit/directives/style-map.js'; import { StyleInfo, styleMap } from 'lit/directives/style-map.js';
import { until } from 'lit/directives/until.js';
import throttle from 'lodash-es/throttle'; import throttle from 'lodash-es/throttle';
import screenfull from 'screenfull'; import screenfull from 'screenfull';
import { z } from 'zod'; import { z } from 'zod';
@@ -33,19 +32,13 @@ import './components/message.js';
import { renderMessage, renderProgressIndicator } from './components/message.js'; import { renderMessage, renderProgressIndicator } from './components/message.js';
import './components/thumbnail-carousel.js'; import './components/thumbnail-carousel.js';
import { isConfigUpgradeable } from './config-mgmt.js'; import { isConfigUpgradeable } from './config-mgmt.js';
import { import { MEDIA_PLAYER_SUPPORT_BROWSE_MEDIA, REPO_URL } from './const.js';
CAMERA_BIRDSEYE,
MEDIA_PLAYER_SUPPORT_BROWSE_MEDIA,
REPO_URL,
} from './const.js';
import { getLanguage, loadLanguages, localize } from './localize/localize.js'; import { getLanguage, loadLanguages, localize } from './localize/localize.js';
import cardStyle from './scss/card.scss'; import cardStyle from './scss/card.scss';
import { import {
Actions, Actions,
ActionType, ActionType,
CameraConfig, CameraConfig,
EntityList,
ExtendedEntity,
ExtendedHomeAssistant, ExtendedHomeAssistant,
FrigateCardConfig, FrigateCardConfig,
frigateCardConfigSchema, frigateCardConfigSchema,
@@ -58,6 +51,8 @@ import {
Message, Message,
RawFrigateCardConfig, RawFrigateCardConfig,
CardWideConfig, CardWideConfig,
FrigateCardError,
FRIGATE_CARD_VIEW_DEFAULT,
} from './types.js'; } from './types.js';
import { import {
convertActionToFrigateCardCustomAction, convertActionToFrigateCardCustomAction,
@@ -67,7 +62,6 @@ import {
getActionConfigGivenAction, getActionConfigGivenAction,
} from './utils/action.js'; } from './utils/action.js';
import { contentsChanged, errorToConsole } from './utils/basic.js'; import { contentsChanged, errorToConsole } from './utils/basic.js';
import { getCameraID } from './utils/camera.js';
import { import {
getEntityIcon, getEntityIcon,
getEntityTitle, getEntityTitle,
@@ -79,12 +73,6 @@ import {
sideLoadHomeAssistantElements, sideLoadHomeAssistantElements,
} from './utils/ha'; } from './utils/ha';
import { DeviceList, getAllDevices } from './utils/ha/device-registry.js'; import { DeviceList, getAllDevices } from './utils/ha/device-registry.js';
import {
ExtendedEntityCache,
getAllEntities,
getExtendedEntities,
getExtendedEntity,
} from './utils/ha/entity-registry.js';
import { ResolvedMediaCache } from './utils/ha/resolved-media.js'; import { ResolvedMediaCache } from './utils/ha/resolved-media.js';
import { supportsFeature } from './utils/ha/update.js'; import { supportsFeature } from './utils/ha/update.js';
import { isValidMediaLoadedInfo } from './utils/media-info.js'; import { isValidMediaLoadedInfo } from './utils/media-info.js';
@@ -95,6 +83,10 @@ import { CameraManager } from './camera-manager/manager.js';
import { setLowPerformanceProfile, setPerformanceCSSStyles } from './performance.js'; import { setLowPerformanceProfile, setPerformanceCSSStyles } from './performance.js';
import { CameraManagerEngineFactory } from './camera-manager/engine-factory.js'; import { CameraManagerEngineFactory } from './camera-manager/engine-factory.js';
import { log } from './utils/debug.js'; import { log } from './utils/debug.js';
import { EntityRegistryManager } from './utils/ha/entity-registry/index.js';
import { EntityCache } from './utils/ha/entity-registry/cache.js';
import { Entity, ExtendedEntity } from './utils/ha/entity-registry/types.js';
import { getAllDependentCameras } from './utils/camera.js';
/** A note on media callbacks: /** A note on media callbacks:
* *
@@ -196,9 +188,6 @@ class FrigateCard extends LitElement {
// Array of dynamic menu buttons to be added to menu. // Array of dynamic menu buttons to be added to menu.
protected _dynamicMenuButtons: MenuButton[] = []; protected _dynamicMenuButtons: MenuButton[] = [];
@state()
protected _cameras?: Map<string, CameraConfig>;
// Error/info message to render. // Error/info message to render.
protected _message: Message | null = null; protected _message: Message | null = null;
@@ -207,6 +196,8 @@ class FrigateCard extends LitElement {
protected _cameraManager?: CameraManager; protected _cameraManager?: CameraManager;
protected _entityRegistryManager: EntityRegistryManager;
// The mouse handler may be called continually, throttle it to at most once // The mouse handler may be called continually, throttle it to at most once
// per second for performance reasons. // per second for performance reasons.
protected _boundMouseHandler = throttle(this._mouseHandler.bind(this), 1 * 1000); protected _boundMouseHandler = throttle(this._mouseHandler.bind(this), 1 * 1000);
@@ -219,6 +210,14 @@ class FrigateCard extends LitElement {
protected _conditionManager: CardConditionManager | null = null; protected _conditionManager: CardConditionManager | null = null;
constructor() {
super();
this._entityRegistryManager = new EntityRegistryManager(
new EntityCache<Entity>(),
new EntityCache<ExtendedEntity>(),
);
}
/** /**
* Set the Home Assistant object. * Set the Home Assistant object.
*/ */
@@ -383,6 +382,17 @@ class FrigateCard extends LitElement {
protected _getMenuButtons(): MenuButton[] { protected _getMenuButtons(): MenuButton[] {
const buttons: MenuButton[] = []; const buttons: MenuButton[] = [];
const cameras = this._cameraManager?.getCameras();
const selectedCameraID = this._view?.camera;
const selectedCameraConfig = this._getSelectedCameraConfig();
const allSelectedCameraIDs =
cameras && selectedCameraID
? getAllDependentCameras(cameras, selectedCameraID)
: null;
const cameraCapabilities = allSelectedCameraIDs
? this._cameraManager?.getAggregateCameraCapabilities(allSelectedCameraIDs)
: null;
buttons.push({ buttons.push({
// Use a magic icon value that the menu will use to render the custom // Use a magic icon value that the menu will use to render the custom
// Frigate icon. // Frigate icon.
@@ -398,8 +408,8 @@ class FrigateCard extends LitElement {
) as FrigateCardCustomAction, ) as FrigateCardCustomAction,
}); });
if (this._cameras && this._cameras.size > 1) { if (cameras) {
const menuItems = Array.from(this._cameras, ([cameraID, config]) => { const menuItems = Array.from(cameras, ([cameraID, config]) => {
const action = createFrigateCardCustomAction('camera_select', { const action = createFrigateCardCustomAction('camera_select', {
camera: cameraID, camera: cameraID,
}); });
@@ -436,16 +446,7 @@ class FrigateCard extends LitElement {
tap_action: createFrigateCardCustomAction('live') as FrigateCardCustomAction, tap_action: createFrigateCardCustomAction('live') as FrigateCardCustomAction,
}); });
const cameraConfig = this._getSelectedCameraConfig(); if (cameraCapabilities?.supportsClips) {
// Don't show `clips` button if there's no `camera_name` (e.g. non-Frigate
// cameras), or is birdseye (unless there are dependent cameras).
if (
cameraConfig?.frigate.camera_name &&
(cameraConfig?.frigate.camera_name !== CAMERA_BIRDSEYE ||
cameraConfig.dependencies.cameras.length ||
cameraConfig.dependencies.all_cameras)
) {
buttons.push({ buttons.push({
icon: 'mdi:filmstrip', icon: 'mdi:filmstrip',
...this._getConfig().menu.buttons.clips, ...this._getConfig().menu.buttons.clips,
@@ -457,14 +458,7 @@ class FrigateCard extends LitElement {
}); });
} }
// Don't show `snapshots` button if there's no `camera_name` (e.g. non-Frigate if (cameraCapabilities?.supportsSnapshots) {
// cameras), or is birdseye (unless there are dependent cameras).
if (
cameraConfig?.frigate.camera_name &&
(cameraConfig?.frigate.camera_name !== CAMERA_BIRDSEYE ||
cameraConfig?.dependencies.cameras.length ||
cameraConfig?.dependencies.all_cameras)
) {
buttons.push({ buttons.push({
icon: 'mdi:camera', icon: 'mdi:camera',
...this._getConfig().menu.buttons.snapshots, ...this._getConfig().menu.buttons.snapshots,
@@ -480,14 +474,7 @@ class FrigateCard extends LitElement {
}); });
} }
// Don't show `recordings` button if there's no `camera_name` (e.g. non-Frigate if (cameraCapabilities?.supportsRecordings) {
// cameras), or is birdseye (unless there are dependent cameras).
if (
cameraConfig?.frigate.camera_name &&
(cameraConfig?.frigate.camera_name !== CAMERA_BIRDSEYE ||
cameraConfig?.dependencies.cameras.length ||
cameraConfig?.dependencies.all_cameras)
) {
buttons.push({ buttons.push({
icon: 'mdi:album', icon: 'mdi:album',
...this._getConfig().menu.buttons.recordings, ...this._getConfig().menu.buttons.recordings,
@@ -514,13 +501,7 @@ class FrigateCard extends LitElement {
// Don't show the timeline button unless there's at least one non-birdseye // Don't show the timeline button unless there's at least one non-birdseye
// camera with a Frigate camera name. // camera with a Frigate camera name.
if ( if (cameraCapabilities?.supportsTimeline) {
this._cameras &&
[...this._cameras.values()].some(
(config) =>
config.frigate.camera_name && config.frigate.camera_name !== CAMERA_BIRDSEYE,
)
) {
buttons.push({ buttons.push({
icon: 'mdi:chart-gantt', icon: 'mdi:chart-gantt',
...this._getConfig().menu.buttons.timeline, ...this._getConfig().menu.buttons.timeline,
@@ -545,7 +526,7 @@ class FrigateCard extends LitElement {
}); });
} }
if (cameraConfig?.frigate.url) { if (this._getCameraURLFromContext()) {
buttons.push({ buttons.push({
icon: 'mdi:web', icon: 'mdi:web',
...this._getConfig().menu.buttons.frigate_ui, ...this._getConfig().menu.buttons.frigate_ui,
@@ -590,7 +571,7 @@ class FrigateCard extends LitElement {
if ( if (
mediaPlayers.length && mediaPlayers.length &&
(this._view?.isViewerView() || (this._view?.isViewerView() ||
(this._view?.is('live') && cameraConfig?.camera_entity)) (this._view?.is('live') && selectedCameraConfig?.camera_entity))
) { ) {
const mediaPlayerItems = mediaPlayers.map((playerEntityID) => { const mediaPlayerItems = mediaPlayers.map((playerEntityID) => {
const title = getEntityTitle(this._hass, playerEntityID) || playerEntityID; const title = getEntityTitle(this._hass, playerEntityID) || playerEntityID;
@@ -660,237 +641,15 @@ class FrigateCard extends LitElement {
} }
} }
/**
* Get the motion sensor entity for a given camera.
* @param cache The ExtendedEntityCache of entity registry information.
* @param cameraConfig The camera config in question.
* @returns The entity id of the motion sensor or null.
*/
protected _getMotionSensor(
cache: ExtendedEntityCache,
cameraConfig: CameraConfig,
): string | null {
if (cameraConfig.frigate.camera_name) {
return (
cache.getMatch(
(ent) =>
!!ent.unique_id?.match(
new RegExp(
`:motion_sensor:${
cameraConfig.frigate.zone || cameraConfig.frigate.camera_name
}`,
),
),
)?.entity_id ?? null
);
}
return null;
}
/**
* Get the occupancy sensor entity for a given camera.
* @param cache The ExtendedEntityCache of entity registry information.
* @param cameraConfig The camera config in question.
* @returns The entity id of the occupancy sensor or null.
*/
protected _getOccupancySensor(
cache: ExtendedEntityCache,
cameraConfig: CameraConfig,
): string | null {
if (cameraConfig.frigate.camera_name) {
return (
cache.getMatch(
(ent) =>
!!ent.unique_id?.match(
new RegExp(
`:occupancy_sensor:${
cameraConfig.frigate.zone || cameraConfig.frigate.camera_name
}_${cameraConfig.frigate.label || 'all'}`,
),
),
)?.entity_id ?? null
);
}
return null;
}
/**
* Fully load the configured cameras.
*/
protected async _loadCameras(): Promise<void> {
if (!this._hass) {
return;
}
const hasAutoTriggers = (config: CameraConfig): boolean => {
return config.triggers.motion || config.triggers.occupancy;
};
const hasAnyTriggers = (config: CameraConfig): boolean => {
return hasAutoTriggers(config) || !!config.triggers.entities.length;
};
const hasCameraName = (config: CameraConfig): boolean => {
return !!config.frigate?.camera_name;
};
// Loading cameras may require a number of calls to Home Assistant.
//
// - getAllEntities: Required if any camera has auto-triggers (motion or
// occupancy sensors).
// - getExtendedEntity: Per camera entity to autodetect the Frigate camera
// name or to compute auto-triggers (motion or occupancy sensors).
// - getExtendedEntities: Per binary sensor associated with a Frigate config
// entry, to compute auto-triggers (motion or occupancy sensors).
//
// For loading performance these are only called when absolutely needed.
let entityList: EntityList | undefined;
const cache = new ExtendedEntityCache();
const loadedCameras: CameraConfig[] = [];
const addCameraConfig = async (config: CameraConfig, index: number) => {
if (!this._hass) {
return;
}
let entity: ExtendedEntity | null = null;
if (config.camera_entity && (hasAnyTriggers(config) || !hasCameraName(config))) {
try {
entity = await getExtendedEntity(this._hass, config.camera_entity, cache);
} catch (e) {
// Silently ignore errors here, as non-Frigate camera entities may not
// necessarily have a registry entry and otherwise this would cause
// log spam for those cases.
}
}
if (entity && !hasCameraName(config)) {
const resolvedName = this._getFrigateCameraNameFromEntity(entity);
if (resolvedName) {
config.frigate.camera_name = resolvedName;
}
}
if (entity && entityList && hasAnyTriggers(config)) {
if (hasAutoTriggers(config)) {
// Try to find the correct entities for the motion & occupancy sensors.
// We know they are binary_sensors, and that they'll have the same
// config entry ID as the camera. Searching via unique_id ensures this
// search still works if the user renames the entity_id.
const binarySensorEntities = entityList.filter(
(ent) =>
ent.config_entry_id === entity?.config_entry_id &&
!ent.disabled_by &&
ent.entity_id.startsWith('binary_sensor.'),
);
try {
await getExtendedEntities(
this._hass,
binarySensorEntities.map((ent) => ent.entity_id),
cache,
);
} catch (e) {
errorToConsole(e as Error);
}
if (config.triggers.motion) {
const motionEntity = this._getMotionSensor(cache, config);
if (motionEntity) {
config.triggers.entities.push(motionEntity);
}
}
if (config.triggers.occupancy) {
const occupancyEntity = this._getOccupancySensor(cache, config);
if (occupancyEntity) {
config.triggers.entities.push(occupancyEntity);
}
}
}
config.triggers.entities = [...new Set(config.triggers.entities)];
}
loadedCameras[index] = config;
};
let errorFree = true;
const cameras: Map<string, CameraConfig> = new Map();
const configCameras = this._getConfig().cameras;
if (configCameras && Array.isArray(configCameras)) {
if (configCameras.some((config) => hasAutoTriggers(config))) {
try {
entityList = await getAllEntities(this._hass);
} catch (e) {
errorToConsole(e as Error);
}
}
// Load all cameras in parallel, but remember the order they were provided
// (they must be added to the cameraMap in this same order).
await Promise.all(
configCameras.map((configCamera, index) => addCameraConfig(configCamera, index)),
);
loadedCameras.forEach((loadedCamera: CameraConfig) => {
const id = getCameraID(loadedCamera);
if (!id) {
this._setMessageAndUpdate({
message: localize('error.no_camera_id'),
type: 'error',
context: loadedCamera,
});
errorFree = false;
} else if (cameras.has(id)) {
this._setMessageAndUpdate({
message: localize('error.duplicate_camera_id'),
type: 'error',
context: loadedCamera,
});
errorFree = false;
} else {
cameras.set(id, loadedCamera);
}
});
}
if (!cameras.size) {
return this._setMessageAndUpdate({
message: localize('error.no_cameras'),
type: 'error',
});
errorFree = false;
}
if (errorFree) {
this._cameras = cameras;
}
}
/** /**
* Get the camera configuration for the selected camera. * Get the camera configuration for the selected camera.
* @returns The CameraConfig object or null if not found. * @returns The CameraConfig object or null if not found.
*/ */
protected _getSelectedCameraConfig(): CameraConfig | null { protected _getSelectedCameraConfig(): CameraConfig | null {
if (!this._cameras || !this._cameras.size || !this._view?.camera) { if (!this._view || !this._cameraManager) {
return null; return null;
} }
return this._cameras.get(this._view.camera) || null; return this._cameraManager.getCameraConfig(this._view.camera);
}
/**
* Get the Frigate camera name from an entity.
* @returns The Frigate camera name or null if unavailable.
*/
protected _getFrigateCameraNameFromEntity(entity: ExtendedEntity): string | null {
if (entity.unique_id && entity.platform === 'frigate') {
const match = entity.unique_id.match(/:camera:(?<camera>[^:]+)$/);
if (match && match.groups) {
return match.groups['camera'];
}
}
return null;
} }
/** /**
@@ -997,7 +756,7 @@ class FrigateCard extends LitElement {
}; };
this._overriddenConfig = undefined; this._overriddenConfig = undefined;
this._cameras = undefined; this._cameraManager = undefined;
this._view = undefined; this._view = undefined;
this._message = null; this._message = null;
@@ -1039,24 +798,27 @@ class FrigateCard extends LitElement {
if (!args?.view) { if (!args?.view) {
// Load the default view. // Load the default view.
let camera; let cameraID: string | null = null;
if (this._cameras?.size) { if (this._cameraManager) {
if (this._view?.camera && this._getConfig().view.update_cycle_camera) { const cameras = this._cameraManager.getCameras();
const keys = Array.from(this._cameras.keys()); if (cameras) {
const currentIndex = keys.indexOf(this._view.camera); if (this._view?.camera && this._getConfig().view.update_cycle_camera) {
const targetIndex = currentIndex + 1 >= keys.length ? 0 : currentIndex + 1; const keys = Array.from(cameras.keys());
camera = keys[targetIndex]; const currentIndex = keys.indexOf(this._view.camera);
} else { const targetIndex = currentIndex + 1 >= keys.length ? 0 : currentIndex + 1;
// Reset to the default camera. cameraID = keys[targetIndex];
camera = this._cameras.keys().next().value; } else {
// Reset to the default camera.
cameraID = cameras.keys().next().value;
}
} }
} }
if (camera) { if (cameraID) {
changeView( changeView(
new View({ new View({
view: this._getConfig().view.default, view: this._getConfig().view.default,
camera: camera, camera: cameraID,
}), }),
); );
@@ -1103,22 +865,53 @@ class FrigateCard extends LitElement {
}); });
} }
protected async _initializeCameras(): Promise<void> {
if (!this._hass || !this._cameraManager) {
return;
}
try {
await this._cameraManager.initializeCameras(
this._hass,
this._entityRegistryManager,
this._getConfig().cameras,
);
} catch (e: unknown) {
if (e instanceof Error) {
errorToConsole(e);
}
if (e instanceof FrigateCardError) {
this._setMessageAndUpdate({
message: e.message,
type: 'error',
context: e.context,
});
}
}
// Don't reset the message which may be set to an error above. This sets the
// first view using the newly loaded cameras.
this._changeView({ resetMessage: false });
}
/** /**
* Called before each update. * Called before each update.
*/ */
protected willUpdate(changedProps: PropertyValues): void { protected willUpdate(changedProps: PropertyValues): void {
if ( if (
this._cameras &&
this._cardWideConfig && this._cardWideConfig &&
(changedProps.has('_config') || (!this._cameraManager ||
changedProps.has('_cameras') || changedProps.has('_config') ||
changedProps.has('_cardWideConfig')) changedProps.has('_cardWideConfig'))
) { ) {
this._cameraManager = new CameraManager( this._cameraManager = new CameraManager(
new CameraManagerEngineFactory(this._cardWideConfig), new CameraManagerEngineFactory(
this._cameras, this._entityRegistryManager,
this._cardWideConfig,
),
this._cardWideConfig, this._cardWideConfig,
); );
this._initializeCameras().then(() => this.requestUpdate());
} }
if (changedProps.has('_cardWideConfig')) { if (changedProps.has('_cardWideConfig')) {
@@ -1162,7 +955,8 @@ class FrigateCard extends LitElement {
let changedCamera = false; let changedCamera = false;
let triggerChanges = false; let triggerChanges = false;
for (const [camera, config] of this._cameras?.entries() ?? []) { const cameras = this._cameraManager?.getCameras();
for (const [cameraID, config] of cameras?.entries() ?? []) {
const triggerEntities = config.triggers.entities ?? []; const triggerEntities = config.triggers.entities ?? [];
const diffs = getHassDifferences(this._hass, oldHass, triggerEntities, { const diffs = getHassDifferences(this._hass, oldHass, triggerEntities, {
stateOnly: true, stateOnly: true,
@@ -1172,10 +966,10 @@ class FrigateCard extends LitElement {
(entity) => !isTriggeredState(this._hass?.states[entity]), (entity) => !isTriggeredState(this._hass?.states[entity]),
); );
if (shouldTrigger) { if (shouldTrigger) {
this._triggers.set(camera, now); this._triggers.set(cameraID, now);
triggerChanges = true; triggerChanges = true;
} else if (shouldUntrigger && this._triggers.has(camera)) { } else if (shouldUntrigger && this._triggers.has(cameraID)) {
this._triggers.delete(camera); this._triggers.delete(cameraID);
triggerChanges = true; triggerChanges = true;
} }
} }
@@ -1419,7 +1213,7 @@ class FrigateCard extends LitElement {
const cameraEntity = cameraConfig.camera_entity ?? null; const cameraEntity = cameraConfig.camera_entity ?? null;
const media = this._view.queryResults?.getSelectedResult(); const media = this._view.queryResults?.getSelectedResult();
if (this._view.isViewerView() && media && this._cameras) { if (this._view.isViewerView() && media) {
media_content_id = media.getContentID(); media_content_id = media.getContentID();
media_content_type = media.getContentType(); media_content_type = media.getContentType();
title = media.getTitle(); title = media.getTitle();
@@ -1491,9 +1285,9 @@ class FrigateCard extends LitElement {
this._downloadViewerMedia(); this._downloadViewerMedia();
break; break;
case 'frigate_ui': case 'frigate_ui':
const frigate_url = this._getFrigateURLFromContext(); const url = this._getCameraURLFromContext();
if (frigate_url) { if (url) {
window.open(frigate_url); window.open(url);
} }
break; break;
case 'fullscreen': case 'fullscreen':
@@ -1508,22 +1302,15 @@ class FrigateCard extends LitElement {
this._refMenu.value?.toggleMenu(); this._refMenu.value?.toggleMenu();
break; break;
case 'camera_select': case 'camera_select':
const camera = frigateCardAction.camera; const cameraID = frigateCardAction.camera;
if (this._cameras?.has(camera) && this._view) { if (this._cameraManager?.hasCameraID(cameraID) && this._view) {
const targetView = View.selectBestViewForUserSpecified( const viewOnCameraSelect = this._getConfig().view.camera_select;
this._getConfig().view.camera_select === 'current' const targetView =
? this._view.view viewOnCameraSelect === 'current' ? this._view.view : viewOnCameraSelect;
: (this._getConfig().view.camera_select as FrigateCardView), const actualView = this.isViewSupportedByCamera(cameraID, targetView)
); ? targetView
this._changeView({ : FRIGATE_CARD_VIEW_DEFAULT;
view: new View({ this._changeView({ view: new View({ view: actualView, camera: cameraID }) });
view: this._cameras?.get(camera)?.frigate.camera_name
? targetView
: // Fallback to supported views for non-Frigate cameras.
View.selectBestViewForNonFrigateCameras(targetView),
camera: camera,
}),
});
} }
break; break;
case 'media_player': case 'media_player':
@@ -1540,6 +1327,33 @@ class FrigateCard extends LitElement {
} }
} }
public isViewSupportedByCamera(cameraID: string, view: FrigateCardView): boolean {
const capabilities = this._cameraManager?.getCameraCapabilities(cameraID);
switch (view) {
case 'live':
case 'image':
return true;
case 'clip':
case 'clips':
return !!capabilities?.supportsClips;
case 'snapshot':
case 'snapshots':
return !!capabilities?.supportsSnapshots;
case 'recording':
case 'recordings':
return !!capabilities?.supportsRecordings;
case 'timeline':
return !!capabilities?.supportsTimeline;
case 'media':
return (
!!capabilities?.supportsClips ||
!!capabilities?.supportsSnapshots ||
!!capabilities?.supportsRecordings
);
}
return false;
}
/** /**
* Generate diagnostics for issue reports. * Generate diagnostics for issue reports.
*/ */
@@ -1593,18 +1407,16 @@ class FrigateCard extends LitElement {
* Get the Frigate UI URL from context. * Get the Frigate UI URL from context.
* @returns The URL or null if unavailable. * @returns The URL or null if unavailable.
*/ */
protected _getFrigateURLFromContext(): string | null { protected _getCameraURLFromContext(): string | null {
const cameraConfig = this._getSelectedCameraConfig(); const view = this._view;
if (!cameraConfig || !cameraConfig.frigate.url || !this._view) { const selectedCameraID = view?.camera;
return null; const media = view?.queryResults?.getSelectedResult() ?? null;
} return this._hass && view && selectedCameraID
if (!cameraConfig.frigate.camera_name) { ? this._cameraManager?.getCameraURL(selectedCameraID, {
return cameraConfig.frigate.url; ...(media && { media: media }),
} ...(view && { view: view.view }),
if (this._view.isViewerView() || this._view.isGalleryView()) { }) ?? null
return `${cameraConfig.frigate.url}/events?camera=${cameraConfig.frigate.camera_name}`; : null;
}
return `${cameraConfig.frigate.url}/cameras/${cameraConfig.frigate.camera_name}`;
} }
/** /**
@@ -1961,23 +1773,14 @@ class FrigateCard extends LitElement {
> >
${renderMenuAbove ? this._renderMenu() : ''} ${renderMenuAbove ? this._renderMenu() : ''}
<div ${ref(this._refMain)} class="${classMap(mainClasses)}"> <div ${ref(this._refMain)} class="${classMap(mainClasses)}">
${this._cameras === undefined && !this._message ${!this._cameraManager?.isInitialized() && !this._message
? until( ? renderProgressIndicator({ cardWideConfig: this._cardWideConfig })
(async () => {
await this._loadCameras();
// Don't reset messages as errors may have been generated
// during the camera load.
this._changeView({ resetMessage: false });
return this._render();
})(),
renderProgressIndicator({ cardWideConfig: this._cardWideConfig }),
)
: // Always want to call render even if there's a message, to : // Always want to call render even if there's a message, to
// ensure live preload is always present (even if not displayed). // ensure live preload is always present (even if not displayed).
this._render()} this._render()}
${ ${
// Keep message rendering to last to show messages that may have // Keep message rendering to last to show messages that may have been
// been generated during the render. // generated during the render.
this._message ? renderMessage(this._message) : '' this._message ? renderMessage(this._message) : ''
} }
</div> </div>
@@ -2011,7 +1814,7 @@ class FrigateCard extends LitElement {
protected _render(): TemplateResult | void { protected _render(): TemplateResult | void {
const cameraConfig = this._getSelectedCameraConfig(); const cameraConfig = this._getSelectedCameraConfig();
if (!this._hass || !this._view || !cameraConfig || !this._cameras) { if (!this._hass || !this._view || !cameraConfig) {
return html``; return html``;
} }
@@ -2037,7 +1840,6 @@ class FrigateCard extends LitElement {
? html` <frigate-card-gallery ? html` <frigate-card-gallery
.hass=${this._hass} .hass=${this._hass}
.view=${this._view} .view=${this._view}
.cameras=${this._cameras}
.galleryConfig=${this._getConfig().event_gallery} .galleryConfig=${this._getConfig().event_gallery}
.cameraManager=${this._cameraManager} .cameraManager=${this._cameraManager}
.cardWideConfig=${this._cardWideConfig} .cardWideConfig=${this._cardWideConfig}
@@ -2048,7 +1850,6 @@ class FrigateCard extends LitElement {
? html` <frigate-card-viewer ? html` <frigate-card-viewer
.hass=${this._hass} .hass=${this._hass}
.view=${this._view} .view=${this._view}
.cameras=${this._cameras}
.viewerConfig=${this._getConfig().media_viewer} .viewerConfig=${this._getConfig().media_viewer}
.resolvedMediaCache=${this._resolvedMediaCache} .resolvedMediaCache=${this._resolvedMediaCache}
.cameraManager=${this._cameraManager} .cameraManager=${this._cameraManager}
@@ -2060,7 +1861,6 @@ class FrigateCard extends LitElement {
? html` <frigate-card-timeline ? html` <frigate-card-timeline
.hass=${this._hass} .hass=${this._hass}
.view=${this._view} .view=${this._view}
.cameras=${this._cameras}
.timelineConfig=${this._getConfig().timeline} .timelineConfig=${this._getConfig().timeline}
.cameraManager=${this._cameraManager} .cameraManager=${this._cameraManager}
> >
@@ -2082,7 +1882,6 @@ class FrigateCard extends LitElement {
.liveConfig=${this._config.live} .liveConfig=${this._config.live}
.conditionState=${this._conditionState} .conditionState=${this._conditionState}
.liveOverrides=${getOverridesByKey(this._getConfig().overrides, 'live')} .liveOverrides=${getOverridesByKey(this._getConfig().overrides, 'live')}
.cameras=${this._cameras}
.cameraManager=${this._cameraManager} .cameraManager=${this._cameraManager}
.cardWideConfig=${this._cardWideConfig} .cardWideConfig=${this._cardWideConfig}
class="${classMap(liveClasses)}" class="${classMap(liveClasses)}"
+9 -15
View File
@@ -10,7 +10,6 @@ import {
import { customElement, property, state } from 'lit/decorators.js'; import { customElement, property, state } from 'lit/decorators.js';
import galleryStyle from '../scss/gallery.scss'; import galleryStyle from '../scss/gallery.scss';
import { import {
CameraConfig,
CardWideConfig, CardWideConfig,
ExtendedHomeAssistant, ExtendedHomeAssistant,
frigateCardConfigDefaults, frigateCardConfigDefaults,
@@ -24,7 +23,7 @@ import {
} from '../utils/media-to-view.js'; } from '../utils/media-to-view.js';
import { CameraManager, ExtendedMediaQueryResult } from '../camera-manager/manager.js'; import { CameraManager, ExtendedMediaQueryResult } from '../camera-manager/manager.js';
import { View } from '../view/view.js'; import { View } from '../view/view.js';
import { renderProgressIndicator } from './message.js'; import { dispatchMessageEvent, renderProgressIndicator } from './message.js';
import './thumbnail.js'; import './thumbnail.js';
import { THUMBNAIL_DETAILS_WIDTH_MIN } from './thumbnail.js'; import { THUMBNAIL_DETAILS_WIDTH_MIN } from './thumbnail.js';
import { createRef, Ref } from 'lit/directives/ref.js'; import { createRef, Ref } from 'lit/directives/ref.js';
@@ -36,6 +35,7 @@ import { errorToConsole } from '../utils/basic';
import './media-filter'; import './media-filter';
import "./surround-basic"; import "./surround-basic";
import { ViewMedia } from '../view/media'; import { ViewMedia } from '../view/media';
import { localize } from '../localize/localize';
const GALLERY_MEDIA_CHUNK_SIZE = 100; const GALLERY_MEDIA_CHUNK_SIZE = 100;
@@ -55,9 +55,6 @@ export class FrigateCardGallery extends LitElement {
@property({ attribute: false }) @property({ attribute: false })
public galleryConfig?: GalleryConfig; public galleryConfig?: GalleryConfig;
@property({ attribute: false })
public cameras?: Map<string, CameraConfig>;
@property({ attribute: false }) @property({ attribute: false })
public cameraManager?: CameraManager; public cameraManager?: CameraManager;
@@ -72,7 +69,6 @@ export class FrigateCardGallery extends LitElement {
if ( if (
!this.hass || !this.hass ||
!this.view || !this.view ||
!this.cameras ||
!this.view.isGalleryView() || !this.view.isGalleryView() ||
!this.cameraManager !this.cameraManager
) { ) {
@@ -85,7 +81,6 @@ export class FrigateCardGallery extends LitElement {
this, this,
this.hass, this.hass,
this.cameraManager, this.cameraManager,
this.cameras,
this.view, this.view,
); );
} else { } else {
@@ -98,7 +93,6 @@ export class FrigateCardGallery extends LitElement {
this, this,
this.hass, this.hass,
this.cameraManager, this.cameraManager,
this.cameras,
this.view, this.view,
{ {
...(mediaType && { mediaType: mediaType }), ...(mediaType && { mediaType: mediaType }),
@@ -120,7 +114,6 @@ export class FrigateCardGallery extends LitElement {
${this.galleryConfig && this.galleryConfig.controls.filter.mode !== 'none' ${this.galleryConfig && this.galleryConfig.controls.filter.mode !== 'none'
? html` <frigate-card-media-filter ? html` <frigate-card-media-filter
.hass=${this.hass} .hass=${this.hass}
.cameras=${this.cameras}
.cameraManager=${this.cameraManager} .cameraManager=${this.cameraManager}
.view=${this.view} .view=${this.view}
.mediaLimit=${GALLERY_MEDIA_CHUNK_SIZE} .mediaLimit=${GALLERY_MEDIA_CHUNK_SIZE}
@@ -132,7 +125,6 @@ export class FrigateCardGallery extends LitElement {
.hass=${this.hass} .hass=${this.hass}
.view=${this.view} .view=${this.view}
.galleryConfig=${this.galleryConfig} .galleryConfig=${this.galleryConfig}
.cameras=${this.cameras}
.cameraManager=${this.cameraManager} .cameraManager=${this.cameraManager}
.cardWideConfig=${this.cardWideConfig} .cardWideConfig=${this.cardWideConfig}
> >
@@ -166,9 +158,6 @@ export class FrigateCardGalleryCore extends LitElement {
@property({ attribute: false }) @property({ attribute: false })
public galleryConfig?: GalleryConfig; public galleryConfig?: GalleryConfig;
@property({ attribute: false })
public cameras?: Map<string, CameraConfig>;
@property({ attribute: false }) @property({ attribute: false })
public cameraManager?: CameraManager; public cameraManager?: CameraManager;
@@ -330,12 +319,17 @@ export class FrigateCardGalleryCore extends LitElement {
!this._media || !this._media ||
!this.hass || !this.hass ||
!this.view || !this.view ||
!this.view.isGalleryView() || !this.view.isGalleryView()
!this.cameras
) { ) {
return html``; return html``;
} }
if ((this.view?.queryResults?.getResultsCount() ?? 0) === 0) {
return dispatchMessageEvent(this, localize('common.no_media'), 'info', {
icon: 'mdi:multimedia',
});
}
return html` return html`
${this._media.map( ${this._media.map(
(media, index) => (media, index) =>
+20 -23
View File
@@ -104,9 +104,6 @@ export class FrigateCardLive extends LitElement {
@property({ attribute: false }) @property({ attribute: false })
public view?: Readonly<View>; public view?: Readonly<View>;
@property({ attribute: false })
public cameras?: Map<string, CameraConfig>;
@property({ attribute: false }) @property({ attribute: false })
public liveConfig?: LiveConfig; public liveConfig?: LiveConfig;
@@ -200,7 +197,7 @@ export class FrigateCardLive extends LitElement {
* @returns A rendered template. * @returns A rendered template.
*/ */
protected render(): TemplateResult | void { protected render(): TemplateResult | void {
if (!this.hass || !this.liveConfig || !this.cameras || !this.view) { if (!this.hass || !this.liveConfig || !this.cameraManager || !this.view) {
return; return;
} }
@@ -229,7 +226,6 @@ export class FrigateCardLive extends LitElement {
.fetchMedia=${config.controls.thumbnails.media} .fetchMedia=${config.controls.thumbnails.media}
.thumbnailConfig=${config.controls.thumbnails} .thumbnailConfig=${config.controls.thumbnails}
.timelineConfig=${config.controls.timeline} .timelineConfig=${config.controls.timeline}
.cameras=${this.cameras}
.cameraManager=${this.cameraManager} .cameraManager=${this.cameraManager}
.inBackground=${this._inBackground} .inBackground=${this._inBackground}
@frigate-card:message=${(ev: CustomEvent<Message>) => { @frigate-card:message=${(ev: CustomEvent<Message>) => {
@@ -254,7 +250,6 @@ export class FrigateCardLive extends LitElement {
<frigate-card-live-carousel <frigate-card-live-carousel
.hass=${this.hass} .hass=${this.hass}
.view=${this.view} .view=${this.view}
.cameras=${this.cameras}
.liveConfig=${this.liveConfig} .liveConfig=${this.liveConfig}
.inBackground=${this._inBackground} .inBackground=${this._inBackground}
.conditionState=${this.conditionState} .conditionState=${this.conditionState}
@@ -286,9 +281,6 @@ export class FrigateCardLiveCarousel extends LitElement {
@property({ attribute: false }) @property({ attribute: false })
public view?: Readonly<View>; public view?: Readonly<View>;
@property({ attribute: false })
public cameras?: Map<string, CameraConfig>;
@property({ attribute: false }) @property({ attribute: false })
public liveConfig?: LiveConfig; public liveConfig?: LiveConfig;
@@ -345,10 +337,11 @@ export class FrigateCardLiveCarousel extends LitElement {
} }
protected _getSelectedCameraIndex(): number { protected _getSelectedCameraIndex(): number {
if (!this.cameras || !this.view) { const cameraIDs = this.cameraManager?.getCameraIDs();
if (!cameraIDs || !this.view) {
return 0; return 0;
} }
return Math.max(0, Array.from(this.cameras.keys()).indexOf(this.view.camera)); return Math.max(0, Array.from(cameraIDs).indexOf(this.view.camera));
} }
/** /**
@@ -367,9 +360,10 @@ export class FrigateCardLiveCarousel extends LitElement {
* @returns A list of EmblaOptionsTypes. * @returns A list of EmblaOptionsTypes.
*/ */
protected _getPlugins(): EmblaCarouselPlugins { protected _getPlugins(): EmblaCarouselPlugins {
const cameras = this.cameraManager?.getCameraIDs();
return [ return [
// Only enable wheel plugin if there is more than one camera. // Only enable wheel plugin if there is more than one camera.
...(this.cameras && this.cameras.size > 1 ...(cameras && cameras.size > 1
? [ ? [
WheelGesturesPlugin({ WheelGesturesPlugin({
// Whether the carousel is vertical or horizontal, interpret y-axis wheel // Whether the carousel is vertical or horizontal, interpret y-axis wheel
@@ -424,14 +418,15 @@ export class FrigateCardLiveCarousel extends LitElement {
* name to slide number. * name to slide number.
*/ */
protected _getSlides(): [TemplateResult[], Record<string, number>] { protected _getSlides(): [TemplateResult[], Record<string, number>] {
if (!this.cameras) { const cameras = this.cameraManager?.getCameras();
if (!cameras) {
return [[], {}]; return [[], {}];
} }
const slides: TemplateResult[] = []; const slides: TemplateResult[] = [];
const cameraToSlide: Record<string, number> = {}; const cameraToSlide: Record<string, number> = {};
for (const [camera, cameraConfig] of this.cameras) { for (const [camera, cameraConfig] of cameras) {
const slide = this._renderLive(camera, cameraConfig, slides.length); const slide = this._renderLive(camera, cameraConfig, slides.length);
if (slide) { if (slide) {
cameraToSlide[camera] = slides.length; cameraToSlide[camera] = slides.length;
@@ -445,8 +440,9 @@ export class FrigateCardLiveCarousel extends LitElement {
* Handle the user selecting a new slide in the carousel. * Handle the user selecting a new slide in the carousel.
*/ */
protected _setViewHandler(ev: CustomEvent<CarouselSelect>): void { protected _setViewHandler(ev: CustomEvent<CarouselSelect>): void {
if (this.cameras && ev.detail.index !== this._getSelectedCameraIndex()) { const cameras = this.cameraManager?.getCameras();
this._setViewCameraID(Array.from(this.cameras.keys())[ev.detail.index]); if (cameras && ev.detail.index !== this._getSelectedCameraIndex()) {
this._setViewCameraID(Array.from(cameras.keys())[ev.detail.index]);
} }
} }
@@ -534,19 +530,20 @@ export class FrigateCardLiveCarousel extends LitElement {
} }
protected _getCameraIDsOfNeighbors(): [string | null, string | null] { protected _getCameraIDsOfNeighbors(): [string | null, string | null] {
if (!this.cameras || !this.view || !this.hass) { const cameras = this.cameraManager?.getCameras();
if (!cameras || !this.view || !this.hass) {
return [null, null]; return [null, null];
} }
const keys = Array.from(this.cameras.keys()); const keys = Array.from(cameras.keys());
const currentIndex = keys.indexOf(this.view.camera); const currentIndex = keys.indexOf(this.view.camera);
if (currentIndex < 0 || this.cameras.size <= 1) { if (currentIndex < 0 || cameras.size <= 1) {
return [null, null]; return [null, null];
} }
return [ return [
keys[currentIndex > 0 ? currentIndex - 1 : this.cameras.size - 1], keys[currentIndex > 0 ? currentIndex - 1 : cameras.size - 1],
keys[currentIndex + 1 < this.cameras.size ? currentIndex + 1 : 0], keys[currentIndex + 1 < cameras.size ? currentIndex + 1 : 0],
]; ];
} }
@@ -602,11 +599,11 @@ export class FrigateCardLiveCarousel extends LitElement {
<frigate-card-media-carousel <frigate-card-media-carousel
${ref(this._refMediaCarousel)} ${ref(this._refMediaCarousel)}
.carouselOptions=${guard( .carouselOptions=${guard(
[this.cameras, this.liveConfig], [this.cameraManager, this.liveConfig],
this._getOptions.bind(this), this._getOptions.bind(this),
)} )}
.carouselPlugins=${guard( .carouselPlugins=${guard(
[this.cameras, this.liveConfig], [this.cameraManager, this.liveConfig],
this._getPlugins.bind(this), this._getPlugins.bind(this),
) as EmblaCarouselPlugins} ) as EmblaCarouselPlugins}
.label="${cameraMetadataCurrent ? `${localize('common.live')}: ${cameraMetadataCurrent.title}` : ''}" .label="${cameraMetadataCurrent ? `${localize('common.live')}: ${cameraMetadataCurrent.title}` : ''}"
+28 -41
View File
@@ -13,7 +13,6 @@ import { createRef, ref, Ref } from 'lit/directives/ref.js';
import { DateRange } from '../camera-manager/range'; import { DateRange } from '../camera-manager/range';
import { localize } from '../localize/localize'; import { localize } from '../localize/localize';
import mediaFilterStyle from '../scss/media-filter.scss'; import mediaFilterStyle from '../scss/media-filter.scss';
import { CameraConfig } from '../types';
import { createViewForEvents, createViewForRecordings } from '../utils/media-to-view.js'; import { createViewForEvents, createViewForRecordings } from '../utils/media-to-view.js';
import { errorToConsole, formatDate, prettifyTitle } from '../utils/basic'; import { errorToConsole, formatDate, prettifyTitle } from '../utils/basic';
import { ScopedRegistryHost } from '@lit-labs/scoped-registry-mixin'; import { ScopedRegistryHost } from '@lit-labs/scoped-registry-mixin';
@@ -77,9 +76,6 @@ class FrigateCardMediaFilter extends ScopedRegistryHost(LitElement) {
@property({ attribute: false }) @property({ attribute: false })
public hass?: HomeAssistant; public hass?: HomeAssistant;
@property({ attribute: false })
public cameras?: Map<string, CameraConfig>;
@property({ attribute: false }) @property({ attribute: false })
public cameraManager?: CameraManager; public cameraManager?: CameraManager;
@@ -173,7 +169,8 @@ class FrigateCardMediaFilter extends ScopedRegistryHost(LitElement) {
// eslint-disable-next-line @typescript-eslint/no-unused-vars // eslint-disable-next-line @typescript-eslint/no-unused-vars
_ev: CustomEvent<{ value: unknown }>, _ev: CustomEvent<{ value: unknown }>,
): Promise<void> { ): Promise<void> {
if (!this.hass || !this.cameras || !this.cameraManager || !this.view) { const cameras = this.cameraManager?.getCameras();
if (!this.hass || !cameras || !this.cameraManager || !this.view) {
return; return;
} }
@@ -187,7 +184,7 @@ class FrigateCardMediaFilter extends ScopedRegistryHost(LitElement) {
}; };
const cameraIDs = const cameraIDs =
getArrayValueAsSet(this._refCamera.value?.value) ?? new Set(this.cameras.keys()); getArrayValueAsSet(this._refCamera.value?.value) ?? new Set(cameras.keys());
const mediaType = this._refMediaType.value?.value as const mediaType = this._refMediaType.value?.value as
| MediaFilterMediaType | MediaFilterMediaType
| undefined; | undefined;
@@ -229,20 +226,13 @@ class FrigateCardMediaFilter extends ScopedRegistryHost(LitElement) {
]; ];
( (
await createViewForEvents( await createViewForEvents(this, this.hass, this.cameraManager, this.view, {
this, query: new EventMediaQueries(queries),
this.hass,
this.cameraManager,
this.cameras,
this.view,
{
query: new EventMediaQueries(queries),
// See 'A note on views' above for these two arguments. // See 'A note on views' above for these two arguments.
...(cameraIDs.size === 1 && { targetCameraID: [...cameraIDs][0] }), ...(cameraIDs.size === 1 && { targetCameraID: [...cameraIDs][0] }),
targetView: mediaType === MediaFilterMediaType.Clips ? 'clips' : 'snapshots', targetView: mediaType === MediaFilterMediaType.Clips ? 'clips' : 'snapshots',
}, })
)
)?.dispatchChangeEvent(this); )?.dispatchChangeEvent(this);
} else if (mediaType === MediaFilterMediaType.Recordings) { } else if (mediaType === MediaFilterMediaType.Recordings) {
const query: RecordingQuery = { const query: RecordingQuery = {
@@ -252,32 +242,28 @@ class FrigateCardMediaFilter extends ScopedRegistryHost(LitElement) {
}; };
( (
await createViewForRecordings( await createViewForRecordings(this, this.hass, this.cameraManager, this.view, {
this, query: new RecordingMediaQueries([query]),
this.hass,
this.cameraManager,
this.cameras,
this.view,
{
query: new RecordingMediaQueries([query]),
// See 'A note on views' above for these two arguments. // See 'A note on views' above for these two arguments.
...(cameraIDs.size === 1 && { targetCameraID: [...cameraIDs][0] }), ...(cameraIDs.size === 1 && { targetCameraID: [...cameraIDs][0] }),
targetView: 'recordings', targetView: 'recordings',
}, })
)
)?.dispatchChangeEvent(this); )?.dispatchChangeEvent(this);
} }
} }
protected willUpdate(changedProps: PropertyValues): void { protected willUpdate(changedProps: PropertyValues): void {
if (changedProps.has('cameras') && this.cameras) { if (changedProps.has('cameraManager')) {
this._cameraOptions = Array.from(this.cameras.keys()).map((cameraID) => ({ const cameras = this.cameraManager?.getCameras();
value: cameraID, if (cameras) {
label: this.hass this._cameraOptions = Array.from(cameras.keys()).map((cameraID) => ({
? this.cameraManager?.getCameraMetadata(this.hass, cameraID)?.title ?? '' value: cameraID,
: '', label: this.hass
})); ? this.cameraManager?.getCameraMetadata(this.hass, cameraID)?.title ?? ''
: '',
}));
}
} }
if (changedProps.has('cameraManager') && this.hass && this.cameraManager) { if (changedProps.has('cameraManager') && this.hass && this.cameraManager) {
@@ -321,7 +307,8 @@ class FrigateCardMediaFilter extends ScopedRegistryHost(LitElement) {
protected _getDefaultsFromView(): MediaFilterCoreDefaults | null { protected _getDefaultsFromView(): MediaFilterCoreDefaults | null {
const queries = this.view?.query?.getQueries(); const queries = this.view?.query?.getQueries();
if (!this.view || !queries) { const cameras = this.cameraManager?.getCameras();
if (!this.view || !queries || !cameras) {
return null; return null;
} }
@@ -337,7 +324,7 @@ class FrigateCardMediaFilter extends ScopedRegistryHost(LitElement) {
); );
// Special note: If all cameras are selected, this is the same as no // Special note: If all cameras are selected, this is the same as no
// selector at all. // selector at all.
if (cameraIDSets.length === 1 && queries[0].cameraIDs.size !== this.cameras?.size) { if (cameraIDSets.length === 1 && queries[0].cameraIDs.size !== cameras.size) {
cameraIDs = [...queries[0].cameraIDs]; cameraIDs = [...queries[0].cameraIDs];
} }
+4 -10
View File
@@ -9,7 +9,6 @@ import {
import { customElement, property } from 'lit/decorators.js'; import { customElement, property } from 'lit/decorators.js';
import surroundStyle from '../scss/surround.scss'; import surroundStyle from '../scss/surround.scss';
import { import {
CameraConfig,
ClipsOrSnapshotsOrAll, ClipsOrSnapshotsOrAll,
ExtendedHomeAssistant, ExtendedHomeAssistant,
MiniTimelineControlConfig, MiniTimelineControlConfig,
@@ -55,9 +54,6 @@ export class FrigateCardSurround extends LitElement {
@property({ attribute: false, hasChanged: contentsChanged }) @property({ attribute: false, hasChanged: contentsChanged })
public fetchMedia?: ClipsOrSnapshotsOrAll; public fetchMedia?: ClipsOrSnapshotsOrAll;
@property({ attribute: false })
public cameras?: Map<string, CameraConfig>;
@property({ attribute: false }) @property({ attribute: false })
public cameraManager?: CameraManager; public cameraManager?: CameraManager;
@@ -71,7 +67,6 @@ export class FrigateCardSurround extends LitElement {
*/ */
protected async _fetchMedia(): Promise<void> { protected async _fetchMedia(): Promise<void> {
if ( if (
!this.cameras ||
!this.cameraManager || !this.cameraManager ||
!this.fetchMedia || !this.fetchMedia ||
this.inBackground || this.inBackground ||
@@ -88,7 +83,6 @@ export class FrigateCardSurround extends LitElement {
this, this,
this.hass, this.hass,
this.cameraManager, this.cameraManager,
this.cameras,
this.view, this.view,
{ {
targetView: this.view.view, targetView: this.view.view,
@@ -138,11 +132,12 @@ export class FrigateCardSurround extends LitElement {
} }
protected _getCameraIDsForTimeline(): Set<string> | null { protected _getCameraIDsForTimeline(): Set<string> | null {
if (!this.view || !this.cameras) { const cameras = this.cameraManager?.getCameras();
if (!this.view || !cameras) {
return null; return null;
} }
if (this.view?.is('live')) { if (this.view?.is('live')) {
return getAllDependentCameras(this.cameras, this.view.camera); return getAllDependentCameras(cameras, this.view.camera);
} }
if (this.view.isViewerView()) { if (this.view.isViewerView()) {
return new Set( return new Set(
@@ -160,7 +155,7 @@ export class FrigateCardSurround extends LitElement {
* @returns A rendered template. * @returns A rendered template.
*/ */
protected render(): TemplateResult | void { protected render(): TemplateResult | void {
if (!this.hass || !this.view || !this.thumbnailConfig || !this.cameras) { if (!this.hass || !this.view || !this.thumbnailConfig) {
return; return;
} }
@@ -220,7 +215,6 @@ export class FrigateCardSurround extends LitElement {
slot=${this.timelineConfig.mode} slot=${this.timelineConfig.mode}
.hass=${this.hass} .hass=${this.hass}
.view=${this.view} .view=${this.view}
.cameras=${this.cameras}
.cameraIDs=${this._cameraIDsForTimeline} .cameraIDs=${this._cameraIDsForTimeline}
.mini=${true} .mini=${true}
.timelineConfig=${this.timelineConfig} .timelineConfig=${this.timelineConfig}
-1
View File
@@ -13,7 +13,6 @@ import { classMap } from 'lit/directives/class-map.js';
import { createRef, ref, Ref } from 'lit/directives/ref.js'; import { createRef, ref, Ref } from 'lit/directives/ref.js';
import thumbnailCarouselStyle from '../scss/thumbnail-carousel.scss'; import thumbnailCarouselStyle from '../scss/thumbnail-carousel.scss';
import { import {
CameraConfig,
ExtendedHomeAssistant, ExtendedHomeAssistant,
ThumbnailsControlConfig, ThumbnailsControlConfig,
} from '../types.js'; } from '../types.js';
+27 -39
View File
@@ -26,7 +26,6 @@ import {
TimelineOptionsCluster, TimelineOptionsCluster,
TimelineWindow, TimelineWindow,
} from 'vis-timeline/esnext'; } from 'vis-timeline/esnext';
import { CAMERA_BIRDSEYE } from '../const';
import { localize } from '../localize/localize'; import { localize } from '../localize/localize';
import timelineCoreStyle from '../scss/timeline-core.scss'; import timelineCoreStyle from '../scss/timeline-core.scss';
import { import {
@@ -165,9 +164,6 @@ export class FrigateCardTimelineCore extends LitElement {
@property({ attribute: false }) @property({ attribute: false })
public view?: Readonly<View>; public view?: Readonly<View>;
@property({ attribute: false })
public cameras?: Map<string, CameraConfig>;
@property({ attribute: false, hasChanged: contentsChanged }) @property({ attribute: false, hasChanged: contentsChanged })
public timelineConfig?: TimelineCoreConfig; public timelineConfig?: TimelineCoreConfig;
@@ -240,11 +236,12 @@ export class FrigateCardTimelineCore extends LitElement {
protected _handleThumbnailDataRequest(request: ThumbnailDataRequestEvent): void { protected _handleThumbnailDataRequest(request: ThumbnailDataRequestEvent): void {
const item = request.detail.item; const item = request.detail.item;
const media = this._timelineSource?.dataset.get(item)?.media; const media = this._timelineSource?.dataset.get(item)?.media;
const cameraConfig = media
? this.cameraManager?.getCameraConfig(media.getCameraID()) ?? undefined
: undefined;
request.detail.hass = this.hass; request.detail.hass = this.hass;
request.detail.cameraConfig = media request.detail.cameraConfig = cameraConfig;
? this.cameras?.get(media.getCameraID())
: undefined;
request.detail.cameraManager = this.cameraManager; request.detail.cameraManager = this.cameraManager;
request.detail.media = media; request.detail.media = media;
request.detail.view = this.view; request.detail.view = this.view;
@@ -255,13 +252,13 @@ export class FrigateCardTimelineCore extends LitElement {
* @returns A rendered template. * @returns A rendered template.
*/ */
protected render(): TemplateResult | void { protected render(): TemplateResult | void {
if (!this.hass || !this.view || !this.timelineConfig) { const cameraIDs = this._getTimelineCameraIDs();
if (!this.hass || !this.view || !this.timelineConfig || !cameraIDs) {
return; return;
} }
const capabilities = this.cameraManager?.getAggregateCameraCapabilities(
this._getTimelineCameraIDs(),
);
const capabilities = this.cameraManager?.getAggregateCameraCapabilities(cameraIDs);
return html` ${capabilities?.supportsTimeline return html` ${capabilities?.supportsTimeline
? html` <div ? html` <div
@frigate-card:timeline:thumbnail-data-request=${this._handleThumbnailDataRequest.bind( @frigate-card:timeline:thumbnail-data-request=${this._handleThumbnailDataRequest.bind(
@@ -292,7 +289,7 @@ export class FrigateCardTimelineCore extends LitElement {
* Get all the keys of the cameras in scope for this timeline. * Get all the keys of the cameras in scope for this timeline.
* @returns A set of camera ids (may be empty). * @returns A set of camera ids (may be empty).
*/ */
protected _getTimelineCameraIDs(): Set<string> { protected _getTimelineCameraIDs(): Set<string> | null {
return this.cameraIDs ?? this._getAllCameraIDs(); return this.cameraIDs ?? this._getAllCameraIDs();
} }
@@ -300,8 +297,8 @@ export class FrigateCardTimelineCore extends LitElement {
* Get all the keys of all cameras. * Get all the keys of all cameras.
* @returns A set of camera ids (may be empty). * @returns A set of camera ids (may be empty).
*/ */
protected _getAllCameraIDs(): Set<string> { protected _getAllCameraIDs(): Set<string> | null {
return new Set(this.cameras?.keys()); return this.cameraManager?.getCameraIDs() ?? null;
} }
/** /**
@@ -395,6 +392,7 @@ export class FrigateCardTimelineCore extends LitElement {
): Promise<void> { ): Promise<void> {
const results = this.view?.queryResults; const results = this.view?.queryResults;
const media = results?.getResults(); const media = results?.getResults();
const cameraIDs = this._getTimelineCameraIDs();
if ( if (
!media || !media ||
!results || !results ||
@@ -402,7 +400,7 @@ export class FrigateCardTimelineCore extends LitElement {
!this.view || !this.view ||
!this.hass || !this.hass ||
!this.cameraManager || !this.cameraManager ||
!this.cameraManager || !cameraIDs ||
// Skip range changes that do not have hammerjs pan directions associated // Skip range changes that do not have hammerjs pan directions associated
// with them, as these outliers cause media matching issues below. // with them, as these outliers cause media matching issues below.
!properties.event.additionalEvent !properties.event.additionalEvent
@@ -420,7 +418,7 @@ export class FrigateCardTimelineCore extends LitElement {
findClosestMediaIndex( findClosestMediaIndex(
media, media,
targetTime, targetTime,
this._getTimelineCameraIDs(), cameraIDs,
properties.event.additionalEvent === 'panright' ? 'end' : 'start', properties.event.additionalEvent === 'panright' ? 'end' : 'start',
), ),
); );
@@ -462,13 +460,14 @@ export class FrigateCardTimelineCore extends LitElement {
stopEventFromActivatingCardWideActions(properties.event); stopEventFromActivatingCardWideActions(properties.event);
} }
const timelineCameraIDs = this._getTimelineCameraIDs();
if ( if (
this._ignoreClick || this._ignoreClick ||
!this.hass || !this.hass ||
!this._timeline || !this._timeline ||
!this.cameras ||
!this.view || !this.view ||
!this.cameraManager || !this.cameraManager ||
!timelineCameraIDs ||
!properties.what !properties.what
) { ) {
return; return;
@@ -484,7 +483,6 @@ export class FrigateCardTimelineCore extends LitElement {
this, this,
this.hass, this.hass,
this.cameraManager, this.cameraManager,
this.cameras,
this.view, this.view,
{ {
targetTime: targetTime:
@@ -501,10 +499,9 @@ export class FrigateCardTimelineCore extends LitElement {
this, this,
this.hass, this.hass,
this.cameraManager, this.cameraManager,
this.cameras,
this.view, this.view,
{ {
cameraIDs: this._getAllCameraIDs(), cameraIDs: timelineCameraIDs,
start: startOfHour(properties.time), start: startOfHour(properties.time),
end: endOfHour(properties.time), end: endOfHour(properties.time),
targetTime: properties.time, targetTime: properties.time,
@@ -514,9 +511,7 @@ export class FrigateCardTimelineCore extends LitElement {
const newResults = this.view.queryResults const newResults = this.view.queryResults
?.clone() ?.clone()
.resetSelectedResult() .resetSelectedResult()
.selectResultIfFound( .selectResultIfFound((media) => media.getID() === properties.item);
(media) => !!this.cameras && media.getID() === properties.item,
);
if (!newResults || !newResults.hasSelectedResult()) { if (!newResults || !newResults.hasSelectedResult()) {
// This can happen if this is a recording query (with recorded hours) // This can happen if this is a recording query (with recorded hours)
@@ -588,7 +583,7 @@ export class FrigateCardTimelineCore extends LitElement {
} }
this._removeTargetBar(); this._removeTargetBar();
if (!this.hass || !this.cameras) { if (!this.hass) {
return; return;
} }
@@ -646,14 +641,13 @@ export class FrigateCardTimelineCore extends LitElement {
selectedItem?: IdType; selectedItem?: IdType;
}, },
): Promise<View | null> { ): Promise<View | null> {
if (!this.hass || !this.cameraManager || !this.cameras || !this.view || !query) { if (!this.hass || !this.cameraManager || !this.view || !query) {
return null; return null;
} }
const view = await createViewForEvents( const view = await createViewForEvents(
this, this,
this.hass, this.hass,
this.cameraManager, this.cameraManager,
this.cameras,
this.view, this.view,
{ {
query: query, query: query,
@@ -666,7 +660,7 @@ export class FrigateCardTimelineCore extends LitElement {
} }
if (options?.selectedItem) { if (options?.selectedItem) {
view.queryResults?.selectResultIfFound( view.queryResults?.selectResultIfFound(
(media) => !!this.cameras && media.getID() === options.selectedItem, (media) => media.getID() === options.selectedItem,
); );
} else { } else {
// If not asked to select a new item, persist the currently selected item // If not asked to select a new item, persist the currently selected item
@@ -687,10 +681,8 @@ export class FrigateCardTimelineCore extends LitElement {
*/ */
protected _getGroups(): DataGroupCollectionType { protected _getGroups(): DataGroupCollectionType {
const groups: FrigateCardGroupData[] = []; const groups: FrigateCardGroupData[] = [];
(this._getTimelineCameraIDs() ?? []).forEach((cameraID) => {
this._getTimelineCameraIDs().forEach((cameraID) => { if (!this.hass || !this.cameraManager) {
const cameraConfig = this.cameras?.get(cameraID);
if (!this.hass || !cameraConfig || !this.cameraManager) {
return; return;
} }
const cameraMetadata = this.cameraManager.getCameraMetadata(this.hass, cameraID); const cameraMetadata = this.cameraManager.getCameraMetadata(this.hass, cameraID);
@@ -806,10 +798,6 @@ export class FrigateCardTimelineCore extends LitElement {
maxItems: this.timelineConfig.clustering_threshold, maxItems: this.timelineConfig.clustering_threshold,
clusterCriteria: (first: TimelineItem, second: TimelineItem): boolean => { clusterCriteria: (first: TimelineItem, second: TimelineItem): boolean => {
if (!this.cameras) {
return false;
}
const media = this.view?.queryResults?.getSelectedResult(); const media = this.view?.queryResults?.getSelectedResult();
const selectedId = media?.getID(); const selectedId = media?.getID();
const firstMedia = (<FrigateCardTimelineItem>first).media; const firstMedia = (<FrigateCardTimelineItem>first).media;
@@ -866,7 +854,7 @@ export class FrigateCardTimelineCore extends LitElement {
*/ */
// eslint-disable-next-line @typescript-eslint/no-unused-vars // eslint-disable-next-line @typescript-eslint/no-unused-vars
protected shouldUpdate(_changedProps: PropertyValues): boolean { protected shouldUpdate(_changedProps: PropertyValues): boolean {
return !!this.hass && !!this.cameras && this.cameras.size > 0; return !!this.hass && !!this.cameraManager;
} }
/** /**
@@ -875,7 +863,6 @@ export class FrigateCardTimelineCore extends LitElement {
protected async _updateTimelineFromView(): Promise<void> { protected async _updateTimelineFromView(): Promise<void> {
if ( if (
!this.hass || !this.hass ||
!this.cameras ||
!this.view || !this.view ||
!this.timelineConfig || !this.timelineConfig ||
!this._timelineSource || !this._timelineSource ||
@@ -1040,10 +1027,11 @@ export class FrigateCardTimelineCore extends LitElement {
changedProps.has('timelineConfig') || changedProps.has('timelineConfig') ||
changedProps.has('cameraIDs') changedProps.has('cameraIDs')
) { ) {
if (this.cameraManager && this.cameras && this.timelineConfig) { const cameraIDs = this._getTimelineCameraIDs();
if (cameraIDs && this.cameraManager && this.timelineConfig) {
this._timelineSource = new TimelineDataSource( this._timelineSource = new TimelineDataSource(
this.cameraManager, this.cameraManager,
this._getTimelineCameraIDs(), cameraIDs,
this.timelineConfig.media, this.timelineConfig.media,
); );
} else { } else {
+1 -6
View File
@@ -1,7 +1,7 @@
import { CSSResultGroup, html, LitElement, TemplateResult, unsafeCSS } from 'lit'; import { CSSResultGroup, html, LitElement, TemplateResult, unsafeCSS } from 'lit';
import { customElement, property } from 'lit/decorators.js'; import { customElement, property } from 'lit/decorators.js';
import timelineStyle from '../scss/timeline.scss'; import timelineStyle from '../scss/timeline.scss';
import { CameraConfig, ExtendedHomeAssistant, TimelineConfig } from '../types'; import { ExtendedHomeAssistant, TimelineConfig } from '../types';
import { CameraManager } from '../camera-manager/manager'; import { CameraManager } from '../camera-manager/manager';
import { View } from '../view/view'; import { View } from '../view/view';
import './surround.js'; import './surround.js';
@@ -20,9 +20,6 @@ export class FrigateCardTimeline extends LitElement {
@property({ attribute: false }) @property({ attribute: false })
public view?: Readonly<View>; public view?: Readonly<View>;
@property({ attribute: false })
public cameras?: Map<string, CameraConfig>;
@property({ attribute: false }) @property({ attribute: false })
public timelineConfig?: TimelineConfig; public timelineConfig?: TimelineConfig;
@@ -43,12 +40,10 @@ export class FrigateCardTimeline extends LitElement {
.view=${this.view} .view=${this.view}
.thumbnailConfig=${this.timelineConfig.controls.thumbnails} .thumbnailConfig=${this.timelineConfig.controls.thumbnails}
.cameraManager=${this.cameraManager} .cameraManager=${this.cameraManager}
.cameras=${this.cameras}
> >
<frigate-card-timeline-core <frigate-card-timeline-core
.hass=${this.hass} .hass=${this.hass}
.view=${this.view} .view=${this.view}
.cameras=${this.cameras}
.timelineConfig=${this.timelineConfig} .timelineConfig=${this.timelineConfig}
.thumbnailDetails=${this.timelineConfig.controls.thumbnails.show_details} .thumbnailDetails=${this.timelineConfig.controls.thumbnails.show_details}
.thumbnailSize=${this.timelineConfig.controls.thumbnails.size} .thumbnailSize=${this.timelineConfig.controls.thumbnails.size}
+7 -21
View File
@@ -16,7 +16,6 @@ import { dispatchMessageEvent, renderProgressIndicator } from '../components/mes
import viewerStyle from '../scss/viewer.scss'; import viewerStyle from '../scss/viewer.scss';
import viewerCarouselStyle from '../scss/viewer-carousel.scss'; import viewerCarouselStyle from '../scss/viewer-carousel.scss';
import { import {
CameraConfig,
CardWideConfig, CardWideConfig,
ExtendedHomeAssistant, ExtendedHomeAssistant,
frigateCardConfigDefaults, frigateCardConfigDefaults,
@@ -78,9 +77,6 @@ export class FrigateCardViewer extends LitElement {
@property({ attribute: false }) @property({ attribute: false })
public viewerConfig?: ViewerConfig; public viewerConfig?: ViewerConfig;
@property({ attribute: false })
public cameras?: Map<string, CameraConfig>;
@property({ attribute: false }) @property({ attribute: false })
public resolvedMediaCache?: ResolvedMediaCache; public resolvedMediaCache?: ResolvedMediaCache;
@@ -98,7 +94,6 @@ export class FrigateCardViewer extends LitElement {
if ( if (
!this.hass || !this.hass ||
!this.view || !this.view ||
!this.cameras ||
!this.viewerConfig || !this.viewerConfig ||
!this.cameraManager !this.cameraManager
) { ) {
@@ -120,7 +115,6 @@ export class FrigateCardViewer extends LitElement {
this, this,
this.hass, this.hass,
this.cameraManager, this.cameraManager,
this.cameras,
this.view, this.view,
{ {
targetView: 'recording', targetView: 'recording',
@@ -131,7 +125,6 @@ export class FrigateCardViewer extends LitElement {
this, this,
this.hass, this.hass,
this.cameraManager, this.cameraManager,
this.cameras,
this.view, this.view,
{ {
targetView: 'media', targetView: 'media',
@@ -148,12 +141,10 @@ export class FrigateCardViewer extends LitElement {
.thumbnailConfig=${this.viewerConfig.controls.thumbnails} .thumbnailConfig=${this.viewerConfig.controls.thumbnails}
.timelineConfig=${this.viewerConfig.controls.timeline} .timelineConfig=${this.viewerConfig.controls.timeline}
.cameraManager=${this.cameraManager} .cameraManager=${this.cameraManager}
.cameras=${this.cameras}
> >
<frigate-card-viewer-carousel <frigate-card-viewer-carousel
.hass=${this.hass} .hass=${this.hass}
.view=${this.view} .view=${this.view}
.cameras=${this.cameras}
.viewerConfig=${this.viewerConfig} .viewerConfig=${this.viewerConfig}
.resolvedMediaCache=${this.resolvedMediaCache} .resolvedMediaCache=${this.resolvedMediaCache}
.cameraManager=${this.cameraManager} .cameraManager=${this.cameraManager}
@@ -195,9 +186,6 @@ export class FrigateCardViewerCarousel extends LitElement {
@property({ attribute: false }) @property({ attribute: false })
public cardWideConfig?: CardWideConfig; public cardWideConfig?: CardWideConfig;
@property({ attribute: false })
public cameras?: Map<string, CameraConfig>;
@property({ attribute: false }) @property({ attribute: false })
public cameraManager?: CameraManager; public cameraManager?: CameraManager;
@@ -205,19 +193,17 @@ export class FrigateCardViewerCarousel extends LitElement {
// A task to resolve target media if lazy loading is disabled. // A task to resolve target media if lazy loading is disabled.
protected _mediaResolutionTask = new Task< protected _mediaResolutionTask = new Task<
[ViewerConfig | undefined, Map<string, CameraConfig> | undefined, View | undefined], [ViewerConfig | undefined, View | undefined],
void void
>( >(
this, this,
async ([viewerConfig, cameras, view]: [ async ([viewerConfig, view]: [
ViewerConfig | undefined, ViewerConfig | undefined,
Map<string, CameraConfig> | undefined,
View | undefined, View | undefined,
]): Promise<void> => { ]): Promise<void> => {
if ( if (
!this.hass || !this.hass ||
!viewerConfig?.lazy_load || !viewerConfig?.lazy_load ||
!cameras ||
!view || !view ||
!view.queryResults?.hasResults() !view.queryResults?.hasResults()
) { ) {
@@ -234,7 +220,7 @@ export class FrigateCardViewerCarousel extends LitElement {
}); });
await Promise.all(promises); await Promise.all(promises);
}, },
() => [this.viewerConfig, this.cameras, this.view], () => [this.viewerConfig, this.view],
); );
/** /**
@@ -453,7 +439,7 @@ export class FrigateCardViewerCarousel extends LitElement {
* @param slide The slide to lazy load. * @param slide The slide to lazy load.
*/ */
protected _lazyloadSlide(index: number, slide: HTMLElement): void { protected _lazyloadSlide(index: number, slide: HTMLElement): void {
if (!this.hass || !this.view || !this.view.query || !this.cameras) { if (!this.hass || !this.view || !this.view.query) {
return; return;
} }
@@ -512,7 +498,7 @@ export class FrigateCardViewerCarousel extends LitElement {
* Determine if all the media in the carousel are resolved. * Determine if all the media in the carousel are resolved.
*/ */
protected _isMediaFullyResolved(): boolean { protected _isMediaFullyResolved(): boolean {
if (!this.resolvedMediaCache || !this.cameras) { if (!this.resolvedMediaCache) {
return false; return false;
} }
for (const media of this.view?.queryResults?.getResults() ?? []) { for (const media of this.view?.queryResults?.getResults() ?? []) {
@@ -560,7 +546,7 @@ export class FrigateCardViewerCarousel extends LitElement {
} }
const media = this.view?.queryResults?.getSelectedResult(); const media = this.view?.queryResults?.getSelectedResult();
if (!media || !this.cameras || !this.view || !this.view.queryResults) { if (!media || !this.view || !this.view.queryResults) {
return; return;
} }
@@ -649,7 +635,7 @@ export class FrigateCardViewerCarousel extends LitElement {
*/ */
protected _renderMediaItem(media: ViewMedia, index: number): TemplateResult | null { protected _renderMediaItem(media: ViewMedia, index: number): TemplateResult | null {
// Skip folders as they cannot be rendered by this viewer. // Skip folders as they cannot be rendered by this viewer.
if (!this.hass || !this.view || !this.viewerConfig || !this.cameras) { if (!this.hass || !this.view || !this.viewerConfig) {
return null; return null;
} }
+2
View File
@@ -354,6 +354,8 @@
"jsmpeg_no_sign": "Could not retrieve or sign JSMPEG websocket path", "jsmpeg_no_sign": "Could not retrieve or sign JSMPEG websocket path",
"live_camera_not_found": "The configured camera_entity was not found", "live_camera_not_found": "The configured camera_entity was not found",
"live_camera_unavailable": "Camera unavailable", "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_id": "Could not determine camera id for the following camera, may need to set 'id' parameter manually", "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_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_cameras": "No valid cameras found, you must configure at least one camera entry",
+2
View File
@@ -325,6 +325,8 @@
"jsmpeg_no_sign": "Impossibile recuperare o firmare il percorso WebSocket JSMPEG", "jsmpeg_no_sign": "Impossibile recuperare o firmare il percorso WebSocket JSMPEG",
"live_camera_not_found": "La telecamera configurata non è stata trovata", "live_camera_not_found": "La telecamera configurata non è stata trovata",
"live_camera_unavailable": "Telecamera non disponibile", "live_camera_unavailable": "Telecamera non disponibile",
"no_camera_engine": "",
"no_camera_entity": "",
"no_camera_id": "Impossibile determinare l'ID della telecamera , potrebbe essere necessario impostare manualmente il parametro 'ID'", "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_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_cameras": "Nessuna telecamera valida trovata, è necessario configurare almeno una voce della telecamera",
+2
View File
@@ -325,6 +325,8 @@
"jsmpeg_no_sign": "Não foi possível recuperar ou assinar o caminho do websocket JSMPEG", "jsmpeg_no_sign": "Não foi possível recuperar ou assinar o caminho do websocket JSMPEG",
"live_camera_not_found": "", "live_camera_not_found": "",
"live_camera_unavailable": "", "live_camera_unavailable": "",
"no_camera_engine": "",
"no_camera_entity": "",
"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_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_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_cameras": "Nenhuma câmera válida encontrada, você deve configurar pelo menos uma câmera",
+13 -20
View File
@@ -49,8 +49,6 @@ const FRIGATE_CARD_VIEWS = [
] as const; ] as const;
export type FrigateCardView = typeof FRIGATE_CARD_VIEWS[number]; export type FrigateCardView = typeof FRIGATE_CARD_VIEWS[number];
export type FrigateCardUserSpecifiedView =
typeof FRIGATE_CARD_VIEWS_USER_SPECIFIED[number];
export const FRIGATE_CARD_VIEW_DEFAULT = 'live' as const; export const FRIGATE_CARD_VIEW_DEFAULT = 'live' as const;
const FRIGATE_MENU_STYLES = [ const FRIGATE_MENU_STYLES = [
@@ -89,6 +87,12 @@ const MEDIA_ACTION_POSITIVE_CONDITIONS = [
export type AutoUnmuteCondition = typeof MEDIA_ACTION_POSITIVE_CONDITIONS[number]; export type AutoUnmuteCondition = typeof MEDIA_ACTION_POSITIVE_CONDITIONS[number];
export type AutoPlayCondition = typeof MEDIA_ACTION_POSITIVE_CONDITIONS[number]; export type AutoPlayCondition = typeof MEDIA_ACTION_POSITIVE_CONDITIONS[number];
const ENGINES = [
'auto',
'frigate',
'generic',
] as const;
export class FrigateCardError extends Error { export class FrigateCardError extends Error {
context?: unknown; context?: unknown;
@@ -382,6 +386,7 @@ const customSchema = z
*/ */
const cameraConfigDefault = { const cameraConfigDefault = {
live_provider: 'auto' as const, live_provider: 'auto' as const,
engine: 'auto' as const,
frigate: { frigate: {
client_id: 'frigate' as const, client_id: 'frigate' as const,
}, },
@@ -413,6 +418,8 @@ const cameraConfigSchema = z
// this card. // this card.
id: z.string().optional(), id: z.string().optional(),
engine: z.enum(ENGINES).default('auto'),
frigate: z frigate: z
.object({ .object({
// No URL validation to allow relative URLs within HA (e.g. Frigate addon). // No URL validation to allow relative URLs within HA (e.g. Frigate addon).
@@ -445,6 +452,9 @@ const cameraConfigSchema = z
.default(cameraConfigDefault); .default(cameraConfigDefault);
export type CameraConfig = z.infer<typeof cameraConfigSchema>; export type CameraConfig = z.infer<typeof cameraConfigSchema>;
const camerasConfigSchema = cameraConfigSchema.array().nonempty();
export type CamerasConfig = z.infer<typeof camerasConfigSchema>;
/** /**
* Custom Element Types. * Custom Element Types.
*/ */
@@ -1234,7 +1244,7 @@ export interface CardWideConfig {
*/ */
export const frigateCardConfigSchema = z.object({ export const frigateCardConfigSchema = z.object({
// Main configuration sections. // Main configuration sections.
cameras: cameraConfigSchema.array().nonempty(), cameras: camerasConfigSchema,
view: viewConfigSchema, view: viewConfigSchema,
menu: menuConfigSchema, menu: menuConfigSchema,
live: liveConfigSchema, live: liveConfigSchema,
@@ -1351,20 +1361,3 @@ export const signedPathSchema = z.object({
path: z.string(), path: z.string(),
}); });
export type SignedPath = z.infer<typeof signedPathSchema>; export type SignedPath = z.infer<typeof signedPathSchema>;
const entitySchema = z.object({
config_entry_id: z.string().nullable(),
disabled_by: z.string().nullable(),
entity_id: z.string(),
platform: z.string(),
});
export type Entity = z.infer<typeof entitySchema>;
export const extendedEntitySchema = entitySchema.extend({
// Extended entity results.
unique_id: z.string().optional(),
});
export type ExtendedEntity = z.infer<typeof extendedEntitySchema>;
export const entityListSchema = entitySchema.array();
export type EntityList = z.infer<typeof entityListSchema>;
+5 -5
View File
@@ -167,11 +167,11 @@ export function getDurationString(start: Date, end: Date): string {
return duration; return duration;
} }
export const allPromises = async <T>( export const allPromises = async <T, R>(
items: T[], items: Iterable<T>,
func: (arg: T) => void, func: (arg: T) => R,
): Promise<void> => { ): Promise<Awaited<R>[]> => {
await Promise.all(Array.from(items).map((item) => func(item))); return await Promise.all(Array.from(items).map((item) => func(item)));
}; };
/** /**
+1 -1
View File
@@ -26,7 +26,7 @@ export function getCameraID(
/** /**
* Get all cameras that depend on a given camera. * Get all cameras that depend on a given camera.
* @param cameras Cameras map. * @param cameras Cameras map.
* @param camera Name of the target camera. * @param cameraID ID of the target camera.
* @returns A set of query parameters. * @returns A set of query parameters.
*/ */
export const getAllDependentCameras = ( export const getAllDependentCameras = (
-109
View File
@@ -1,109 +0,0 @@
import { HomeAssistant } from 'custom-card-helpers';
import { homeAssistantWSRequest } from '.';
import {
Entity,
EntityList,
entityListSchema,
ExtendedEntity,
extendedEntitySchema,
} from '../../types.js';
export class ExtendedEntityCache {
protected _cache: Map<string, ExtendedEntity> = new Map();
/**
* Determine if the cache has a given entity_id.
* @param id
* @returns `true` if the id is in the cache, `false` otherwise.
*/
public has(id: string): boolean {
return this._cache.has(id);
}
/**
* Get the first value that returns true for the given predicate.
* @param func A callback function that returns a boolean.
* @returns The first matching value.
*/
public getMatch(func: (arg: ExtendedEntity) => boolean): ExtendedEntity | null {
return [...this._cache.values()].find(func) ?? null;
}
/**
* Get entity information given an id.
* @param id The entity id.
* @returns The `ExtendedEntity` for this id.
*/
public get(id: string): ExtendedEntity | undefined {
return this._cache.get(id);
}
/**
* Add a given ExtendedEntity to the cache.
* @param extendedEntity
*/
public set(extendedEntity: ExtendedEntity): void {
this._cache.set(extendedEntity.entity_id, extendedEntity);
}
}
/**
* Get the extended entity information for an entity. May throw.
* @param hass The Home Assistant object.
* @param entity The entity id.
* @param cache An optional ExtendedEntityCache.
* @returns The ExtendedEntity information.
*/
export const getExtendedEntity = async (
hass: HomeAssistant,
entity: string,
cache?: ExtendedEntityCache,
): Promise<ExtendedEntity> => {
const cachedValue = cache ? cache.get(entity) : undefined;
if (cachedValue) {
return cachedValue;
}
const result = await homeAssistantWSRequest<ExtendedEntity>(
hass,
extendedEntitySchema,
{
type: 'config/entity_registry/get',
entity_id: entity,
},
);
if (cache) {
cache.set(result);
}
return result;
};
/**
* Get the extended entity information for an array of entities. May throw.
* @param hass The Home Assistant object.
* @param entities An array of entity ids.
* @param cache An optional ExtendedEntityCache.
* @returns A map of entity id to ExtendedEntity objects.
*/
export const getExtendedEntities = async (
hass: HomeAssistant,
entities: string[],
cache?: ExtendedEntityCache,
): Promise<Map<string, Entity>> => {
const output: Map<string, Entity> = new Map();
const _storeExtendedEntity = async (entity: string): Promise<void> => {
output.set(entity, await getExtendedEntity(hass, entity, cache));
};
await Promise.all(entities.map(_storeExtendedEntity));
return output;
};
/**
* Get a list of all entities from the entity registry. May throw.
* @param hass The Home Assistant object.
* @returns An entity list object.
*/
export const getAllEntities = async (hass: HomeAssistant): Promise<EntityList> => {
return await homeAssistantWSRequest<EntityList>(hass, entityListSchema, {
type: 'config/entity_registry/list',
});
};
+50
View File
@@ -0,0 +1,50 @@
import { Entity, ExtendedEntity } from './types.js';
export class EntityCache<T extends Entity | ExtendedEntity> {
protected _cache: Map<string, T> = new Map();
/**
* Determine if the cache has a given entity_id.
* @param id
* @returns `true` if the id is in the cache, `false` otherwise.
*/
public has(id: string): boolean {
return this._cache.has(id);
}
/**
* Get the first value that returns true for the given predicate.
* @param func A callback function that returns a boolean.
* @returns The first matching value.
*/
// public getFirstMatch(func: (arg: T) => boolean): T | null {
// return [...this._cache.values()].find(func) ?? null;
// }
public getMatches(func: (arg: T) => boolean): T[] {
return [...this._cache.values()].filter(func);
}
/**
* Get entity information given an id.
* @param id The entity id.
* @returns The `ExtendedEntity` for this id.
*/
public get(id: string): T | undefined {
return this._cache.get(id);
}
/**
* Add a given entity to the cache.
* @param extendedEntity
*/
public set(input: T | T[]): void {
const _set = (entity: T) => this._cache.set(entity.entity_id, entity);
if (Array.isArray(input)) {
input.forEach(_set);
} else {
_set(input);
}
}
}
+95
View File
@@ -0,0 +1,95 @@
import { HomeAssistant } from 'custom-card-helpers';
import { homeAssistantWSRequest } from '..';
import { EntityCache } from './cache';
import {
Entity,
EntityList,
entityListSchema,
ExtendedEntity,
extendedEntitySchema,
} from './types.js';
type EntityRegistryCache = EntityCache<Entity>;
type ExtendedEntityRegistryCache = EntityCache<ExtendedEntity>;
// Tne `entity_registry/list` call returns a smaller set of information for
// every entity, than the full `entity_registry/get` call returns for a single
// entity. This class manages interactions with entities, caching results
// (either the partial or extended versions) and fetching as necessary. Some
// calls require every entity to be fetched, which may be non-trivial in size
// (after which it is cached forever).
export class EntityRegistryManager {
protected _cache: EntityRegistryCache;
protected _extendedCache: ExtendedEntityRegistryCache;
protected _fetchedEntityList = false;
constructor(cache: EntityCache<Entity>, extendedCache: EntityCache<ExtendedEntity>) {
this._cache = cache;
this._extendedCache = extendedCache;
}
public async getEntity(hass: HomeAssistant, entityID: string): Promise<Entity | null> {
const cachedEntity = this._cache.get(entityID);
if (cachedEntity) {
return cachedEntity;
}
const cachedExtendedEntity = this._extendedCache.get(entityID);
if (cachedExtendedEntity) {
return cachedExtendedEntity;
}
return await this.getExtendedEntity(hass, entityID);
}
public async getMatchingEntities(hass: HomeAssistant, func: (arg: Entity) => boolean): Promise<Entity[]> {
await this.fetchEntityList(hass);
return this._cache.getMatches(func);
}
public async getExtendedEntity(
hass: HomeAssistant,
entityID: string,
): Promise<ExtendedEntity | null> {
const cachedValue = this._extendedCache.get(entityID);
if (cachedValue) {
return cachedValue;
}
const extendedEntity = await homeAssistantWSRequest<ExtendedEntity>(
hass,
extendedEntitySchema,
{
type: 'config/entity_registry/get',
entity_id: entityID,
},
);
this._extendedCache.set(extendedEntity);
return extendedEntity;
}
public async getExtendedEntities(
hass: HomeAssistant,
entityIDs: string[],
): Promise<Map<string, ExtendedEntity>> {
const output: Map<string, ExtendedEntity> = new Map();
const _storeExtendedEntity = async (entityID: string): Promise<void> => {
const extendedEntity = await this.getExtendedEntity(hass, entityID);
if (extendedEntity) {
output.set(entityID, extendedEntity);
}
};
await Promise.all(entityIDs.map(_storeExtendedEntity));
return output;
}
public async fetchEntityList(hass: HomeAssistant): Promise<void> {
if (this._fetchedEntityList) {
return;
}
const entityList = await homeAssistantWSRequest<EntityList>(hass, entityListSchema, {
type: 'config/entity_registry/list',
});
this._cache.set(entityList);
this._fetchedEntityList = true;
}
}
+19
View File
@@ -0,0 +1,19 @@
import { z } from 'zod';
const entitySchema = z.object({
config_entry_id: z.string().nullable(),
disabled_by: z.string().nullable(),
entity_id: z.string(),
hidden_by: z.string().nullable(),
platform: z.string(),
});
export type Entity = z.infer<typeof entitySchema>;
export const extendedEntitySchema = entitySchema.extend({
// Extended entity results.
unique_id: z.string().optional(),
});
export type ExtendedEntity = z.infer<typeof extendedEntitySchema>;
export const entityListSchema = entitySchema.array();
export type EntityList = z.infer<typeof entityListSchema>;
+11 -7
View File
@@ -1,7 +1,7 @@
import add from 'date-fns/add'; import add from 'date-fns/add';
import sub from 'date-fns/sub'; import sub from 'date-fns/sub';
import { ViewContext } from 'view'; import { ViewContext } from 'view';
import { CameraConfig, ClipsOrSnapshotsOrAll, FrigateCardView } from '../types'; import { ClipsOrSnapshotsOrAll, FrigateCardView } from '../types';
import { View } from '../view/view'; import { View } from '../view/view';
import { import {
EventMediaQueries, EventMediaQueries,
@@ -21,7 +21,6 @@ export const changeViewToRecentEventsForCameraAndDependents = async (
element: HTMLElement, element: HTMLElement,
hass: HomeAssistant, hass: HomeAssistant,
cameraManager: CameraManager, cameraManager: CameraManager,
cameras: Map<string, CameraConfig>,
view: View, view: View,
options?: { options?: {
mediaType?: ClipsOrSnapshotsOrAll; mediaType?: ClipsOrSnapshotsOrAll;
@@ -29,7 +28,7 @@ export const changeViewToRecentEventsForCameraAndDependents = async (
}, },
): Promise<void> => { ): Promise<void> => {
( (
await createViewForEvents(element, hass, cameraManager, cameras, view, { await createViewForEvents(element, hass, cameraManager, view, {
...options, ...options,
limit: 50, // Capture the 50 most recent events. limit: 50, // Capture the 50 most recent events.
}) })
@@ -40,7 +39,6 @@ export const createViewForEvents = async (
element: HTMLElement, element: HTMLElement,
hass: HomeAssistant, hass: HomeAssistant,
cameraManager: CameraManager, cameraManager: CameraManager,
cameras: Map<string, CameraConfig>,
view: View, view: View,
options?: { options?: {
query?: EventMediaQueries; query?: EventMediaQueries;
@@ -51,6 +49,10 @@ export const createViewForEvents = async (
limit?: number; limit?: number;
}, },
): Promise<View | null> => { ): Promise<View | null> => {
const cameras = cameraManager.getCameras();
if (!cameras) {
return null;
}
let query: EventMediaQueries; let query: EventMediaQueries;
const cameraIDs: Set<string> = options?.cameraIDs const cameraIDs: Set<string> = options?.cameraIDs
? options.cameraIDs ? options.cameraIDs
@@ -94,7 +96,6 @@ export const changeViewToRecentRecordingForCameraAndDependents = async (
element: HTMLElement, element: HTMLElement,
hass: HomeAssistant, hass: HomeAssistant,
cameraManager: CameraManager, cameraManager: CameraManager,
cameras: Map<string, CameraConfig>,
view: View, view: View,
options?: { options?: {
targetView?: 'recording' | 'recordings'; targetView?: 'recording' | 'recordings';
@@ -102,7 +103,7 @@ export const changeViewToRecentRecordingForCameraAndDependents = async (
): Promise<void> => { ): Promise<void> => {
const now = new Date(); const now = new Date();
( (
await createViewForRecordings(element, hass, cameraManager, cameras, view, { await createViewForRecordings(element, hass, cameraManager, view, {
...options, ...options,
// Fetch 7 days worth of recordings (including recordings that are for the // Fetch 7 days worth of recordings (including recordings that are for the
// current hour). // current hour).
@@ -127,7 +128,6 @@ export const createViewForRecordings = async (
element: HTMLElement, element: HTMLElement,
hass: HomeAssistant, hass: HomeAssistant,
cameraManager: CameraManager, cameraManager: CameraManager,
cameras: Map<string, CameraConfig>,
view: View, view: View,
options?: { options?: {
query?: RecordingMediaQueries; query?: RecordingMediaQueries;
@@ -139,6 +139,10 @@ export const createViewForRecordings = async (
end?: Date; end?: Date;
}, },
): Promise<View | null> => { ): Promise<View | null> => {
const cameras = cameraManager.getCameras();
if (!cameras) {
return null;
}
const cameraIDs: Set<string> = options?.cameraIDs const cameraIDs: Set<string> = options?.cameraIDs
? options.cameraIDs ? options.cameraIDs
: new Set(getAllDependentCameras(cameras, view.camera)); : new Set(getAllDependentCameras(cameras, view.camera));
+3 -30
View File
@@ -1,10 +1,5 @@
import { ViewContext } from 'view'; import { ViewContext } from 'view';
import { import { FrigateCardView } from '../types.js';
FrigateCardUserSpecifiedView,
FrigateCardView,
FRIGATE_CARD_VIEWS_USER_SPECIFIED,
FRIGATE_CARD_VIEW_DEFAULT,
} from '../types.js';
import { dispatchFrigateCardEvent } from '../utils/basic.js'; import { dispatchFrigateCardEvent } from '../utils/basic.js';
import { MediaQueries } from './media-queries'; import { MediaQueries } from './media-queries';
import { MediaQueriesResults } from './media-queries-results'; import { MediaQueriesResults } from './media-queries-results';
@@ -37,28 +32,6 @@ export class View {
this.context = params.context ?? null; this.context = params.context ?? null;
} }
/**
* Selects the best view for a non-Frigate camera.
* @param view The wanted view.
* @returns The closest view supported by the non-Frigate camera.
*/
public static selectBestViewForNonFrigateCameras(view: FrigateCardView) {
return ['timeline', 'image'].includes(view) ? view : FRIGATE_CARD_VIEW_DEFAULT;
}
/**
* Selects the best view for a user specified view.
* @param view The wanted view.
* @returns The closest view supported that is user changeable.
*/
public static selectBestViewForUserSpecified(view: FrigateCardView) {
return FRIGATE_CARD_VIEWS_USER_SPECIFIED.includes(
view as FrigateCardUserSpecifiedView,
)
? view
: FRIGATE_CARD_VIEW_DEFAULT;
}
/** /**
* Detect if a view change represents a major "media change" for the given * Detect if a view change represents a major "media change" for the given
* view. * view.
@@ -138,8 +111,8 @@ export class View {
/** /**
* Determine if current view matches a named view. * Determine if current view matches a named view.
*/ */
public is(name: string): boolean { public is(view: FrigateCardView): boolean {
return this.view == name; return this.view == view;
} }
/** /**