Allow camera engines to signal events to the card.

This commit is contained in:
Dermot Duffy
2024-03-03 15:40:20 -08:00
parent 305a2db573
commit 88bea0ae7d
61 changed files with 7480 additions and 4213 deletions
+30
View File
@@ -0,0 +1,30 @@
import { HomeAssistant } from '@dermotduffy/custom-card-helpers';
import { localize } from '../../localize/localize';
import { EntityRegistryManager } from '../../utils/ha/entity-registry';
import { Entity } from '../../utils/ha/entity-registry/types';
import { Camera } from '../camera';
import { CameraInitializationError } from '../error';
export class BrowseMediaCamera extends Camera {
protected _entity: Entity | null = null;
public async initialize(
hass: HomeAssistant,
entityRegistryManager: EntityRegistryManager,
): Promise<Camera> {
const config = this.getConfig();
const entity = config.camera_entity
? await entityRegistryManager.getEntity(hass, config.camera_entity)
: null;
if (!entity || !config.camera_entity) {
throw new CameraInitializationError(localize('error.no_camera_entity'), config);
}
this._entity = entity;
return this;
}
public getEntity(): Entity | null {
return this._entity;
}
}
@@ -1,6 +1,5 @@
import { HomeAssistant } from '@dermotduffy/custom-card-helpers';
import { CameraConfig } from '../../config/types';
import { localize } from '../../localize/localize';
import { ExtendedHomeAssistant } from '../../types';
import { canonicalizeHAURL } from '../../utils/ha';
import { BrowseMediaManager } from '../../utils/ha/browse-media/browse-media-manager';
@@ -11,24 +10,24 @@ import {
RichBrowseMedia,
} from '../../utils/ha/browse-media/types';
import { EntityRegistryManager } from '../../utils/ha/entity-registry';
import { Entity } from '../../utils/ha/entity-registry/types';
import { ResolvedMediaCache, resolveMedia } from '../../utils/ha/resolved-media';
import { ViewMedia } from '../../view/media';
import { RequestCache } from '../cache';
import { Camera } from '../camera';
import { CameraManagerEngine } from '../engine';
import { CameraInitializationError } from '../error';
import { GenericCameraManagerEngine } from '../generic/engine-generic';
import { rangesOverlap } from '../range';
import { CameraManagerReadOnlyConfigStore } from '../store';
import {
CameraEndpoint,
CameraEventCallback,
CameraManagerMediaCapabilities,
DataQuery,
EventQuery,
PartialEventQuery,
QueryType,
} from '../types';
import { BrowseMediaCamera } from './camera';
import { BrowseMediaViewMediaFactory } from './media';
import { BrowseMediaMetadata } from './types';
@@ -121,7 +120,6 @@ export class BrowseMediaCameraManagerEngine
extends GenericCameraManagerEngine
implements CameraManagerEngine
{
protected _cameraEntities: Map<string, Entity> = new Map();
protected _browseMediaManager: BrowseMediaManager<BrowseMediaMetadata>;
protected _resolvedMediaCache: ResolvedMediaCache;
protected _requestCache: RequestCache;
@@ -130,38 +128,32 @@ export class BrowseMediaCameraManagerEngine
browseMediaManager: BrowseMediaManager<BrowseMediaMetadata>,
resolvedMediaCache: ResolvedMediaCache,
requestCache: RequestCache,
eventCallback?: CameraEventCallback,
) {
super();
super(eventCallback);
this._browseMediaManager = browseMediaManager;
this._resolvedMediaCache = resolvedMediaCache;
this._requestCache = requestCache;
}
public async initializeCamera(
public async createCamera(
hass: HomeAssistant,
entityRegistryManager: EntityRegistryManager,
cameraConfig: CameraConfig,
): Promise<Camera> {
const entity = cameraConfig.camera_entity
? await entityRegistryManager.getEntity(hass, cameraConfig.camera_entity)
: null;
if (!entity || !cameraConfig.camera_entity) {
throw new CameraInitializationError(
localize('error.no_camera_entity'),
cameraConfig,
);
}
this._cameraEntities.set(cameraConfig.camera_entity, entity);
return new Camera(cameraConfig, this, {
canFavoriteEvents: false,
canFavoriteRecordings: false,
canSeek: false,
supportsClips: true,
supportsRecordings: false,
supportsSnapshots: true,
supportsTimeline: true,
const camera = new BrowseMediaCamera(cameraConfig, this, {
capabilities: {
canFavoriteEvents: false,
canFavoriteRecordings: false,
canSeek: false,
supportsClips: true,
supportsRecordings: false,
supportsSnapshots: true,
supportsTimeline: true,
},
eventCallback: this._eventCallback,
});
return await camera.initialize(hass, entityRegistryManager);
}
public generateDefaultEventQuery(
+65 -6
View File
@@ -1,22 +1,81 @@
import { HomeAssistant } from '@dermotduffy/custom-card-helpers';
import { CameraConfig } from '../config/types';
import { localize } from '../localize/localize';
import { allPromises } from '../utils/basic';
import {
isTriggeredState,
parseStateChangeTrigger,
subscribeToTrigger,
} from '../utils/ha';
import { EntityRegistryManager } from '../utils/ha/entity-registry';
import { CameraManagerEngine } from './engine';
import { CameraNoIDError } from './error';
import { CameraManagerCameraCapabilities } from './types';
import { CameraEventCallback, CameraManagerCameraCapabilities } from './types';
type DestroyCallback = () => Promise<void>;
export class Camera {
protected _config: CameraConfig;
protected _engine: CameraManagerEngine;
protected _capabilities: CameraManagerCameraCapabilities;
protected _capabilities?: CameraManagerCameraCapabilities;
protected _eventCallback?: CameraEventCallback;
protected _destroyCallbacks: DestroyCallback[] = [];
constructor(
config: CameraConfig,
engine: CameraManagerEngine,
capabilities: CameraManagerCameraCapabilities,
options?: {
capabilities?: CameraManagerCameraCapabilities;
eventCallback?: CameraEventCallback;
},
) {
this._config = config;
this._engine = engine;
this._capabilities = capabilities;
this._capabilities = options?.capabilities;
this._eventCallback = options?.eventCallback;
}
protected async _convertStateChangeToCameraEvent(data: unknown): Promise<void> {
const stateChange = parseStateChangeTrigger(data);
if (!stateChange) {
return;
}
this._eventCallback?.({
cameraID: this.getID(),
type: isTriggeredState(stateChange.to_state.state) ? 'new' : 'end',
});
}
protected async _subscribeToTriggerEntities(hass: HomeAssistant): Promise<void> {
if (!this._config.triggers.entities.length) {
return;
}
this._destroyCallbacks.push(
await subscribeToTrigger(
hass,
(data) => this._convertStateChangeToCameraEvent(data),
{
entityID: this._config.triggers.entities,
platform: 'state',
stateOnly: true,
},
),
);
}
async initialize(
hass: HomeAssistant,
// eslint-disable-next-line @typescript-eslint/no-unused-vars
_entityRegistryManager: EntityRegistryManager,
): Promise<Camera> {
await this._subscribeToTriggerEntities(hass);
return this;
}
async destroy(): Promise<void> {
await allPromises(this._destroyCallbacks, (cb) => cb());
}
public getConfig(): CameraConfig {
@@ -38,7 +97,7 @@ export class Camera {
return this._engine;
}
public getCapabilities(): CameraManagerCameraCapabilities {
return this._capabilities;
public getCapabilities(): CameraManagerCameraCapabilities | null {
return this._capabilities ?? null;
}
}
+5 -3
View File
@@ -9,7 +9,7 @@ import { ResolvedMediaCache } from '../utils/ha/resolved-media';
import { MemoryRequestCache, RecordingSegmentsCache, RequestCache } from './cache';
import { CameraManagerEngine } from './engine';
import { CameraInitializationError } from './error';
import { Engine } from './types';
import { CameraEventCallback, Engine } from './types';
import { getCameraEntityFromConfig } from './utils';
export class CameraManagerEngineFactory {
@@ -24,18 +24,19 @@ export class CameraManagerEngineFactory {
this._resolvedMediaCache = resolvedMediaCache;
}
public async createEngine(engine: Engine): Promise<CameraManagerEngine> {
public async createEngine(engine: Engine, eventCallback?: CameraEventCallback): Promise<CameraManagerEngine> {
let cameraManagerEngine: CameraManagerEngine;
switch (engine) {
case Engine.Generic:
const { GenericCameraManagerEngine } = await import('./generic/engine-generic');
cameraManagerEngine = new GenericCameraManagerEngine();
cameraManagerEngine = new GenericCameraManagerEngine(eventCallback);
break;
case Engine.Frigate:
const { FrigateCameraManagerEngine } = await import('./frigate/engine-frigate');
cameraManagerEngine = new FrigateCameraManagerEngine(
new RecordingSegmentsCache(),
new RequestCache(),
eventCallback,
);
break;
case Engine.MotionEye:
@@ -46,6 +47,7 @@ export class CameraManagerEngineFactory {
new BrowseMediaManager(new MemoryRequestCache<string, BrowseMedia>()),
this._resolvedMediaCache,
new RequestCache(),
eventCallback,
);
}
return cameraManagerEngine;
+5 -9
View File
@@ -1,9 +1,5 @@
import { HomeAssistant } from '@dermotduffy/custom-card-helpers';
import {
CameraConfig,
PTZAction,
PTZPhase,
} from '../config/types';
import { CameraConfig, PTZAction, PTZPhase } from '../config/types';
import { ExtendedHomeAssistant } from '../types';
import { EntityRegistryManager } from '../utils/ha/entity-registry';
import { ViewMedia } from '../view/media';
@@ -37,7 +33,7 @@ export const CAMERA_MANAGER_ENGINE_EVENT_LIMIT_DEFAULT = 10000;
export interface CameraManagerEngine {
getEngineType(): Engine;
initializeCamera(
createCamera(
hass: HomeAssistant,
entityRegistryManager: EntityRegistryManager,
cameraConfig: CameraConfig,
@@ -143,8 +139,8 @@ export interface CameraManagerEngine {
cameraConfig: CameraConfig,
action: PTZAction,
options?: {
phase?: PTZPhase,
preset?: string,
}
phase?: PTZPhase;
preset?: string;
},
): Promise<void>;
}
+308
View File
@@ -0,0 +1,308 @@
import { HomeAssistant } from '@dermotduffy/custom-card-helpers';
import uniq from 'lodash-es/uniq';
import { CameraConfig } from '../../config/types';
import { localize } from '../../localize/localize';
import { errorToConsole } from '../../utils/basic';
import { subscribeToTrigger } from '../../utils/ha';
import { EntityRegistryManager } from '../../utils/ha/entity-registry';
import { Entity } from '../../utils/ha/entity-registry/types';
import { Camera } from '../camera';
import { CameraInitializationError } from '../error';
import { PTZCapabilities, PTZMovementType } from '../types';
import { getCameraEntityFromConfig } from '../utils.js';
import { getPTZInfo } from './requests';
import { PTZInfo, frigateEventChangeTriggerResponseSchema } from './types';
const CAMERA_BIRDSEYE = 'birdseye' as const;
export const isBirdseye = (cameraConfig: CameraConfig): boolean => {
return cameraConfig.frigate.camera_name === CAMERA_BIRDSEYE;
};
export class FrigateCamera extends Camera {
public async initialize(
hass: HomeAssistant,
entityRegistryManager: EntityRegistryManager,
): Promise<Camera> {
await this._initializeConfig(hass, entityRegistryManager);
await this._initializeCapabilities(hass);
await this._subscribeToEvents(hass);
return await super.initialize(hass, entityRegistryManager);
}
protected async _initializeConfig(
hass: HomeAssistant,
entityRegistryManager: EntityRegistryManager,
): Promise<void> {
const config = this.getConfig();
const hasCameraName = !!config.frigate?.camera_name;
const hasAutoTriggers = config.triggers.motion || config.triggers.occupancy;
let entity: Entity | null = null;
const cameraEntity = getCameraEntityFromConfig(config);
// Entity information is required if the Frigate camera name is missing, or
// if the entity requires automatic resolution of motion/occupancy sensors.
if (cameraEntity && (!hasCameraName || hasAutoTriggers)) {
try {
entity = await entityRegistryManager.getEntity(hass, cameraEntity);
} catch (e) {
throw new CameraInitializationError(localize('error.no_camera_entity'), config);
}
}
if (entity && !hasCameraName) {
const resolvedName = this._getFrigateCameraNameFromEntity(entity);
if (resolvedName) {
this._config.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.'),
);
if (config.triggers.motion) {
const motionEntity = this._getMotionSensor(config, [
...binarySensorEntities.values(),
]);
if (motionEntity) {
config.triggers.entities.push(motionEntity);
}
}
if (config.triggers.occupancy) {
const occupancyEntities = this._getOccupancySensor(config, [
...binarySensorEntities.values(),
]);
if (occupancyEntities) {
config.triggers.entities.push(...occupancyEntities);
}
}
// De-duplicate triggering entities.
config.triggers.entities = uniq(config.triggers.entities);
}
}
protected async _initializeCapabilities(hass: HomeAssistant): Promise<void> {
const config = this.getConfig();
const ptz = await this._getPTZCapabilities(hass, config);
const birdseye = isBirdseye(config);
this._capabilities = {
canFavoriteEvents: !birdseye,
canFavoriteRecordings: !birdseye,
canSeek: true,
supportsClips: !birdseye,
supportsSnapshots: !birdseye,
supportsRecordings: !birdseye,
supportsTimeline: !birdseye,
...(ptz && { ptz: ptz }),
};
}
protected _getFrigateCameraNameFromEntity(entity: Entity): string | null {
if (
entity.platform === 'frigate' &&
entity.unique_id &&
typeof entity.unique_id === 'string'
) {
const match = entity.unique_id.match(/:camera:(?<camera>[^:]+)$/);
if (match && match.groups) {
return match.groups['camera'];
}
}
return null;
}
protected async _getPTZCapabilities(
hass: HomeAssistant,
cameraConfig: CameraConfig,
): Promise<PTZCapabilities | null> {
if (!cameraConfig.frigate.camera_name) {
return null;
}
let ptzInfo: PTZInfo | null = null;
try {
ptzInfo = await getPTZInfo(
hass,
cameraConfig.frigate.client_id,
cameraConfig.frigate.camera_name,
);
} catch (e) {
errorToConsole(e as Error);
return null;
}
const panTilt: PTZMovementType[] = [
...(ptzInfo.features?.includes('pt') ? ['continuous' as const] : []),
...(ptzInfo.features?.includes('pt-r') ? ['relative' as const] : []),
];
const zoom: PTZMovementType[] = [
...(ptzInfo.features?.includes('zoom') ? ['continuous' as const] : []),
...(ptzInfo.features?.includes('zoom-r') ? ['relative' as const] : []),
];
const presets = ptzInfo.presets;
if (panTilt.length || zoom.length || presets?.length) {
return {
...(panTilt && { panTilt: panTilt }),
...(zoom && { zoom: zoom }),
...(presets && { presets: presets }),
};
}
return null;
}
/**
* Get the motion sensor entity for a given camera.
* @param cache The EntityCache 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,
entities: Entity[],
): string | null {
if (cameraConfig.frigate.camera_name) {
return (
entities.find(
(entity) =>
typeof entity.unique_id === 'string' &&
!!entity.unique_id?.match(
new RegExp(`:motion_sensor:${cameraConfig.frigate.camera_name}`),
),
)?.entity_id ?? null
);
}
return null;
}
/**
* Get the occupancy sensor entity for a given camera.
* @param cache The EntityCache 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,
entities: Entity[],
): string[] | null {
const entityIDs: string[] = [];
const addEntityIDIfFound = (cameraOrZone: string, label: string): void => {
const entityID =
entities.find(
(entity) =>
typeof entity.unique_id === 'string' &&
!!entity.unique_id?.match(
new RegExp(`:occupancy_sensor:${cameraOrZone}_${label}`),
),
)?.entity_id ?? null;
if (entityID) {
entityIDs.push(entityID);
}
};
if (cameraConfig.frigate.camera_name) {
// If zone(s) are specified, the master occupancy sensor for the overall
// camera is not used by default (but could be manually added by the
// user).
const camerasAndZones = cameraConfig.frigate.zones?.length
? cameraConfig.frigate.zones
: [cameraConfig.frigate.camera_name];
const labels = cameraConfig.frigate.labels?.length
? cameraConfig.frigate.labels
: ['all'];
for (const cameraOrZone of camerasAndZones) {
for (const label of labels) {
addEntityIDIfFound(cameraOrZone, label);
}
}
if (entityIDs.length) {
return entityIDs;
}
}
return null;
}
protected async _subscribeToEvents(hass: HomeAssistant): Promise<void> {
const config = this.getConfig();
if (!config.triggers.events.length || !config.frigate.camera_name) {
return;
}
this._destroyCallbacks.push(
await subscribeToTrigger(hass, (ev) => this._handleEventChange(ev), {
platform: 'mqtt',
topic: `${config.frigate.client_id}/events`,
// Only trigger for events pertaining to this camera.
payload: config.frigate.camera_name,
valueTemplate: '{{ value_json.after.camera }}',
}),
);
}
protected _handleEventChange(ev: unknown): void {
const parseResult = frigateEventChangeTriggerResponseSchema.safeParse(ev);
if (!parseResult.success) {
console.warn('Ignoring unparseable Frigate event', ev);
return;
}
const change = parseResult.data.variables.trigger.payload_json;
const snapshotChange =
(!change.before.has_snapshot && change.after.has_snapshot) ||
change.before.snapshot?.frame_time !== change.after.snapshot?.frame_time;
const clipChange = !change.before.has_clip && change.after.has_clip;
const config = this.getConfig();
if (config.frigate.camera_name !== change.after.camera) {
return;
}
if (
(config.frigate.zones?.length &&
!config.frigate.zones.some((zone) =>
change.after.current_zones.includes(zone),
)) ||
(config.frigate.labels?.length &&
!config.frigate.labels.includes(change.after.label))
) {
return;
}
const eventsToTriggerOn = config.triggers.events;
if (
!(
eventsToTriggerOn.includes('events') ||
(eventsToTriggerOn.includes('snapshots') && snapshotChange) ||
(eventsToTriggerOn.includes('clips') && clipChange)
)
) {
return;
}
this._eventCallback?.({
fidelity: 'high',
cameraID: this.getID(),
type: change.type,
// In cases where there are both clip and snapshot media, ensure to only
// trigger on the media type that is allowed by the configuration.
clip: clipChange && eventsToTriggerOn.includes('clips'),
snapshot: snapshotChange && eventsToTriggerOn.includes('snapshots'),
});
}
}
+14 -229
View File
@@ -7,30 +7,25 @@ import startOfHour from 'date-fns/startOfHour';
import isEqual from 'lodash-es/isEqual';
import orderBy from 'lodash-es/orderBy';
import throttle from 'lodash-es/throttle';
import uniq from 'lodash-es/uniq';
import uniqWith from 'lodash-es/uniqWith';
import { CameraConfig, PTZAction, PTZPhase } from '../../config/types';
import { localize } from '../../localize/localize';
import { ExtendedHomeAssistant } from '../../types';
import {
allPromises,
errorToConsole,
formatDate,
prettifyTitle,
runWhenIdleIfSupported,
} from '../../utils/basic';
import { getEntityTitle } from '../../utils/ha';
import { EntityRegistryManager } from '../../utils/ha/entity-registry';
import { Entity } from '../../utils/ha/entity-registry/types';
import { ViewMedia } from '../../view/media';
import { ViewMediaClassifier } from '../../view/media-classifier';
import { RecordingSegmentsCache, RequestCache } from '../cache';
import { Camera } from '../camera';
import {
CAMERA_MANAGER_ENGINE_EVENT_LIMIT_DEFAULT,
CameraManagerEngine,
CAMERA_MANAGER_ENGINE_EVENT_LIMIT_DEFAULT,
} from '../engine';
import { CameraInitializationError } from '../error';
import { GenericCameraManagerEngine } from '../generic/engine-generic';
import { DateRange } from '../range';
import { CameraManagerReadOnlyConfigStore } from '../store';
@@ -38,6 +33,7 @@ import {
CameraEndpoint,
CameraEndpoints,
CameraEndpointsContext,
CameraEventCallback,
CameraManagerCameraMetadata,
CameraManagerMediaCapabilities,
DataQuery,
@@ -52,8 +48,6 @@ import {
PartialEventQuery,
PartialRecordingQuery,
PartialRecordingSegmentsQuery,
PTZCapabilities,
PTZMovementType,
QueryResults,
QueryResultsType,
QueryReturnType,
@@ -65,34 +59,31 @@ import {
RecordingSegmentsQuery,
RecordingSegmentsQueryResultsMap,
} from '../types';
import { getCameraEntityFromConfig, getDefaultGo2RTCEndpoint } from '../utils.js';
import { getDefaultGo2RTCEndpoint } from '../utils.js';
import frigateLogo from './assets/frigate-logo-dark.svg';
import { FrigateCamera, isBirdseye } from './camera';
import { FrigateViewMediaFactory } from './media';
import { FrigateViewMediaClassifier } from './media-classifier';
import {
NativeFrigateEventQuery,
NativeFrigateRecordingSegmentsQuery,
getEventSummary,
getEvents,
getEventSummary,
getRecordingSegments,
getRecordingsSummary,
NativeFrigateEventQuery,
NativeFrigateRecordingSegmentsQuery,
retainEvent,
getPTZInfo,
} from './requests';
import {
FrigateEventQueryResults,
FrigateRecording,
FrigateRecordingQueryResults,
FrigateRecordingSegmentsQueryResults,
PTZInfo,
} from './types';
const EVENT_REQUEST_CACHE_MAX_AGE_SECONDS = 60;
const RECORDING_SUMMARY_REQUEST_CACHE_MAX_AGE_SECONDS = 60;
const MEDIA_METADATA_REQUEST_CACHE_AGE_SECONDS = 60;
const CAMERA_BIRDSEYE = 'birdseye' as const;
class FrigateQueryResultsClassifier {
public static isFrigateEventQueryResults(
results: QueryResults,
@@ -135,8 +126,9 @@ export class FrigateCameraManagerEngine
constructor(
recordingSegmentsCache: RecordingSegmentsCache,
requestCache: RequestCache,
eventCallback?: CameraEventCallback,
) {
super();
super(eventCallback);
this._recordingSegmentsCache = recordingSegmentsCache;
this._requestCache = requestCache;
}
@@ -145,222 +137,15 @@ export class FrigateCameraManagerEngine
return Engine.Frigate;
}
public async initializeCamera(
public async createCamera(
hass: HomeAssistant,
entityRegistryManager: EntityRegistryManager,
cameraConfig: CameraConfig,
): Promise<Camera> {
const hasCameraName = !!cameraConfig.frigate?.camera_name;
const hasAutoTriggers =
cameraConfig.triggers.motion || cameraConfig.triggers.occupancy;
let entity: Entity | null = null;
const cameraEntity = getCameraEntityFromConfig(cameraConfig);
// Entity information is required if the Frigate camera name is missing, or
// if the entity requires automatic resolution of motion/occupancy sensors.
if (cameraEntity && (!hasCameraName || hasAutoTriggers)) {
try {
entity = await entityRegistryManager.getEntity(hass, cameraEntity);
} 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.'),
);
if (cameraConfig.triggers.motion) {
const motionEntity = this._getMotionSensor(cameraConfig, [
...binarySensorEntities.values(),
]);
if (motionEntity) {
cameraConfig.triggers.entities.push(motionEntity);
}
}
if (cameraConfig.triggers.occupancy) {
const occupancyEntities = this._getOccupancySensor(cameraConfig, [
...binarySensorEntities.values(),
]);
if (occupancyEntities) {
cameraConfig.triggers.entities.push(...occupancyEntities);
}
}
// De-duplicate triggering entities.
cameraConfig.triggers.entities = uniq(cameraConfig.triggers.entities);
}
const ptz = await this._getPTZCapabilities(hass, cameraConfig);
const isBirdseye = this._isBirdseye(cameraConfig);
return new Camera(cameraConfig, this, {
canFavoriteEvents: !isBirdseye,
canFavoriteRecordings: !isBirdseye,
canSeek: true,
supportsClips: !isBirdseye,
supportsSnapshots: !isBirdseye,
supportsRecordings: !isBirdseye,
supportsTimeline: !isBirdseye,
...(ptz && { ptz: ptz }),
const camera = new FrigateCamera(cameraConfig, this, {
eventCallback: this._eventCallback,
});
}
protected async _getPTZCapabilities(
hass: HomeAssistant,
cameraConfig: CameraConfig,
): Promise<PTZCapabilities | null> {
if (!cameraConfig.frigate.camera_name) {
return null;
}
let ptzInfo: PTZInfo | null = null;
try {
ptzInfo = await getPTZInfo(
hass,
cameraConfig.frigate.client_id,
cameraConfig.frigate.camera_name,
);
} catch (e) {
errorToConsole(e as Error);
return null;
}
const panTilt: PTZMovementType[] = [
...(ptzInfo.features?.includes('pt') ? ['continuous' as const] : []),
...(ptzInfo.features?.includes('pt-r') ? ['relative' as const] : []),
];
const zoom: PTZMovementType[] = [
...(ptzInfo.features?.includes('zoom') ? ['continuous' as const] : []),
...(ptzInfo.features?.includes('zoom-r') ? ['relative' as const] : []),
];
const presets = ptzInfo.presets;
if (panTilt.length || zoom.length || presets?.length) {
return {
...(panTilt && { panTilt: panTilt }),
...(zoom && { zoom: zoom }),
...(presets && { presets: presets }),
};
}
return null;
}
protected _isBirdseye(cameraConfig: CameraConfig): boolean {
return cameraConfig.frigate.camera_name === CAMERA_BIRDSEYE;
}
/**
* Get the Frigate camera name from an entity.
* @returns The Frigate camera name or null if unavailable.
*/
protected _getFrigateCameraNameFromEntity(entity: Entity): string | null {
if (
entity.platform === 'frigate' &&
entity.unique_id &&
typeof entity.unique_id === 'string'
) {
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 EntityCache 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,
entities: Entity[],
): string | null {
if (cameraConfig.frigate.camera_name) {
return (
entities.find(
(entity) =>
typeof entity.unique_id === 'string' &&
!!entity.unique_id?.match(
new RegExp(`:motion_sensor:${cameraConfig.frigate.camera_name}`),
),
)?.entity_id ?? null
);
}
return null;
}
/**
* Get the occupancy sensor entity for a given camera.
* @param cache The EntityCache 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,
entities: Entity[],
): string[] | null {
const entityIDs: string[] = [];
const addEntityIDIfFound = (cameraOrZone: string, label: string): void => {
const entityID =
entities.find(
(entity) =>
typeof entity.unique_id === 'string' &&
!!entity.unique_id?.match(
new RegExp(`:occupancy_sensor:${cameraOrZone}_${label}`),
),
)?.entity_id ?? null;
if (entityID) {
entityIDs.push(entityID);
}
};
if (cameraConfig.frigate.camera_name) {
// If zone(s) are specified, the master occupancy sensor for the overall
// camera is not used by default (but could be manually added by the
// user).
const camerasAndZones = cameraConfig.frigate.zones?.length
? cameraConfig.frigate.zones
: [cameraConfig.frigate.camera_name];
const labels = cameraConfig.frigate.labels?.length
? cameraConfig.frigate.labels
: ['all'];
for (const cameraOrZone of camerasAndZones) {
for (const label of labels) {
addEntityIDIfFound(cameraOrZone, label);
}
}
if (entityIDs.length) {
return entityIDs;
}
}
return null;
return await camera.initialize(hass, entityRegistryManager);
}
public async getMediaDownloadPath(
@@ -904,7 +689,7 @@ export class FrigateCameraManagerEngine
cameraID: string,
): CameraConfig | null {
const cameraConfig = store.getCameraConfig(cameraID);
if (!cameraConfig || this._isBirdseye(cameraConfig)) {
if (!cameraConfig || isBirdseye(cameraConfig)) {
return null;
}
return cameraConfig;
+33
View File
@@ -83,6 +83,39 @@ export const ptzInfoSchema = z.object({
});
export type PTZInfo = z.infer<typeof ptzInfoSchema>;
// Frigate events as stored in MQTT updates.
const frigateEventChangeSchema = z.object({
camera: z.string(),
snapshot: z
.object({
frame_time: z.number(),
})
.nullable(),
has_clip: z.boolean(),
has_snapshot: z.boolean(),
label: z.string(),
current_zones: z.string().array(),
});
export type FrigateEventChange = z.infer<typeof frigateEventChangeSchema>;
const frigateEventChangeType = z.enum(['new', 'update', 'end']);
export type FrigateEventChangeType = z.infer<typeof frigateEventChangeType>;
export const frigateEventChangeTriggerResponseSchema = z.object({
variables: z.object({
trigger: z.object({
payload_json: z.object({
before: frigateEventChangeSchema,
after: frigateEventChangeSchema,
type: frigateEventChangeType,
}),
}),
}),
});
export type FrigateEventChangeTriggerResponse = z.infer<
typeof frigateEventChangeTriggerResponseSchema
>;
// ==============================
// Frigate concrete query results
// ==============================
+24 -18
View File
@@ -1,11 +1,7 @@
/* eslint-disable @typescript-eslint/no-unused-vars */
import { HomeAssistant } from '@dermotduffy/custom-card-helpers';
import {
CameraConfig,
PTZAction,
PTZPhase
} from '../../config/types';
import { CameraConfig, PTZAction, PTZPhase } from '../../config/types';
import { ExtendedHomeAssistant } from '../../types';
import { getEntityIcon, getEntityTitle } from '../../utils/ha';
import { EntityRegistryManager } from '../../utils/ha/entity-registry';
@@ -17,6 +13,7 @@ import {
CameraEndpoint,
CameraEndpoints,
CameraEndpointsContext,
CameraEventCallback,
CameraManagerCameraMetadata,
CameraManagerMediaCapabilities,
DataQuery,
@@ -33,29 +30,38 @@ import {
RecordingQuery,
RecordingQueryResultsMap,
RecordingSegmentsQuery,
RecordingSegmentsQueryResultsMap
RecordingSegmentsQueryResultsMap,
} from '../types';
import { getCameraEntityFromConfig, getDefaultGo2RTCEndpoint } from '../utils.js';
export class GenericCameraManagerEngine implements CameraManagerEngine {
protected _eventCallback?: CameraEventCallback;
constructor(eventCallback?: CameraEventCallback) {
this._eventCallback = eventCallback;
}
public getEngineType(): Engine {
return Engine.Generic;
}
public async initializeCamera(
_hass: HomeAssistant,
_entityRegistryManager: EntityRegistryManager,
public async createCamera(
hass: HomeAssistant,
entityRegistryManager: EntityRegistryManager,
cameraConfig: CameraConfig,
): Promise<Camera> {
return new Camera(cameraConfig, this, {
canFavoriteEvents: false,
canFavoriteRecordings: false,
canSeek: false,
supportsClips: false,
supportsRecordings: false,
supportsSnapshots: false,
supportsTimeline: false,
});
return await new Camera(cameraConfig, this, {
capabilities: {
canFavoriteEvents: false,
canFavoriteRecordings: false,
canSeek: false,
supportsClips: false,
supportsRecordings: false,
supportsSnapshots: false,
supportsTimeline: false,
},
eventCallback: this._eventCallback,
}).initialize(hass, entityRegistryManager);
}
public generateDefaultEventQuery(
+80 -63
View File
@@ -47,11 +47,11 @@ import {
RecordingSegmentsQuery,
RecordingSegmentsQueryResults,
RecordingSegmentsQueryResultsMap,
ResultsMap
ResultsMap,
} from './types.js';
import { sortMedia } from './utils.js';
class QueryClassifier {
export class QueryClassifier {
public static isEventQuery(query: DataQuery | PartialDataQuery): query is EventQuery {
return query.type === QueryType.Event;
}
@@ -72,23 +72,23 @@ class QueryClassifier {
}
}
class QueryResultClassifier {
export class QueryResultClassifier {
public static isEventQueryResult(
queryResults: QueryResults,
): queryResults is EventQueryResults {
return queryResults.type === QueryResultsType.Event;
}
public static isRecordingQuery(
public static isRecordingQueryResult(
queryResults: QueryResults,
): queryResults is RecordingQueryResults {
return queryResults.type === QueryResultsType.Recording;
}
public static isRecordingSegmentsQuery(
public static isRecordingSegmentsQueryResult(
queryResults: QueryResults,
): queryResults is RecordingSegmentsQueryResults {
return queryResults.type === QueryResultsType.RecordingSegments;
}
public static isMediaMetadataQuery(
public static isMediaMetadataQueryResult(
queryResults: QueryResults,
): queryResults is MediaMetadataQueryResults {
return queryResults.type === QueryResultsType.MediaMetadata;
@@ -105,13 +105,25 @@ export class CameraManager {
protected _engineFactory: CameraManagerEngineFactory;
protected _store: CameraManagerStore;
constructor(api: CardCameraAPI, store?: CameraManagerStore) {
constructor(
api: CardCameraAPI,
options?: {
store?: CameraManagerStore;
factory?: CameraManagerEngineFactory;
},
) {
this._api = api;
this._engineFactory = new CameraManagerEngineFactory(
this._api.getEntityRegistryManager(),
this._api.getResolvedMediaCache(),
);
this._store = store ?? new CameraManagerStore();
this._engineFactory =
options?.factory ??
new CameraManagerEngineFactory(
this._api.getEntityRegistryManager(),
this._api.getResolvedMediaCache(),
);
this._store = options?.store ?? new CameraManagerStore();
}
public async reset(): Promise<void> {
await this._store.reset();
}
public async initializeCamerasFromConfig(): Promise<boolean> {
@@ -122,7 +134,7 @@ export class CameraManager {
return false;
}
this._store.reset();
await this.reset();
// For each camera merge the config (which has no defaults) into the camera
// global config (which does have defaults). The merging must happen in this
@@ -148,6 +160,7 @@ export class CameraManager {
const engines: Map<Engine, CameraManagerEngine> = new Map();
const hass = this._api.getHASSManager().getHASS();
/* istanbul ignore if: the if path cannot be reached -- @preserve */
if (!hass) {
return output;
}
@@ -162,7 +175,10 @@ export class CameraManager {
for (const [index, cameraConfig] of camerasConfig.entries()) {
const engineType = engineTypes[index];
const engine = engineType
? engines.get(engineType) ?? (await this._engineFactory.createEngine(engineType))
? engines.get(engineType) ??
(await this._engineFactory.createEngine(engineType, (ev) =>
this._api.getTriggersManager().handleCameraEvent(ev),
))
: null;
if (!engine || !engineType) {
throw new CameraInitializationError(
@@ -182,6 +198,7 @@ export class CameraManager {
const initializationStartTime = new Date();
const hass = this._api.getHASSManager().getHASS();
/* istanbul ignore if: the if path cannot be reached -- @preserve */
if (!hass) {
return;
}
@@ -208,7 +225,7 @@ export class CameraManager {
const cameras = await allPromises(
engineByConfig.entries(),
async ([cameraConfig, engine]) =>
await engine.initializeCamera(
await engine.createCamera(
hass,
this._api.getEntityRegistryManager(),
cameraConfig,
@@ -292,45 +309,6 @@ export class CameraManager {
});
}
public async getMediaMetadata(): Promise<MediaMetadata | null> {
const tags: Set<string> = new Set();
const what: Set<string> = new Set();
const where: Set<string> = new Set();
const days: Set<string> = new Set();
const query: MediaMetadataQuery = {
type: QueryType.MediaMetadata,
cameraIDs: this._store.getCameraIDs(),
};
const results = await this._handleQuery(query);
for (const result of results?.values() ?? []) {
if (result.metadata.tags) {
result.metadata.tags.forEach(tags.add, tags);
}
if (result.metadata.what) {
result.metadata.what.forEach(what.add, what);
}
if (result.metadata.where) {
result.metadata.where.forEach(where.add, where);
}
if (result.metadata.days) {
result.metadata.days.forEach(days.add, days);
}
}
if (!what.size && !where.size && !days.size) {
return null;
}
return {
...(tags.size && { tags: tags }),
...(what.size && { what: what }),
...(where.size && { where: where }),
...(days.size && { days: days }),
};
}
protected _generateDefaultQueries<PQT extends PartialDataQuery>(
cameraIDs: string | Set<string>,
partialQuery: PQT,
@@ -338,13 +316,13 @@ export class CameraManager {
const concreteQueries: PartialQueryConcreteType<PQT>[] = [];
const _cameraIDs = setify(cameraIDs);
const engines = this._store.getEnginesForCameraIDs(_cameraIDs);
if (!engines) {
return null;
}
for (const [engine, cameraIDs] of engines) {
let queries: DataQuery[] | null = null;
/* istanbul ignore else: the else path cannot be reached -- @preserve */
if (QueryClassifier.isEventQuery(partialQuery)) {
queries = engine.generateDefaultEventQuery(this._store, cameraIDs, partialQuery);
} else if (QueryClassifier.isRecordingQuery(partialQuery)) {
@@ -368,6 +346,45 @@ export class CameraManager {
return concreteQueries.length ? concreteQueries : null;
}
public async getMediaMetadata(): Promise<MediaMetadata | null> {
const tags: Set<string> = new Set();
const what: Set<string> = new Set();
const where: Set<string> = new Set();
const days: Set<string> = new Set();
const query: MediaMetadataQuery = {
type: QueryType.MediaMetadata,
cameraIDs: this._store.getCameraIDs(),
};
const results = await this._handleQuery(query);
for (const result of results.values()) {
if (result.metadata.tags) {
result.metadata.tags.forEach(tags.add, tags);
}
if (result.metadata.what) {
result.metadata.what.forEach(what.add, what);
}
if (result.metadata.where) {
result.metadata.where.forEach(where.add, where);
}
if (result.metadata.days) {
result.metadata.days.forEach(days.add, days);
}
}
if (!what.size && !where.size && !days.size && !tags.size) {
return null;
}
return {
...(tags.size && { tags: tags }),
...(what.size && { what: what }),
...(where.size && { where: where }),
...(days.size && { days: days }),
};
}
public async getEvents(
query: EventQuery | EventQuery[],
engineOptions?: EngineOptions,
@@ -438,15 +455,18 @@ export class CameraManager {
for (const query of queries) {
const newChunkQuery = { ...query };
/* istanbul ignore else: the else path cannot be reached -- @preserve */
if (direction === 'later') {
const latestResult = getTimeFromResults('latest');
if (latestResult) {
newChunkQuery.start = latestResult;
delete(newChunkQuery.end);
}
} else if (direction === 'earlier') {
const earliestResult = getTimeFromResults('earliest');
if (earliestResult) {
newChunkQuery.end = earliestResult;
delete(newChunkQuery.start);
}
}
newChunkQuery.limit = chunkSize;
@@ -550,13 +570,11 @@ export class CameraManager {
public async getMediaSeekTime(media: ViewMedia, target: Date): Promise<number | null> {
const startTime = media.getStartTime();
const endTime = media.getEndTime();
const cameraConfig = this._store.getCameraConfigForMedia(media);
const engine = this._store.getEngineForMedia(media);
const hass = this._api.getHASSManager().getHASS();
if (
!hass ||
!cameraConfig ||
!engine ||
!startTime ||
!endTime ||
@@ -584,13 +602,11 @@ export class CameraManager {
const processEngineQuery = async (
engine: CameraManagerEngine,
query?: QT,
query: QT,
): Promise<void> => {
if (!query) {
return;
}
let engineResult: Map<QT, QueryReturnType<QT>> | null = null;
/* istanbul ignore else: the else path cannot be reached -- @preserve */
if (QueryClassifier.isEventQuery(query)) {
engineResult = (await engine.getEvents(
hass,
@@ -676,6 +692,7 @@ export class CameraManager {
if (engine) {
let media: ViewMedia[] | null = null;
/* istanbul ignore else: the else path cannot be reached -- @preserve */
if (
QueryClassifier.isEventQuery(query) &&
QueryResultClassifier.isEventQueryResult(result)
@@ -683,7 +700,7 @@ export class CameraManager {
media = engine.generateMediaFromEvents(hass, this._store, query, result);
} else if (
QueryClassifier.isRecordingQuery(query) &&
QueryResultClassifier.isRecordingQuery(result)
QueryResultClassifier.isRecordingQueryResult(result)
) {
media = engine.generateMediaFromRecordings(hass, this._store, query, result);
}
@@ -8,18 +8,21 @@ import { CameraConfig } from '../../config/types';
import { allPromises, formatDate, isValidDate } from '../../utils/basic';
import {
BrowseMediaStep,
BrowseMediaTarget
BrowseMediaTarget,
} from '../../utils/ha/browse-media/browse-media-manager';
import {
BrowseMedia, BROWSE_MEDIA_CACHE_SECONDS, MEDIA_CLASS_IMAGE,
BrowseMedia,
BROWSE_MEDIA_CACHE_SECONDS,
MEDIA_CLASS_IMAGE,
MEDIA_CLASS_VIDEO,
RichBrowseMedia
RichBrowseMedia,
} from '../../utils/ha/browse-media/types';
import { ViewMedia } from '../../view/media';
import { BrowseMediaCamera } from '../browse-media/camera';
import {
BrowseMediaCameraManagerEngine,
getViewMediaFromBrowseMediaArray,
isMediaWithinDates
isMediaWithinDates,
} from '../browse-media/engine-browse-media';
import { BrowseMediaMetadata } from '../browse-media/types';
import { CAMERA_MANAGER_ENGINE_EVENT_LIMIT_DEFAULT } from '../engine';
@@ -39,7 +42,7 @@ import {
MediaMetadataQueryResultsMap,
QueryResults,
QueryResultsType,
QueryReturnType
QueryReturnType,
} from '../types';
import motioneyeLogo from './assets/motioneye-logo.svg';
import { MotionEyeEventQueryResults } from './types';
@@ -134,13 +137,18 @@ export class MotionEyeCameraManagerEngine extends BrowseMediaCameraManagerEngine
} | null,
engineOptions?: EngineOptions,
): Promise<RichBrowseMedia<BrowseMediaMetadata>[] | null> {
const cameraConfig = store.getCameraConfig(cameraID);
const cameraEntityID = cameraConfig?.camera_entity;
const entity = cameraEntityID ? this._cameraEntities.get(cameraEntityID) : null;
const camera = store.getCamera(cameraID);
const cameraConfig = camera?.getConfig();
if (!(camera instanceof BrowseMediaCamera) || !cameraConfig) {
return null;
}
const entity = camera.getEntity();
const configID = entity?.config_entry_id;
const deviceID = entity?.device_id;
if (!configID || !deviceID || !cameraConfig) {
if (!configID || !deviceID) {
return null;
}
+4 -1
View File
@@ -1,4 +1,5 @@
import { CameraConfig } from '../config/types';
import { allPromises } from '../utils/basic';
import { ViewMedia } from '../view/media';
import { Camera } from './camera';
import { CameraManagerEngine } from './engine';
@@ -35,7 +36,9 @@ export class CameraManagerStore implements CameraManagerReadOnlyConfigStore {
this._cameras.set(camera.getID(), camera);
this._enginesByType.set(camera.getEngine().getEngineType(), camera.getEngine());
}
public reset(): void {
public async reset(): Promise<void> {
await allPromises(this._cameras.values(), (camera) => camera.destroy());
this._cameras.clear();
this._enginesByType.clear();
}
+16
View File
@@ -150,6 +150,22 @@ export interface EngineOptions {
useCache?: boolean;
}
export interface CameraEvent {
cameraID: string;
type: 'new' | 'update' | 'end';
// When fidelity is `high`, the engine is assumed to provide exact details of
// what new media is available. Otherwise all media types are assumed to be
// possibly newly available.
fidelity?: 'high' | 'low';
// Whether a new clip/snapshot/recording may be available.
clip?: boolean;
snapshot?: boolean;
}
export type CameraEventCallback = (ev: CameraEvent) => void;
// ===========
// Event Query
// ===========
+1 -1
View File
@@ -51,7 +51,7 @@ export const sortMedia = (mediaArray: ViewMedia[]): ViewMedia[] => {
// Sort all items leading oldest -> youngest (so media is loaded in this
// order in the viewer which matches the left-to-right timeline order).
(media) => media.getStartTime(),
(media) => media.getStartTime() ?? media.getID(),
'asc',
);
};
@@ -2,6 +2,7 @@ import { LitElement, ReactiveControllerHost } from 'lit';
import { ActionEventTarget } from '../action-handler-directive';
import { setOrRemoveAttribute } from '../utils/basic';
import { isCardInPanel } from '../utils/ha';
import { InitializationAspect } from './initialization-manager';
import { CardElementAPI } from './types';
export type ScrollCallback = () => void;
@@ -103,6 +104,16 @@ export class CardElementManager {
this._api.getMediaLoadedInfoManager().clear();
this._api.getFullscreenManager().disconnect();
// Reset and uninitialize cameras to cause them to reinitialize on
// reconnection, to ensure the state subscription/unsubscription works
// correctly for triggers.
this._api
.getCameraManager()
.reset()
.then(() =>
this._api.getInitializationManager().uninitialize(InitializationAspect.CAMERAS),
);
this._element.removeEventListener(
'mousemove',
this._api.getInteractionManager().reportInteraction,
@@ -192,6 +192,10 @@ export class ConditionsManager {
};
this._triggerChange();
}
public getState(): ConditionState {
return this._state;
}
public hasHAStateConditions(): boolean {
return this._hasHAStateConditions;
+5 -16
View File
@@ -1,4 +1,3 @@
import { CameraConfig } from '../config/types';
import { localize } from '../localize/localize';
import { ExtendedHomeAssistant } from '../types';
import { hasHAConnectionStateChanged, isHassDifferent } from '../utils/ha';
@@ -17,15 +16,6 @@ export class HASSManager {
}
public setHASS(hass?: ExtendedHomeAssistant | null): void {
const getSelectedCameraConfig = (): CameraConfig | null => {
const view = this._api.getViewManager().getView();
const cameraManager = this._api.getCameraManager();
return view && cameraManager
? cameraManager?.getStore().getCameraConfig(view.camera) ?? null
: null;
};
if (hasHAConnectionStateChanged(this._hass, hass)) {
if (!hass?.connected) {
this._api.getMessageManager().setMessageIfHigherPriority({
@@ -54,10 +44,11 @@ export class HASSManager {
// Assistant update if there's been recent interaction (e.g. clicks on the
// card) or if there is media active playing.
this._isAutomatedViewUpdateAllowed() &&
isHassDifferent(this._hass, oldHass, [
...(this._api.getConfigManager().getConfig()?.view.update_entities ?? []),
...(getSelectedCameraConfig()?.triggers.entities ?? []),
])
isHassDifferent(
this._hass,
oldHass,
this._api.getConfigManager().getConfig()?.view.update_entities ?? [],
)
) {
// If entities being monitored have changed then reset the view to the
// default.
@@ -74,8 +65,6 @@ export class HASSManager {
this._api.getCardElementManager().update();
}
this._api.getTriggersManager().updateTriggerHAState(oldHass);
if (this._api.getConditionsManager().hasHAStateConditions()) {
this._api.getConditionsManager().setState({ state: this._hass.states });
}
+78 -88
View File
@@ -1,8 +1,8 @@
import { HomeAssistant } from '@dermotduffy/custom-card-helpers';
import isEqual from 'lodash-es/isEqual';
import orderBy from 'lodash-es/orderBy';
import { getHassDifferences, isTriggeredState } from '../utils/ha';
import throttle from 'lodash-es/throttle';
import { CameraEvent } from '../camera-manager/types';
import { Timer } from '../utils/timer';
import { View } from '../view/view';
import { CardTriggersAPI } from './types';
export class TriggersManager {
@@ -10,7 +10,10 @@ export class TriggersManager {
protected _triggeredCameras: Map<string, Date> = new Map();
protected _triggeredCameraTimers: Map<string, Timer> = new Map();
protected _triggeredState: Set<string> = new Set();
protected _throttledTriggerAction = throttle(this._triggerAction.bind(this), 1000, {
trailing: true,
});
constructor(api: CardTriggersAPI) {
this._api = api;
@@ -33,125 +36,112 @@ export class TriggersManager {
return sorted.length ? sorted[0][0] : null;
}
public updateTriggerHAState(oldHass?: HomeAssistant | null): void {
const scanConfig = this._api.getConfigManager().getConfig()?.view.scan;
if (!scanConfig || !scanConfig.enabled) {
public handleCameraEvent(ev: CameraEvent): void {
const triggersConfig = this._api.getConfigManager().getConfig()?.view.triggers;
const selectedCameraID = this._api.getViewManager().getView()?.camera;
if (!triggersConfig || !selectedCameraID) {
return;
}
const hass = this._api.getHASSManager().getHASS();
let triggerChanges = false;
const visibleCameraIDs = this._api
const dependentCameraIDs = this._api
.getCameraManager()
.getStore()
.getVisibleCameraIDs();
for (const [cameraID, config] of this._api
.getCameraManager()
.getStore()
.getCameraConfigEntries(visibleCameraIDs)) {
const triggerEntities = config.triggers.entities;
const diffs = getHassDifferences(hass, oldHass, triggerEntities, {
stateOnly: true,
});
const shouldTrigger = diffs.some((diff) => isTriggeredState(diff.newState));
const shouldUntrigger = triggerEntities.every(
(entity) => !isTriggeredState(hass?.states[entity]),
);
if (shouldTrigger) {
this._triggeredState.add(cameraID);
triggerChanges = true;
} else if (shouldUntrigger && this._triggeredState.has(cameraID)) {
this._triggeredState.delete(cameraID);
triggerChanges = true;
}
}
.getAllDependentCameras(selectedCameraID);
if (triggerChanges) {
this._evaluateTriggers();
}
}
public updateView(oldView?: View | null): void {
if (oldView?.camera !== this._api.getViewManager().getView()?.camera) {
// If the view changes, a new camera may have been selected, which may
// mean a trigger is required (in the case that `filter_selected_camera`
// is true).
this._evaluateTriggers();
}
}
protected _evaluateTriggers(): void {
const scanConfig = this._api.getConfigManager().getConfig()?.view.scan;
if (!scanConfig) {
if (triggersConfig.filter_selected_camera && !dependentCameraIDs.has(ev.cameraID)) {
return;
}
const now = new Date();
for (const cameraID of this._triggeredState.keys()) {
if (
!this._triggeredCameras.has(cameraID) &&
(!scanConfig.filter_selected_camera ||
cameraID === this._api.getViewManager().getView()?.camera)
) {
this._triggeredCameras.set(cameraID, now);
this._setConditionState();
this._triggerAction(cameraID);
}
if (ev.type === 'end') {
this._startUntriggerTimer(ev.cameraID);
return;
}
for (const cameraID of this._triggeredCameras.keys()) {
if (!this._triggeredState.has(cameraID)) {
this._startUntriggerTimer(cameraID);
}
}
this._triggeredCameras.set(ev.cameraID, new Date());
this._setConditionStateIfNecessary();
this._throttledTriggerAction(ev);
}
protected _hasAllowableInteractionStateForAction(): boolean {
const scanConfig = this._api.getConfigManager().getConfig()?.view.scan;
const triggersConfig = this._api.getConfigManager().getConfig()?.view.triggers;
const hasInteraction = this._api.getInteractionManager().hasInteraction();
return (
!!scanConfig &&
(scanConfig.actions.interaction_mode === 'all' ||
(scanConfig.actions.interaction_mode === 'active' && hasInteraction) ||
(scanConfig.actions.interaction_mode === 'inactive' && !hasInteraction))
!!triggersConfig &&
(triggersConfig.actions.interaction_mode === 'all' ||
(triggersConfig.actions.interaction_mode === 'active' && hasInteraction) ||
(triggersConfig.actions.interaction_mode === 'inactive' && !hasInteraction))
);
}
protected _triggerAction(cameraID: string): void {
const action = this._api.getConfigManager().getConfig()?.view.scan.actions.trigger;
protected _triggerAction(ev: CameraEvent): void {
const triggerAction = this._api.getConfigManager().getConfig()?.view.triggers
.actions.trigger;
const defaultView = this._api.getConfigManager().getConfig()?.view.default;
if (action === 'live' && this._hasAllowableInteractionStateForAction()) {
this._api.getViewManager().setViewByParameters({
viewName: 'live',
cameraID: cameraID,
});
// If this is a high-fidelity event where we are certain about new media,
// don't take action unless it's to change to live (Frigate engine may pump
// out events where there's no new media to show).
if (
ev.fidelity === 'high' &&
!ev.snapshot &&
!ev.clip &&
!(
triggerAction === 'live' ||
(triggerAction === 'default' && defaultView === 'live')
)
) {
return;
}
// Must update master element to add border pulsing.
if (this._hasAllowableInteractionStateForAction()) {
if (triggerAction === 'live') {
this._api.getViewManager().setViewByParameters({
viewName: 'live',
cameraID: ev.cameraID,
});
} else if (triggerAction === 'default') {
this._api.getViewManager().setViewDefault({
cameraID: ev.cameraID,
});
} else if (ev.fidelity === 'high' && triggerAction === 'media') {
this._api.getViewManager().setViewByParameters({
viewName: ev.clip ? 'clip' : 'snapshot',
cameraID: ev.cameraID,
});
}
}
// Must update master element to add border pulsing to live view.
this._api.getCardElementManager().update();
}
protected _setConditionState(): void {
this._api.getConditionsManager().setState({
triggered: this._triggeredCameras.size
? new Set(this._triggeredCameras.keys())
: undefined,
});
protected _setConditionStateIfNecessary(): void {
const triggeredCameraIDs = new Set(this._triggeredCameras.keys());
const triggeredState = triggeredCameraIDs.size ? triggeredCameraIDs : undefined;
if (
!isEqual(triggeredState, this._api.getConditionsManager().getState().triggered)
) {
this._api.getConditionsManager().setState({
triggered: triggeredState,
});
}
}
protected _untriggerAction(cameraID: string): void {
const action = this._api.getConfigManager().getConfig()?.view.scan.actions.untrigger;
const action = this._api.getConfigManager().getConfig()?.view.triggers
.actions.untrigger;
if (action === 'default' && this._hasAllowableInteractionStateForAction()) {
this._api.getViewManager().setViewDefault();
}
this._triggeredCameras.delete(cameraID);
this._deleteTimer(cameraID);
this._setConditionState();
this._setConditionStateIfNecessary();
// Must update master element to remove border pulsing.
// Must update master element to remove border pulsing from live view.
this._api.getCardElementManager().update();
}
@@ -164,7 +154,7 @@ export class TriggersManager {
/* istanbul ignore next: the case of config being null here cannot be
reached, as there's no way to have the untrigger call happen without
a config. -- @preserve */
this._api.getConfigManager().getConfig()?.view.scan.untrigger_seconds ?? 0,
this._api.getConfigManager().getConfig()?.view.triggers.untrigger_seconds ?? 0,
() => {
this._untriggerAction(cameraID);
},
+27 -25
View File
@@ -1,27 +1,27 @@
import { CameraManager } from '../camera-manager/manager';
import { ConditionsManager } from './conditions-manager';
import { EntityRegistryManager } from '../utils/ha/entity-registry';
import { ResolvedMediaCache } from '../utils/ha/resolved-media';
import { ActionsManager } from './actions-manager';
import { AutoUpdateManager } from './auto-update-manager';
import { AutomationsManager } from './automations-manager';
import { CameraURLManager } from './camera-url-manager';
import { CardElementManager } from './card-element-manager';
import { ConfigManager } from './config-manager';
import { DownloadManager } from './download-manager';
import { ExpandManager } from './expand-manager';
import { FullscreenManager } from './fullscreen-manager';
import { HASSManager } from './hass-manager';
import { InitializationManager } from './initialization-manager';
import { InteractionManager } from './interaction-manager';
import { MediaLoadedInfoManager } from './media-info-manager';
import { MediaPlayerManager } from './media-player-manager';
import { MessageManager } from './message-manager';
import { MicrophoneManager } from './microphone-manager';
import { StyleManager } from './style-manager';
import { TriggersManager } from './triggers-manager';
import { ViewManager } from './view-manager';
import { QueryStringManager } from './query-string-manager';
import type { CameraManager } from '../camera-manager/manager';
import type { ConditionsManager } from './conditions-manager';
import type { EntityRegistryManager } from '../utils/ha/entity-registry';
import type { ResolvedMediaCache } from '../utils/ha/resolved-media';
import type { ActionsManager } from './actions-manager';
import type { AutoUpdateManager } from './auto-update-manager';
import type { AutomationsManager } from './automations-manager';
import type { CameraURLManager } from './camera-url-manager';
import type { CardElementManager } from './card-element-manager';
import type { ConfigManager } from './config-manager';
import type { DownloadManager } from './download-manager';
import type { ExpandManager } from './expand-manager';
import type { FullscreenManager } from './fullscreen-manager';
import type { HASSManager } from './hass-manager';
import type { InitializationManager } from './initialization-manager';
import type { InteractionManager } from './interaction-manager';
import type { MediaLoadedInfoManager } from './media-info-manager';
import type { MediaPlayerManager } from './media-player-manager';
import type { MessageManager } from './message-manager';
import type { MicrophoneManager } from './microphone-manager';
import type { StyleManager } from './style-manager';
import type { TriggersManager } from './triggers-manager';
import type { ViewManager } from './view-manager';
import type { QueryStringManager } from './query-string-manager';
/**
* This defines a series of limited APIs that various manager helpers use to
@@ -67,6 +67,7 @@ export interface CardCameraAPI {
getHASSManager(): HASSManager;
getMessageManager(): MessageManager;
getResolvedMediaCache(): ResolvedMediaCache;
getTriggersManager(): TriggersManager;
}
export interface CardCameraURLAPI {
@@ -100,8 +101,10 @@ export interface CardDownloadAPI {
export interface CardElementAPI {
getActionsManager(): ActionsManager;
getCameraManager(): CameraManager;
getExpandManager(): ExpandManager;
getFullscreenManager(): FullscreenManager;
getInitializationManager(): InitializationManager;
getInteractionManager(): InteractionManager;
getMediaLoadedInfoManager(): MediaLoadedInfoManager;
getMicrophoneManager(): MicrophoneManager;
@@ -205,7 +208,6 @@ export interface CardTriggersAPI {
getConditionsManager(): ConditionsManager;
getCardElementManager(): CardElementManager;
getConfigManager(): ConfigManager;
getHASSManager(): HASSManager;
getInteractionManager(): InteractionManager;
getViewManager(): ViewManager;
}
-1
View File
@@ -245,7 +245,6 @@ export class ViewManager {
displayMode: view.displayMode ?? undefined,
});
this._api.getTriggersManager().updateView(oldView);
this._api.getCardElementManager().update();
}
+8 -1
View File
@@ -163,6 +163,13 @@ class FrigateCard extends LitElement {
}
protected shouldUpdate(): boolean {
// Do not allow a disconnected element to update, as it may cause cameras to
// reinitialize/subscribe for an element that is no longer part of the
// document.
if (!this.isConnected) {
return false;
}
// Always allow messages to render, as a message may be generated during
// initialization.
if (this._controller.getMessageManager().hasMessage()) {
@@ -307,7 +314,7 @@ class FrigateCard extends LitElement {
?.getEpoch()}
.hide=${!!this._controller.getMessageManager().hasMessage()}
.microphoneManager=${this._controller.getMicrophoneManager()}
.triggeredCameraIDs=${this._config?.view.scan.show_trigger_status
.triggeredCameraIDs=${this._config?.view.triggers.show_trigger_status
? this._controller.getTriggersManager().getTriggeredCameraIDs()
: undefined}
></frigate-card-views>`}
+4
View File
@@ -29,6 +29,10 @@ export interface FrigateCardTimelineItem extends TimelineItem {
// high-quantity recording segments).
start: number;
end?: number;
// DataSet requires string (not HTMLElement) content.
content: string;
media?: ViewMedia;
}
+1 -1
View File
@@ -9,7 +9,7 @@ export class VideoRTC extends HTMLElement {
pcConfig: RTCConfiguration;
wsState: number;
pcState: number;
video: HTMLVideoElement;
video: HTMLVideoElement | null;
ws: WebSocket | null;
wsURL: string;
pc: RTCPeerConnection | null;
+3 -3
View File
@@ -71,7 +71,7 @@ export class FrigateCardGo2RTC extends LitElement implements FrigateCardMediaPla
}
public isMuted(): boolean {
return this._player?.video.muted ?? true;
return this._player?.video?.muted ?? true;
}
public async seek(seconds: number): Promise<void> {
@@ -87,11 +87,11 @@ export class FrigateCardGo2RTC extends LitElement implements FrigateCardMediaPla
}
public isPaused(): boolean {
return this._player?.video.paused ?? true;
return this._player?.video?.paused ?? true;
}
public async getScreenshotURL(): Promise<string | null> {
return this._player ? screenshotMedia(this._player.video) : null;
return this._player?.video ? screenshotMedia(this._player.video) : null;
}
disconnectedCallback(): void {
+1 -1
View File
@@ -133,7 +133,7 @@ export class FrigateCardSurround extends LitElement {
*/
protected willUpdate(changedProperties: PropertyValues): void {
if (this.timelineConfig?.mode && this.timelineConfig.mode !== 'none') {
import('./timeline.js');
import('./timeline-core.js');
}
// Only reset the timeline cameraIDs when the media or display mode
+3 -1
View File
@@ -139,7 +139,7 @@ export class FrigateCardViewer extends LitElement {
const mediaType = this.view.getDefaultMediaType();
if (!mediaType) {
// Directly render an error message (instead of dispatching it upwards)
// to preserve the mini-timeline if the user scans into an area with no
// to preserve the mini-timeline if the user pans into an area with no
// media.
return renderMessage({
type: 'info',
@@ -157,6 +157,7 @@ export class FrigateCardViewer extends LitElement {
{
allCameras: this.view.isGrid(),
targetView: 'recording',
useCache: false,
},
);
} else {
@@ -169,6 +170,7 @@ export class FrigateCardViewer extends LitElement {
allCameras: this.view.isGrid(),
targetView: 'media',
eventsMediaType: mediaType,
useCache: false,
},
);
}
+42 -13
View File
@@ -18,7 +18,10 @@ import {
CONF_OVERRIDES,
CONF_TIMELINE_EVENTS_MEDIA_TYPE,
CONF_VIEW_INTERACTION_SECONDS,
CONF_VIEW_SCAN_ACTIONS_UNTRIGGER,
CONF_VIEW_TRIGGERS,
CONF_VIEW_TRIGGERS_ACTIONS_TRIGGER,
CONF_VIEW_TRIGGERS_ACTIONS_UNTRIGGER,
CONF_VIEW_TRIGGERS_FILTER_SELECTED_CAMERA,
} from './const';
import { arrayify } from './utils/basic';
@@ -213,6 +216,18 @@ export const upgradeWithOverrides = function (
return upgradeMoveToWithOverrides(path, path, { transform: transform });
};
/**
* Delete a property in place with overrides.
* @param path The property path.
* @returns A function that returns `true` if the configuration was modified.
*/
export const deleteWithOverrides = function (
path: string,
): (obj: RawFrigateCardConfig) => boolean {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
return upgradeMoveToWithOverrides(path, path, { transform: (_) => null });
};
/**
* Given a path to an array, apply an upgrade to each object in the array.
* @param arrayPath The path to the array to upgrade.
@@ -480,14 +495,6 @@ const UPGRADES = [
);
},
upgradePTZElementsToLive(),
upgradeMoveToWithOverrides(
'view.scan.untrigger_reset',
CONF_VIEW_SCAN_ACTIONS_UNTRIGGER,
{
// Delete the value if it's set to the default.
transform: (val) => (val ? 'default' : null),
},
),
upgradeMoveToWithOverrides('view.timeout_seconds', CONF_VIEW_INTERACTION_SECONDS),
upgradeWithOverrides('live.lazy_unload', (data) =>
data === 'all' ? ['unselected', 'hidden'] : data === 'never' ? null : arrayify(data),
@@ -524,10 +531,7 @@ const UPGRADES = [
'live.controls.thumbnails.media',
CONF_LIVE_CONTROLS_THUMBNAILS_EVENTS_MEDIA_TYPE,
),
upgradeMoveToWithOverrides(
'timeline.media',
CONF_TIMELINE_EVENTS_MEDIA_TYPE,
),
upgradeMoveToWithOverrides('timeline.media', CONF_TIMELINE_EVENTS_MEDIA_TYPE),
upgradeMoveToWithOverrides(
'live.controls.timeline.media',
CONF_LIVE_CONTROLS_TIMELINE_EVENTS_MEDIA_TYPE,
@@ -536,4 +540,29 @@ const UPGRADES = [
'media_viewer.controls.timeline.media',
CONF_MEDIA_VIEWER_CONTROLS_TIMELINE_EVENTS_MEDIA_TYPE,
),
upgradeMoveToWithOverrides('view.scan', CONF_VIEW_TRIGGERS),
upgradeMoveToWithOverrides(
'view.triggers.enabled',
CONF_VIEW_TRIGGERS_ACTIONS_TRIGGER,
{
transform: (data) => (data === true ? 'live' : null),
// Keep it around, for the following transform.
keepOriginal: true,
},
),
upgradeMoveToWithOverrides(
'view.triggers.enabled',
CONF_VIEW_TRIGGERS_FILTER_SELECTED_CAMERA,
{
transform: (data) => (data === true ? false : null),
},
),
upgradeMoveToWithOverrides(
'view.triggers.untrigger_reset',
CONF_VIEW_TRIGGERS_ACTIONS_UNTRIGGER,
{
// Delete the value if it's set to the default.
transform: (val) => (val ? 'default' : null),
},
),
];
+36 -20
View File
@@ -77,6 +77,16 @@ export type PTZAction = (typeof PTZ_ACTIONS)[number];
const PTZ_PHASES = ['start', 'stop'] as const;
export type PTZPhase = (typeof PTZ_PHASES)[number];
const CAMERA_TRIGGER_EVENT_TYPES = [
// An event whether or not it has any media yet associated with it.
'events',
// Specific media availability.
'clips',
'snapshots',
] as const;
export type CameraTriggerEventType = (typeof CAMERA_TRIGGER_EVENT_TYPES)[number];
// *************************************************************************
// View Display Mode
// *************************************************************************
@@ -326,7 +336,7 @@ const actionsSchema = z.object({
});
const elementsBaseSchema = actionsBaseSchema.extend({
style: z.record(z.string().nullable().or(z.undefined())).optional(),
style: z.record(z.string().nullable().or(z.undefined()).or(z.number())).optional(),
title: z.string().nullable().optional(),
});
@@ -1038,7 +1048,8 @@ const cameraConfigDefault = {
},
triggers: {
motion: false,
occupancy: true,
occupancy: false,
events: [...CAMERA_TRIGGER_EVENT_TYPES],
entities: [],
},
};
@@ -1071,6 +1082,10 @@ export const cameraConfigSchema = z
motion: z.boolean().default(cameraConfigDefault.triggers.motion),
occupancy: z.boolean().default(cameraConfigDefault.triggers.occupancy),
entities: z.string().array().default(cameraConfigDefault.triggers.entities),
events: z
.enum(CAMERA_TRIGGER_EVENT_TYPES)
.array()
.default(cameraConfigDefault.triggers.events),
})
.default(cameraConfigDefault.triggers),
@@ -1145,41 +1160,42 @@ const viewConfigDefault = {
update_force: false,
update_cycle_camera: false,
dark_mode: 'off' as const,
scan: {
enabled: false,
show_trigger_status: true,
filter_selected_camera: false,
triggers: {
show_trigger_status: false,
filter_selected_camera: true,
actions: {
interaction_mode: 'inactive' as const,
trigger: 'live' as const,
untrigger: 'default' as const,
trigger: 'default' as const,
untrigger: 'none' as const,
},
untrigger_seconds: 0,
},
};
export const scanSchema = z.object({
enabled: z.boolean().default(viewConfigDefault.scan.enabled),
export const triggersSchema = z.object({
filter_selected_camera: z
.boolean()
.default(viewConfigDefault.scan.filter_selected_camera),
show_trigger_status: z.boolean().default(viewConfigDefault.scan.show_trigger_status),
.default(viewConfigDefault.triggers.filter_selected_camera),
show_trigger_status: z
.boolean()
.default(viewConfigDefault.triggers.show_trigger_status),
actions: z
.object({
interaction_mode: z
.enum(['all', 'inactive', 'active'])
.default(viewConfigDefault.scan.actions.interaction_mode),
trigger: z.enum(['live', 'none']).default(viewConfigDefault.scan.actions.trigger),
.default(viewConfigDefault.triggers.actions.interaction_mode),
trigger: z
.enum(['live', 'default', 'media', 'none'])
.default(viewConfigDefault.triggers.actions.trigger),
untrigger: z
.enum(['default', 'none'])
.default(viewConfigDefault.scan.actions.untrigger),
.default(viewConfigDefault.triggers.actions.untrigger),
})
.default(viewConfigDefault.scan.actions),
untrigger_seconds: z.number().default(viewConfigDefault.scan.untrigger_seconds),
.default(viewConfigDefault.triggers.actions),
untrigger_seconds: z.number().default(viewConfigDefault.triggers.untrigger_seconds),
});
export type ScanOptions = z.infer<typeof scanSchema>;
export type TriggersOptions = z.infer<typeof triggersSchema>;
const viewConfigSchema = z
.object({
@@ -1199,7 +1215,7 @@ const viewConfigSchema = z
update_entities: z.string().array().optional(),
render_entities: z.string().array().optional(),
dark_mode: z.enum(['on', 'off', 'auto']).optional(),
scan: scanSchema.default(viewConfigDefault.scan),
triggers: triggersSchema.default(viewConfigDefault.triggers),
})
.merge(actionsSchema)
.default(viewConfigDefault);
+18 -16
View File
@@ -54,6 +54,8 @@ export const CONF_CAMERAS_ARRAY_TRIGGERS_OCCUPANCY =
`${CONF_CAMERAS}.#.triggers.occupancy` as const;
export const CONF_CAMERAS_ARRAY_TRIGGERS_ENTITIES =
`${CONF_CAMERAS}.#.triggers.entities` as const;
export const CONF_CAMERAS_ARRAY_TRIGGERS_EVENTS =
`${CONF_CAMERAS}.#.triggers.events` as const;
const CONF_CAMERAS_GLOBAL = 'cameras_global' as const;
export const CONF_CAMERAS_GLOBAL_IMAGE = `${CONF_CAMERAS_GLOBAL}.image` as const;
@@ -77,21 +79,20 @@ export const CONF_VIEW_UPDATE_FORCE = `${CONF_VIEW}.update_force` as const;
export const CONF_VIEW_UPDATE_SECONDS = `${CONF_VIEW}.update_seconds` as const;
export const CONF_VIEW_RESET_AFTER_INTERACTION =
`${CONF_VIEW}.reset_after_interaction` as const;
export const CONF_VIEW_SCAN = `${CONF_VIEW}.scan` as const;
export const CONF_VIEW_SCAN_ENABLED = `${CONF_VIEW_SCAN}.enabled` as const;
export const CONF_VIEW_SCAN_SHOW_TRIGGER_STATUS =
`${CONF_VIEW_SCAN}.show_trigger_status` as const;
export const CONF_VIEW_SCAN_FILTER_SELECTED_CAMERA =
`${CONF_VIEW_SCAN}.filter_selected_camera` as const;
export const CONF_VIEW_SCAN_UNTRIGGER_SECONDS =
`${CONF_VIEW_SCAN}.untrigger_seconds` as const;
export const CONF_VIEW_SCAN_ACTIONS = `${CONF_VIEW_SCAN}.actions` as const;
export const CONF_VIEW_SCAN_ACTIONS_TRIGGER =
`${CONF_VIEW_SCAN_ACTIONS}.trigger` as const;
export const CONF_VIEW_SCAN_ACTIONS_UNTRIGGER =
`${CONF_VIEW_SCAN_ACTIONS}.untrigger` as const;
export const CONF_VIEW_SCAN_ACTIONS_INTERACTION_MODE =
`${CONF_VIEW_SCAN_ACTIONS}.interaction_mode` as const;
export const CONF_VIEW_TRIGGERS = `${CONF_VIEW}.triggers` as const;
export const CONF_VIEW_TRIGGERS_SHOW_TRIGGER_STATUS =
`${CONF_VIEW_TRIGGERS}.show_trigger_status` as const;
export const CONF_VIEW_TRIGGERS_FILTER_SELECTED_CAMERA =
`${CONF_VIEW_TRIGGERS}.filter_selected_camera` as const;
export const CONF_VIEW_TRIGGERS_UNTRIGGER_SECONDS =
`${CONF_VIEW_TRIGGERS}.untrigger_seconds` as const;
export const CONF_VIEW_TRIGGERS_ACTIONS = `${CONF_VIEW_TRIGGERS}.actions` as const;
export const CONF_VIEW_TRIGGERS_ACTIONS_TRIGGER =
`${CONF_VIEW_TRIGGERS_ACTIONS}.trigger` as const;
export const CONF_VIEW_TRIGGERS_ACTIONS_UNTRIGGER =
`${CONF_VIEW_TRIGGERS_ACTIONS}.untrigger` as const;
export const CONF_VIEW_TRIGGERS_ACTIONS_INTERACTION_MODE =
`${CONF_VIEW_TRIGGERS_ACTIONS}.interaction_mode` as const;
export const CONF_MEDIA_GALLERY = 'media_gallery' as const;
export const CONF_MEDIA_GALLERY_CONTROLS_FILTER_MODE =
@@ -258,7 +259,8 @@ const CONF_TIMELINE = 'timeline' as const;
export const CONF_TIMELINE_WINDOW_SECONDS = `${CONF_TIMELINE}.window_seconds` as const;
export const CONF_TIMELINE_CLUSTERING_THRESHOLD =
`${CONF_TIMELINE}.clustering_threshold` as const;
export const CONF_TIMELINE_EVENTS_MEDIA_TYPE = `${CONF_TIMELINE}.events_media_type` as const;
export const CONF_TIMELINE_EVENTS_MEDIA_TYPE =
`${CONF_TIMELINE}.events_media_type` as const;
export const CONF_TIMELINE_SHOW_RECORDINGS = `${CONF_TIMELINE}.show_recordings` as const;
export const CONF_TIMELINE_STYLE = `${CONF_TIMELINE}.style` as const;
export const CONF_TIMELINE_CONTROLS_THUMBNAILS_MODE =
+130 -79
View File
@@ -22,9 +22,9 @@ import {
} from './config-mgmt.js';
import {
BUTTON_SIZE_MIN,
FRIGATE_MENU_PRIORITY_MAX,
FrigateCardConfig,
frigateCardConfigDefaults,
FRIGATE_MENU_PRIORITY_MAX,
RawFrigateCardConfig,
RawFrigateCardConfigArray,
THUMBNAIL_WIDTH_MAX,
@@ -58,6 +58,7 @@ import {
CONF_CAMERAS_ARRAY_MOTIONEYE_URL,
CONF_CAMERAS_ARRAY_TITLE,
CONF_CAMERAS_ARRAY_TRIGGERS_ENTITIES,
CONF_CAMERAS_ARRAY_TRIGGERS_EVENTS,
CONF_CAMERAS_ARRAY_TRIGGERS_MOTION,
CONF_CAMERAS_ARRAY_TRIGGERS_OCCUPANCY,
CONF_CAMERAS_ARRAY_WEBRTC_CARD_ENTITY,
@@ -159,8 +160,8 @@ import {
CONF_MEDIA_VIEWER_TRANSITION_EFFECT,
CONF_MEDIA_VIEWER_ZOOMABLE,
CONF_MENU_ALIGNMENT,
CONF_MENU_BUTTONS,
CONF_MENU_BUTTON_SIZE,
CONF_MENU_BUTTONS,
CONF_MENU_POSITION,
CONF_MENU_STYLE,
CONF_PERFORMANCE_FEATURES_ANIMATED_PROGRESS_INDICATOR,
@@ -184,15 +185,14 @@ import {
CONF_VIEW_DEFAULT,
CONF_VIEW_INTERACTION_SECONDS,
CONF_VIEW_RESET_AFTER_INTERACTION,
CONF_VIEW_SCAN,
CONF_VIEW_SCAN_ACTIONS,
CONF_VIEW_SCAN_ACTIONS_INTERACTION_MODE,
CONF_VIEW_SCAN_ACTIONS_TRIGGER,
CONF_VIEW_SCAN_ACTIONS_UNTRIGGER,
CONF_VIEW_SCAN_ENABLED,
CONF_VIEW_SCAN_FILTER_SELECTED_CAMERA,
CONF_VIEW_SCAN_SHOW_TRIGGER_STATUS,
CONF_VIEW_SCAN_UNTRIGGER_SECONDS,
CONF_VIEW_TRIGGERS,
CONF_VIEW_TRIGGERS_ACTIONS,
CONF_VIEW_TRIGGERS_ACTIONS_INTERACTION_MODE,
CONF_VIEW_TRIGGERS_ACTIONS_TRIGGER,
CONF_VIEW_TRIGGERS_ACTIONS_UNTRIGGER,
CONF_VIEW_TRIGGERS_FILTER_SELECTED_CAMERA,
CONF_VIEW_TRIGGERS_SHOW_TRIGGER_STATUS,
CONF_VIEW_TRIGGERS_UNTRIGGER_SECONDS,
CONF_VIEW_UPDATE_CYCLE_CAMERA,
CONF_VIEW_UPDATE_FORCE,
CONF_VIEW_UPDATE_SECONDS,
@@ -244,8 +244,8 @@ const MENU_OPTIONS = 'options';
const MENU_PERFORMANCE_FEATURES = 'performance.features';
const MENU_PERFORMANCE_STYLE = 'performance.style';
const MENU_TIMELINE_CONTROLS_THUMBNAILS = 'timeline.controls.thumbnails';
const MENU_VIEW_SCAN = 'scan';
const MENU_VIEW_SCAN_ACTIONS = 'scan.actions';
const MENU_VIEW_TRIGGERS = 'view.triggers';
const MENU_VIEW_TRIGGERS_ACTIONS = 'view.triggers.actions';
interface EditorOptionsSet {
icon: string;
@@ -514,8 +514,14 @@ export class FrigateCardEditor extends LitElement implements LovelaceCardEditor
protected _timelineEventsMediaTypes: EditorSelectOption[] = [
{ value: '', label: '' },
{ value: 'all', label: localize('config.common.timeline.events_media_types.all') },
{ value: 'clips', label: localize('config.common.timeline.events_media_types.clips') },
{ value: 'snapshots', label: localize('config.common.timeline.events_media_types.snapshots') },
{
value: 'clips',
label: localize('config.common.timeline.events_media_types.clips'),
},
{
value: 'snapshots',
label: localize('config.common.timeline.events_media_types.snapshots'),
},
];
protected _timelineStyleTypes: EditorSelectOption[] = [
@@ -653,43 +659,67 @@ export class FrigateCardEditor extends LitElement implements LovelaceCardEditor
},
];
protected _scanActionsInteractionModes: EditorSelectOption[] = [
protected _triggersActionsInteractionModes: EditorSelectOption[] = [
{ value: '', label: '' },
{
value: 'all',
label: localize('config.view.scan.actions.interaction_modes.all'),
label: localize('config.view.triggers.actions.interaction_modes.all'),
},
{
value: 'inactive',
label: localize('config.view.scan.actions.interaction_modes.inactive'),
label: localize('config.view.triggers.actions.interaction_modes.inactive'),
},
{
value: 'active',
label: localize('config.view.scan.actions.interaction_modes.active'),
label: localize('config.view.triggers.actions.interaction_modes.active'),
},
];
protected _scanActionsTrigger: EditorSelectOption[] = [
{ value: '', label: '' },
{
value: 'live',
label: localize('config.view.scan.actions.triggers.live'),
},
{
value: 'none',
label: localize('config.view.scan.actions.triggers.none'),
},
];
protected _scanActionsUntrigger: EditorSelectOption[] = [
protected _triggersActionsTrigger: EditorSelectOption[] = [
{ value: '', label: '' },
{
value: 'default',
label: localize('config.view.scan.actions.untriggers.default'),
label: localize('config.view.triggers.actions.triggers.default'),
},
{
value: 'live',
label: localize('config.view.triggers.actions.triggers.live'),
},
{
value: 'media',
label: localize('config.view.triggers.actions.triggers.media'),
},
{
value: 'none',
label: localize('config.view.scan.actions.untriggers.none'),
label: localize('config.view.triggers.actions.triggers.none'),
},
];
protected _triggersActionsUntrigger: EditorSelectOption[] = [
{ value: '', label: '' },
{
value: 'default',
label: localize('config.view.triggers.actions.untriggers.default'),
},
{
value: 'none',
label: localize('config.view.triggers.actions.untriggers.none'),
},
];
protected _triggersEvents: EditorSelectOption[] = [
{ value: '', label: '' },
{
value: 'events',
label: localize('config.cameras.triggers.events.events'),
},
{
value: 'clips',
label: localize('config.cameras.triggers.events.clips'),
},
{
value: 'snapshots',
label: localize('config.cameras.triggers.events.snapshots'),
},
];
@@ -948,57 +978,54 @@ export class FrigateCardEditor extends LitElement implements LovelaceCardEditor
);
}
protected _renderViewScanMenu(): TemplateResult {
protected _renderViewTriggersMenu(): TemplateResult {
return this._putInSubmenu(
MENU_VIEW_SCAN,
MENU_VIEW_TRIGGERS,
true,
`config.${CONF_VIEW_SCAN}.editor_label`,
`config.${CONF_VIEW_TRIGGERS}.editor_label`,
{ name: 'mdi:target-account' },
html`
${this._renderSwitch(CONF_VIEW_SCAN_ENABLED, this._defaults.view.scan.enabled, {
label: localize(`config.${CONF_VIEW_SCAN_ENABLED}`),
})}
${this._renderSwitch(
CONF_VIEW_SCAN_FILTER_SELECTED_CAMERA,
this._defaults.view.scan.filter_selected_camera,
CONF_VIEW_TRIGGERS_FILTER_SELECTED_CAMERA,
this._defaults.view.triggers.filter_selected_camera,
{
label: localize(`config.${CONF_VIEW_SCAN_FILTER_SELECTED_CAMERA}`),
label: localize(`config.${CONF_VIEW_TRIGGERS_FILTER_SELECTED_CAMERA}`),
},
)}
${this._renderSwitch(
CONF_VIEW_SCAN_SHOW_TRIGGER_STATUS,
this._defaults.view.scan.show_trigger_status,
CONF_VIEW_TRIGGERS_SHOW_TRIGGER_STATUS,
this._defaults.view.triggers.show_trigger_status,
{
label: localize(`config.${CONF_VIEW_SCAN_SHOW_TRIGGER_STATUS}`),
label: localize(`config.${CONF_VIEW_TRIGGERS_SHOW_TRIGGER_STATUS}`),
},
)}
${this._renderNumberInput(CONF_VIEW_SCAN_UNTRIGGER_SECONDS, {
default: this._defaults.view.scan.untrigger_seconds,
${this._renderNumberInput(CONF_VIEW_TRIGGERS_UNTRIGGER_SECONDS, {
default: this._defaults.view.triggers.untrigger_seconds,
})}
${this._putInSubmenu(
MENU_VIEW_SCAN_ACTIONS,
MENU_VIEW_TRIGGERS_ACTIONS,
true,
`config.${CONF_VIEW_SCAN_ACTIONS}.editor_label`,
`config.${CONF_VIEW_TRIGGERS_ACTIONS}.editor_label`,
{ name: 'mdi:cogs' },
html` ${this._renderOptionSelector(
CONF_VIEW_SCAN_ACTIONS_TRIGGER,
this._scanActionsTrigger,
CONF_VIEW_TRIGGERS_ACTIONS_TRIGGER,
this._triggersActionsTrigger,
{
label: localize('config.view.scan.actions.trigger'),
label: localize('config.view.triggers.actions.trigger'),
},
)}
${this._renderOptionSelector(
CONF_VIEW_SCAN_ACTIONS_UNTRIGGER,
this._scanActionsUntrigger,
CONF_VIEW_TRIGGERS_ACTIONS_UNTRIGGER,
this._triggersActionsUntrigger,
{
label: localize('config.view.scan.actions.untrigger'),
label: localize('config.view.triggers.actions.untrigger'),
},
)}
${this._renderOptionSelector(
CONF_VIEW_SCAN_ACTIONS_INTERACTION_MODE,
this._scanActionsInteractionModes,
CONF_VIEW_TRIGGERS_ACTIONS_INTERACTION_MODE,
this._triggersActionsInteractionModes,
{
label: localize('config.view.scan.actions.interaction_mode'),
label: localize('config.view.triggers.actions.interaction_mode'),
},
)}`,
)}
@@ -1188,9 +1215,13 @@ export class FrigateCardEditor extends LitElement implements LovelaceCardEditor
${this._renderNumberInput(configPathClusteringThreshold, {
label: localize(`config.common.${CONF_TIMELINE_CLUSTERING_THRESHOLD}`),
})}
${this._renderOptionSelector(configPathTimelineEventsMediaType, this._timelineEventsMediaTypes, {
label: localize(`config.common.${CONF_TIMELINE_EVENTS_MEDIA_TYPE}`),
})}
${this._renderOptionSelector(
configPathTimelineEventsMediaType,
this._timelineEventsMediaTypes,
{
label: localize(`config.common.${CONF_TIMELINE_EVENTS_MEDIA_TYPE}`),
},
)}
${this._renderSwitch(configPathShowRecordings, defaultShowRecordings, {
label: localize(`config.common.${CONF_TIMELINE_SHOW_RECORDINGS}`),
})}`;
@@ -1801,21 +1832,40 @@ export class FrigateCardEditor extends LitElement implements LovelaceCardEditor
cameraIndex,
'config.cameras.triggers.editor_label',
{ name: 'mdi:magnify-scan' },
html` ${this._renderSwitch(
getArrayConfigPath(CONF_CAMERAS_ARRAY_TRIGGERS_OCCUPANCY, cameraIndex),
this._defaults.cameras.triggers.occupancy,
)}
${this._renderSwitch(
getArrayConfigPath(CONF_CAMERAS_ARRAY_TRIGGERS_MOTION, cameraIndex),
this._defaults.cameras.triggers.motion,
)}
${this._renderOptionSelector(
getArrayConfigPath(CONF_CAMERAS_ARRAY_TRIGGERS_ENTITIES, cameraIndex),
entities,
{
multiple: true,
},
)}`,
html`
${this._renderSwitch(
getArrayConfigPath(
CONF_CAMERAS_ARRAY_TRIGGERS_OCCUPANCY,
cameraIndex,
),
this._defaults.cameras.triggers.occupancy,
)}
${this._renderSwitch(
getArrayConfigPath(CONF_CAMERAS_ARRAY_TRIGGERS_MOTION, cameraIndex),
this._defaults.cameras.triggers.motion,
)}
${this._renderOptionSelector(
getArrayConfigPath(
CONF_CAMERAS_ARRAY_TRIGGERS_ENTITIES,
cameraIndex,
),
entities,
{
multiple: true,
},
)}
${this._renderOptionSelector(
getArrayConfigPath(
CONF_CAMERAS_ARRAY_TRIGGERS_EVENTS,
cameraIndex,
),
this._triggersEvents,
{
multiple: true,
label: localize('config.cameras.triggers.events.editor_label'),
},
)}
`,
)}
${this._putInSubmenu(
MENU_CAMERAS_CAST,
@@ -1992,7 +2042,7 @@ export class FrigateCardEditor extends LitElement implements LovelaceCardEditor
CONF_VIEW_UPDATE_CYCLE_CAMERA,
this._defaults.view.update_cycle_camera,
)}
${this._renderViewScanMenu()}
${this._renderViewTriggersMenu()}
</div>
`
: ''}
@@ -2125,7 +2175,8 @@ export class FrigateCardEditor extends LitElement implements LovelaceCardEditor
this._defaults.live.controls.thumbnails,
{
configPathMediaType: CONF_LIVE_CONTROLS_THUMBNAILS_MEDIA_TYPE,
configPathEventsMediaType: CONF_LIVE_CONTROLS_THUMBNAILS_EVENTS_MEDIA_TYPE,
configPathEventsMediaType:
CONF_LIVE_CONTROLS_THUMBNAILS_EVENTS_MEDIA_TYPE,
configPathMode: CONF_LIVE_CONTROLS_THUMBNAILS_MODE,
},
)}
+12 -5
View File
@@ -85,6 +85,12 @@
"triggers": {
"editor_label": "Trigger Options",
"entities": "Trigger from other entities",
"events": {
"clips": "Events with new clips",
"editor_label": "Trigger Events",
"events": "All events",
"snapshots": "Events with new snapshots"
},
"motion": "Trigger by auto-detecting the motion sensor",
"occupancy": "Trigger by auto-detecting the occupancy sensor"
},
@@ -394,9 +400,9 @@
"default": "Default view",
"interaction_seconds": "Seconds after user action to remain interacted with (0=never)",
"reset_after_interaction": "Reset to the default view after user interaction",
"scan": {
"triggers": {
"actions": {
"editor_label": "Scan mode actions",
"editor_label": "Trigger actions",
"interaction_mode": "How to handle actions when the card has human interaction",
"interaction_modes": {
"active": "Only trigger actions when card has human interaction",
@@ -405,7 +411,9 @@
},
"trigger": "Trigger action",
"triggers": {
"live": "Change to live",
"default": "Change to or update default view",
"live": "Change to or update live view",
"media": "Change to the relevant media view for new media",
"none": "No action"
},
"untrigger": "Untrigger action",
@@ -414,8 +422,7 @@
"none": "No action"
}
},
"editor_label": "Scan mode",
"enabled": "Scan mode enabled",
"editor_label": "Behavior when a camera is triggered",
"filter_selected_camera": "Only trigger on selected camera",
"show_trigger_status": "Show pulsing border when triggered",
"untrigger_seconds": "Seconds after inactive state change to untrigger"
+10 -3
View File
@@ -85,6 +85,12 @@
"triggers": {
"editor_label": "Trigger Opzioni",
"entities": "Trigger da altre entità",
"events": {
"clips": "",
"editor_label": "",
"events": "",
"snapshots": ""
},
"motion": "Trigger rilevando automaticamente dal sensore di movimento",
"occupancy": "Attivare rilevando automatico tramite il sensore di presenza"
},
@@ -390,7 +396,7 @@
"default": "Visualizzazione predefinita",
"interaction_seconds": "",
"reset_after_interaction": "",
"scan": {
"triggers": {
"actions": {
"editor_label": "",
"interaction_mode": "",
@@ -401,7 +407,9 @@
},
"trigger": "",
"triggers": {
"default": "",
"live": "",
"media": "",
"none": ""
},
"untrigger": "",
@@ -410,8 +418,7 @@
"none": ""
}
},
"editor_label": "Modalità di scansione",
"enabled": "Modalità di scansione abilitata",
"editor_label": "",
"filter_selected_camera": "",
"show_trigger_status": "Mostra bordo pulsante quando attivato",
"untrigger_seconds": "Reimposta la vista ai valori predefiniti dopo aver annullato l'attivazione"
+10 -3
View File
@@ -85,6 +85,12 @@
"triggers": {
"editor_label": "Opções de acionamento",
"entities": "Acionar a partir de outras entidades",
"events": {
"clips": "",
"editor_label": "",
"events": "",
"snapshots": ""
},
"motion": "Acionar detectando automaticamente o sensor de movimento",
"occupancy": "Acionar detectando automaticamente o sensor de ocupação"
},
@@ -393,7 +399,7 @@
"default": "Visualização padrão",
"interaction_seconds": "",
"reset_after_interaction": "",
"scan": {
"triggers": {
"actions": {
"editor_label": "",
"interaction_mode": "",
@@ -404,7 +410,9 @@
},
"trigger": "",
"triggers": {
"default": "",
"live": "",
"media": "",
"none": ""
},
"untrigger": "",
@@ -413,8 +421,7 @@
"none": ""
}
},
"editor_label": "Modo scan",
"enabled": "Modo scan ativado",
"editor_label": "",
"filter_selected_camera": "",
"show_trigger_status": "Pulsar borda quando acionado",
"untrigger_seconds": "Segundos após a mudar para o estado inativo para desacionar"
+10 -3
View File
@@ -85,6 +85,12 @@
"triggers": {
"editor_label": "Opções de activação",
"entities": "Activar a partir de outras entidades",
"events": {
"editor_label": "",
"events": "",
"clips": "",
"snapshots": ""
},
"motion": "Activar detectando automaticamente o sensor de movimento",
"occupancy": "Activar detectando automaticamente o sensor de ocupação"
},
@@ -383,7 +389,7 @@
"default": "Visualização padrão",
"interaction_seconds": "",
"reset_after_interaction": "",
"scan": {
"triggers": {
"actions": {
"editor_label": "",
"interaction_mode": "",
@@ -394,7 +400,9 @@
},
"trigger": "",
"triggers": {
"default": "",
"live": "",
"media": "",
"none": ""
},
"untrigger": "",
@@ -403,8 +411,7 @@
"none": ""
}
},
"editor_label": "Modo scan",
"enabled": "Modo scan ativado",
"editor_label": "",
"filter_selected_camera": "",
"show_trigger_status": "Exibir estado do gatilho",
"untrigger_seconds": "Segundos após a mudar para o estado inativo para desacionar"
+65 -5
View File
@@ -1,4 +1,8 @@
import { computeDomain, computeStateDomain, HomeAssistant } from '@dermotduffy/custom-card-helpers';
import {
computeDomain,
computeStateDomain,
HomeAssistant,
} from '@dermotduffy/custom-card-helpers';
import { HassEntity, MessageBase } from 'home-assistant-js-websocket';
import { StyleInfo } from 'lit/directives/style-map.js';
import { ZodSchema } from 'zod';
@@ -13,6 +17,12 @@ import {
} from '../../types.js';
import { domainIcon } from '../icons/domain-icon.js';
import { getParseErrorKeys } from '../zod.js';
import {
HAStateChangeFromTo,
haStateChangeTriggerResponseSchema,
SubscriptionCallback,
SubscriptionUnsubscribe,
} from './types.js';
/**
* Make a HomeAssistant websocket request. May throw.
@@ -103,7 +113,7 @@ interface HassStateDifference {
* strings only, firstOnly: whether or not to get the first difference only.
* @returns An array of HassStateDifference objects.
*/
export function getHassDifferences(
function getHassDifferences(
newHass: HomeAssistant | undefined | null,
oldHass: HomeAssistant | undefined | null,
entities: string[] | null,
@@ -326,11 +336,11 @@ export const sideLoadHomeAssistantElements = async (): Promise<boolean> => {
/**
* Determine if a given state qualifies as 'triggered'.
* @param state The HASSEntity.
* @param state The HA entity state string.
* @returns `true` if triggered, `false` otherwise.
*/
export const isTriggeredState = (state?: HassEntity): boolean => {
return !!state && ['on', 'open'].includes(state.state);
export const isTriggeredState = (state?: string): boolean => {
return !!state && ['on', 'open'].includes(state);
};
/**
@@ -390,3 +400,53 @@ export const hasHAConnectionStateChanged = (
): boolean => {
return oldHass?.connected !== newHass?.connected;
};
/**
* Subscribe to a HA trigger
* @param hass The HA object.
* @param callback The callback to call with the data.
* @param options Parameters to the trigger, see:
* https://www.home-assistant.io/docs/automation/trigger/#state-trigger
* @returns A callback to unsubscribe.
*/
export const subscribeToTrigger = async (
hass: HomeAssistant,
callback: SubscriptionCallback,
options?: {
entityID?: string | string[];
platform?: string;
topic?: string;
payload?: string;
valueTemplate?: string;
stateOnly?: boolean;
},
): Promise<SubscriptionUnsubscribe> => {
return await hass.connection.subscribeMessage(callback, {
type: 'subscribe_trigger',
trigger: {
...(options?.platform && { platform: options.platform }),
...(options?.entityID && { entity_id: options.entityID }),
...(options?.topic && { topic: options.topic }),
...(options?.payload && { payload: options.payload }),
...(options?.valueTemplate && { value_template: options.valueTemplate }),
...(options?.stateOnly && {
from: null,
to: null,
}),
},
});
};
/**
* Parse a state change trigger response.
* @param data The raw data.
* @returns A HAStateChangeFromTo object.
*/
export const parseStateChangeTrigger = (data: unknown): HAStateChangeFromTo | null => {
const parseResult = haStateChangeTriggerResponseSchema.safeParse(data);
if (!parseResult.success) {
console.warn('Ignoring unparseable HA state change', data);
return null;
}
return parseResult.data.variables.trigger;
};
+21
View File
@@ -0,0 +1,21 @@
import { z } from 'zod';
export type SubscriptionUnsubscribe = () => Promise<void>;
export type SubscriptionCallback = (data: unknown) => void;
const haStateChangeSchema = z.object({
entity_id: z.string(),
state: z.string(),
});
const haStateChangeFromToSchema = z.object({
from_state: haStateChangeSchema,
to_state: haStateChangeSchema,
});
export type HAStateChangeFromTo = z.infer<typeof haStateChangeFromToSchema>;
export const haStateChangeTriggerResponseSchema = z.object({
variables: z.object({
trigger: haStateChangeFromToSchema,
}),
});
+1 -1
View File
@@ -22,7 +22,7 @@ export class Initializer {
): Promise<boolean> {
const results = await allPromises(
Object.entries(aspects),
async ([aspect, options]) => this.initializeIfNecessary(aspect, options),
async ([aspect, options]) => await this.initializeIfNecessary(aspect, options),
);
return results.every(Boolean);
}
+10 -1
View File
@@ -27,6 +27,7 @@ export const changeViewToRecentEventsForCameraAndDependents = async (
eventsMediaType?: ClipsOrSnapshotsOrAll;
targetView?: FrigateCardView;
select?: ResultSelectType;
useCache?: boolean;
},
): Promise<void> => {
const cameraIDs = options?.allCameras
@@ -52,6 +53,7 @@ export const changeViewToRecentEventsForCameraAndDependents = async (
{
targetView: options?.targetView,
select: options?.select,
useCache: options?.useCache,
},
)
)?.dispatchChangeEvent(element);
@@ -93,6 +95,7 @@ export const changeViewToRecentRecordingForCameraAndDependents = async (
allCameras?: boolean;
targetView?: FrigateCardView;
select?: ResultSelectType;
useCache?: boolean;
},
): Promise<void> => {
const cameraIDs = options?.allCameras
@@ -120,6 +123,7 @@ export const changeViewToRecentRecordingForCameraAndDependents = async (
{
targetView: options?.targetView,
select: options?.select,
useCache: options?.useCache,
},
)
)?.dispatchChangeEvent(element);
@@ -147,6 +151,7 @@ export const executeMediaQueryForView = async (
targetView?: FrigateCardView;
targetTime?: Date;
select?: ResultSelectType;
useCache?: boolean;
},
): Promise<View | null> => {
const queries = query.getQueries();
@@ -154,7 +159,9 @@ export const executeMediaQueryForView = async (
return null;
}
const mediaArray = await cameraManager.executeMediaQueries<MediaQuery>(queries);
const mediaArray = await cameraManager.executeMediaQueries<MediaQuery>(queries, {
useCache: options?.useCache,
});
if (!mediaArray) {
return null;
}
@@ -194,6 +201,7 @@ export const executeMediaQueryForViewWithErrorDispatching = async (
targetView?: FrigateCardView;
targetTime?: Date;
select?: ResultSelectType;
useCache?: boolean;
},
): Promise<View | null> => {
try {
@@ -202,6 +210,7 @@ export const executeMediaQueryForViewWithErrorDispatching = async (
targetView: options?.targetView,
targetTime: options?.targetTime,
select: options?.select,
useCache: options?.useCache,
});
} catch (e: unknown) {
errorToConsole(e as Error);