Allow camera engines to signal events to the card.
This commit is contained in:
@@ -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(
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
|
||||
@@ -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'),
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
// ==============================
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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
|
||||
// ===========
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -245,7 +245,6 @@ export class ViewManager {
|
||||
displayMode: view.displayMode ?? undefined,
|
||||
});
|
||||
|
||||
this._api.getTriggersManager().updateView(oldView);
|
||||
this._api.getCardElementManager().update();
|
||||
}
|
||||
|
||||
|
||||
+8
-1
@@ -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>`}
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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,
|
||||
},
|
||||
)}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user