Merge pull request #936 from dermotduffy/refactor-initialization
Refactor camera initialization logic into the camera engines
This commit is contained in:
@@ -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. |
|
||||
| `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). |
|
||||
| `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. |
|
||||
| `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. |
|
||||
@@ -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.|
|
||||
|`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>
|
||||
|
||||
#### Camera Frigate configuration
|
||||
@@ -1304,6 +1314,7 @@ Reference: [Camera Options](#camera-options).
|
||||
cameras:
|
||||
- camera_entity: camera.front_Door
|
||||
live_provider: ha
|
||||
engine: auto
|
||||
frigate:
|
||||
url: http://my.frigate.local
|
||||
client_id: frigate
|
||||
@@ -1322,6 +1333,7 @@ cameras:
|
||||
- binary_sensor.front_door_sensor
|
||||
- camera_entity: camera.entrance
|
||||
live_provider: webrtc-card
|
||||
engine: auto
|
||||
frigate:
|
||||
url: http://my-other.frigate.local
|
||||
client_id: frigate-other
|
||||
|
||||
@@ -1,27 +1,30 @@
|
||||
import { HomeAssistant } from 'custom-card-helpers';
|
||||
import { CameraConfig, CardWideConfig } from '../types';
|
||||
import { ViewMedia } from '../view/media';
|
||||
import { EntityRegistryManager } from '../utils/ha/entity-registry';
|
||||
import { RecordingSegmentsCache, RequestCache } from './cache';
|
||||
import { CameraManagerEngine } from './engine';
|
||||
import { FrigateCameraManagerEngine } from './frigate/engine-frigate';
|
||||
import { GenericCameraManagerEngine } from './generic/engine-generic';
|
||||
import { Engine } from './types';
|
||||
|
||||
type CameraManagerEngineCameraIDMap = Map<CameraManagerEngine, Set<string>>;
|
||||
|
||||
export class CameraManagerEngineFactory {
|
||||
protected _engines: Map<Engine, CameraManagerEngine> = new Map();
|
||||
protected _entityRegistryManager: EntityRegistryManager;
|
||||
protected _cardWideConfig: CardWideConfig;
|
||||
|
||||
constructor(cardWideConfig: CardWideConfig) {
|
||||
constructor(
|
||||
entityRegistryManager: EntityRegistryManager,
|
||||
cardWideConfig: CardWideConfig,
|
||||
) {
|
||||
this._entityRegistryManager = entityRegistryManager;
|
||||
this._cardWideConfig = cardWideConfig;
|
||||
}
|
||||
|
||||
public getEngine(engine: Engine): CameraManagerEngine | null {
|
||||
const cachedEngine = this._engines.get(engine);
|
||||
if (cachedEngine) {
|
||||
return cachedEngine;
|
||||
}
|
||||
public async createEngine(engine: Engine): Promise<CameraManagerEngine | null> {
|
||||
let cameraManagerEngine: CameraManagerEngine | null = null;
|
||||
switch (engine) {
|
||||
case Engine.Generic:
|
||||
cameraManagerEngine = new GenericCameraManagerEngine();
|
||||
break;
|
||||
case Engine.Frigate:
|
||||
cameraManagerEngine = new FrigateCameraManagerEngine(
|
||||
this._cardWideConfig,
|
||||
@@ -30,64 +33,39 @@ export class CameraManagerEngineFactory {
|
||||
);
|
||||
break;
|
||||
}
|
||||
if (cameraManagerEngine) {
|
||||
this._engines.set(engine, cameraManagerEngine);
|
||||
}
|
||||
return cameraManagerEngine;
|
||||
}
|
||||
|
||||
public getEngineForCamera(cameraConfig?: CameraConfig): CameraManagerEngine | null {
|
||||
public async getEngineForCamera(
|
||||
hass: HomeAssistant,
|
||||
cameraConfig: CameraConfig,
|
||||
): Promise<Engine | null> {
|
||||
if (!cameraConfig) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let engine: Engine | null = null;
|
||||
if (cameraConfig.frigate.camera_name) {
|
||||
if (cameraConfig.engine === 'frigate') {
|
||||
engine = Engine.Frigate;
|
||||
}
|
||||
return engine ? this.getEngine(engine) : null;
|
||||
}
|
||||
} else if (cameraConfig.engine === 'auto') {
|
||||
const cameraEntity = cameraConfig.camera_entity;
|
||||
|
||||
public getEnginesForCameraIDs(
|
||||
cameras: Map<string, CameraConfig>,
|
||||
cameraIDs: Set<string>,
|
||||
): CameraManagerEngineCameraIDMap | null {
|
||||
const output: CameraManagerEngineCameraIDMap = new Map();
|
||||
|
||||
for (const cameraID of cameraIDs) {
|
||||
const cameraConfig = cameras.get(cameraID);
|
||||
if (!cameraConfig) {
|
||||
continue;
|
||||
if (cameraEntity) {
|
||||
const entity = await this._entityRegistryManager.getEntity(hass, cameraEntity);
|
||||
switch (entity?.platform) {
|
||||
case 'frigate':
|
||||
engine = Engine.Frigate;
|
||||
break;
|
||||
default:
|
||||
engine = Engine.Generic;
|
||||
}
|
||||
} 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(
|
||||
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;
|
||||
return engine;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { HomeAssistant } from 'custom-card-helpers';
|
||||
import { CameraConfig } from '../types';
|
||||
import { EntityRegistryManager } from '../utils/ha/entity-registry';
|
||||
import { ViewMedia } from '../view/media';
|
||||
import {
|
||||
DataQuery,
|
||||
@@ -17,57 +18,68 @@ import {
|
||||
CameraManagerCameraCapabilities,
|
||||
CameraManagerMediaCapabilities,
|
||||
CameraManagerCameraMetadata,
|
||||
CameraURLContext,
|
||||
CameraConfigs,
|
||||
Engine,
|
||||
} from './types';
|
||||
|
||||
export const CAMERA_MANAGER_ENGINE_EVENT_LIMIT_DEFAULT = 10000;
|
||||
|
||||
export interface CameraManagerEngine {
|
||||
getEngineType(): Engine;
|
||||
|
||||
initializeCamera(
|
||||
hass: HomeAssistant,
|
||||
entityRegistryManager: EntityRegistryManager,
|
||||
cameraConfig: CameraConfig,
|
||||
): Promise<CameraConfig>;
|
||||
|
||||
generateDefaultEventQuery(
|
||||
cameras: Map<string, CameraConfig>,
|
||||
cameras: CameraConfigs,
|
||||
cameraIDs: Set<string>,
|
||||
query: PartialEventQuery,
|
||||
): EventQuery[] | null;
|
||||
|
||||
generateDefaultRecordingQuery(
|
||||
cameras: Map<string, CameraConfig>,
|
||||
cameras: CameraConfigs,
|
||||
cameraIDs: Set<string>,
|
||||
query: PartialRecordingQuery,
|
||||
): RecordingQuery[] | null;
|
||||
|
||||
generateDefaultRecordingSegmentsQuery(
|
||||
cameras: Map<string, CameraConfig>,
|
||||
cameras: CameraConfigs,
|
||||
cameraIDs: Set<string>,
|
||||
query: PartialRecordingSegmentsQuery,
|
||||
): RecordingSegmentsQuery[] | null;
|
||||
|
||||
getEvents(
|
||||
hass: HomeAssistant,
|
||||
cameras: Map<string, CameraConfig>,
|
||||
cameras: CameraConfigs,
|
||||
query: EventQuery,
|
||||
): Promise<EventQueryResultsMap | null>;
|
||||
|
||||
getRecordings(
|
||||
hass: HomeAssistant,
|
||||
cameras: Map<string, CameraConfig>,
|
||||
cameras: CameraConfigs,
|
||||
query: RecordingQuery,
|
||||
): Promise<RecordingQueryResultsMap | null>;
|
||||
|
||||
getRecordingSegments(
|
||||
hass: HomeAssistant,
|
||||
cameras: Map<string, CameraConfig>,
|
||||
cameras: CameraConfigs,
|
||||
query: RecordingSegmentsQuery,
|
||||
): Promise<RecordingSegmentsQueryResultsMap | null>;
|
||||
|
||||
generateMediaFromEvents(
|
||||
hass: HomeAssistant,
|
||||
cameras: Map<string, CameraConfig>,
|
||||
cameras: CameraConfigs,
|
||||
query: EventQuery,
|
||||
results: QueryReturnType<EventQuery>,
|
||||
): ViewMedia[] | null;
|
||||
|
||||
generateMediaFromRecordings(
|
||||
hass: HomeAssistant,
|
||||
cameras: Map<string, CameraConfig>,
|
||||
cameras: CameraConfigs,
|
||||
query: RecordingQuery,
|
||||
results: QueryReturnType<RecordingQuery>,
|
||||
): ViewMedia[] | null;
|
||||
@@ -85,14 +97,14 @@ export interface CameraManagerEngine {
|
||||
|
||||
getMediaSeekTime(
|
||||
hass: HomeAssistant,
|
||||
cameras: Map<string, CameraConfig>,
|
||||
cameras: CameraConfigs,
|
||||
media: ViewMedia,
|
||||
target: Date,
|
||||
): Promise<number | null>;
|
||||
|
||||
getMediaMetadata(
|
||||
hass: HomeAssistant,
|
||||
cameras: Map<string, CameraConfig>,
|
||||
cameras: CameraConfigs,
|
||||
): Promise<MediaMetadata | null>;
|
||||
|
||||
getCameraMetadata(
|
||||
@@ -105,4 +117,9 @@ export interface CameraManagerEngine {
|
||||
): CameraManagerCameraCapabilities | null;
|
||||
|
||||
getMediaCapabilities(media: ViewMedia): CameraManagerMediaCapabilities | null;
|
||||
|
||||
getCameraURL(
|
||||
cameraConfig: CameraConfig,
|
||||
context?: CameraURLContext,
|
||||
): string | null;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
import { FrigateCardError } from '../types.js';
|
||||
|
||||
export class CameraInitializationError extends FrigateCardError {}
|
||||
@@ -37,6 +37,8 @@ import {
|
||||
RecordingSegment,
|
||||
RecordingSegmentsQuery,
|
||||
RecordingSegmentsQueryResultsMap,
|
||||
CameraURLContext,
|
||||
CameraConfigs,
|
||||
} from '../types';
|
||||
import { FrigateRecording } from './types';
|
||||
import {
|
||||
@@ -62,7 +64,14 @@ import { FrigateViewMediaClassifier } from './media-classifier';
|
||||
import { ViewMediaClassifier } from '../../view/media-classifier';
|
||||
import { FrigateViewMediaFactory } from './media';
|
||||
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 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 _requestCache: RequestCache;
|
||||
protected _cardWideConfig: CardWideConfig;
|
||||
@@ -109,11 +121,162 @@ export class FrigateCameraManagerEngine implements CameraManagerEngine {
|
||||
recordingSegmentsCache: RecordingSegmentsCache,
|
||||
requestCache: RequestCache,
|
||||
) {
|
||||
super();
|
||||
this._cardWideConfig = cardWideConfig;
|
||||
this._recordingSegmentsCache = recordingSegmentsCache;
|
||||
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(
|
||||
cameraConfig: CameraConfig,
|
||||
media: ViewMedia,
|
||||
@@ -137,7 +300,7 @@ export class FrigateCameraManagerEngine implements CameraManagerEngine {
|
||||
}
|
||||
|
||||
public generateDefaultEventQuery(
|
||||
cameras: Map<string, CameraConfig>,
|
||||
cameras: CameraConfigs,
|
||||
cameraIDs: Set<string>,
|
||||
query?: PartialEventQuery,
|
||||
): EventQuery[] | null {
|
||||
@@ -182,7 +345,7 @@ export class FrigateCameraManagerEngine implements CameraManagerEngine {
|
||||
}
|
||||
|
||||
public generateDefaultRecordingQuery(
|
||||
_cameras: Map<string, CameraConfig>,
|
||||
_cameras: CameraConfigs,
|
||||
cameraIDs: Set<string>,
|
||||
query?: PartialRecordingQuery,
|
||||
): RecordingQuery[] {
|
||||
@@ -196,7 +359,7 @@ export class FrigateCameraManagerEngine implements CameraManagerEngine {
|
||||
}
|
||||
|
||||
public generateDefaultRecordingSegmentsQuery(
|
||||
_cameras: Map<string, CameraConfig>,
|
||||
_cameras: CameraConfigs,
|
||||
cameraIDs: Set<string>,
|
||||
query: PartialRecordingSegmentsQuery,
|
||||
): RecordingSegmentsQuery[] | null {
|
||||
@@ -229,7 +392,7 @@ export class FrigateCameraManagerEngine implements CameraManagerEngine {
|
||||
}
|
||||
|
||||
protected _buildInstanceToCameraIDMapFromQuery(
|
||||
cameras: Map<string, CameraConfig>,
|
||||
cameras: CameraConfigs,
|
||||
cameraIDs: Set<string>,
|
||||
): Map<string, Set<string>> {
|
||||
const output: Map<string, Set<string>> = new Map();
|
||||
@@ -247,7 +410,7 @@ export class FrigateCameraManagerEngine implements CameraManagerEngine {
|
||||
}
|
||||
|
||||
protected _getFrigateCameraNamesForCameraIDs(
|
||||
cameras: Map<string, CameraConfig>,
|
||||
cameras: CameraConfigs,
|
||||
cameraIDs: Set<string>,
|
||||
): Set<string> {
|
||||
const output = new Set<string>();
|
||||
@@ -262,7 +425,7 @@ export class FrigateCameraManagerEngine implements CameraManagerEngine {
|
||||
|
||||
public async getEvents(
|
||||
hass: HomeAssistant,
|
||||
cameras: Map<string, CameraConfig>,
|
||||
cameras: CameraConfigs,
|
||||
query: EventQuery,
|
||||
): Promise<EventQueryResultsMap | null> {
|
||||
const output: EventQueryResultsMap = new Map();
|
||||
@@ -326,7 +489,7 @@ export class FrigateCameraManagerEngine implements CameraManagerEngine {
|
||||
|
||||
public async getRecordings(
|
||||
hass: HomeAssistant,
|
||||
cameras: Map<string, CameraConfig>,
|
||||
cameras: CameraConfigs,
|
||||
query: RecordingQuery,
|
||||
): Promise<RecordingQueryResultsMap | null> {
|
||||
const output: RecordingQueryResultsMap = new Map();
|
||||
@@ -408,7 +571,7 @@ export class FrigateCameraManagerEngine implements CameraManagerEngine {
|
||||
|
||||
public async getRecordingSegments(
|
||||
hass: HomeAssistant,
|
||||
cameras: Map<string, CameraConfig>,
|
||||
cameras: CameraConfigs,
|
||||
query: RecordingSegmentsQuery,
|
||||
): Promise<RecordingSegmentsQueryResultsMap | null> {
|
||||
const output: RecordingSegmentsQueryResultsMap = new Map();
|
||||
@@ -474,7 +637,7 @@ export class FrigateCameraManagerEngine implements CameraManagerEngine {
|
||||
}
|
||||
|
||||
protected _getCameraIDMatch(
|
||||
cameras: Map<string, CameraConfig>,
|
||||
cameras: CameraConfigs,
|
||||
query: DataQuery,
|
||||
instanceID: string,
|
||||
cameraName: string,
|
||||
@@ -499,7 +662,7 @@ export class FrigateCameraManagerEngine implements CameraManagerEngine {
|
||||
|
||||
public generateMediaFromEvents(
|
||||
_hass: HomeAssistant,
|
||||
cameras: Map<string, CameraConfig>,
|
||||
cameras: CameraConfigs,
|
||||
query: EventQuery,
|
||||
results: QueryReturnType<EventQuery>,
|
||||
): ViewMedia[] | null {
|
||||
@@ -552,7 +715,7 @@ export class FrigateCameraManagerEngine implements CameraManagerEngine {
|
||||
|
||||
public generateMediaFromRecordings(
|
||||
hass: HomeAssistant,
|
||||
cameras: Map<string, CameraConfig>,
|
||||
cameras: CameraConfigs,
|
||||
_query: RecordingQuery,
|
||||
results: QueryReturnType<RecordingQuery>,
|
||||
): ViewMedia[] | null {
|
||||
@@ -590,7 +753,7 @@ export class FrigateCameraManagerEngine implements CameraManagerEngine {
|
||||
|
||||
public async getMediaSeekTime(
|
||||
hass: HomeAssistant,
|
||||
cameras: Map<string, CameraConfig>,
|
||||
cameras: CameraConfigs,
|
||||
media: ViewMedia,
|
||||
target: Date,
|
||||
): Promise<number | null> {
|
||||
@@ -623,7 +786,7 @@ export class FrigateCameraManagerEngine implements CameraManagerEngine {
|
||||
}
|
||||
|
||||
protected _getQueryableCameraConfig(
|
||||
cameras: Map<string, CameraConfig>,
|
||||
cameras: CameraConfigs,
|
||||
cameraID: string,
|
||||
): CameraConfig | null {
|
||||
const cameraConfig = cameras.get(cameraID);
|
||||
@@ -635,7 +798,7 @@ export class FrigateCameraManagerEngine implements CameraManagerEngine {
|
||||
|
||||
public async getMediaMetadata(
|
||||
hass: HomeAssistant,
|
||||
cameras: Map<string, CameraConfig>,
|
||||
cameras: CameraConfigs,
|
||||
): Promise<MediaMetadata | null> {
|
||||
const what: Set<string> = new Set();
|
||||
const where: Set<string> = new Set();
|
||||
@@ -715,7 +878,7 @@ export class FrigateCameraManagerEngine implements CameraManagerEngine {
|
||||
*/
|
||||
protected async _garbageCollectSegments(
|
||||
hass: HomeAssistant,
|
||||
cameras: Map<string, CameraConfig>,
|
||||
cameras: CameraConfigs,
|
||||
): Promise<void> {
|
||||
const cameraIDs = this._recordingSegmentsCache.getCameraIDs();
|
||||
const recordingQuery: RecordingQuery = {
|
||||
@@ -812,6 +975,9 @@ export class FrigateCameraManagerEngine implements CameraManagerEngine {
|
||||
return {
|
||||
canFavoriteEvents: !isBirdseye,
|
||||
canFavoriteRecordings: !isBirdseye,
|
||||
supportsClips: !isBirdseye,
|
||||
supportsSnapshots: !isBirdseye,
|
||||
supportsRecordings: !isBirdseye,
|
||||
supportsTimeline: !isBirdseye,
|
||||
};
|
||||
}
|
||||
@@ -826,6 +992,7 @@ export class FrigateCameraManagerEngine implements CameraManagerEngine {
|
||||
hass: HomeAssistant,
|
||||
cameraConfig: CameraConfig,
|
||||
): CameraManagerCameraMetadata {
|
||||
const metadata = super.getCameraMetadata(hass, cameraConfig);
|
||||
return {
|
||||
title:
|
||||
cameraConfig.title ??
|
||||
@@ -834,10 +1001,51 @@ export class FrigateCameraManagerEngine implements CameraManagerEngine {
|
||||
prettifyTitle(cameraConfig.frigate?.camera_name) ??
|
||||
cameraConfig.id ??
|
||||
'',
|
||||
icon:
|
||||
cameraConfig?.icon ??
|
||||
getEntityIcon(hass, cameraConfig.camera_entity) ??
|
||||
'mdi:video',
|
||||
icon: metadata.icon,
|
||||
};
|
||||
}
|
||||
|
||||
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
@@ -1,11 +1,12 @@
|
||||
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 {
|
||||
CameraManagerCameraCapabilities,
|
||||
CameraManagerCameraMetadata,
|
||||
CameraManagerCapabilities,
|
||||
CameraManagerMediaCapabilities,
|
||||
CameraURLContext,
|
||||
DataQuery,
|
||||
EventQuery,
|
||||
EventQueryResults,
|
||||
@@ -37,6 +38,12 @@ import { CameraManagerEngine } from './engine.js';
|
||||
import sum from 'lodash-es/sum';
|
||||
import add from 'date-fns/add';
|
||||
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 {
|
||||
public static isEventQuery(query: DataQuery | PartialDataQuery): query is EventQuery {
|
||||
@@ -77,19 +84,132 @@ export interface ExtendedMediaQueryResult<T extends MediaQuery> {
|
||||
results: ViewMedia[];
|
||||
}
|
||||
|
||||
interface InitializedCamera {
|
||||
inputConfig: CameraConfig;
|
||||
initializedConfig: CameraConfig;
|
||||
engine: CameraManagerEngine;
|
||||
}
|
||||
|
||||
export class CameraManager {
|
||||
protected _engineFactory: CameraManagerEngineFactory;
|
||||
protected _cameras: Map<string, CameraConfig>;
|
||||
protected _cardWideConfig?: CardWideConfig;
|
||||
protected _store: CameraManagerStore;
|
||||
|
||||
constructor(
|
||||
engineFactory: CameraManagerEngineFactory,
|
||||
cameras: Map<string, CameraConfig>,
|
||||
cardWideConfig?: CardWideConfig,
|
||||
) {
|
||||
this._engineFactory = engineFactory;
|
||||
this._cameras = cameras;
|
||||
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(
|
||||
@@ -127,13 +247,13 @@ export class CameraManager {
|
||||
const where: Set<string> = new Set();
|
||||
const days: Set<string> = new Set();
|
||||
|
||||
const engines = this._engineFactory.getAllEngines(this._cameras);
|
||||
if (!engines) {
|
||||
return null;
|
||||
}
|
||||
const engines = this._store.getAllEngines();
|
||||
|
||||
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.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) {
|
||||
return null;
|
||||
@@ -165,11 +285,7 @@ export class CameraManager {
|
||||
): PartialQueryConcreteType<PQT>[] | null {
|
||||
const concreteQueries: PartialQueryConcreteType<PQT>[] = [];
|
||||
const _cameraIDs = setify(cameraIDs);
|
||||
|
||||
const engines = this._engineFactory.getEnginesForCameraIDs(
|
||||
this._cameras,
|
||||
_cameraIDs,
|
||||
);
|
||||
const engines = this._store.getEnginesForCameraIDs(_cameraIDs);
|
||||
|
||||
if (!engines) {
|
||||
return null;
|
||||
@@ -179,19 +295,19 @@ export class CameraManager {
|
||||
let queries: DataQuery[] | null = null;
|
||||
if (QueryClassifier.isEventQuery(partialQuery)) {
|
||||
queries = engine.generateDefaultEventQuery(
|
||||
this._cameras,
|
||||
this._store.getCameras(),
|
||||
cameraIDs,
|
||||
partialQuery,
|
||||
);
|
||||
} else if (QueryClassifier.isRecordingQuery(partialQuery)) {
|
||||
queries = engine.generateDefaultRecordingQuery(
|
||||
this._cameras,
|
||||
this._store.getCameras(),
|
||||
cameraIDs,
|
||||
partialQuery,
|
||||
);
|
||||
} else if (QueryClassifier.isRecordingSegmentsQuery(partialQuery)) {
|
||||
queries = engine.generateDefaultRecordingSegmentsQuery(
|
||||
this._cameras,
|
||||
this._store.getCameras(),
|
||||
cameraIDs,
|
||||
partialQuery,
|
||||
);
|
||||
@@ -303,10 +419,9 @@ export class CameraManager {
|
||||
}
|
||||
|
||||
public getMediaDownloadPath(media: ViewMedia): string | null {
|
||||
const cameraConfig = this._cameras.get(media.getCameraID());
|
||||
const engine = cameraConfig
|
||||
? this._engineFactory.getEngineForCamera(cameraConfig)
|
||||
: null;
|
||||
const cameraConfig = this._store.getCameraConfigForMedia(media);
|
||||
const engine = this._store.getEngineForMedia(media);
|
||||
|
||||
if (!cameraConfig || !engine) {
|
||||
return null;
|
||||
}
|
||||
@@ -314,7 +429,7 @@ export class CameraManager {
|
||||
}
|
||||
|
||||
public getMediaCapabilities(media: ViewMedia): CameraManagerMediaCapabilities | null {
|
||||
const engine = this._engineFactory.getEngineForMedia(this._cameras, media);
|
||||
const engine = this._store.getEngineForMedia(media);
|
||||
if (!engine) {
|
||||
return null;
|
||||
}
|
||||
@@ -326,26 +441,26 @@ export class CameraManager {
|
||||
media: ViewMedia,
|
||||
favorite: boolean,
|
||||
): Promise<void> {
|
||||
const cameraConfig = this._cameras.get(media.getCameraID());
|
||||
if (!cameraConfig) {
|
||||
const cameraConfig = this._store.getCameraConfigForMedia(media);
|
||||
const engine = this._store.getEngineForMedia(media);
|
||||
|
||||
if (!cameraConfig || !engine) {
|
||||
return;
|
||||
}
|
||||
const engine = this._engineFactory.getEngineForCamera(cameraConfig);
|
||||
if (engine) {
|
||||
const queryStartTime = new Date();
|
||||
await engine.favoriteMedia(hass, cameraConfig, media, favorite);
|
||||
|
||||
log(
|
||||
this._cardWideConfig,
|
||||
'Frigate Card CameraManager favorite request (',
|
||||
`Duration: ${(new Date().getTime() - queryStartTime.getTime()) / 1000}s,`,
|
||||
'Media:',
|
||||
media.getID(),
|
||||
', Favorite:',
|
||||
favorite,
|
||||
')',
|
||||
);
|
||||
}
|
||||
const queryStartTime = new Date();
|
||||
await engine.favoriteMedia(hass, cameraConfig, media, favorite);
|
||||
|
||||
log(
|
||||
this._cardWideConfig,
|
||||
'Frigate Card CameraManager favorite request (',
|
||||
`Duration: ${(new Date().getTime() - queryStartTime.getTime()) / 1000}s,`,
|
||||
'Media:',
|
||||
media.getID(),
|
||||
', Favorite:',
|
||||
favorite,
|
||||
')',
|
||||
);
|
||||
}
|
||||
|
||||
public areMediaQueriesResultsFresh<T extends MediaQuery>(
|
||||
@@ -355,10 +470,7 @@ export class CameraManager {
|
||||
const now = new Date();
|
||||
|
||||
for (const query of queries) {
|
||||
const engines = this._engineFactory.getEnginesForCameraIDs(
|
||||
this._cameras,
|
||||
query.cameraIDs,
|
||||
);
|
||||
const engines = this._store.getEnginesForCameraIDs(query.cameraIDs);
|
||||
for (const [engine, cameraIDs] of engines ?? []) {
|
||||
const maxAgeSeconds = engine.getQueryResultMaxAge({
|
||||
...query,
|
||||
@@ -382,9 +494,11 @@ export class CameraManager {
|
||||
): Promise<number | null> {
|
||||
const startTime = media.getStartTime();
|
||||
const endTime = media.getEndTime();
|
||||
const cameraConfig = this._cameras.get(media.getCameraID());
|
||||
const cameraConfig = this._store.getCameraConfigForMedia(media);
|
||||
const engine = this._store.getEngineForMedia(media);
|
||||
if (
|
||||
!cameraConfig ||
|
||||
!engine ||
|
||||
!startTime ||
|
||||
!endTime ||
|
||||
target < startTime ||
|
||||
@@ -393,8 +507,7 @@ export class CameraManager {
|
||||
return null;
|
||||
}
|
||||
|
||||
const engine = this._engineFactory.getEngineForCamera(cameraConfig);
|
||||
return (await engine?.getMediaSeekTime(hass, this._cameras, media, target)) ?? null;
|
||||
return await engine.getMediaSeekTime(hass, this._store.getCameras(), media, target);
|
||||
}
|
||||
|
||||
protected async _handleQuery<QT extends DataQuery>(
|
||||
@@ -415,19 +528,21 @@ export class CameraManager {
|
||||
|
||||
let engineResult: Map<QT, QueryReturnType<QT>> | null = null;
|
||||
if (QueryClassifier.isEventQuery(query)) {
|
||||
engineResult = (await engine.getEvents(hass, this._cameras, query)) as Map<
|
||||
QT,
|
||||
QueryReturnType<QT>
|
||||
> | null;
|
||||
engineResult = (await engine.getEvents(
|
||||
hass,
|
||||
this._store.getCameras(),
|
||||
query,
|
||||
)) as Map<QT, QueryReturnType<QT>> | null;
|
||||
} else if (QueryClassifier.isRecordingQuery(query)) {
|
||||
engineResult = (await engine.getRecordings(hass, this._cameras, query)) as Map<
|
||||
QT,
|
||||
QueryReturnType<QT>
|
||||
> | null;
|
||||
engineResult = (await engine.getRecordings(
|
||||
hass,
|
||||
this._store.getCameras(),
|
||||
query,
|
||||
)) as Map<QT, QueryReturnType<QT>> | null;
|
||||
} else if (QueryClassifier.isRecordingSegmentsQuery(query)) {
|
||||
engineResult = (await engine.getRecordingSegments(
|
||||
hass,
|
||||
this._cameras,
|
||||
this._store.getCameras(),
|
||||
query,
|
||||
)) as Map<QT, QueryReturnType<QT>> | null;
|
||||
}
|
||||
@@ -436,10 +551,7 @@ export class CameraManager {
|
||||
};
|
||||
|
||||
const processQuery = async (query: QT): Promise<void> => {
|
||||
const engines = this._engineFactory.getEnginesForCameraIDs(
|
||||
this._cameras,
|
||||
query.cameraIDs,
|
||||
);
|
||||
const engines = this._store.getEnginesForCameraIDs(query.cameraIDs);
|
||||
if (!engines) {
|
||||
return;
|
||||
}
|
||||
@@ -481,7 +593,7 @@ export class CameraManager {
|
||||
): ViewMedia[] {
|
||||
const mediaArray: ViewMedia[] = [];
|
||||
for (const [query, result] of results.entries()) {
|
||||
const engine = this._engineFactory.getEngine(result.engine);
|
||||
const engine = this._store.getEngineOfType(result.engine);
|
||||
|
||||
if (engine) {
|
||||
let media: ViewMedia[] | null = null;
|
||||
@@ -489,12 +601,22 @@ export class CameraManager {
|
||||
QueryClassifier.isEventQuery(query) &&
|
||||
QueryResultClassifier.isEventQueryResult(result)
|
||||
) {
|
||||
media = engine.generateMediaFromEvents(hass, this._cameras, query, result);
|
||||
media = engine.generateMediaFromEvents(
|
||||
hass,
|
||||
this._store.getCameras(),
|
||||
query,
|
||||
result,
|
||||
);
|
||||
} else if (
|
||||
QueryClassifier.isRecordingQuery(query) &&
|
||||
QueryResultClassifier.isRecordingQuery(result)
|
||||
) {
|
||||
media = engine.generateMediaFromRecordings(hass, this._cameras, query, result);
|
||||
media = engine.generateMediaFromRecordings(
|
||||
hass,
|
||||
this._store.getCameras(),
|
||||
query,
|
||||
result,
|
||||
);
|
||||
}
|
||||
if (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(
|
||||
hass: HomeAssistant,
|
||||
cameraID: string,
|
||||
): CameraManagerCameraMetadata | null {
|
||||
const cameraConfig = this._cameras.get(cameraID);
|
||||
const engine = this._engineFactory.getEngineForCamera(cameraConfig);
|
||||
if (!engine || !cameraConfig) {
|
||||
const cameraConfig = this._store.getCameraConfig(cameraID);
|
||||
const engine = this._store.getEngineForCameraID(cameraID);
|
||||
if (!cameraConfig || !engine) {
|
||||
return null;
|
||||
}
|
||||
return engine.getCameraMetadata(hass, cameraConfig);
|
||||
@@ -532,23 +663,18 @@ export class CameraManager {
|
||||
public getCameraCapabilities(
|
||||
cameraID: string,
|
||||
): CameraManagerCameraCapabilities | null {
|
||||
const cameraConfig = this._cameras.get(cameraID);
|
||||
if (!cameraConfig) {
|
||||
const cameraConfig = this._store.getCameraConfig(cameraID);
|
||||
const engine = this._store.getEngineForCameraID(cameraID);
|
||||
if (!cameraConfig || !engine) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const engine = this._engineFactory.getEngineForCamera(cameraConfig);
|
||||
if (!engine) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return engine.getCameraCapabilities(cameraConfig);
|
||||
}
|
||||
|
||||
public getAggregateCameraCapabilities(
|
||||
cameraIDs?: Set<string>,
|
||||
): CameraManagerCapabilities {
|
||||
const perCameraCapabilities = [...(cameraIDs ?? this._cameras.keys())].map(
|
||||
): CameraManagerCapabilities | null {
|
||||
const perCameraCapabilities = [...(cameraIDs ?? this._store.getCameraIDs())].map(
|
||||
(cameraID) => this.getCameraCapabilities(cameraID),
|
||||
);
|
||||
|
||||
@@ -557,6 +683,10 @@ export class CameraManager {
|
||||
canFavoriteRecordings: perCameraCapabilities.some(
|
||||
(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),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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()]);
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,5 @@
|
||||
import { CameraConfig, FrigateCardView } from '../types';
|
||||
import { ViewMedia } from '../view/media';
|
||||
import { FrigateEvent, FrigateRecording } from './frigate/types';
|
||||
|
||||
// ====
|
||||
@@ -18,6 +20,7 @@ export enum QueryResultsType {
|
||||
|
||||
export enum Engine {
|
||||
Frigate = 'frigate',
|
||||
Generic = 'generic',
|
||||
}
|
||||
|
||||
export interface DataQuery {
|
||||
@@ -85,6 +88,10 @@ export interface MediaMetadata {
|
||||
interface BaseCapabilities {
|
||||
canFavoriteEvents: boolean;
|
||||
canFavoriteRecordings: boolean;
|
||||
|
||||
supportsClips: boolean;
|
||||
supportsRecordings: boolean;
|
||||
supportsSnapshots: boolean;
|
||||
supportsTimeline: boolean;
|
||||
}
|
||||
|
||||
@@ -99,6 +106,13 @@ export interface CameraManagerCameraMetadata {
|
||||
icon: string;
|
||||
}
|
||||
|
||||
export interface CameraURLContext {
|
||||
media?: ViewMedia;
|
||||
view?: FrigateCardView;
|
||||
}
|
||||
|
||||
export type CameraConfigs = Map<string, CameraConfig>;
|
||||
|
||||
// ===========
|
||||
// Event Query
|
||||
// ===========
|
||||
|
||||
+150
-351
@@ -11,7 +11,6 @@ import { customElement, property, state } from 'lit/decorators.js';
|
||||
import { classMap } from 'lit/directives/class-map.js';
|
||||
import { createRef, ref, Ref } from 'lit/directives/ref.js';
|
||||
import { StyleInfo, styleMap } from 'lit/directives/style-map.js';
|
||||
import { until } from 'lit/directives/until.js';
|
||||
import throttle from 'lodash-es/throttle';
|
||||
import screenfull from 'screenfull';
|
||||
import { z } from 'zod';
|
||||
@@ -33,19 +32,13 @@ import './components/message.js';
|
||||
import { renderMessage, renderProgressIndicator } from './components/message.js';
|
||||
import './components/thumbnail-carousel.js';
|
||||
import { isConfigUpgradeable } from './config-mgmt.js';
|
||||
import {
|
||||
CAMERA_BIRDSEYE,
|
||||
MEDIA_PLAYER_SUPPORT_BROWSE_MEDIA,
|
||||
REPO_URL,
|
||||
} from './const.js';
|
||||
import { MEDIA_PLAYER_SUPPORT_BROWSE_MEDIA, REPO_URL } from './const.js';
|
||||
import { getLanguage, loadLanguages, localize } from './localize/localize.js';
|
||||
import cardStyle from './scss/card.scss';
|
||||
import {
|
||||
Actions,
|
||||
ActionType,
|
||||
CameraConfig,
|
||||
EntityList,
|
||||
ExtendedEntity,
|
||||
ExtendedHomeAssistant,
|
||||
FrigateCardConfig,
|
||||
frigateCardConfigSchema,
|
||||
@@ -58,6 +51,8 @@ import {
|
||||
Message,
|
||||
RawFrigateCardConfig,
|
||||
CardWideConfig,
|
||||
FrigateCardError,
|
||||
FRIGATE_CARD_VIEW_DEFAULT,
|
||||
} from './types.js';
|
||||
import {
|
||||
convertActionToFrigateCardCustomAction,
|
||||
@@ -67,7 +62,6 @@ import {
|
||||
getActionConfigGivenAction,
|
||||
} from './utils/action.js';
|
||||
import { contentsChanged, errorToConsole } from './utils/basic.js';
|
||||
import { getCameraID } from './utils/camera.js';
|
||||
import {
|
||||
getEntityIcon,
|
||||
getEntityTitle,
|
||||
@@ -79,12 +73,6 @@ import {
|
||||
sideLoadHomeAssistantElements,
|
||||
} from './utils/ha';
|
||||
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 { supportsFeature } from './utils/ha/update.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 { CameraManagerEngineFactory } from './camera-manager/engine-factory.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:
|
||||
*
|
||||
@@ -196,9 +188,6 @@ class FrigateCard extends LitElement {
|
||||
// Array of dynamic menu buttons to be added to menu.
|
||||
protected _dynamicMenuButtons: MenuButton[] = [];
|
||||
|
||||
@state()
|
||||
protected _cameras?: Map<string, CameraConfig>;
|
||||
|
||||
// Error/info message to render.
|
||||
protected _message: Message | null = null;
|
||||
|
||||
@@ -207,6 +196,8 @@ class FrigateCard extends LitElement {
|
||||
|
||||
protected _cameraManager?: CameraManager;
|
||||
|
||||
protected _entityRegistryManager: EntityRegistryManager;
|
||||
|
||||
// The mouse handler may be called continually, throttle it to at most once
|
||||
// per second for performance reasons.
|
||||
protected _boundMouseHandler = throttle(this._mouseHandler.bind(this), 1 * 1000);
|
||||
@@ -219,6 +210,14 @@ class FrigateCard extends LitElement {
|
||||
|
||||
protected _conditionManager: CardConditionManager | null = null;
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this._entityRegistryManager = new EntityRegistryManager(
|
||||
new EntityCache<Entity>(),
|
||||
new EntityCache<ExtendedEntity>(),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the Home Assistant object.
|
||||
*/
|
||||
@@ -383,6 +382,17 @@ class FrigateCard extends LitElement {
|
||||
protected _getMenuButtons(): 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({
|
||||
// Use a magic icon value that the menu will use to render the custom
|
||||
// Frigate icon.
|
||||
@@ -398,8 +408,8 @@ class FrigateCard extends LitElement {
|
||||
) as FrigateCardCustomAction,
|
||||
});
|
||||
|
||||
if (this._cameras && this._cameras.size > 1) {
|
||||
const menuItems = Array.from(this._cameras, ([cameraID, config]) => {
|
||||
if (cameras) {
|
||||
const menuItems = Array.from(cameras, ([cameraID, config]) => {
|
||||
const action = createFrigateCardCustomAction('camera_select', {
|
||||
camera: cameraID,
|
||||
});
|
||||
@@ -436,16 +446,7 @@ class FrigateCard extends LitElement {
|
||||
tap_action: createFrigateCardCustomAction('live') as FrigateCardCustomAction,
|
||||
});
|
||||
|
||||
const cameraConfig = this._getSelectedCameraConfig();
|
||||
|
||||
// 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)
|
||||
) {
|
||||
if (cameraCapabilities?.supportsClips) {
|
||||
buttons.push({
|
||||
icon: 'mdi:filmstrip',
|
||||
...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
|
||||
// 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)
|
||||
) {
|
||||
if (cameraCapabilities?.supportsSnapshots) {
|
||||
buttons.push({
|
||||
icon: 'mdi:camera',
|
||||
...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
|
||||
// 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)
|
||||
) {
|
||||
if (cameraCapabilities?.supportsRecordings) {
|
||||
buttons.push({
|
||||
icon: 'mdi:album',
|
||||
...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
|
||||
// camera with a Frigate camera name.
|
||||
if (
|
||||
this._cameras &&
|
||||
[...this._cameras.values()].some(
|
||||
(config) =>
|
||||
config.frigate.camera_name && config.frigate.camera_name !== CAMERA_BIRDSEYE,
|
||||
)
|
||||
) {
|
||||
if (cameraCapabilities?.supportsTimeline) {
|
||||
buttons.push({
|
||||
icon: 'mdi:chart-gantt',
|
||||
...this._getConfig().menu.buttons.timeline,
|
||||
@@ -545,7 +526,7 @@ class FrigateCard extends LitElement {
|
||||
});
|
||||
}
|
||||
|
||||
if (cameraConfig?.frigate.url) {
|
||||
if (this._getCameraURLFromContext()) {
|
||||
buttons.push({
|
||||
icon: 'mdi:web',
|
||||
...this._getConfig().menu.buttons.frigate_ui,
|
||||
@@ -590,7 +571,7 @@ class FrigateCard extends LitElement {
|
||||
if (
|
||||
mediaPlayers.length &&
|
||||
(this._view?.isViewerView() ||
|
||||
(this._view?.is('live') && cameraConfig?.camera_entity))
|
||||
(this._view?.is('live') && selectedCameraConfig?.camera_entity))
|
||||
) {
|
||||
const mediaPlayerItems = mediaPlayers.map((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.
|
||||
* @returns The CameraConfig object or null if not found.
|
||||
*/
|
||||
protected _getSelectedCameraConfig(): CameraConfig | null {
|
||||
if (!this._cameras || !this._cameras.size || !this._view?.camera) {
|
||||
if (!this._view || !this._cameraManager) {
|
||||
return null;
|
||||
}
|
||||
return this._cameras.get(this._view.camera) || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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;
|
||||
return this._cameraManager.getCameraConfig(this._view.camera);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -997,7 +756,7 @@ class FrigateCard extends LitElement {
|
||||
};
|
||||
|
||||
this._overriddenConfig = undefined;
|
||||
this._cameras = undefined;
|
||||
this._cameraManager = undefined;
|
||||
this._view = undefined;
|
||||
this._message = null;
|
||||
|
||||
@@ -1039,24 +798,27 @@ class FrigateCard extends LitElement {
|
||||
|
||||
if (!args?.view) {
|
||||
// Load the default view.
|
||||
let camera;
|
||||
if (this._cameras?.size) {
|
||||
if (this._view?.camera && this._getConfig().view.update_cycle_camera) {
|
||||
const keys = Array.from(this._cameras.keys());
|
||||
const currentIndex = keys.indexOf(this._view.camera);
|
||||
const targetIndex = currentIndex + 1 >= keys.length ? 0 : currentIndex + 1;
|
||||
camera = keys[targetIndex];
|
||||
} else {
|
||||
// Reset to the default camera.
|
||||
camera = this._cameras.keys().next().value;
|
||||
let cameraID: string | null = null;
|
||||
if (this._cameraManager) {
|
||||
const cameras = this._cameraManager.getCameras();
|
||||
if (cameras) {
|
||||
if (this._view?.camera && this._getConfig().view.update_cycle_camera) {
|
||||
const keys = Array.from(cameras.keys());
|
||||
const currentIndex = keys.indexOf(this._view.camera);
|
||||
const targetIndex = currentIndex + 1 >= keys.length ? 0 : currentIndex + 1;
|
||||
cameraID = keys[targetIndex];
|
||||
} else {
|
||||
// Reset to the default camera.
|
||||
cameraID = cameras.keys().next().value;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (camera) {
|
||||
if (cameraID) {
|
||||
changeView(
|
||||
new View({
|
||||
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.
|
||||
*/
|
||||
protected willUpdate(changedProps: PropertyValues): void {
|
||||
if (
|
||||
this._cameras &&
|
||||
this._cardWideConfig &&
|
||||
(changedProps.has('_config') ||
|
||||
changedProps.has('_cameras') ||
|
||||
(!this._cameraManager ||
|
||||
changedProps.has('_config') ||
|
||||
changedProps.has('_cardWideConfig'))
|
||||
) {
|
||||
this._cameraManager = new CameraManager(
|
||||
new CameraManagerEngineFactory(this._cardWideConfig),
|
||||
this._cameras,
|
||||
new CameraManagerEngineFactory(
|
||||
this._entityRegistryManager,
|
||||
this._cardWideConfig,
|
||||
),
|
||||
this._cardWideConfig,
|
||||
);
|
||||
this._initializeCameras().then(() => this.requestUpdate());
|
||||
}
|
||||
|
||||
if (changedProps.has('_cardWideConfig')) {
|
||||
@@ -1162,7 +955,8 @@ class FrigateCard extends LitElement {
|
||||
let changedCamera = 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 diffs = getHassDifferences(this._hass, oldHass, triggerEntities, {
|
||||
stateOnly: true,
|
||||
@@ -1172,10 +966,10 @@ class FrigateCard extends LitElement {
|
||||
(entity) => !isTriggeredState(this._hass?.states[entity]),
|
||||
);
|
||||
if (shouldTrigger) {
|
||||
this._triggers.set(camera, now);
|
||||
this._triggers.set(cameraID, now);
|
||||
triggerChanges = true;
|
||||
} else if (shouldUntrigger && this._triggers.has(camera)) {
|
||||
this._triggers.delete(camera);
|
||||
} else if (shouldUntrigger && this._triggers.has(cameraID)) {
|
||||
this._triggers.delete(cameraID);
|
||||
triggerChanges = true;
|
||||
}
|
||||
}
|
||||
@@ -1419,7 +1213,7 @@ class FrigateCard extends LitElement {
|
||||
const cameraEntity = cameraConfig.camera_entity ?? null;
|
||||
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_type = media.getContentType();
|
||||
title = media.getTitle();
|
||||
@@ -1491,9 +1285,9 @@ class FrigateCard extends LitElement {
|
||||
this._downloadViewerMedia();
|
||||
break;
|
||||
case 'frigate_ui':
|
||||
const frigate_url = this._getFrigateURLFromContext();
|
||||
if (frigate_url) {
|
||||
window.open(frigate_url);
|
||||
const url = this._getCameraURLFromContext();
|
||||
if (url) {
|
||||
window.open(url);
|
||||
}
|
||||
break;
|
||||
case 'fullscreen':
|
||||
@@ -1508,22 +1302,15 @@ class FrigateCard extends LitElement {
|
||||
this._refMenu.value?.toggleMenu();
|
||||
break;
|
||||
case 'camera_select':
|
||||
const camera = frigateCardAction.camera;
|
||||
if (this._cameras?.has(camera) && this._view) {
|
||||
const targetView = View.selectBestViewForUserSpecified(
|
||||
this._getConfig().view.camera_select === 'current'
|
||||
? this._view.view
|
||||
: (this._getConfig().view.camera_select as FrigateCardView),
|
||||
);
|
||||
this._changeView({
|
||||
view: new View({
|
||||
view: this._cameras?.get(camera)?.frigate.camera_name
|
||||
? targetView
|
||||
: // Fallback to supported views for non-Frigate cameras.
|
||||
View.selectBestViewForNonFrigateCameras(targetView),
|
||||
camera: camera,
|
||||
}),
|
||||
});
|
||||
const cameraID = frigateCardAction.camera;
|
||||
if (this._cameraManager?.hasCameraID(cameraID) && this._view) {
|
||||
const viewOnCameraSelect = this._getConfig().view.camera_select;
|
||||
const targetView =
|
||||
viewOnCameraSelect === 'current' ? this._view.view : viewOnCameraSelect;
|
||||
const actualView = this.isViewSupportedByCamera(cameraID, targetView)
|
||||
? targetView
|
||||
: FRIGATE_CARD_VIEW_DEFAULT;
|
||||
this._changeView({ view: new View({ view: actualView, camera: cameraID }) });
|
||||
}
|
||||
break;
|
||||
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.
|
||||
*/
|
||||
@@ -1593,18 +1407,16 @@ class FrigateCard extends LitElement {
|
||||
* Get the Frigate UI URL from context.
|
||||
* @returns The URL or null if unavailable.
|
||||
*/
|
||||
protected _getFrigateURLFromContext(): string | null {
|
||||
const cameraConfig = this._getSelectedCameraConfig();
|
||||
if (!cameraConfig || !cameraConfig.frigate.url || !this._view) {
|
||||
return null;
|
||||
}
|
||||
if (!cameraConfig.frigate.camera_name) {
|
||||
return cameraConfig.frigate.url;
|
||||
}
|
||||
if (this._view.isViewerView() || this._view.isGalleryView()) {
|
||||
return `${cameraConfig.frigate.url}/events?camera=${cameraConfig.frigate.camera_name}`;
|
||||
}
|
||||
return `${cameraConfig.frigate.url}/cameras/${cameraConfig.frigate.camera_name}`;
|
||||
protected _getCameraURLFromContext(): string | null {
|
||||
const view = this._view;
|
||||
const selectedCameraID = view?.camera;
|
||||
const media = view?.queryResults?.getSelectedResult() ?? null;
|
||||
return this._hass && view && selectedCameraID
|
||||
? this._cameraManager?.getCameraURL(selectedCameraID, {
|
||||
...(media && { media: media }),
|
||||
...(view && { view: view.view }),
|
||||
}) ?? null
|
||||
: null;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1961,23 +1773,14 @@ class FrigateCard extends LitElement {
|
||||
>
|
||||
${renderMenuAbove ? this._renderMenu() : ''}
|
||||
<div ${ref(this._refMain)} class="${classMap(mainClasses)}">
|
||||
${this._cameras === undefined && !this._message
|
||||
? until(
|
||||
(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 }),
|
||||
)
|
||||
${!this._cameraManager?.isInitialized() && !this._message
|
||||
? renderProgressIndicator({ cardWideConfig: this._cardWideConfig })
|
||||
: // Always want to call render even if there's a message, to
|
||||
// ensure live preload is always present (even if not displayed).
|
||||
this._render()}
|
||||
${
|
||||
// Keep message rendering to last to show messages that may have
|
||||
// been generated during the render.
|
||||
// Keep message rendering to last to show messages that may have been
|
||||
// generated during the render.
|
||||
this._message ? renderMessage(this._message) : ''
|
||||
}
|
||||
</div>
|
||||
@@ -2011,7 +1814,7 @@ class FrigateCard extends LitElement {
|
||||
protected _render(): TemplateResult | void {
|
||||
const cameraConfig = this._getSelectedCameraConfig();
|
||||
|
||||
if (!this._hass || !this._view || !cameraConfig || !this._cameras) {
|
||||
if (!this._hass || !this._view || !cameraConfig) {
|
||||
return html``;
|
||||
}
|
||||
|
||||
@@ -2037,7 +1840,6 @@ class FrigateCard extends LitElement {
|
||||
? html` <frigate-card-gallery
|
||||
.hass=${this._hass}
|
||||
.view=${this._view}
|
||||
.cameras=${this._cameras}
|
||||
.galleryConfig=${this._getConfig().event_gallery}
|
||||
.cameraManager=${this._cameraManager}
|
||||
.cardWideConfig=${this._cardWideConfig}
|
||||
@@ -2048,7 +1850,6 @@ class FrigateCard extends LitElement {
|
||||
? html` <frigate-card-viewer
|
||||
.hass=${this._hass}
|
||||
.view=${this._view}
|
||||
.cameras=${this._cameras}
|
||||
.viewerConfig=${this._getConfig().media_viewer}
|
||||
.resolvedMediaCache=${this._resolvedMediaCache}
|
||||
.cameraManager=${this._cameraManager}
|
||||
@@ -2060,7 +1861,6 @@ class FrigateCard extends LitElement {
|
||||
? html` <frigate-card-timeline
|
||||
.hass=${this._hass}
|
||||
.view=${this._view}
|
||||
.cameras=${this._cameras}
|
||||
.timelineConfig=${this._getConfig().timeline}
|
||||
.cameraManager=${this._cameraManager}
|
||||
>
|
||||
@@ -2082,7 +1882,6 @@ class FrigateCard extends LitElement {
|
||||
.liveConfig=${this._config.live}
|
||||
.conditionState=${this._conditionState}
|
||||
.liveOverrides=${getOverridesByKey(this._getConfig().overrides, 'live')}
|
||||
.cameras=${this._cameras}
|
||||
.cameraManager=${this._cameraManager}
|
||||
.cardWideConfig=${this._cardWideConfig}
|
||||
class="${classMap(liveClasses)}"
|
||||
|
||||
@@ -10,7 +10,6 @@ import {
|
||||
import { customElement, property, state } from 'lit/decorators.js';
|
||||
import galleryStyle from '../scss/gallery.scss';
|
||||
import {
|
||||
CameraConfig,
|
||||
CardWideConfig,
|
||||
ExtendedHomeAssistant,
|
||||
frigateCardConfigDefaults,
|
||||
@@ -24,7 +23,7 @@ import {
|
||||
} from '../utils/media-to-view.js';
|
||||
import { CameraManager, ExtendedMediaQueryResult } from '../camera-manager/manager.js';
|
||||
import { View } from '../view/view.js';
|
||||
import { renderProgressIndicator } from './message.js';
|
||||
import { dispatchMessageEvent, renderProgressIndicator } from './message.js';
|
||||
import './thumbnail.js';
|
||||
import { THUMBNAIL_DETAILS_WIDTH_MIN } from './thumbnail.js';
|
||||
import { createRef, Ref } from 'lit/directives/ref.js';
|
||||
@@ -36,6 +35,7 @@ import { errorToConsole } from '../utils/basic';
|
||||
import './media-filter';
|
||||
import "./surround-basic";
|
||||
import { ViewMedia } from '../view/media';
|
||||
import { localize } from '../localize/localize';
|
||||
|
||||
const GALLERY_MEDIA_CHUNK_SIZE = 100;
|
||||
|
||||
@@ -55,9 +55,6 @@ export class FrigateCardGallery extends LitElement {
|
||||
@property({ attribute: false })
|
||||
public galleryConfig?: GalleryConfig;
|
||||
|
||||
@property({ attribute: false })
|
||||
public cameras?: Map<string, CameraConfig>;
|
||||
|
||||
@property({ attribute: false })
|
||||
public cameraManager?: CameraManager;
|
||||
|
||||
@@ -72,7 +69,6 @@ export class FrigateCardGallery extends LitElement {
|
||||
if (
|
||||
!this.hass ||
|
||||
!this.view ||
|
||||
!this.cameras ||
|
||||
!this.view.isGalleryView() ||
|
||||
!this.cameraManager
|
||||
) {
|
||||
@@ -85,7 +81,6 @@ export class FrigateCardGallery extends LitElement {
|
||||
this,
|
||||
this.hass,
|
||||
this.cameraManager,
|
||||
this.cameras,
|
||||
this.view,
|
||||
);
|
||||
} else {
|
||||
@@ -98,7 +93,6 @@ export class FrigateCardGallery extends LitElement {
|
||||
this,
|
||||
this.hass,
|
||||
this.cameraManager,
|
||||
this.cameras,
|
||||
this.view,
|
||||
{
|
||||
...(mediaType && { mediaType: mediaType }),
|
||||
@@ -120,7 +114,6 @@ export class FrigateCardGallery extends LitElement {
|
||||
${this.galleryConfig && this.galleryConfig.controls.filter.mode !== 'none'
|
||||
? html` <frigate-card-media-filter
|
||||
.hass=${this.hass}
|
||||
.cameras=${this.cameras}
|
||||
.cameraManager=${this.cameraManager}
|
||||
.view=${this.view}
|
||||
.mediaLimit=${GALLERY_MEDIA_CHUNK_SIZE}
|
||||
@@ -132,7 +125,6 @@ export class FrigateCardGallery extends LitElement {
|
||||
.hass=${this.hass}
|
||||
.view=${this.view}
|
||||
.galleryConfig=${this.galleryConfig}
|
||||
.cameras=${this.cameras}
|
||||
.cameraManager=${this.cameraManager}
|
||||
.cardWideConfig=${this.cardWideConfig}
|
||||
>
|
||||
@@ -166,9 +158,6 @@ export class FrigateCardGalleryCore extends LitElement {
|
||||
@property({ attribute: false })
|
||||
public galleryConfig?: GalleryConfig;
|
||||
|
||||
@property({ attribute: false })
|
||||
public cameras?: Map<string, CameraConfig>;
|
||||
|
||||
@property({ attribute: false })
|
||||
public cameraManager?: CameraManager;
|
||||
|
||||
@@ -330,12 +319,17 @@ export class FrigateCardGalleryCore extends LitElement {
|
||||
!this._media ||
|
||||
!this.hass ||
|
||||
!this.view ||
|
||||
!this.view.isGalleryView() ||
|
||||
!this.cameras
|
||||
!this.view.isGalleryView()
|
||||
) {
|
||||
return html``;
|
||||
}
|
||||
|
||||
if ((this.view?.queryResults?.getResultsCount() ?? 0) === 0) {
|
||||
return dispatchMessageEvent(this, localize('common.no_media'), 'info', {
|
||||
icon: 'mdi:multimedia',
|
||||
});
|
||||
}
|
||||
|
||||
return html`
|
||||
${this._media.map(
|
||||
(media, index) =>
|
||||
|
||||
+20
-23
@@ -104,9 +104,6 @@ export class FrigateCardLive extends LitElement {
|
||||
@property({ attribute: false })
|
||||
public view?: Readonly<View>;
|
||||
|
||||
@property({ attribute: false })
|
||||
public cameras?: Map<string, CameraConfig>;
|
||||
|
||||
@property({ attribute: false })
|
||||
public liveConfig?: LiveConfig;
|
||||
|
||||
@@ -200,7 +197,7 @@ export class FrigateCardLive extends LitElement {
|
||||
* @returns A rendered template.
|
||||
*/
|
||||
protected render(): TemplateResult | void {
|
||||
if (!this.hass || !this.liveConfig || !this.cameras || !this.view) {
|
||||
if (!this.hass || !this.liveConfig || !this.cameraManager || !this.view) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -229,7 +226,6 @@ export class FrigateCardLive extends LitElement {
|
||||
.fetchMedia=${config.controls.thumbnails.media}
|
||||
.thumbnailConfig=${config.controls.thumbnails}
|
||||
.timelineConfig=${config.controls.timeline}
|
||||
.cameras=${this.cameras}
|
||||
.cameraManager=${this.cameraManager}
|
||||
.inBackground=${this._inBackground}
|
||||
@frigate-card:message=${(ev: CustomEvent<Message>) => {
|
||||
@@ -254,7 +250,6 @@ export class FrigateCardLive extends LitElement {
|
||||
<frigate-card-live-carousel
|
||||
.hass=${this.hass}
|
||||
.view=${this.view}
|
||||
.cameras=${this.cameras}
|
||||
.liveConfig=${this.liveConfig}
|
||||
.inBackground=${this._inBackground}
|
||||
.conditionState=${this.conditionState}
|
||||
@@ -286,9 +281,6 @@ export class FrigateCardLiveCarousel extends LitElement {
|
||||
@property({ attribute: false })
|
||||
public view?: Readonly<View>;
|
||||
|
||||
@property({ attribute: false })
|
||||
public cameras?: Map<string, CameraConfig>;
|
||||
|
||||
@property({ attribute: false })
|
||||
public liveConfig?: LiveConfig;
|
||||
|
||||
@@ -345,10 +337,11 @@ export class FrigateCardLiveCarousel extends LitElement {
|
||||
}
|
||||
|
||||
protected _getSelectedCameraIndex(): number {
|
||||
if (!this.cameras || !this.view) {
|
||||
const cameraIDs = this.cameraManager?.getCameraIDs();
|
||||
if (!cameraIDs || !this.view) {
|
||||
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.
|
||||
*/
|
||||
protected _getPlugins(): EmblaCarouselPlugins {
|
||||
const cameras = this.cameraManager?.getCameraIDs();
|
||||
return [
|
||||
// Only enable wheel plugin if there is more than one camera.
|
||||
...(this.cameras && this.cameras.size > 1
|
||||
...(cameras && cameras.size > 1
|
||||
? [
|
||||
WheelGesturesPlugin({
|
||||
// Whether the carousel is vertical or horizontal, interpret y-axis wheel
|
||||
@@ -424,14 +418,15 @@ export class FrigateCardLiveCarousel extends LitElement {
|
||||
* name to slide number.
|
||||
*/
|
||||
protected _getSlides(): [TemplateResult[], Record<string, number>] {
|
||||
if (!this.cameras) {
|
||||
const cameras = this.cameraManager?.getCameras();
|
||||
if (!cameras) {
|
||||
return [[], {}];
|
||||
}
|
||||
|
||||
const slides: TemplateResult[] = [];
|
||||
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);
|
||||
if (slide) {
|
||||
cameraToSlide[camera] = slides.length;
|
||||
@@ -445,8 +440,9 @@ export class FrigateCardLiveCarousel extends LitElement {
|
||||
* Handle the user selecting a new slide in the carousel.
|
||||
*/
|
||||
protected _setViewHandler(ev: CustomEvent<CarouselSelect>): void {
|
||||
if (this.cameras && ev.detail.index !== this._getSelectedCameraIndex()) {
|
||||
this._setViewCameraID(Array.from(this.cameras.keys())[ev.detail.index]);
|
||||
const cameras = this.cameraManager?.getCameras();
|
||||
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] {
|
||||
if (!this.cameras || !this.view || !this.hass) {
|
||||
const cameras = this.cameraManager?.getCameras();
|
||||
if (!cameras || !this.view || !this.hass) {
|
||||
return [null, null];
|
||||
}
|
||||
const keys = Array.from(this.cameras.keys());
|
||||
const keys = Array.from(cameras.keys());
|
||||
const currentIndex = keys.indexOf(this.view.camera);
|
||||
|
||||
if (currentIndex < 0 || this.cameras.size <= 1) {
|
||||
if (currentIndex < 0 || cameras.size <= 1) {
|
||||
return [null, null];
|
||||
}
|
||||
|
||||
return [
|
||||
keys[currentIndex > 0 ? currentIndex - 1 : this.cameras.size - 1],
|
||||
keys[currentIndex + 1 < this.cameras.size ? currentIndex + 1 : 0],
|
||||
keys[currentIndex > 0 ? currentIndex - 1 : cameras.size - 1],
|
||||
keys[currentIndex + 1 < cameras.size ? currentIndex + 1 : 0],
|
||||
];
|
||||
}
|
||||
|
||||
@@ -602,11 +599,11 @@ export class FrigateCardLiveCarousel extends LitElement {
|
||||
<frigate-card-media-carousel
|
||||
${ref(this._refMediaCarousel)}
|
||||
.carouselOptions=${guard(
|
||||
[this.cameras, this.liveConfig],
|
||||
[this.cameraManager, this.liveConfig],
|
||||
this._getOptions.bind(this),
|
||||
)}
|
||||
.carouselPlugins=${guard(
|
||||
[this.cameras, this.liveConfig],
|
||||
[this.cameraManager, this.liveConfig],
|
||||
this._getPlugins.bind(this),
|
||||
) as EmblaCarouselPlugins}
|
||||
.label="${cameraMetadataCurrent ? `${localize('common.live')}: ${cameraMetadataCurrent.title}` : ''}"
|
||||
|
||||
@@ -13,7 +13,6 @@ import { createRef, ref, Ref } from 'lit/directives/ref.js';
|
||||
import { DateRange } from '../camera-manager/range';
|
||||
import { localize } from '../localize/localize';
|
||||
import mediaFilterStyle from '../scss/media-filter.scss';
|
||||
import { CameraConfig } from '../types';
|
||||
import { createViewForEvents, createViewForRecordings } from '../utils/media-to-view.js';
|
||||
import { errorToConsole, formatDate, prettifyTitle } from '../utils/basic';
|
||||
import { ScopedRegistryHost } from '@lit-labs/scoped-registry-mixin';
|
||||
@@ -77,9 +76,6 @@ class FrigateCardMediaFilter extends ScopedRegistryHost(LitElement) {
|
||||
@property({ attribute: false })
|
||||
public hass?: HomeAssistant;
|
||||
|
||||
@property({ attribute: false })
|
||||
public cameras?: Map<string, CameraConfig>;
|
||||
|
||||
@property({ attribute: false })
|
||||
public cameraManager?: CameraManager;
|
||||
|
||||
@@ -173,7 +169,8 @@ class FrigateCardMediaFilter extends ScopedRegistryHost(LitElement) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
_ev: CustomEvent<{ value: unknown }>,
|
||||
): 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;
|
||||
}
|
||||
|
||||
@@ -187,7 +184,7 @@ class FrigateCardMediaFilter extends ScopedRegistryHost(LitElement) {
|
||||
};
|
||||
|
||||
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
|
||||
| MediaFilterMediaType
|
||||
| undefined;
|
||||
@@ -229,20 +226,13 @@ class FrigateCardMediaFilter extends ScopedRegistryHost(LitElement) {
|
||||
];
|
||||
|
||||
(
|
||||
await createViewForEvents(
|
||||
this,
|
||||
this.hass,
|
||||
this.cameraManager,
|
||||
this.cameras,
|
||||
this.view,
|
||||
{
|
||||
query: new EventMediaQueries(queries),
|
||||
await createViewForEvents(this, this.hass, this.cameraManager, this.view, {
|
||||
query: new EventMediaQueries(queries),
|
||||
|
||||
// See 'A note on views' above for these two arguments.
|
||||
...(cameraIDs.size === 1 && { targetCameraID: [...cameraIDs][0] }),
|
||||
targetView: mediaType === MediaFilterMediaType.Clips ? 'clips' : 'snapshots',
|
||||
},
|
||||
)
|
||||
// See 'A note on views' above for these two arguments.
|
||||
...(cameraIDs.size === 1 && { targetCameraID: [...cameraIDs][0] }),
|
||||
targetView: mediaType === MediaFilterMediaType.Clips ? 'clips' : 'snapshots',
|
||||
})
|
||||
)?.dispatchChangeEvent(this);
|
||||
} else if (mediaType === MediaFilterMediaType.Recordings) {
|
||||
const query: RecordingQuery = {
|
||||
@@ -252,32 +242,28 @@ class FrigateCardMediaFilter extends ScopedRegistryHost(LitElement) {
|
||||
};
|
||||
|
||||
(
|
||||
await createViewForRecordings(
|
||||
this,
|
||||
this.hass,
|
||||
this.cameraManager,
|
||||
this.cameras,
|
||||
this.view,
|
||||
{
|
||||
query: new RecordingMediaQueries([query]),
|
||||
await createViewForRecordings(this, this.hass, this.cameraManager, this.view, {
|
||||
query: new RecordingMediaQueries([query]),
|
||||
|
||||
// See 'A note on views' above for these two arguments.
|
||||
...(cameraIDs.size === 1 && { targetCameraID: [...cameraIDs][0] }),
|
||||
targetView: 'recordings',
|
||||
},
|
||||
)
|
||||
// See 'A note on views' above for these two arguments.
|
||||
...(cameraIDs.size === 1 && { targetCameraID: [...cameraIDs][0] }),
|
||||
targetView: 'recordings',
|
||||
})
|
||||
)?.dispatchChangeEvent(this);
|
||||
}
|
||||
}
|
||||
|
||||
protected willUpdate(changedProps: PropertyValues): void {
|
||||
if (changedProps.has('cameras') && this.cameras) {
|
||||
this._cameraOptions = Array.from(this.cameras.keys()).map((cameraID) => ({
|
||||
value: cameraID,
|
||||
label: this.hass
|
||||
? this.cameraManager?.getCameraMetadata(this.hass, cameraID)?.title ?? ''
|
||||
: '',
|
||||
}));
|
||||
if (changedProps.has('cameraManager')) {
|
||||
const cameras = this.cameraManager?.getCameras();
|
||||
if (cameras) {
|
||||
this._cameraOptions = Array.from(cameras.keys()).map((cameraID) => ({
|
||||
value: cameraID,
|
||||
label: this.hass
|
||||
? this.cameraManager?.getCameraMetadata(this.hass, cameraID)?.title ?? ''
|
||||
: '',
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
if (changedProps.has('cameraManager') && this.hass && this.cameraManager) {
|
||||
@@ -321,7 +307,8 @@ class FrigateCardMediaFilter extends ScopedRegistryHost(LitElement) {
|
||||
|
||||
protected _getDefaultsFromView(): MediaFilterCoreDefaults | null {
|
||||
const queries = this.view?.query?.getQueries();
|
||||
if (!this.view || !queries) {
|
||||
const cameras = this.cameraManager?.getCameras();
|
||||
if (!this.view || !queries || !cameras) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -337,7 +324,7 @@ class FrigateCardMediaFilter extends ScopedRegistryHost(LitElement) {
|
||||
);
|
||||
// Special note: If all cameras are selected, this is the same as no
|
||||
// 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];
|
||||
}
|
||||
|
||||
|
||||
@@ -9,7 +9,6 @@ import {
|
||||
import { customElement, property } from 'lit/decorators.js';
|
||||
import surroundStyle from '../scss/surround.scss';
|
||||
import {
|
||||
CameraConfig,
|
||||
ClipsOrSnapshotsOrAll,
|
||||
ExtendedHomeAssistant,
|
||||
MiniTimelineControlConfig,
|
||||
@@ -55,9 +54,6 @@ export class FrigateCardSurround extends LitElement {
|
||||
@property({ attribute: false, hasChanged: contentsChanged })
|
||||
public fetchMedia?: ClipsOrSnapshotsOrAll;
|
||||
|
||||
@property({ attribute: false })
|
||||
public cameras?: Map<string, CameraConfig>;
|
||||
|
||||
@property({ attribute: false })
|
||||
public cameraManager?: CameraManager;
|
||||
|
||||
@@ -71,7 +67,6 @@ export class FrigateCardSurround extends LitElement {
|
||||
*/
|
||||
protected async _fetchMedia(): Promise<void> {
|
||||
if (
|
||||
!this.cameras ||
|
||||
!this.cameraManager ||
|
||||
!this.fetchMedia ||
|
||||
this.inBackground ||
|
||||
@@ -88,7 +83,6 @@ export class FrigateCardSurround extends LitElement {
|
||||
this,
|
||||
this.hass,
|
||||
this.cameraManager,
|
||||
this.cameras,
|
||||
this.view,
|
||||
{
|
||||
targetView: this.view.view,
|
||||
@@ -138,11 +132,12 @@ export class FrigateCardSurround extends LitElement {
|
||||
}
|
||||
|
||||
protected _getCameraIDsForTimeline(): Set<string> | null {
|
||||
if (!this.view || !this.cameras) {
|
||||
const cameras = this.cameraManager?.getCameras();
|
||||
if (!this.view || !cameras) {
|
||||
return null;
|
||||
}
|
||||
if (this.view?.is('live')) {
|
||||
return getAllDependentCameras(this.cameras, this.view.camera);
|
||||
return getAllDependentCameras(cameras, this.view.camera);
|
||||
}
|
||||
if (this.view.isViewerView()) {
|
||||
return new Set(
|
||||
@@ -160,7 +155,7 @@ export class FrigateCardSurround extends LitElement {
|
||||
* @returns A rendered template.
|
||||
*/
|
||||
protected render(): TemplateResult | void {
|
||||
if (!this.hass || !this.view || !this.thumbnailConfig || !this.cameras) {
|
||||
if (!this.hass || !this.view || !this.thumbnailConfig) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -220,7 +215,6 @@ export class FrigateCardSurround extends LitElement {
|
||||
slot=${this.timelineConfig.mode}
|
||||
.hass=${this.hass}
|
||||
.view=${this.view}
|
||||
.cameras=${this.cameras}
|
||||
.cameraIDs=${this._cameraIDsForTimeline}
|
||||
.mini=${true}
|
||||
.timelineConfig=${this.timelineConfig}
|
||||
|
||||
@@ -13,7 +13,6 @@ import { classMap } from 'lit/directives/class-map.js';
|
||||
import { createRef, ref, Ref } from 'lit/directives/ref.js';
|
||||
import thumbnailCarouselStyle from '../scss/thumbnail-carousel.scss';
|
||||
import {
|
||||
CameraConfig,
|
||||
ExtendedHomeAssistant,
|
||||
ThumbnailsControlConfig,
|
||||
} from '../types.js';
|
||||
|
||||
@@ -26,7 +26,6 @@ import {
|
||||
TimelineOptionsCluster,
|
||||
TimelineWindow,
|
||||
} from 'vis-timeline/esnext';
|
||||
import { CAMERA_BIRDSEYE } from '../const';
|
||||
import { localize } from '../localize/localize';
|
||||
import timelineCoreStyle from '../scss/timeline-core.scss';
|
||||
import {
|
||||
@@ -165,9 +164,6 @@ export class FrigateCardTimelineCore extends LitElement {
|
||||
@property({ attribute: false })
|
||||
public view?: Readonly<View>;
|
||||
|
||||
@property({ attribute: false })
|
||||
public cameras?: Map<string, CameraConfig>;
|
||||
|
||||
@property({ attribute: false, hasChanged: contentsChanged })
|
||||
public timelineConfig?: TimelineCoreConfig;
|
||||
|
||||
@@ -240,11 +236,12 @@ export class FrigateCardTimelineCore extends LitElement {
|
||||
protected _handleThumbnailDataRequest(request: ThumbnailDataRequestEvent): void {
|
||||
const item = request.detail.item;
|
||||
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.cameraConfig = media
|
||||
? this.cameras?.get(media.getCameraID())
|
||||
: undefined;
|
||||
request.detail.cameraConfig = cameraConfig;
|
||||
request.detail.cameraManager = this.cameraManager;
|
||||
request.detail.media = media;
|
||||
request.detail.view = this.view;
|
||||
@@ -255,13 +252,13 @@ export class FrigateCardTimelineCore extends LitElement {
|
||||
* @returns A rendered template.
|
||||
*/
|
||||
protected render(): TemplateResult | void {
|
||||
if (!this.hass || !this.view || !this.timelineConfig) {
|
||||
const cameraIDs = this._getTimelineCameraIDs();
|
||||
|
||||
if (!this.hass || !this.view || !this.timelineConfig || !cameraIDs) {
|
||||
return;
|
||||
}
|
||||
const capabilities = this.cameraManager?.getAggregateCameraCapabilities(
|
||||
this._getTimelineCameraIDs(),
|
||||
);
|
||||
|
||||
const capabilities = this.cameraManager?.getAggregateCameraCapabilities(cameraIDs);
|
||||
return html` ${capabilities?.supportsTimeline
|
||||
? html` <div
|
||||
@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.
|
||||
* @returns A set of camera ids (may be empty).
|
||||
*/
|
||||
protected _getTimelineCameraIDs(): Set<string> {
|
||||
protected _getTimelineCameraIDs(): Set<string> | null {
|
||||
return this.cameraIDs ?? this._getAllCameraIDs();
|
||||
}
|
||||
|
||||
@@ -300,8 +297,8 @@ export class FrigateCardTimelineCore extends LitElement {
|
||||
* Get all the keys of all cameras.
|
||||
* @returns A set of camera ids (may be empty).
|
||||
*/
|
||||
protected _getAllCameraIDs(): Set<string> {
|
||||
return new Set(this.cameras?.keys());
|
||||
protected _getAllCameraIDs(): Set<string> | null {
|
||||
return this.cameraManager?.getCameraIDs() ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -395,6 +392,7 @@ export class FrigateCardTimelineCore extends LitElement {
|
||||
): Promise<void> {
|
||||
const results = this.view?.queryResults;
|
||||
const media = results?.getResults();
|
||||
const cameraIDs = this._getTimelineCameraIDs();
|
||||
if (
|
||||
!media ||
|
||||
!results ||
|
||||
@@ -402,7 +400,7 @@ export class FrigateCardTimelineCore extends LitElement {
|
||||
!this.view ||
|
||||
!this.hass ||
|
||||
!this.cameraManager ||
|
||||
!this.cameraManager ||
|
||||
!cameraIDs ||
|
||||
// Skip range changes that do not have hammerjs pan directions associated
|
||||
// with them, as these outliers cause media matching issues below.
|
||||
!properties.event.additionalEvent
|
||||
@@ -420,7 +418,7 @@ export class FrigateCardTimelineCore extends LitElement {
|
||||
findClosestMediaIndex(
|
||||
media,
|
||||
targetTime,
|
||||
this._getTimelineCameraIDs(),
|
||||
cameraIDs,
|
||||
properties.event.additionalEvent === 'panright' ? 'end' : 'start',
|
||||
),
|
||||
);
|
||||
@@ -462,13 +460,14 @@ export class FrigateCardTimelineCore extends LitElement {
|
||||
stopEventFromActivatingCardWideActions(properties.event);
|
||||
}
|
||||
|
||||
const timelineCameraIDs = this._getTimelineCameraIDs();
|
||||
if (
|
||||
this._ignoreClick ||
|
||||
!this.hass ||
|
||||
!this._timeline ||
|
||||
!this.cameras ||
|
||||
!this.view ||
|
||||
!this.cameraManager ||
|
||||
!timelineCameraIDs ||
|
||||
!properties.what
|
||||
) {
|
||||
return;
|
||||
@@ -484,7 +483,6 @@ export class FrigateCardTimelineCore extends LitElement {
|
||||
this,
|
||||
this.hass,
|
||||
this.cameraManager,
|
||||
this.cameras,
|
||||
this.view,
|
||||
{
|
||||
targetTime:
|
||||
@@ -501,10 +499,9 @@ export class FrigateCardTimelineCore extends LitElement {
|
||||
this,
|
||||
this.hass,
|
||||
this.cameraManager,
|
||||
this.cameras,
|
||||
this.view,
|
||||
{
|
||||
cameraIDs: this._getAllCameraIDs(),
|
||||
cameraIDs: timelineCameraIDs,
|
||||
start: startOfHour(properties.time),
|
||||
end: endOfHour(properties.time),
|
||||
targetTime: properties.time,
|
||||
@@ -514,9 +511,7 @@ export class FrigateCardTimelineCore extends LitElement {
|
||||
const newResults = this.view.queryResults
|
||||
?.clone()
|
||||
.resetSelectedResult()
|
||||
.selectResultIfFound(
|
||||
(media) => !!this.cameras && media.getID() === properties.item,
|
||||
);
|
||||
.selectResultIfFound((media) => media.getID() === properties.item);
|
||||
|
||||
if (!newResults || !newResults.hasSelectedResult()) {
|
||||
// This can happen if this is a recording query (with recorded hours)
|
||||
@@ -588,7 +583,7 @@ export class FrigateCardTimelineCore extends LitElement {
|
||||
}
|
||||
this._removeTargetBar();
|
||||
|
||||
if (!this.hass || !this.cameras) {
|
||||
if (!this.hass) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -646,14 +641,13 @@ export class FrigateCardTimelineCore extends LitElement {
|
||||
selectedItem?: IdType;
|
||||
},
|
||||
): Promise<View | null> {
|
||||
if (!this.hass || !this.cameraManager || !this.cameras || !this.view || !query) {
|
||||
if (!this.hass || !this.cameraManager || !this.view || !query) {
|
||||
return null;
|
||||
}
|
||||
const view = await createViewForEvents(
|
||||
this,
|
||||
this.hass,
|
||||
this.cameraManager,
|
||||
this.cameras,
|
||||
this.view,
|
||||
{
|
||||
query: query,
|
||||
@@ -666,7 +660,7 @@ export class FrigateCardTimelineCore extends LitElement {
|
||||
}
|
||||
if (options?.selectedItem) {
|
||||
view.queryResults?.selectResultIfFound(
|
||||
(media) => !!this.cameras && media.getID() === options.selectedItem,
|
||||
(media) => media.getID() === options.selectedItem,
|
||||
);
|
||||
} else {
|
||||
// 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 {
|
||||
const groups: FrigateCardGroupData[] = [];
|
||||
|
||||
this._getTimelineCameraIDs().forEach((cameraID) => {
|
||||
const cameraConfig = this.cameras?.get(cameraID);
|
||||
if (!this.hass || !cameraConfig || !this.cameraManager) {
|
||||
(this._getTimelineCameraIDs() ?? []).forEach((cameraID) => {
|
||||
if (!this.hass || !this.cameraManager) {
|
||||
return;
|
||||
}
|
||||
const cameraMetadata = this.cameraManager.getCameraMetadata(this.hass, cameraID);
|
||||
@@ -806,10 +798,6 @@ export class FrigateCardTimelineCore extends LitElement {
|
||||
maxItems: this.timelineConfig.clustering_threshold,
|
||||
|
||||
clusterCriteria: (first: TimelineItem, second: TimelineItem): boolean => {
|
||||
if (!this.cameras) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const media = this.view?.queryResults?.getSelectedResult();
|
||||
const selectedId = media?.getID();
|
||||
const firstMedia = (<FrigateCardTimelineItem>first).media;
|
||||
@@ -866,7 +854,7 @@ export class FrigateCardTimelineCore extends LitElement {
|
||||
*/
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
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> {
|
||||
if (
|
||||
!this.hass ||
|
||||
!this.cameras ||
|
||||
!this.view ||
|
||||
!this.timelineConfig ||
|
||||
!this._timelineSource ||
|
||||
@@ -1040,10 +1027,11 @@ export class FrigateCardTimelineCore extends LitElement {
|
||||
changedProps.has('timelineConfig') ||
|
||||
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.cameraManager,
|
||||
this._getTimelineCameraIDs(),
|
||||
cameraIDs,
|
||||
this.timelineConfig.media,
|
||||
);
|
||||
} else {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { CSSResultGroup, html, LitElement, TemplateResult, unsafeCSS } from 'lit';
|
||||
import { customElement, property } from 'lit/decorators.js';
|
||||
import timelineStyle from '../scss/timeline.scss';
|
||||
import { CameraConfig, ExtendedHomeAssistant, TimelineConfig } from '../types';
|
||||
import { ExtendedHomeAssistant, TimelineConfig } from '../types';
|
||||
import { CameraManager } from '../camera-manager/manager';
|
||||
import { View } from '../view/view';
|
||||
import './surround.js';
|
||||
@@ -20,9 +20,6 @@ export class FrigateCardTimeline extends LitElement {
|
||||
@property({ attribute: false })
|
||||
public view?: Readonly<View>;
|
||||
|
||||
@property({ attribute: false })
|
||||
public cameras?: Map<string, CameraConfig>;
|
||||
|
||||
@property({ attribute: false })
|
||||
public timelineConfig?: TimelineConfig;
|
||||
|
||||
@@ -43,12 +40,10 @@ export class FrigateCardTimeline extends LitElement {
|
||||
.view=${this.view}
|
||||
.thumbnailConfig=${this.timelineConfig.controls.thumbnails}
|
||||
.cameraManager=${this.cameraManager}
|
||||
.cameras=${this.cameras}
|
||||
>
|
||||
<frigate-card-timeline-core
|
||||
.hass=${this.hass}
|
||||
.view=${this.view}
|
||||
.cameras=${this.cameras}
|
||||
.timelineConfig=${this.timelineConfig}
|
||||
.thumbnailDetails=${this.timelineConfig.controls.thumbnails.show_details}
|
||||
.thumbnailSize=${this.timelineConfig.controls.thumbnails.size}
|
||||
|
||||
@@ -16,7 +16,6 @@ import { dispatchMessageEvent, renderProgressIndicator } from '../components/mes
|
||||
import viewerStyle from '../scss/viewer.scss';
|
||||
import viewerCarouselStyle from '../scss/viewer-carousel.scss';
|
||||
import {
|
||||
CameraConfig,
|
||||
CardWideConfig,
|
||||
ExtendedHomeAssistant,
|
||||
frigateCardConfigDefaults,
|
||||
@@ -78,9 +77,6 @@ export class FrigateCardViewer extends LitElement {
|
||||
@property({ attribute: false })
|
||||
public viewerConfig?: ViewerConfig;
|
||||
|
||||
@property({ attribute: false })
|
||||
public cameras?: Map<string, CameraConfig>;
|
||||
|
||||
@property({ attribute: false })
|
||||
public resolvedMediaCache?: ResolvedMediaCache;
|
||||
|
||||
@@ -98,7 +94,6 @@ export class FrigateCardViewer extends LitElement {
|
||||
if (
|
||||
!this.hass ||
|
||||
!this.view ||
|
||||
!this.cameras ||
|
||||
!this.viewerConfig ||
|
||||
!this.cameraManager
|
||||
) {
|
||||
@@ -120,7 +115,6 @@ export class FrigateCardViewer extends LitElement {
|
||||
this,
|
||||
this.hass,
|
||||
this.cameraManager,
|
||||
this.cameras,
|
||||
this.view,
|
||||
{
|
||||
targetView: 'recording',
|
||||
@@ -131,7 +125,6 @@ export class FrigateCardViewer extends LitElement {
|
||||
this,
|
||||
this.hass,
|
||||
this.cameraManager,
|
||||
this.cameras,
|
||||
this.view,
|
||||
{
|
||||
targetView: 'media',
|
||||
@@ -148,12 +141,10 @@ export class FrigateCardViewer extends LitElement {
|
||||
.thumbnailConfig=${this.viewerConfig.controls.thumbnails}
|
||||
.timelineConfig=${this.viewerConfig.controls.timeline}
|
||||
.cameraManager=${this.cameraManager}
|
||||
.cameras=${this.cameras}
|
||||
>
|
||||
<frigate-card-viewer-carousel
|
||||
.hass=${this.hass}
|
||||
.view=${this.view}
|
||||
.cameras=${this.cameras}
|
||||
.viewerConfig=${this.viewerConfig}
|
||||
.resolvedMediaCache=${this.resolvedMediaCache}
|
||||
.cameraManager=${this.cameraManager}
|
||||
@@ -195,9 +186,6 @@ export class FrigateCardViewerCarousel extends LitElement {
|
||||
@property({ attribute: false })
|
||||
public cardWideConfig?: CardWideConfig;
|
||||
|
||||
@property({ attribute: false })
|
||||
public cameras?: Map<string, CameraConfig>;
|
||||
|
||||
@property({ attribute: false })
|
||||
public cameraManager?: CameraManager;
|
||||
|
||||
@@ -205,19 +193,17 @@ export class FrigateCardViewerCarousel extends LitElement {
|
||||
|
||||
// A task to resolve target media if lazy loading is disabled.
|
||||
protected _mediaResolutionTask = new Task<
|
||||
[ViewerConfig | undefined, Map<string, CameraConfig> | undefined, View | undefined],
|
||||
[ViewerConfig | undefined, View | undefined],
|
||||
void
|
||||
>(
|
||||
this,
|
||||
async ([viewerConfig, cameras, view]: [
|
||||
async ([viewerConfig, view]: [
|
||||
ViewerConfig | undefined,
|
||||
Map<string, CameraConfig> | undefined,
|
||||
View | undefined,
|
||||
]): Promise<void> => {
|
||||
if (
|
||||
!this.hass ||
|
||||
!viewerConfig?.lazy_load ||
|
||||
!cameras ||
|
||||
!view ||
|
||||
!view.queryResults?.hasResults()
|
||||
) {
|
||||
@@ -234,7 +220,7 @@ export class FrigateCardViewerCarousel extends LitElement {
|
||||
});
|
||||
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.
|
||||
*/
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -512,7 +498,7 @@ export class FrigateCardViewerCarousel extends LitElement {
|
||||
* Determine if all the media in the carousel are resolved.
|
||||
*/
|
||||
protected _isMediaFullyResolved(): boolean {
|
||||
if (!this.resolvedMediaCache || !this.cameras) {
|
||||
if (!this.resolvedMediaCache) {
|
||||
return false;
|
||||
}
|
||||
for (const media of this.view?.queryResults?.getResults() ?? []) {
|
||||
@@ -560,7 +546,7 @@ export class FrigateCardViewerCarousel extends LitElement {
|
||||
}
|
||||
|
||||
const media = this.view?.queryResults?.getSelectedResult();
|
||||
if (!media || !this.cameras || !this.view || !this.view.queryResults) {
|
||||
if (!media || !this.view || !this.view.queryResults) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -649,7 +635,7 @@ export class FrigateCardViewerCarousel extends LitElement {
|
||||
*/
|
||||
protected _renderMediaItem(media: ViewMedia, index: number): TemplateResult | null {
|
||||
// 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;
|
||||
}
|
||||
|
||||
|
||||
@@ -354,6 +354,8 @@
|
||||
"jsmpeg_no_sign": "Could not retrieve or sign JSMPEG websocket path",
|
||||
"live_camera_not_found": "The configured camera_entity was not found",
|
||||
"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_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",
|
||||
|
||||
@@ -325,6 +325,8 @@
|
||||
"jsmpeg_no_sign": "Impossibile recuperare o firmare il percorso WebSocket JSMPEG",
|
||||
"live_camera_not_found": "La telecamera configurata non è stata trovata",
|
||||
"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_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",
|
||||
|
||||
@@ -325,6 +325,8 @@
|
||||
"jsmpeg_no_sign": "Não foi possível recuperar ou assinar o caminho do websocket JSMPEG",
|
||||
"live_camera_not_found": "",
|
||||
"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_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",
|
||||
|
||||
+13
-20
@@ -49,8 +49,6 @@ const FRIGATE_CARD_VIEWS = [
|
||||
] as const;
|
||||
|
||||
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;
|
||||
|
||||
const FRIGATE_MENU_STYLES = [
|
||||
@@ -89,6 +87,12 @@ const MEDIA_ACTION_POSITIVE_CONDITIONS = [
|
||||
export type AutoUnmuteCondition = 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 {
|
||||
context?: unknown;
|
||||
|
||||
@@ -382,6 +386,7 @@ const customSchema = z
|
||||
*/
|
||||
const cameraConfigDefault = {
|
||||
live_provider: 'auto' as const,
|
||||
engine: 'auto' as const,
|
||||
frigate: {
|
||||
client_id: 'frigate' as const,
|
||||
},
|
||||
@@ -413,6 +418,8 @@ const cameraConfigSchema = z
|
||||
// this card.
|
||||
id: z.string().optional(),
|
||||
|
||||
engine: z.enum(ENGINES).default('auto'),
|
||||
|
||||
frigate: z
|
||||
.object({
|
||||
// No URL validation to allow relative URLs within HA (e.g. Frigate addon).
|
||||
@@ -445,6 +452,9 @@ const cameraConfigSchema = z
|
||||
.default(cameraConfigDefault);
|
||||
export type CameraConfig = z.infer<typeof cameraConfigSchema>;
|
||||
|
||||
const camerasConfigSchema = cameraConfigSchema.array().nonempty();
|
||||
export type CamerasConfig = z.infer<typeof camerasConfigSchema>;
|
||||
|
||||
/**
|
||||
* Custom Element Types.
|
||||
*/
|
||||
@@ -1234,7 +1244,7 @@ export interface CardWideConfig {
|
||||
*/
|
||||
export const frigateCardConfigSchema = z.object({
|
||||
// Main configuration sections.
|
||||
cameras: cameraConfigSchema.array().nonempty(),
|
||||
cameras: camerasConfigSchema,
|
||||
view: viewConfigSchema,
|
||||
menu: menuConfigSchema,
|
||||
live: liveConfigSchema,
|
||||
@@ -1351,20 +1361,3 @@ export const signedPathSchema = z.object({
|
||||
path: z.string(),
|
||||
});
|
||||
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
@@ -167,11 +167,11 @@ export function getDurationString(start: Date, end: Date): string {
|
||||
return duration;
|
||||
}
|
||||
|
||||
export const allPromises = async <T>(
|
||||
items: T[],
|
||||
func: (arg: T) => void,
|
||||
): Promise<void> => {
|
||||
await Promise.all(Array.from(items).map((item) => func(item)));
|
||||
export const allPromises = async <T, R>(
|
||||
items: Iterable<T>,
|
||||
func: (arg: T) => R,
|
||||
): Promise<Awaited<R>[]> => {
|
||||
return await Promise.all(Array.from(items).map((item) => func(item)));
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
+1
-1
@@ -26,7 +26,7 @@ export function getCameraID(
|
||||
/**
|
||||
* Get all cameras that depend on a given camera.
|
||||
* @param cameras Cameras map.
|
||||
* @param camera Name of the target camera.
|
||||
* @param cameraID ID of the target camera.
|
||||
* @returns A set of query parameters.
|
||||
*/
|
||||
export const getAllDependentCameras = (
|
||||
|
||||
@@ -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',
|
||||
});
|
||||
};
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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>;
|
||||
@@ -1,7 +1,7 @@
|
||||
import add from 'date-fns/add';
|
||||
import sub from 'date-fns/sub';
|
||||
import { ViewContext } from 'view';
|
||||
import { CameraConfig, ClipsOrSnapshotsOrAll, FrigateCardView } from '../types';
|
||||
import { ClipsOrSnapshotsOrAll, FrigateCardView } from '../types';
|
||||
import { View } from '../view/view';
|
||||
import {
|
||||
EventMediaQueries,
|
||||
@@ -21,7 +21,6 @@ export const changeViewToRecentEventsForCameraAndDependents = async (
|
||||
element: HTMLElement,
|
||||
hass: HomeAssistant,
|
||||
cameraManager: CameraManager,
|
||||
cameras: Map<string, CameraConfig>,
|
||||
view: View,
|
||||
options?: {
|
||||
mediaType?: ClipsOrSnapshotsOrAll;
|
||||
@@ -29,7 +28,7 @@ export const changeViewToRecentEventsForCameraAndDependents = async (
|
||||
},
|
||||
): Promise<void> => {
|
||||
(
|
||||
await createViewForEvents(element, hass, cameraManager, cameras, view, {
|
||||
await createViewForEvents(element, hass, cameraManager, view, {
|
||||
...options,
|
||||
limit: 50, // Capture the 50 most recent events.
|
||||
})
|
||||
@@ -40,7 +39,6 @@ export const createViewForEvents = async (
|
||||
element: HTMLElement,
|
||||
hass: HomeAssistant,
|
||||
cameraManager: CameraManager,
|
||||
cameras: Map<string, CameraConfig>,
|
||||
view: View,
|
||||
options?: {
|
||||
query?: EventMediaQueries;
|
||||
@@ -51,6 +49,10 @@ export const createViewForEvents = async (
|
||||
limit?: number;
|
||||
},
|
||||
): Promise<View | null> => {
|
||||
const cameras = cameraManager.getCameras();
|
||||
if (!cameras) {
|
||||
return null;
|
||||
}
|
||||
let query: EventMediaQueries;
|
||||
const cameraIDs: Set<string> = options?.cameraIDs
|
||||
? options.cameraIDs
|
||||
@@ -94,7 +96,6 @@ export const changeViewToRecentRecordingForCameraAndDependents = async (
|
||||
element: HTMLElement,
|
||||
hass: HomeAssistant,
|
||||
cameraManager: CameraManager,
|
||||
cameras: Map<string, CameraConfig>,
|
||||
view: View,
|
||||
options?: {
|
||||
targetView?: 'recording' | 'recordings';
|
||||
@@ -102,7 +103,7 @@ export const changeViewToRecentRecordingForCameraAndDependents = async (
|
||||
): Promise<void> => {
|
||||
const now = new Date();
|
||||
(
|
||||
await createViewForRecordings(element, hass, cameraManager, cameras, view, {
|
||||
await createViewForRecordings(element, hass, cameraManager, view, {
|
||||
...options,
|
||||
// Fetch 7 days worth of recordings (including recordings that are for the
|
||||
// current hour).
|
||||
@@ -127,7 +128,6 @@ export const createViewForRecordings = async (
|
||||
element: HTMLElement,
|
||||
hass: HomeAssistant,
|
||||
cameraManager: CameraManager,
|
||||
cameras: Map<string, CameraConfig>,
|
||||
view: View,
|
||||
options?: {
|
||||
query?: RecordingMediaQueries;
|
||||
@@ -139,6 +139,10 @@ export const createViewForRecordings = async (
|
||||
end?: Date;
|
||||
},
|
||||
): Promise<View | null> => {
|
||||
const cameras = cameraManager.getCameras();
|
||||
if (!cameras) {
|
||||
return null;
|
||||
}
|
||||
const cameraIDs: Set<string> = options?.cameraIDs
|
||||
? options.cameraIDs
|
||||
: new Set(getAllDependentCameras(cameras, view.camera));
|
||||
|
||||
+3
-30
@@ -1,10 +1,5 @@
|
||||
import { ViewContext } from 'view';
|
||||
import {
|
||||
FrigateCardUserSpecifiedView,
|
||||
FrigateCardView,
|
||||
FRIGATE_CARD_VIEWS_USER_SPECIFIED,
|
||||
FRIGATE_CARD_VIEW_DEFAULT,
|
||||
} from '../types.js';
|
||||
import { FrigateCardView } from '../types.js';
|
||||
import { dispatchFrigateCardEvent } from '../utils/basic.js';
|
||||
import { MediaQueries } from './media-queries';
|
||||
import { MediaQueriesResults } from './media-queries-results';
|
||||
@@ -37,28 +32,6 @@ export class View {
|
||||
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
|
||||
* view.
|
||||
@@ -138,8 +111,8 @@ export class View {
|
||||
/**
|
||||
* Determine if current view matches a named view.
|
||||
*/
|
||||
public is(name: string): boolean {
|
||||
return this.view == name;
|
||||
public is(view: FrigateCardView): boolean {
|
||||
return this.view == view;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user