From c8d9e89b789a101c32d1db6a7973433615b1378b Mon Sep 17 00:00:00 2001 From: Dermot Duffy Date: Sat, 21 May 2022 11:23:31 -0700 Subject: [PATCH] Autodetect motion and occupancy sensors. --- src/card.ts | 188 +++++++++++++++++++++------ src/components/live.ts | 3 +- src/components/viewer.ts | 2 +- src/const.ts | 14 +- src/editor.ts | 45 ++++--- src/localize/languages/en.json | 3 + src/types.ts | 21 ++- src/utils/ha/entity-registry.ts | 109 ++++++++++++++++ src/utils/ha/index.ts | 20 ++- src/utils/{ => ha}/resolved-media.ts | 33 ++++- 10 files changed, 358 insertions(+), 80 deletions(-) create mode 100644 src/utils/ha/entity-registry.ts rename src/utils/{ => ha}/resolved-media.ts (67%) diff --git a/src/card.ts b/src/card.ts index 086b9443..8224c115 100644 --- a/src/card.ts +++ b/src/card.ts @@ -48,23 +48,21 @@ import './patches/ha-camera-stream.js'; import './patches/ha-hls-player.js'; import './patches/ha-web-rtc-player.ts'; import cardStyle from './scss/card.scss'; -import type { - Entity, - ExtendedHomeAssistant, - FrigateCardConfig, - MediaShowInfo, - MenuButton, - Message -} from './types.js'; import { Actions, ActionType, CameraConfig, - entitySchema, + EntityList, + ExtendedEntity, + ExtendedHomeAssistant, + FrigateCardConfig, frigateCardConfigSchema, FrigateCardCustomAction, FrigateCardView, FRIGATE_CARD_VIEWS_USER_SPECIFIED, + MediaShowInfo, + MenuButton, + Message, RawFrigateCardConfig } from './types.js'; import { @@ -81,15 +79,20 @@ import { getEntityTitle, getHassDifferences, homeAssistantSignPath, - homeAssistantWSRequest, isHassDifferent, isTriggeredState, sideLoadHomeAssistantElements } from './utils/ha'; import { getEventID } from './utils/ha/browse-media.js'; +import { + ExtendedEntityCache, + getAllEntities, + getExtendedEntities, + getExtendedEntity +} from './utils/ha/entity-registry.js'; +import { ResolvedMediaCache } from './utils/ha/resolved-media.js'; import { supportsFeature } from './utils/ha/update.js'; import { isValidMediaShowInfo } from './utils/media-info.js'; -import { ResolvedMediaCache } from './utils/resolved-media.js'; import { View } from './view.js'; /** A note on media callbacks: @@ -579,23 +582,143 @@ export class FrigateCard extends LitElement { } } + /** + * Get the motion sensor entity for a given camera. + * @param cache The ExtendedEntityCache of entity registry information. + * @param cameraConfig The camera config in question. + * @returns The entity id of the motion sensor or null. + */ + protected _getMotionSensor( + cache: ExtendedEntityCache, + cameraConfig: CameraConfig, + ): string | null { + if (cameraConfig.camera_name) { + return ( + cache.getMatch( + (ent) => + !!ent.unique_id?.match( + new RegExp( + `:motion_sensor:${cameraConfig.zone || cameraConfig.camera_name}`, + ), + ), + )?.entity_id ?? null + ); + } + return null; + } + + /** + * Get the occupancy sensor entity for a given camera. + * @param cache The ExtendedEntityCache of entity registry information. + * @param cameraConfig The camera config in question. + * @returns The entity id of the occupancy sensor or null. + */ + protected _getOccupancySensor( + cache: ExtendedEntityCache, + cameraConfig: CameraConfig, + ): string | null { + if (cameraConfig.camera_name) { + return ( + cache.getMatch( + (ent) => + !!ent.unique_id?.match( + new RegExp( + `:occupancy_sensor:${cameraConfig.zone || cameraConfig.camera_name}_${ + cameraConfig.label || 'all' + }`, + ), + ), + )?.entity_id ?? null + ); + } + return null; + } + /** * Fully load the configured cameras. */ protected async _loadCameras(): Promise { + if (!this._hass) { + return; + } + + const cache = new ExtendedEntityCache(); + let entityList: EntityList | undefined; + try { + entityList = await getAllEntities(this._hass); + } catch (e) { + console.error(e, (e as Error).stack); + } + const cameras: Map = new Map(); let errorFree = true; const addCameraConfig = async (config: CameraConfig) => { - if (!config.camera_name && config.camera_entity) { - const resolvedName = await this._getFrigateCameraNameFromEntity( - config.camera_entity, - ); + if (!this._hass) { + return; + } + + let entity: ExtendedEntity | null = null; + if (config.camera_entity) { + try { + entity = await getExtendedEntity(this._hass, config.camera_entity, cache); + } catch (e) { + console.error(e, (e as Error).stack); + } + } + + if (!config.camera_name && entity) { + const resolvedName = this._getFrigateCameraNameFromEntity(entity); if (resolvedName) { config.camera_name = resolvedName; } } + if (entity && entityList) { + // Try to find the correct entities for the motion & occupancy sensors. + // We know they are binary_sensors, and that they'll have the same + // config entry ID as the camera. Searching via unique_id ensures this + // search still works if the user renames the entity_id. + const binarySensorEntities = entityList.filter( + (ent) => + ent.config_entry_id === entity?.config_entry_id && + !ent.disabled_by && + ent.entity_id.startsWith('binary_sensor.'), + ); + + try { + await getExtendedEntities( + this._hass, + binarySensorEntities.map((ent) => ent.entity_id), + cache, + ); + } catch(e) { + console.error(e, (e as Error).stack); + } + + if (config.trigger_by_motion) { + const motionEntity = this._getMotionSensor(cache, config); + if (motionEntity) { + config.trigger_by_entities.push(motionEntity); + } + } + + if (config.trigger_by_occupancy) { + const occupancyEntity = this._getOccupancySensor(cache, config); + if (occupancyEntity) { + config.trigger_by_entities.push(occupancyEntity); + } + } + + // TODO: Remove this auto-detection information. + console.info( + `Trigger entities sensor for ${entity.entity_id} are ${JSON.stringify( + config.trigger_by_entities, + )}`, + ); + } + config.trigger_by_entities = [...new Set(config.trigger_by_entities)]; + const id = getCameraID(config); if (!id) { this._setMessageAndUpdate({ @@ -649,37 +772,16 @@ export class FrigateCard extends LitElement { } /** - * Get the Frigate camera name from an entity name. + * Get the Frigate camera name from an entity. * @returns The Frigate camera name or null if unavailable. */ - protected async _getFrigateCameraNameFromEntity( - entity: string, - ): Promise { - if (!this._hass) { - return null; - } - - // Find entity unique_id in registry. - const request = { - type: 'config/entity_registry/get', - entity_id: entity, - }; - try { - const entityResult = await homeAssistantWSRequest( - this._hass, - entitySchema, - request, - ); - if (entityResult && entityResult.platform == 'frigate') { - const match = entityResult.unique_id.match(/:camera:(?[^:]+)$/); - if (match && match.groups) { - return match.groups['camera']; - } + protected _getFrigateCameraNameFromEntity(entity: ExtendedEntity): string | null { + if (entity.unique_id && entity.platform === 'frigate') { + const match = entity.unique_id.match(/:camera:(?[^:]+)$/); + if (match && match.groups) { + return match.groups['camera']; } - } catch (e: unknown) { - // Pass. } - return null; } @@ -1498,7 +1600,7 @@ export class FrigateCard extends LitElement { container: true, outer: true, triggered: !!this._triggered && this._getConfig().view.scan.trigger_show_border, - } + }; const contentClasses = { 'frigate-card-contents': true, diff --git a/src/components/live.ts b/src/components/live.ts index 83ad8627..8cf3c6f0 100644 --- a/src/components/live.ts +++ b/src/components/live.ts @@ -1090,7 +1090,8 @@ export class FrigateCardLiveJSMPEG extends LitElement { if (!this.cameraConfig?.camera_name) { return dispatchErrorMessageEvent( this, - localize('error.no_camera_name') + `: ${JSON.stringify(this.cameraConfig)}`, + localize('error.no_camera_name'), + this.cameraConfig, ); } diff --git a/src/components/viewer.ts b/src/components/viewer.ts index 0819b960..f48975c6 100644 --- a/src/components/viewer.ts +++ b/src/components/viewer.ts @@ -37,7 +37,7 @@ import { overrideMultiBrowseMediaQueryParameters } from '../utils/ha/browse-media.js'; import { createMediaShowInfo } from '../utils/media-info.js'; -import { ResolvedMediaCache, resolveMedia } from '../utils/resolved-media.js'; +import { ResolvedMediaCache, resolveMedia } from '../utils/ha/resolved-media.js'; import { View } from '../view.js'; import { AutoMediaPlugin } from './embla-plugins/automedia.js'; import { Lazyload, LazyloadType } from './embla-plugins/lazyload.js'; diff --git a/src/const.ts b/src/const.ts index f38a81ac..f9e3bd3b 100644 --- a/src/const.ts +++ b/src/const.ts @@ -23,6 +23,12 @@ export const CONF_CAMERAS_ARRAY_LIVE_PROVIDER = `${CONF_CAMERAS}.#.live_provider` as const; export const CONF_CAMERAS_ARRAY_DEPENDENT_CAMERAS = `${CONF_CAMERAS}.#.dependent_cameras` as const; +export const CONF_CAMERAS_ARRAY_TRIGGER_BY_MOTION = + `${CONF_CAMERAS}.#.trigger_by_motion` as const; +export const CONF_CAMERAS_ARRAY_TRIGGER_BY_OCCUPANCY = + `${CONF_CAMERAS}.#.trigger_by_occupancy` as const; +export const CONF_CAMERAS_ARRAY_TRIGGER_BY_ENTITIES = + `${CONF_CAMERAS}.#.trigger_by_entities` as const; export const CONF_VIEW = 'view' as const; export const CONF_VIEW_CAMERA_SELECT = `${CONF_VIEW}.camera_select` as const; @@ -35,8 +41,10 @@ export const CONF_VIEW_UPDATE_ENTITIES = `${CONF_VIEW}.update_entities` as const export const CONF_VIEW_UPDATE_SECONDS = `${CONF_VIEW}.update_seconds` 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_TRIGGER_MIN_SECONDS = `${CONF_VIEW_SCAN}.trigger_min_seconds` as const; -export const CONF_VIEW_SCAN_TRIGGER_SHOW_BORDER = `${CONF_VIEW_SCAN}.trigger_show_border` as const; +export const CONF_VIEW_SCAN_TRIGGER_MIN_SECONDS = + `${CONF_VIEW_SCAN}.trigger_min_seconds` as const; +export const CONF_VIEW_SCAN_TRIGGER_SHOW_BORDER = + `${CONF_VIEW_SCAN}.trigger_show_border` as const; export const CONF_EVENT_GALLERY = 'event_gallery' as const; export const CONF_EVENT_GALLERY_CONTROLS_THUMBNAILS_SHOW_DETAILS = @@ -146,4 +154,4 @@ export const CONF_DIMENSIONS_ASPECT_RATIO_MODE = export const CONF_OVERRIDES = 'overrides' as const; // Taken from https://github.dev/home-assistant/frontend/blob/b5861869e39290fd2e15737e89571dfc543b3ad3/src/data/media-player.ts#L93 -export const MEDIA_PLAYER_SUPPORT_BROWSE_MEDIA = 131072; \ No newline at end of file +export const MEDIA_PLAYER_SUPPORT_BROWSE_MEDIA = 131072; diff --git a/src/editor.ts b/src/editor.ts index edb2812c..44252643 100644 --- a/src/editor.ts +++ b/src/editor.ts @@ -21,6 +21,9 @@ import { CONF_CAMERAS_ARRAY_LABEL, CONF_CAMERAS_ARRAY_LIVE_PROVIDER, CONF_CAMERAS_ARRAY_TITLE, + CONF_CAMERAS_ARRAY_TRIGGER_BY_ENTITIES, + CONF_CAMERAS_ARRAY_TRIGGER_BY_MOTION, + CONF_CAMERAS_ARRAY_TRIGGER_BY_OCCUPANCY, CONF_CAMERAS_ARRAY_URL, CONF_CAMERAS_ARRAY_WEBRTC_CARD_ENTITY, CONF_CAMERAS_ARRAY_WEBRTC_CARD_URL, @@ -103,7 +106,7 @@ import { } from './types.js'; import { arrayMove } from './utils/basic.js'; import { getCameraID, getCameraTitle } from './utils/camera.js'; -import { sideLoadHomeAssistantElements } from './utils/ha'; +import { getEntitiesFromHASS, sideLoadHomeAssistantElements } from './utils/ha'; const MENU_BUTTONS = 'buttons'; const MENU_CAMERAS = 'cameras'; @@ -403,20 +406,6 @@ export class FrigateCardEditor extends LitElement implements LovelaceCardEditor } } - protected _getEntities(domain: string): string[] { - if (!this.hass) { - return []; - } - const entities = Object.keys(this.hass.states).filter( - (eid) => eid.substr(0, eid.indexOf('.')) === domain, - ); - entities.sort(); - - // Add a blank entry to unset a selection. - entities.unshift(''); - return entities; - } - /** * Render an option set header * @param optionSetName The name of the EditorOptionsSet. @@ -615,9 +604,7 @@ export class FrigateCardEditor extends LitElement implements LovelaceCardEditor .key=${true} > - ${localize(`config.${CONF_VIEW_SCAN}.scan_mode`)} + ${localize(`config.${CONF_VIEW_SCAN}.scan_mode`)} ${this._expandedMenus[MENU_VIEW_SCAN] ? html`
@@ -743,6 +730,7 @@ export class FrigateCardEditor extends LitElement implements LovelaceCardEditor protected _renderCamera( cameras: RawFrigateCardConfigArray, cameraIndex: number, + entities: string[], addNewCamera?: boolean, ): TemplateResult | void { const liveProviders: EditorSelectOption[] = [ @@ -891,6 +879,21 @@ export class FrigateCardEditor extends LitElement implements LovelaceCardEditor multiple: true, }, )} + ${this._renderSwitch( + getArrayConfigPath(CONF_CAMERAS_ARRAY_TRIGGER_BY_OCCUPANCY, cameraIndex), + frigateCardConfigDefaults.cameras.trigger_by_occupancy, + )} + ${this._renderSwitch( + getArrayConfigPath(CONF_CAMERAS_ARRAY_TRIGGER_BY_MOTION, cameraIndex), + frigateCardConfigDefaults.cameras.trigger_by_motion, + )} + ${this._renderOptionSelector( + getArrayConfigPath(CONF_CAMERAS_ARRAY_TRIGGER_BY_ENTITIES, cameraIndex), + entities, + { + multiple: true, + }, + )}
` : ``} `; @@ -978,7 +981,7 @@ export class FrigateCardEditor extends LitElement implements LovelaceCardEditor } const defaults = frigateCardConfigDefaults; - + const entities = getEntitiesFromHASS(this.hass); const cameras = (getConfigValue(this._config, CONF_CAMERAS) || []) as RawFrigateCardConfigArray; @@ -1007,8 +1010,8 @@ export class FrigateCardEditor extends LitElement implements LovelaceCardEditor ${this._renderOptionSetHeader('cameras')} ${this._expandedMenus[MENU_OPTIONS] === 'cameras' ? html` ` : ''} ${this._renderOptionSetHeader('view')} diff --git a/src/localize/languages/en.json b/src/localize/languages/en.json index 3ef558c6..054fc49e 100644 --- a/src/localize/languages/en.json +++ b/src/localize/languages/en.json @@ -27,6 +27,9 @@ "frigate-jsmpeg": "Frigate JSMpeg", "webrtc-card": "WebRTC Card (i.e. AlexxIT's WebRTC Card)" }, + "trigger_by_entities": "Trigger from other entities", + "trigger_by_motion": "Trigger by auto-detecting the motion sensor", + "trigger_by_occupancy": "Trigger by auto-detecting the occupancy sensor", "webrtc_card": { "entity": "WebRTC Card Camera Entity (Not a Frigate camera)", "url": "WebRTC Card Camera URL" diff --git a/src/types.ts b/src/types.ts index 77771786..20d69453 100644 --- a/src/types.ts +++ b/src/types.ts @@ -364,6 +364,9 @@ const customSchema = z export const cameraConfigDefault = { client_id: 'frigate' as const, live_provider: 'auto' as const, + trigger_by_motion: true, + trigger_by_occupancy: true, + trigger_by_entities: [], }; const webrtcCardCameraConfigSchema = z.object({ entity: z.string().optional(), @@ -395,9 +398,9 @@ const cameraConfigSchema = z // Set of cameras IDs upon which this camera depends. dependent_cameras: z.string().array().optional(), - trigger_by_motion: z.boolean().optional(), - trigger_by_occupancy: z.boolean().optional(), - trigger_by_entities: z.string().array().optional(), + trigger_by_motion: z.boolean().default(cameraConfigDefault.trigger_by_motion), + trigger_by_occupancy: z.boolean().default(cameraConfigDefault.trigger_by_occupancy), + trigger_by_entities: z.string().array().default(cameraConfigDefault.trigger_by_entities), }) .default(cameraConfigDefault); export type CameraConfig = z.infer; @@ -1263,8 +1266,18 @@ export const signedPathSchema = z.object({ export type SignedPath = z.infer; export const entitySchema = z.object({ + config_entry_id: z.string().nullable(), + disabled_by: z.string().nullable(), entity_id: z.string(), - unique_id: z.string(), platform: z.string(), }); export type Entity = z.infer; + +export const extendedEntitySchema = entitySchema.extend({ + // Extended entity results. + unique_id: z.string().optional(), +}) +export type ExtendedEntity = z.infer; + +export const entityListSchema = entitySchema.array(); +export type EntityList = z.infer; diff --git a/src/utils/ha/entity-registry.ts b/src/utils/ha/entity-registry.ts new file mode 100644 index 00000000..6567e66f --- /dev/null +++ b/src/utils/ha/entity-registry.ts @@ -0,0 +1,109 @@ +import { HomeAssistant } from 'custom-card-helpers'; +import { homeAssistantWSRequest } from '.'; +import { + Entity, + EntityList, + entityListSchema, + ExtendedEntity, + extendedEntitySchema +} from '../../types.js'; + +export class ExtendedEntityCache { + protected _cache: Map = new Map(); + + /** + * Determine if the cache has a given entity_id. + * @param id + * @returns `true` if the id is in the cache, `false` otherwise. + */ + public has(id: string): boolean { + return this._cache.has(id); + } + + /** + * Get the first value that returns true for the given predicate. + * @param func A callback function that returns a boolean. + * @returns The first matching value. + */ + public getMatch(func: (arg: ExtendedEntity) => boolean): ExtendedEntity | null { + return [...this._cache.values()].find(func) ?? null; + } + + /** + * Get entity information given an id. + * @param id The entity id. + * @returns The `ExtendedEntity` for this id. + */ + public get(id: string): ExtendedEntity | undefined { + return this._cache.get(id); + } + + /** + * Add a given ExtendedEntity to the cache. + * @param extendedEntity + */ + public set(extendedEntity: ExtendedEntity): void { + this._cache.set(extendedEntity.entity_id, extendedEntity); + } +} + +/** + * Get the extended entity information for an entity. May throw. + * @param hass The Home Assistant object. + * @param entity The entity id. + * @param cache An optional ExtendedEntityCache. + * @returns The ExtendedEntity information. + */ +export const getExtendedEntity = async ( + hass: HomeAssistant, + entity: string, + cache?: ExtendedEntityCache, +): Promise => { + const cachedValue = cache ? cache.get(entity) : undefined; + if (cachedValue) { + return cachedValue; + } + const result = await homeAssistantWSRequest( + hass, + extendedEntitySchema, + { + type: 'config/entity_registry/get', + entity_id: entity, + }, + ); + if (cache) { + cache.set(result); + } + return result; +}; + +/** + * Get the extended entity information for an array of entities. + * @param hass The Home Assistant object. + * @param entities An array of entity ids. + * @param cache An optional ExtendedEntityCache. + * @returns A map of entity id to ExtendedEntity objects. + */ +export const getExtendedEntities = async ( + hass: HomeAssistant, + entities: string[], + cache?: ExtendedEntityCache, +): Promise> => { + const output: Map = new Map(); + const _storeExtendedEntity = async (entity: string): Promise => { + output.set(entity, await getExtendedEntity(hass, entity, cache)); + }; + await Promise.all(entities.map(_storeExtendedEntity)); + return output; +}; + +/** + * Get a list of all entities from the entity registry. + * @param hass The Home Assistant object. + * @returns An entity list object. + */ +export const getAllEntities = async (hass: HomeAssistant): Promise => { + return await homeAssistantWSRequest(hass, entityListSchema, { + type: 'config/entity_registry/list', + }); +}; diff --git a/src/utils/ha/index.ts b/src/utils/ha/index.ts index 567a4992..db7b3448 100644 --- a/src/utils/ha/index.ts +++ b/src/utils/ha/index.ts @@ -4,8 +4,7 @@ import { StyleInfo } from 'lit/directives/style-map.js'; import { ZodSchema } from 'zod'; import { localize } from '../../localize/localize.js'; import { - CardHelpers, - ExtendedHomeAssistant, + CardHelpers, ExtendedHomeAssistant, SignedPath, signedPathSchema, StateParameters @@ -306,3 +305,20 @@ export const sideLoadHomeAssistantElements = async (): Promise => { export const isTriggeredState = (state?: HassEntity): boolean => { return !!state && ['on', 'open'].includes(state.state); }; + +/** + * Get entities from the HASS object. + * @param hass + * @param domain + * @returns + */ +export const getEntitiesFromHASS = (hass: HomeAssistant, domain?: string): string[] => { + if (!hass) { + return []; + } + const entities = Object.keys(hass.states).filter( + (eid) => !domain || eid.substr(0, eid.indexOf('.')) === domain, + ); + entities.sort(); + return entities; +} diff --git a/src/utils/resolved-media.ts b/src/utils/ha/resolved-media.ts similarity index 67% rename from src/utils/resolved-media.ts rename to src/utils/ha/resolved-media.ts index 3ce77436..e816df52 100644 --- a/src/utils/resolved-media.ts +++ b/src/utils/ha/resolved-media.ts @@ -1,11 +1,11 @@ import { HomeAssistant } from 'custom-card-helpers'; import QuickLRU from 'quick-lru'; +import { homeAssistantWSRequest } from '.'; import { - FrigateBrowseMediaSource, - ResolvedMedia, - resolvedMediaSchema -} from '../types.js'; -import { homeAssistantWSRequest } from './ha'; + FrigateBrowseMediaSource, + ResolvedMedia, + resolvedMediaSchema +} from '../../types.js'; // It's important the cache size be at least as large as the largest likely // media query or media items will from a given query will be evicted for other @@ -21,19 +21,42 @@ export class ResolvedMediaCache { this._cache = new QuickLRU({ maxSize: RESOLVED_MEDIA_CACHE_SIZE }); } + /** + * Determine if the cache has a given id. + * @param id + * @returns `true` if the id is in the cache, `false` otherwise. + */ public has(id: string): boolean { return this._cache.has(id); } + + /** + * Get resolved media information given an id. + * @param id The id. + * @returns The `ResolvedMedia` for this id. + */ public get(id: string): ResolvedMedia | undefined { return this._cache.get(id); } + /** + * Add a given ResolvedMedia to the cache. + * @param id The id for the object. + * @param resolvedMedia The `ResolvedMedia` object. + */ public set(id: string, resolvedMedia: ResolvedMedia): void { this._cache.set(id, resolvedMedia); } } +/** + * Resolve a given media source item. + * @param hass The Home Assistant object. + * @param mediaSource The media source object. + * @param cache An optional ResolvedMediaCache object. + * @returns + */ export const resolveMedia = async ( hass: HomeAssistant, mediaSource?: FrigateBrowseMediaSource,