From ab3202c9ac1c82c87e7aeb244be7ff8e0e1886a6 Mon Sep 17 00:00:00 2001 From: Dermot Duffy Date: Tue, 17 May 2022 22:09:30 -0700 Subject: [PATCH 01/11] First skeleton of scan mode. --- src/card.ts | 119 ++++++++++++++++++++++++++++++++++++-- src/components/image.ts | 16 ++--- src/components/submenu.ts | 20 +++---- src/scss/card.scss | 18 ++++++ src/types.ts | 14 +++++ src/utils/ha/index.ts | 78 ++++++++++++++++++++----- 6 files changed, 228 insertions(+), 37 deletions(-) diff --git a/src/card.ts b/src/card.ts index 59aaa443..45af1757 100644 --- a/src/card.ts +++ b/src/card.ts @@ -79,9 +79,11 @@ import { getCameraIcon, getCameraID, getCameraTitle } from './utils/camera.js'; import { getEntityIcon, getEntityTitle, + getHassDifferences, homeAssistantSignPath, homeAssistantWSRequest, - shouldUpdateBasedOnHass, + isHassDifferent, + isTriggeredState, sideLoadHomeAssistantElements } from './utils/ha'; import { getEventID } from './utils/ha/browse-media.js'; @@ -169,6 +171,9 @@ export class FrigateCard extends LitElement { // Automated refreshes of the default view. protected _updateTimerID: number | null = null; + // Untrigger timer. + protected _untriggerTimerID: number | null = null; + // Information about the most recently loaded media item. protected _mediaShowInfo: MediaShowInfo | null = null; @@ -191,6 +196,10 @@ export class FrigateCard extends LitElement { // Whether the card has been successfully initialized. protected _initialized = false; + @state() + protected _triggered: Date | null = null; + protected _triggers: Map = new Map(); + /** * Set the Home Assistant object. */ @@ -488,7 +497,6 @@ export class FrigateCard extends LitElement { const isValidMediaPlayer = (entity: string): boolean => { if (entity.startsWith('media_player.')) { const stateObj = this._hass?.states[entity]; - if (stateObj && supportsFeature(stateObj, MEDIA_PLAYER_SUPPORT_BROWSE_MEDIA)) { return true; } @@ -775,6 +783,7 @@ export class FrigateCard extends LitElement { this._message = null; this._generateConditionState(); this._setLightOrDarkMode(); + this._triggered = null; } /** @@ -850,6 +859,7 @@ export class FrigateCard extends LitElement { * Called before each update. */ protected willUpdate(): void { + // Side load the necessary elements if not already initialized. if (!this._initialized) { sideLoadHomeAssistantElements().then((success) => { if (success) { @@ -859,6 +869,96 @@ export class FrigateCard extends LitElement { } } + /** + * Determine if a camera has been triggered. + * @param oldHass The old HA object. + * @returns A boolean indicating whether the camera was changed. + */ + protected _updateTriggeredCameras(oldHass: HomeAssistant): boolean { + if (!this._view) { + return false; + } + + const now = new Date(); + let changedCamera = false; + let untriggerCard = true; + + for (const [camera, config] of this._cameras?.entries() ?? []) { + const triggerEntities = config?.trigger_by_entities ?? []; + const diffs = getHassDifferences(this._hass, oldHass, triggerEntities, { + stateOnly: true, + }); + const shouldTrigger = diffs.some((diff) => isTriggeredState(diff.newState)); + const shouldUntrigger = triggerEntities.every( + (entity) => !isTriggeredState(this._hass?.states[entity]), + ); + + const priorTrigger = this._triggers.get(camera); + if (shouldTrigger) { + if ( + !priorTrigger || + (now.getTime() - priorTrigger.getTime()) / 1000 > + this._getConfig().view.scan.trigger_min_seconds + ) { + this._clearUntriggerTimer(); + this._triggers.set(camera, new Date()); + if (this._isAutomatedViewUpdateAllowed()) { + if (!changedCamera) { + this._changeView({ view: this._view.evolve({ camera: camera }) }); + changedCamera = true; + if (!this._triggered) { + this._triggered = now; + } + } + } + } + } + untriggerCard &&= shouldUntrigger; + } + + if (this._triggered && untriggerCard && !this._untriggerTimerID) { + this._untriggerTimerID = window.setInterval( + this._untriggerTimerHandler.bind(this), + Math.max( + 0, + this._getConfig().view.scan.trigger_min_seconds * 1000 - + (now.getTime() - this._triggered.getTime()), + ), + ); + } + + return changedCamera; + } + + /** + * Reset the untrigger timer. + */ + protected _clearUntriggerTimer() { + if (this._untriggerTimerID) { + window.clearTimeout(this._untriggerTimerID); + this._untriggerTimerID = null; + } + } + + /** + * Untrigger the card. + */ + protected _untrigger(): void { + this._clearUntriggerTimer(); + this._triggered = null; + } + + /** + * Handler for the untrigger timer. + */ + protected _untriggerTimerHandler(): void { + this._untrigger(); + + // Change back to the default view if the untrigger is + // timer-based/automated. + this._changeView(); + } + /** * Determine whether the element should be updated. * @param changedProps The changed properties if any. @@ -875,9 +975,11 @@ export class FrigateCard extends LitElement { // are browsing the mini-gallery). Do not allow re-rendering from a Home // Assistant update if there's been recent interaction (e.g. clicks on the // card) or if there is media active playing. - if ( + if (this._updateTriggeredCameras(oldHass)) { + shouldUpdate ||= true; + } else if ( this._isAutomatedViewUpdateAllowed() && - shouldUpdateBasedOnHass( + isHassDifferent( this._hass, oldHass, this._getConfig().view.update_entities || [], @@ -889,7 +991,7 @@ export class FrigateCard extends LitElement { this._changeView(); shouldUpdate ||= true; } else { - shouldUpdate ||= shouldUpdateBasedOnHass( + shouldUpdate ||= isHassDifferent( this._hass, oldHass, this._getConfig().view.render_entities || [], @@ -1168,6 +1270,10 @@ export class FrigateCard extends LitElement { */ protected _startInteractionTimer(): void { this._clearInteractionTimer(); + + // Interactions reset the trigger state. + this._untrigger(); + if (this._getConfig().view.timeout_seconds) { this._interactionTimerID = window.setTimeout(() => { this._changeView(); @@ -1189,7 +1295,7 @@ export class FrigateCard extends LitElement { } if (this._getConfig().view.update_seconds) { this._updateTimerID = window.setTimeout(() => { - if (this._isAutomatedViewUpdateAllowed()) { + if (!this._triggered && this._isAutomatedViewUpdateAllowed()) { this._changeView(); } else { // Not allowed to update this time around, but try again at the next @@ -1391,6 +1497,7 @@ export class FrigateCard extends LitElement { const contentClasses = { 'frigate-card-contents': true, absolute: padding != null, + triggered: !!this._triggered && this._getConfig().view.scan.trigger_show_border, }; const actions = this._getMergedActions(); diff --git a/src/components/image.ts b/src/components/image.ts index 0ba8f042..98e6e10b 100644 --- a/src/components/image.ts +++ b/src/components/image.ts @@ -1,11 +1,11 @@ import { HomeAssistant } from 'custom-card-helpers'; import { - CSSResultGroup, - html, - LitElement, - PropertyValues, - TemplateResult, - unsafeCSS + CSSResultGroup, + html, + LitElement, + PropertyValues, + TemplateResult, + unsafeCSS } from 'lit'; import { customElement, property, state } from 'lit/decorators.js'; import { createRef, ref, Ref } from 'lit/directives/ref.js'; @@ -14,7 +14,7 @@ import defaultImage from '../images/frigate-bird-in-sky.jpg'; import { localize } from '../localize/localize.js'; import imageStyle from '../scss/image.scss'; import { CameraConfig, ImageViewConfig } from '../types.js'; -import { shouldUpdateBasedOnHass } from '../utils/ha'; +import { isHassDifferent } from '../utils/ha'; import { dispatchMediaShowEvent } from '../utils/media-info.js'; import { View } from '../view.js'; import { dispatchErrorMessageEvent } from './message.js'; @@ -96,7 +96,7 @@ export class FrigateCardImage extends LitElement { this._imageConfig?.mode === 'camera' && cameraEntity ) { - if (shouldUpdateBasedOnHass(this.hass, changedProps.get('hass'), [cameraEntity])) { + if (isHassDifferent(this.hass, changedProps.get('hass'), [cameraEntity])) { // If the state of the camera entity has changed, remove the cached // value (will be re-calculated in willUpdate). This is important to // ensure a changed access token is immediately used. diff --git a/src/components/submenu.ts b/src/components/submenu.ts index f7a77562..270bfb76 100644 --- a/src/components/submenu.ts +++ b/src/components/submenu.ts @@ -1,12 +1,12 @@ import type { Corner } from '@material/mwc-menu'; import { HomeAssistant } from 'custom-card-helpers'; import { - CSSResultGroup, - html, - LitElement, - PropertyValues, - TemplateResult, - unsafeCSS + CSSResultGroup, + html, + LitElement, + PropertyValues, + TemplateResult, + unsafeCSS } from 'lit'; import { customElement, property } from 'lit/decorators.js'; import { ifDefined } from 'lit/directives/if-defined.js'; @@ -15,10 +15,10 @@ import { actionHandler } from '../action-handler-directive.js'; import submenuStyle from '../scss/submenu.scss'; import { MenuSubmenu, MenuSubmenuItem, MenuSubmenuSelect } from '../types.js'; import { - frigateCardHasAction, - stopEventFromActivatingCardWideActions + frigateCardHasAction, + stopEventFromActivatingCardWideActions } from '../utils/action.js'; -import { refreshDynamicStateParameters, shouldUpdateBasedOnHass } from '../utils/ha'; +import { isHassDifferent, refreshDynamicStateParameters } from '../utils/ha'; import { domainIcon } from '../utils/icons/domain-icon.js'; @customElement('frigate-card-submenu') @@ -139,7 +139,7 @@ export class FrigateCardSubmenuSelect extends LitElement { changedProps.size != 1 || !this.submenuSelect || (!!oldHass && - shouldUpdateBasedOnHass(this.hass, oldHass, [this.submenuSelect.entity])) + isHassDifferent(this.hass, oldHass, [this.submenuSelect.entity])) ); } diff --git a/src/scss/card.scss b/src/scss/card.scss index 955b25c0..09f3509d 100644 --- a/src/scss/card.scss +++ b/src/scss/card.scss @@ -33,11 +33,29 @@ align-items: center; justify-content: center; + // Include the borders in the sizing (to keep the triggered warning pulse + // visible in fullscreen). + box-sizing: border-box; + // Hide scrollbar: Firefox scrollbar-width: none; // Hide scrollbar: IE and Edge -ms-overflow-style: none; } +.frigate-card-contents.triggered { + @keyframes warning-pulse { + 0% { + border: solid 1px rgba(0,0,0,0); + } + 50% { + border: solid 1px var(--warning-color); + } + 100% { + border: solid 1px rgba(0,0,0,0); + } + } + animation: warning-pulse 5s infinite; +} /* Hide scrollbar for Chrome, Safari and Opera */ .frigate-card-contents::-webkit-scrollbar { diff --git a/src/types.ts b/src/types.ts index 77ee60be..77771786 100644 --- a/src/types.ts +++ b/src/types.ts @@ -394,6 +394,10 @@ 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(), }) .default(cameraConfigDefault); export type CameraConfig = z.infer; @@ -498,6 +502,11 @@ const viewConfigDefault = { update_force: false, update_cycle_camera: false, dark_mode: 'off' as const, + scan: { + enabled: false, + trigger_min_seconds: 20, + trigger_show_border: true, + } }; const viewConfigSchema = z .object({ @@ -514,6 +523,11 @@ const viewConfigSchema = z update_entities: z.string().array().optional(), render_entities: z.string().array().optional(), dark_mode: z.enum(['on', 'off', 'auto']).optional(), + scan: z.object({ + enabled: z.boolean().default(viewConfigDefault.scan.enabled), + trigger_min_seconds: z.number().default(viewConfigDefault.scan.trigger_min_seconds), + trigger_show_border: z.boolean().default(viewConfigDefault.scan.trigger_show_border), + }).default(viewConfigDefault.scan) }) .merge(actionsSchema) .default(viewConfigDefault); diff --git a/src/utils/ha/index.ts b/src/utils/ha/index.ts index 633638a8..d94acb15 100644 --- a/src/utils/ha/index.ts +++ b/src/utils/ha/index.ts @@ -75,32 +75,75 @@ export async function homeAssistantSignPath( return hass.hassUrl(response.path); } +interface HassStateDifference { + entity: string; + oldState?: HassEntity; + newState: HassEntity; +} + /** - * Determine whether the card should be updated based on Home Assistant changes. + * Get the difference between two hass objects. * @param newHass The new HA object. * @param oldHass The old HA object. * @param entities The entities to examine for changes. - * @returns A boolean indicating whether or not to allow an update. + * @param options An options object. stateOnly: whether or not to compare state + * strings only, firstOnly: whether or not to get the first difference only. + * @returns An array of HassStateDifference objects. */ -export function shouldUpdateBasedOnHass( +export function getHassDifferences( newHass: HomeAssistant | undefined | null, oldHass: HomeAssistant | undefined | null, entities: string[] | null, -): boolean { + options?: { + firstOnly?: boolean; + stateOnly?: boolean; + }, +): HassStateDifference[] { if (!newHass || !entities || !entities.length) { - return false; - } - if (!oldHass) { - return true; + return []; } - for (let i = 0; i < entities.length; i++) { - const entity = entities[i]; - if (entity && oldHass.states[entity] !== newHass.states[entity]) { - return true; + const differences: HassStateDifference[] = []; + for (const entity of entities) { + const oldState = oldHass?.states[entity]; + const newState = newHass.states[entity]; + if ( + (options?.stateOnly && oldState?.state !== newState.state) || + (!options?.stateOnly && oldState !== newState) + ) { + differences.push({ + entity: entity, + oldState: oldState, + newState: newState, + }); + if (options?.firstOnly) { + break; + } } } - return false; + return differences; +} + +/** + * Determine if two hass objects are different for a list of entities. + * @param newHass The new HA object. + * @param oldHass The old HA object. + * @param entities The entities to examine for changes. + * @param options An options object. stateOnly: whether or not to compare state strings only. + * @returns An array of HassStateDifference objects. + */ +export function isHassDifferent( + newHass: HomeAssistant | undefined | null, + oldHass: HomeAssistant | undefined | null, + entities: string[] | null, + options?: { + stateOnly?: boolean; + }, +): boolean { + return !!getHassDifferences(newHass, oldHass, entities, { + ...options, + firstOnly: true, + }).length; } /** @@ -254,3 +297,12 @@ export const sideLoadHomeAssistantElements = async (): Promise => { } return false; }; + +/** + * Determine if a given state qualifies as 'triggered'. + * @param state The HASSEntity. + * @returns `true` if triggered, `false` otherwise. + */ +export const isTriggeredState = (state?: HassEntity): boolean => { + return !!state && ['on', 'open'].includes(state.state); +}; From 3090087e73ec846a808f75078824771494d47e06 Mon Sep 17 00:00:00 2001 From: Dermot Duffy Date: Wed, 18 May 2022 21:34:13 -0700 Subject: [PATCH 02/11] Add editor support. --- src/const.ts | 4 ++++ src/editor.ts | 44 ++++++++++++++++++++++++++++++++++ src/localize/languages/en.json | 8 ++++++- src/scss/editor.scss | 2 +- 4 files changed, 56 insertions(+), 2 deletions(-) diff --git a/src/const.ts b/src/const.ts index e61138ac..f38a81ac 100644 --- a/src/const.ts +++ b/src/const.ts @@ -33,6 +33,10 @@ export const CONF_VIEW_UPDATE_CYCLE_CAMERA = `${CONF_VIEW}.update_cycle_camera` export const CONF_VIEW_UPDATE_FORCE = `${CONF_VIEW}.update_force` as const; 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_EVENT_GALLERY = 'event_gallery' as const; export const CONF_EVENT_GALLERY_CONTROLS_THUMBNAILS_SHOW_DETAILS = diff --git a/src/editor.ts b/src/editor.ts index df36a05b..edb2812c 100644 --- a/src/editor.ts +++ b/src/editor.ts @@ -81,6 +81,10 @@ import { CONF_VIEW_CAMERA_SELECT, CONF_VIEW_DARK_MODE, CONF_VIEW_DEFAULT, + CONF_VIEW_SCAN, + CONF_VIEW_SCAN_ENABLED, + CONF_VIEW_SCAN_TRIGGER_MIN_SECONDS, + CONF_VIEW_SCAN_TRIGGER_SHOW_BORDER, CONF_VIEW_TIMEOUT_SECONDS, CONF_VIEW_UPDATE_CYCLE_CAMERA, CONF_VIEW_UPDATE_FORCE, @@ -104,6 +108,7 @@ import { sideLoadHomeAssistantElements } from './utils/ha'; const MENU_BUTTONS = 'buttons'; const MENU_CAMERAS = 'cameras'; const MENU_OPTIONS = 'options'; +const MENU_VIEW_SCAN = 'scan'; interface EditorOptionsSet { icon: string; @@ -601,6 +606,44 @@ export class FrigateCardEditor extends LitElement implements LovelaceCardEditor ); } + protected _renderViewScanMenu(): TemplateResult { + return html` + + ${this._expandedMenus[MENU_VIEW_SCAN] + ? html`
+ ${this._renderSwitch( + CONF_VIEW_SCAN_ENABLED, + frigateCardConfigDefaults.view.scan.enabled ?? true, + { + label: localize('config.view.scan.enabled'), + }, + )} + ${this._renderSwitch( + CONF_VIEW_SCAN_TRIGGER_SHOW_BORDER, + frigateCardConfigDefaults.view.scan.trigger_show_border ?? true, + { + label: localize('config.view.scan.trigger_show_border'), + }, + )} + ${this._renderNumberInput(CONF_VIEW_SCAN_TRIGGER_MIN_SECONDS, { + default: frigateCardConfigDefaults.view.scan.trigger_min_seconds, + label: localize('config.view.scan.trigger_min_seconds'), + })} +
` + : ''} + `; + } + /** * Render an editor menu for the card menu buttons. * @param button The name of the button. @@ -985,6 +1028,7 @@ export class FrigateCardEditor extends LitElement implements LovelaceCardEditor CONF_VIEW_UPDATE_CYCLE_CAMERA, defaults.view.update_cycle_camera, )} + ${this._renderViewScanMenu()} ` : ''} diff --git a/src/localize/languages/en.json b/src/localize/languages/en.json index 20d1b7bd..3ef558c6 100644 --- a/src/localize/languages/en.json +++ b/src/localize/languages/en.json @@ -65,7 +65,13 @@ "timeout_seconds": "Reset to default view X seconds after user action (0=never)", "update_cycle_camera": "Cycle through cameras when default view updates", "update_force": "Force card updates (ignore user interaction)", - "update_seconds": "Refresh default view every X seconds (0=never)" + "update_seconds": "Refresh default view every X seconds (0=never)", + "scan": { + "scan_mode": "Scan mode", + "enabled": "Scan mode enabled", + "trigger_min_seconds": "Minimum seconds to trigger for", + "trigger_show_border": "Show pulsing border when triggered" + } }, "event_gallery": { "controls": { diff --git a/src/scss/editor.scss b/src/scss/editor.scss index c965f9a9..5ddfa307 100644 --- a/src/scss/editor.scss +++ b/src/scss/editor.scss @@ -42,7 +42,7 @@ div.upgrade span { .submenu-header { display: flex; - margin-top: 4px; + margin-top: 10px; cursor: pointer; } From 4a40e4a943e9accf1161cb743197f11c90545da6 Mon Sep 17 00:00:00 2001 From: Dermot Duffy Date: Thu, 19 May 2022 17:58:17 -0700 Subject: [PATCH 03/11] Add trigger border to outer container. --- src/card.ts | 9 +++++++-- src/scss/card.scss | 28 ++++++++++++++-------------- src/utils/ha/index.ts | 6 +++--- 3 files changed, 24 insertions(+), 19 deletions(-) diff --git a/src/card.ts b/src/card.ts index 45af1757..086b9443 100644 --- a/src/card.ts +++ b/src/card.ts @@ -1494,10 +1494,15 @@ export class FrigateCard extends LitElement { outerStyle['padding-top'] = `${padding}%`; } + const outerClasses = { + container: true, + outer: true, + triggered: !!this._triggered && this._getConfig().view.scan.trigger_show_border, + } + const contentClasses = { 'frigate-card-contents': true, absolute: padding != null, - triggered: !!this._triggered && this._getConfig().view.scan.trigger_show_border, }; const actions = this._getMergedActions(); @@ -1518,7 +1523,7 @@ export class FrigateCard extends LitElement { @frigate-card:render=${() => this.requestUpdate()} > ${renderMenuAbove ? this._renderMenu() : ''} -
+
${this._cameras === undefined ? until( diff --git a/src/scss/card.scss b/src/scss/card.scss index 09f3509d..989b85f9 100644 --- a/src/scss/card.scss +++ b/src/scss/card.scss @@ -23,6 +23,20 @@ // boundary. border-radius: var(--ha-card-border-radius, 4px); } +.container.triggered { + @keyframes warning-pulse { + 0% { + border: solid 1px rgba(0,0,0,0); + } + 50% { + border: solid 1px var(--warning-color); + } + 100% { + border: solid 1px rgba(0,0,0,0); + } + } + animation: warning-pulse 5s infinite; +} .frigate-card-contents { width: inherit; @@ -42,20 +56,6 @@ // Hide scrollbar: IE and Edge -ms-overflow-style: none; } -.frigate-card-contents.triggered { - @keyframes warning-pulse { - 0% { - border: solid 1px rgba(0,0,0,0); - } - 50% { - border: solid 1px var(--warning-color); - } - 100% { - border: solid 1px rgba(0,0,0,0); - } - } - animation: warning-pulse 5s infinite; -} /* Hide scrollbar for Chrome, Safari and Opera */ .frigate-card-contents::-webkit-scrollbar { diff --git a/src/utils/ha/index.ts b/src/utils/ha/index.ts index d94acb15..567a4992 100644 --- a/src/utils/ha/index.ts +++ b/src/utils/ha/index.ts @@ -105,10 +105,10 @@ export function getHassDifferences( const differences: HassStateDifference[] = []; for (const entity of entities) { - const oldState = oldHass?.states[entity]; - const newState = newHass.states[entity]; + const oldState: HassEntity | undefined = oldHass?.states[entity]; + const newState: HassEntity | undefined = newHass.states[entity]; if ( - (options?.stateOnly && oldState?.state !== newState.state) || + (options?.stateOnly && oldState?.state !== newState?.state) || (!options?.stateOnly && oldState !== newState) ) { differences.push({ From c8d9e89b789a101c32d1db6a7973433615b1378b Mon Sep 17 00:00:00 2001 From: Dermot Duffy Date: Sat, 21 May 2022 11:23:31 -0700 Subject: [PATCH 04/11] 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, From 4abf187aa6ad46a5497b872ba71d98a7f199db2f Mon Sep 17 00:00:00 2001 From: Dermot Duffy Date: Sat, 21 May 2022 12:16:14 -0700 Subject: [PATCH 05/11] Simplify trigger logic. --- src/card.ts | 104 ++++++++++++++++++++-------------------------------- 1 file changed, 39 insertions(+), 65 deletions(-) diff --git a/src/card.ts b/src/card.ts index 8224c115..56345dee 100644 --- a/src/card.ts +++ b/src/card.ts @@ -174,9 +174,6 @@ export class FrigateCard extends LitElement { // Automated refreshes of the default view. protected _updateTimerID: number | null = null; - // Untrigger timer. - protected _untriggerTimerID: number | null = null; - // Information about the most recently loaded media item. protected _mediaShowInfo: MediaShowInfo | null = null; @@ -200,7 +197,6 @@ export class FrigateCard extends LitElement { protected _initialized = false; @state() - protected _triggered: Date | null = null; protected _triggers: Map = new Map(); /** @@ -692,7 +688,7 @@ export class FrigateCard extends LitElement { binarySensorEntities.map((ent) => ent.entity_id), cache, ); - } catch(e) { + } catch (e) { console.error(e, (e as Error).stack); } @@ -885,7 +881,7 @@ export class FrigateCard extends LitElement { this._message = null; this._generateConditionState(); this._setLightOrDarkMode(); - this._triggered = null; + this._untrigger(); } /** @@ -983,7 +979,7 @@ export class FrigateCard extends LitElement { const now = new Date(); let changedCamera = false; - let untriggerCard = true; + let triggerChanges = false; for (const [camera, config] of this._cameras?.entries() ?? []) { const triggerEntities = config?.trigger_by_entities ?? []; @@ -995,70 +991,42 @@ export class FrigateCard extends LitElement { (entity) => !isTriggeredState(this._hass?.states[entity]), ); - const priorTrigger = this._triggers.get(camera); if (shouldTrigger) { - if ( - !priorTrigger || - (now.getTime() - priorTrigger.getTime()) / 1000 > - this._getConfig().view.scan.trigger_min_seconds - ) { - this._clearUntriggerTimer(); - this._triggers.set(camera, new Date()); - if (this._isAutomatedViewUpdateAllowed()) { - if (!changedCamera) { - this._changeView({ view: this._view.evolve({ camera: camera }) }); - changedCamera = true; - if (!this._triggered) { - this._triggered = now; - } - } + this._triggers.set(camera, now); + triggerChanges = true; + } else if (shouldUntrigger) { + this._triggers.delete(camera); + triggerChanges = true; + } + } + + if (triggerChanges && this._isAutomatedViewUpdateAllowed(true)) { + if (!this._triggers.size) { + this._changeView(); + changedCamera = true; + } else { + let targetCamera: string | null = null; + let targetCameraDate: Date | null = null; + for (const [camera, date] of this._triggers.entries()) { + if (!targetCamera || !targetCameraDate || date > targetCameraDate) { + targetCamera = camera; + targetCameraDate = date; } } + if (targetCamera) { + this._changeView({ view: this._view.evolve({ camera: targetCamera }) }); + changedCamera = true; + } } - untriggerCard &&= shouldUntrigger; } - - if (this._triggered && untriggerCard && !this._untriggerTimerID) { - this._untriggerTimerID = window.setInterval( - this._untriggerTimerHandler.bind(this), - Math.max( - 0, - this._getConfig().view.scan.trigger_min_seconds * 1000 - - (now.getTime() - this._triggered.getTime()), - ), - ); - } - return changedCamera; } - /** - * Reset the untrigger timer. - */ - protected _clearUntriggerTimer() { - if (this._untriggerTimerID) { - window.clearTimeout(this._untriggerTimerID); - this._untriggerTimerID = null; - } - } - /** * Untrigger the card. */ protected _untrigger(): void { - this._clearUntriggerTimer(); - this._triggered = null; - } - - /** - * Handler for the untrigger timer. - */ - protected _untriggerTimerHandler(): void { - this._untrigger(); - - // Change back to the default view if the untrigger is - // timer-based/automated. - this._changeView(); + this._triggers.clear(); } /** @@ -1378,9 +1346,11 @@ export class FrigateCard extends LitElement { if (this._getConfig().view.timeout_seconds) { this._interactionTimerID = window.setTimeout(() => { - this._changeView(); this._clearInteractionTimer(); - this._setLightOrDarkMode(); + if (this._isAutomatedViewUpdateAllowed()) { + this._changeView(); + this._setLightOrDarkMode(); + } }, this._getConfig().view.timeout_seconds * 1000); } this._setLightOrDarkMode(); @@ -1397,7 +1367,7 @@ export class FrigateCard extends LitElement { } if (this._getConfig().view.update_seconds) { this._updateTimerID = window.setTimeout(() => { - if (!this._triggered && this._isAutomatedViewUpdateAllowed()) { + if (this._isAutomatedViewUpdateAllowed()) { this._changeView(); } else { // Not allowed to update this time around, but try again at the next @@ -1412,8 +1382,11 @@ export class FrigateCard extends LitElement { * Determine if an automated view update is allowed. * @returns `true` if it's allowed, `false` otherwise. */ - protected _isAutomatedViewUpdateAllowed(): boolean { - return this._getConfig().view.update_force || !this._interactionTimerID; + protected _isAutomatedViewUpdateAllowed(ignoreTriggers?: boolean): boolean { + return ( + (ignoreTriggers || !this._triggers.size) && + (this._getConfig().view.update_force || !this._interactionTimerID) + ); } /** @@ -1599,7 +1572,8 @@ export class FrigateCard extends LitElement { const outerClasses = { container: true, outer: true, - triggered: !!this._triggered && this._getConfig().view.scan.trigger_show_border, + triggered: + !!this._triggers.size && this._getConfig().view.scan.trigger_show_border, }; const contentClasses = { From dba309d69f2b76bcbf06962578bb39bf52c250f9 Mon Sep 17 00:00:00 2001 From: Dermot Duffy Date: Sat, 21 May 2022 12:19:29 -0700 Subject: [PATCH 06/11] Get rid of `trigger_min_seconds`. --- src/const.ts | 2 -- src/editor.ts | 5 ----- src/localize/languages/en.json | 1 - src/types.ts | 2 -- 4 files changed, 10 deletions(-) diff --git a/src/const.ts b/src/const.ts index f9e3bd3b..4fcbdda9 100644 --- a/src/const.ts +++ b/src/const.ts @@ -41,8 +41,6 @@ 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; diff --git a/src/editor.ts b/src/editor.ts index 44252643..0550899b 100644 --- a/src/editor.ts +++ b/src/editor.ts @@ -86,7 +86,6 @@ import { CONF_VIEW_DEFAULT, CONF_VIEW_SCAN, CONF_VIEW_SCAN_ENABLED, - CONF_VIEW_SCAN_TRIGGER_MIN_SECONDS, CONF_VIEW_SCAN_TRIGGER_SHOW_BORDER, CONF_VIEW_TIMEOUT_SECONDS, CONF_VIEW_UPDATE_CYCLE_CAMERA, @@ -622,10 +621,6 @@ export class FrigateCardEditor extends LitElement implements LovelaceCardEditor label: localize('config.view.scan.trigger_show_border'), }, )} - ${this._renderNumberInput(CONF_VIEW_SCAN_TRIGGER_MIN_SECONDS, { - default: frigateCardConfigDefaults.view.scan.trigger_min_seconds, - label: localize('config.view.scan.trigger_min_seconds'), - })}
` : ''} `; diff --git a/src/localize/languages/en.json b/src/localize/languages/en.json index 054fc49e..3b551450 100644 --- a/src/localize/languages/en.json +++ b/src/localize/languages/en.json @@ -72,7 +72,6 @@ "scan": { "scan_mode": "Scan mode", "enabled": "Scan mode enabled", - "trigger_min_seconds": "Minimum seconds to trigger for", "trigger_show_border": "Show pulsing border when triggered" } }, diff --git a/src/types.ts b/src/types.ts index 20d69453..6af02db7 100644 --- a/src/types.ts +++ b/src/types.ts @@ -507,7 +507,6 @@ const viewConfigDefault = { dark_mode: 'off' as const, scan: { enabled: false, - trigger_min_seconds: 20, trigger_show_border: true, } }; @@ -528,7 +527,6 @@ const viewConfigSchema = z dark_mode: z.enum(['on', 'off', 'auto']).optional(), scan: z.object({ enabled: z.boolean().default(viewConfigDefault.scan.enabled), - trigger_min_seconds: z.number().default(viewConfigDefault.scan.trigger_min_seconds), trigger_show_border: z.boolean().default(viewConfigDefault.scan.trigger_show_border), }).default(viewConfigDefault.scan) }) From bbf8160318a9dc9ae5cb89a4f22b24956e132806 Mon Sep 17 00:00:00 2001 From: Dermot Duffy Date: Sun, 22 May 2022 11:46:16 -0700 Subject: [PATCH 07/11] Simplify trigger/untrigger logic. --- src/card.ts | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/src/card.ts b/src/card.ts index 56345dee..b2b8243e 100644 --- a/src/card.ts +++ b/src/card.ts @@ -196,7 +196,6 @@ export class FrigateCard extends LitElement { // Whether the card has been successfully initialized. protected _initialized = false; - @state() protected _triggers: Map = new Map(); /** @@ -980,6 +979,7 @@ export class FrigateCard extends LitElement { const now = new Date(); let changedCamera = false; let triggerChanges = false; + const isTriggered = !!this._triggers.size; for (const [camera, config] of this._cameras?.entries() ?? []) { const triggerEntities = config?.trigger_by_entities ?? []; @@ -990,14 +990,12 @@ export class FrigateCard extends LitElement { const shouldUntrigger = triggerEntities.every( (entity) => !isTriggeredState(this._hass?.states[entity]), ); - if (shouldTrigger) { - this._triggers.set(camera, now); - triggerChanges = true; - } else if (shouldUntrigger) { + this._triggers.set(camera, now) + } else if (shouldUntrigger && this._triggers.has(camera)) { this._triggers.delete(camera); - triggerChanges = true; } + triggerChanges ||= (!isTriggered && shouldTrigger) || (isTriggered && !this._triggers.size); } if (triggerChanges && this._isAutomatedViewUpdateAllowed(true)) { From c10fe5ca76281603dc463f38ed5f800ef8e489ab Mon Sep 17 00:00:00 2001 From: Dermot Duffy Date: Sun, 22 May 2022 12:38:58 -0700 Subject: [PATCH 08/11] Add initial documentation. --- README.md | 62 ++++++++++++++++++++++++++++++++++ src/card.ts | 2 +- src/localize/languages/en.json | 2 +- src/types.ts | 6 ++-- 4 files changed, 67 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index dcf5b3b6..9929603e 100644 --- a/README.md +++ b/README.md @@ -114,6 +114,9 @@ See the [fully expanded cameras configuration example](#config-expanded-cameras) | `webrtc_card` | | :heavy_multiplication_x: | The WebRTC entity/URL to use for this camera with the `webrtc-card` live provider. See below. | | `id` | `camera_entity`, `webrtc_card.entity` or `camera_name` if set (in that preference order). | :heavy_multiplication_x: | An optional identifier to use throughout the card configuration to refer unambiguously to this camera. See [camera IDs](#camera-ids). | | `dependent_cameras` | | :heavy_multiplication_x: | An optional array of other camera identifiers (see [camera IDs](#camera-ids)). If specified the card will fetch events for this camera and *also* recursively events for the named `dependent_cameras`. All `dependent_cameras` must themselves be a configured camera in the card. This can be useful to group events for cameras that are close together, or to show events for the `birdseye` camera that otherwise would not have events itself.| +| `trigger_by_motion` | `false` | :heavy_multiplication_x: | Whether to not to trigger the camera (see [scan mode](#scan-mode)) by automatically detecting and using the motion `binary_sensor` for this camera. This autodetection only works for Frigate cameras, and only when the motion `binary_sensor` entity has been enabled in Home Assistant.| +| `trigger_by_occupancy` | `true` | :heavy_multiplication_x: | Whether to not to trigger the camera (see [scan mode](#scan-mode)) by automatically detecting and using the occupancy `binary_sensor` for this camera. This autodetection only works for Frigate cameras, and only when the occupancy `binary_sensor` entity has been enabled in Home Assistant.| +| `trigger_by_entities` | | :heavy_multiplication_x: | Whether to not to trigger the camera (see [scan mode](#scan-mode)) when the state of any Home Assistant entity becomes active (i.e. state becomes `on` or `open`). This works for Frigate or non-Frigate cameras.| @@ -174,7 +177,29 @@ See the [fully expanded view configuration example](#config-expanded-view) for h | `update_entities` | | :white_check_mark: | **YAML only**: A list of entity ids that should cause the view to reset to the default. See [card updates](#card-updates) below for behavior and usecases.| | `update_cycle_camera` | `false` | :white_check_mark: | When set to `true` the selected camera is cycled on each default view change. | | `render_entities` | | :white_check_mark: | **YAML only**: A list of entity ids that should cause the card to re-render 'in-place'. The view/camera is not changed. `update_*` flags do not pertain/relate to the behavior of this flag. This should **very** rarely be needed, but could be useful if the card is both setting and changing HA state of the same object as could be the case for some complex `card_mod` scenarios ([example](https://github.com/dermotduffy/frigate-hass-card/issues/343)). | +| `scan` | | :white_check_mark: | Configuration for [scan mode](#scan-mode). | | `actions` | | :white_check_mark: | Actions to use for all views, individual actions may be overriden by view-specific actions. See [actions](#actions) below.| + + + +#### View: Scan Mode configuration + +All configuration is under: + +```yaml +view: + scan: +``` + +Scan mode allows the card to automatically "follow the action". In this mode the card will automatically select a camera to view when it is triggered (as defined by your camera configuration, see `trigger_by_motion`, `trigger_by_occupancy` and `trigger_by_entities` parameters). When the camera untriggers, the camera selection will return to the next most recently triggered camera (as long as it is still triggered) -- if there are no triggered cameras remaining, the camera will return to the default. Triggering is only allowed when there is no ongoing human interaction with the card -- interaction will automatically untrigger it and further triggering will not occur until after the card has been unattended for `view.timeout_seconds`. + +Scan mode tracks Home Assistant state changes -- when the card is first started, it takes a positive change in state to trigger (i.e. an already occupied room will not trigger it, but a newly occupied room would trigger it). + +| Option | Default | Overridable | Description | +| - | - | - | - | +| `enabled` | `false` | :white_check_mark: | Whether to enable scan mode. | +| `show_trigger_status` | `true` | :white_check_mark: | Whether or not the card should show a visual indication that it is triggered (a pulsing border around the card edge). | + ### Menu Options All configuration is under: @@ -921,6 +946,10 @@ cameras: # Show events for camera-2 when this camera is viewed. dependent_cameras: - camera-2 + trigger_by_motion: false + trigger_by_occupancy: true + trigger_by_entities: + - binary_sensor.front_door_sensor - frigate_url: http://my-other.frigate.local client_id: frigate-other camera_name: entrance @@ -935,6 +964,10 @@ cameras: webrtc_card: entity: camera.entrance_rtsp url: 'rtsp://username:password@camera:554/av_stream/ch0' + trigger_by_motion: false + trigger_by_occupancy: true + trigger_by_entities: + - binary_sensor.entrance_sensor ``` @@ -958,6 +991,9 @@ view: render_entities: - switch.render_card dark_mode: 'off' + scan: + enabled: false + show_trigger_status: true actions: entity: light.office_main_lights tap_action: @@ -2365,6 +2401,30 @@ cameras: ``` +### Using Scan Mode + +Have your card follow the action with Scan Mode. + +
+ Expand: Using scan mode + +```yaml +type: custom:frigate-card +cameras: + - camera_entity: camera.back_yard + # This camera will automatically trigger by occupancy. + - camera_entity: camera.front_door + trigger_by_occupancy: false + trigger_by_motion: true + trigger_by_entities: + - binary_sensor.door_opened +view: + scan: + enabled: true + trigger_show_border: true +``` +
+ ## Card Refreshes @@ -2376,6 +2436,8 @@ The following table describes the behavior these flags have. ### Card Update Truth Table +Note that no (other) automated updates are permitted when [scan mode](#scan-mode) is being triggered. + | `view . update_seconds` | `view . timeout_seconds` | `view . update_force` | `view . update_entities` | Behavior | | :-: | :-: | :-: | :-: | - | | `0` | `0` | *(Any value)* | Unset | Card will not automatically refresh. | diff --git a/src/card.ts b/src/card.ts index b2b8243e..bc040332 100644 --- a/src/card.ts +++ b/src/card.ts @@ -1571,7 +1571,7 @@ export class FrigateCard extends LitElement { container: true, outer: true, triggered: - !!this._triggers.size && this._getConfig().view.scan.trigger_show_border, + !!this._triggers.size && this._getConfig().view.scan.show_trigger_status, }; const contentClasses = { diff --git a/src/localize/languages/en.json b/src/localize/languages/en.json index 3b551450..4f7dd1a5 100644 --- a/src/localize/languages/en.json +++ b/src/localize/languages/en.json @@ -72,7 +72,7 @@ "scan": { "scan_mode": "Scan mode", "enabled": "Scan mode enabled", - "trigger_show_border": "Show pulsing border when triggered" + "show_trigger_status": "Show pulsing border when triggered" } }, "event_gallery": { diff --git a/src/types.ts b/src/types.ts index 6af02db7..d01a00e1 100644 --- a/src/types.ts +++ b/src/types.ts @@ -364,7 +364,7 @@ const customSchema = z export const cameraConfigDefault = { client_id: 'frigate' as const, live_provider: 'auto' as const, - trigger_by_motion: true, + trigger_by_motion: false, trigger_by_occupancy: true, trigger_by_entities: [], }; @@ -507,7 +507,7 @@ const viewConfigDefault = { dark_mode: 'off' as const, scan: { enabled: false, - trigger_show_border: true, + show_trigger_status: true, } }; const viewConfigSchema = z @@ -527,7 +527,7 @@ const viewConfigSchema = z dark_mode: z.enum(['on', 'off', 'auto']).optional(), scan: z.object({ enabled: z.boolean().default(viewConfigDefault.scan.enabled), - trigger_show_border: z.boolean().default(viewConfigDefault.scan.trigger_show_border), + show_trigger_status: z.boolean().default(viewConfigDefault.scan.show_trigger_status), }).default(viewConfigDefault.scan) }) .merge(actionsSchema) From 913056f9c8d6dfe4a815fc921630055bcc996ce0 Mon Sep 17 00:00:00 2001 From: Dermot Duffy Date: Sun, 22 May 2022 13:30:24 -0700 Subject: [PATCH 09/11] README and option naming fixes. --- README.md | 26 +++++++++++++------------- src/card.ts | 33 ++++++++++++++++++--------------- src/const.ts | 4 ++-- src/editor.ts | 10 +++++----- 4 files changed, 38 insertions(+), 35 deletions(-) diff --git a/README.md b/README.md index 9929603e..0fc222e2 100644 --- a/README.md +++ b/README.md @@ -114,9 +114,9 @@ See the [fully expanded cameras configuration example](#config-expanded-cameras) | `webrtc_card` | | :heavy_multiplication_x: | The WebRTC entity/URL to use for this camera with the `webrtc-card` live provider. See below. | | `id` | `camera_entity`, `webrtc_card.entity` or `camera_name` if set (in that preference order). | :heavy_multiplication_x: | An optional identifier to use throughout the card configuration to refer unambiguously to this camera. See [camera IDs](#camera-ids). | | `dependent_cameras` | | :heavy_multiplication_x: | An optional array of other camera identifiers (see [camera IDs](#camera-ids)). If specified the card will fetch events for this camera and *also* recursively events for the named `dependent_cameras`. All `dependent_cameras` must themselves be a configured camera in the card. This can be useful to group events for cameras that are close together, or to show events for the `birdseye` camera that otherwise would not have events itself.| -| `trigger_by_motion` | `false` | :heavy_multiplication_x: | Whether to not to trigger the camera (see [scan mode](#scan-mode)) by automatically detecting and using the motion `binary_sensor` for this camera. This autodetection only works for Frigate cameras, and only when the motion `binary_sensor` entity has been enabled in Home Assistant.| -| `trigger_by_occupancy` | `true` | :heavy_multiplication_x: | Whether to not to trigger the camera (see [scan mode](#scan-mode)) by automatically detecting and using the occupancy `binary_sensor` for this camera. This autodetection only works for Frigate cameras, and only when the occupancy `binary_sensor` entity has been enabled in Home Assistant.| -| `trigger_by_entities` | | :heavy_multiplication_x: | Whether to not to trigger the camera (see [scan mode](#scan-mode)) when the state of any Home Assistant entity becomes active (i.e. state becomes `on` or `open`). This works for Frigate or non-Frigate cameras.| +| `trigger_by_motion` | `false` | :heavy_multiplication_x: | Whether to not to trigger the camera (used to trigger [scan mode](#scan-mode) or reseting the default view) by automatically detecting and using the motion `binary_sensor` for this camera. This autodetection only works for Frigate cameras, and only when the motion `binary_sensor` entity has been enabled in Home Assistant.| +| `trigger_by_occupancy` | `true` | :heavy_multiplication_x: | Whether to not to trigger the camera (used to trigger [scan mode](#scan-mode) or reseting the default view) by automatically detecting and using the occupancy `binary_sensor` for this camera. This autodetection only works for Frigate cameras, and only when the occupancy `binary_sensor` entity has been enabled in Home Assistant.| +| `trigger_by_entities` | | :heavy_multiplication_x: | Whether to not to trigger the camera (used to trigger [scan mode](#scan-mode) or reseting the default view) when the state of any Home Assistant entity becomes active (i.e. state becomes `on` or `open`). This works for Frigate or non-Frigate cameras.| @@ -174,7 +174,7 @@ See the [fully expanded view configuration example](#config-expanded-view) for h | `timeout_seconds` | `300` | :white_check_mark: | A numbers of seconds of inactivity after user interaction, after which the card will reset to the default configured view (i.e. 'screensaver' functionality). Inactivity is defined as lack of mouse/touch interaction with the Frigate card. If the default view occurs sooner (e.g. via `update_seconds` or manually) the timer will be stopped. `0` means disable this functionality. | | `update_seconds` | `0` | :white_check_mark: | A number of seconds after which to automatically update/refresh the default view. See [card updates](#card-updates) below for behavior and usecases. If the default view occurs sooner (e.g. manually) the timer will start over. `0` disables this functionality.| | `update_force` | `false` | :white_check_mark: | Whether automated card updates/refreshes should ignore user interaction. See [card updates](#card-updates) below for behavior and usecases.| -| `update_entities` | | :white_check_mark: | **YAML only**: A list of entity ids that should cause the view to reset to the default. See [card updates](#card-updates) below for behavior and usecases.| +| `update_entities` | | :white_check_mark: | **YAML only**: A card-wide list of entities that should cause the view to reset to the default (if the entity only pertains to a particular camera use `trigger_by_entities` for the selected camera instead) See [card updates](#card-updates) below for behavior and usecases.| | `update_cycle_camera` | `false` | :white_check_mark: | When set to `true` the selected camera is cycled on each default view change. | | `render_entities` | | :white_check_mark: | **YAML only**: A list of entity ids that should cause the card to re-render 'in-place'. The view/camera is not changed. `update_*` flags do not pertain/relate to the behavior of this flag. This should **very** rarely be needed, but could be useful if the card is both setting and changing HA state of the same object as could be the case for some complex `card_mod` scenarios ([example](https://github.com/dermotduffy/frigate-hass-card/issues/343)). | | `scan` | | :white_check_mark: | Configuration for [scan mode](#scan-mode). | @@ -191,7 +191,7 @@ view: scan: ``` -Scan mode allows the card to automatically "follow the action". In this mode the card will automatically select a camera to view when it is triggered (as defined by your camera configuration, see `trigger_by_motion`, `trigger_by_occupancy` and `trigger_by_entities` parameters). When the camera untriggers, the camera selection will return to the next most recently triggered camera (as long as it is still triggered) -- if there are no triggered cameras remaining, the camera will return to the default. Triggering is only allowed when there is no ongoing human interaction with the card -- interaction will automatically untrigger it and further triggering will not occur until after the card has been unattended for `view.timeout_seconds`. +Scan mode allows the card to automatically "follow the action". In this mode the card will automatically select a camera in the `live` view when it is triggered (as defined by your camera configuration, see `trigger_by_motion`, `trigger_by_occupancy` and `trigger_by_entities` parameters). When the camera untriggers, the camera selection will return to the next most recently triggered camera (as long as it is still triggered) -- if there are no triggered cameras remaining, the camera will return to the default. Triggering is only allowed when there is no ongoing human interaction with the card -- interaction will automatically untrigger it and further triggering will not occur until after the card has been unattended for `view.timeout_seconds`. Scan mode tracks Home Assistant state changes -- when the card is first started, it takes a positive change in state to trigger (i.e. an already occupied room will not trigger it, but a newly occupied room would trigger it). @@ -2270,7 +2270,7 @@ the card to trigger a card update based on that entity -- which causes it to use the new overriden default immediately. Alternatives to trigger the card to change view but without `update_entities` would just be having an `update_seconds` parameter which reloads the default view that many seconds -after user interaction stops. +after user interaction stops or through the use of the `trigger_by_entities` option for a given camera. ```yaml view: @@ -2438,7 +2438,9 @@ The following table describes the behavior these flags have. Note that no (other) automated updates are permitted when [scan mode](#scan-mode) is being triggered. -| `view . update_seconds` | `view . timeout_seconds` | `view . update_force` | `view . update_entities` | Behavior | +In the below "Trigger Entities" refers to the combination of `view.update_entities` and the `trigger_by_entities` for the currently selected camera (which in turn will also include the occupancy and motion sensor entities for Frigate cameras if `trigger_by_occupancy` and `trigger_by_motion` options are enabled). + +| `view . update_seconds` | `view . timeout_seconds` | `view . update_force` | Trigger Entities | Behavior | | :-: | :-: | :-: | :-: | - | | `0` | `0` | *(Any value)* | Unset | Card will not automatically refresh. | | `0` | `0` | *(Any value)* | *(Any entity)* | Card will reload default view & camera when entity state changes. | @@ -2462,13 +2464,11 @@ view: ``` * Using `clip` or `snapshot` as the default view (for the most recent clip or snapshot respectively) and having the card automatically refresh (to fetch a - newer clip/snapshot) when an entity state changes. Use the Frigate - binary_sensor for that camera (or any other entity at your discretion) to - trigger the update: + newer clip/snapshot) on motion. ```yaml -view: - update_entities: - - binary_sensor.office_person_motion +cameras: + - entity: camera.office + trigger_by_motion: true ``` * Cycle the live view of the camera every 60 seconds ```yaml diff --git a/src/card.ts b/src/card.ts index bc040332..3cf3d1da 100644 --- a/src/card.ts +++ b/src/card.ts @@ -991,11 +991,12 @@ export class FrigateCard extends LitElement { (entity) => !isTriggeredState(this._hass?.states[entity]), ); if (shouldTrigger) { - this._triggers.set(camera, now) + this._triggers.set(camera, now); } else if (shouldUntrigger && this._triggers.has(camera)) { this._triggers.delete(camera); } - triggerChanges ||= (!isTriggered && shouldTrigger) || (isTriggered && !this._triggers.size); + triggerChanges ||= + (!isTriggered && shouldTrigger) || (isTriggered && !this._triggers.size); } if (triggerChanges && this._isAutomatedViewUpdateAllowed(true)) { @@ -1012,7 +1013,9 @@ export class FrigateCard extends LitElement { } } if (targetCamera) { - this._changeView({ view: this._view.evolve({ camera: targetCamera }) }); + this._changeView( + { view: new View({ view: 'live', camera: targetCamera }) } + ); changedCamera = true; } } @@ -1037,21 +1040,21 @@ export class FrigateCard extends LitElement { let shouldUpdate = !oldHass || changedProps.size != 1; if (oldHass) { - // Home Assistant pumps a lot of updates through. Re-rendering the card is - // necessary at times (e.g. to update the 'clip' view as new clips - // arrive), but also is a jarring experience for the user (e.g. if they - // are browsing the mini-gallery). Do not allow re-rendering from a Home - // Assistant update if there's been recent interaction (e.g. clicks on the - // card) or if there is media active playing. - if (this._updateTriggeredCameras(oldHass)) { + const selectedCamera = this._getSelectedCameraConfig(); + if (this._getConfig().view.scan.enabled && this._updateTriggeredCameras(oldHass)) { shouldUpdate ||= true; } else if ( + // Home Assistant pumps a lot of updates through. Re-rendering the card is + // necessary at times (e.g. to update the 'clip' view as new clips + // arrive), but also is a jarring experience for the user (e.g. if they + // are browsing the mini-gallery). Do not allow re-rendering from a Home + // 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._getConfig().view.update_entities || [], - ) + isHassDifferent(this._hass, oldHass, [ + ...(this._getConfig().view.update_entities || []), + ...(selectedCamera?.trigger_by_entities || []), + ]) ) { // If entities being monitored have changed then reset the view to the // default. Note that as per the Lit lifecycle, the setting of the view diff --git a/src/const.ts b/src/const.ts index 4fcbdda9..717e82f4 100644 --- a/src/const.ts +++ b/src/const.ts @@ -41,8 +41,8 @@ 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_SHOW_BORDER = - `${CONF_VIEW_SCAN}.trigger_show_border` as const; +export const CONF_VIEW_SCAN_SHOW_TRIGGER_STATUS = + `${CONF_VIEW_SCAN}.show_trigger_status` as const; export const CONF_EVENT_GALLERY = 'event_gallery' as const; export const CONF_EVENT_GALLERY_CONTROLS_THUMBNAILS_SHOW_DETAILS = diff --git a/src/editor.ts b/src/editor.ts index 0550899b..f0bf24b7 100644 --- a/src/editor.ts +++ b/src/editor.ts @@ -86,7 +86,7 @@ import { CONF_VIEW_DEFAULT, CONF_VIEW_SCAN, CONF_VIEW_SCAN_ENABLED, - CONF_VIEW_SCAN_TRIGGER_SHOW_BORDER, + CONF_VIEW_SCAN_SHOW_TRIGGER_STATUS, CONF_VIEW_TIMEOUT_SECONDS, CONF_VIEW_UPDATE_CYCLE_CAMERA, CONF_VIEW_UPDATE_FORCE, @@ -611,14 +611,14 @@ export class FrigateCardEditor extends LitElement implements LovelaceCardEditor CONF_VIEW_SCAN_ENABLED, frigateCardConfigDefaults.view.scan.enabled ?? true, { - label: localize('config.view.scan.enabled'), + label: localize(`config.${CONF_VIEW_SCAN_ENABLED}`), }, )} ${this._renderSwitch( - CONF_VIEW_SCAN_TRIGGER_SHOW_BORDER, - frigateCardConfigDefaults.view.scan.trigger_show_border ?? true, + CONF_VIEW_SCAN_SHOW_TRIGGER_STATUS, + frigateCardConfigDefaults.view.scan.show_trigger_status ?? true, { - label: localize('config.view.scan.trigger_show_border'), + label: localize(`config.${CONF_VIEW_SCAN_SHOW_TRIGGER_STATUS}`), }, )}
` From e54ed30f2c3d8154e6c7b7487dafbf31dfdd5df9 Mon Sep 17 00:00:00 2001 From: Dermot Duffy Date: Sun, 22 May 2022 13:55:00 -0700 Subject: [PATCH 10/11] Strip diagnostic info. --- src/card.ts | 7 ------- 1 file changed, 7 deletions(-) diff --git a/src/card.ts b/src/card.ts index 3cf3d1da..91e868f3 100644 --- a/src/card.ts +++ b/src/card.ts @@ -704,13 +704,6 @@ export class FrigateCard extends LitElement { 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)]; From 697c602d7496e572a641453489f22efc8ce3b3c5 Mon Sep 17 00:00:00 2001 From: Dermot Duffy Date: Sun, 22 May 2022 17:21:51 -0700 Subject: [PATCH 11/11] Re-sort the triggered cameras after each trigger. --- src/card.ts | 32 +++++++++++++++++--------------- 1 file changed, 17 insertions(+), 15 deletions(-) diff --git a/src/card.ts b/src/card.ts index 91e868f3..c92c33b2 100644 --- a/src/card.ts +++ b/src/card.ts @@ -959,6 +959,18 @@ export class FrigateCard extends LitElement { } } + /** + * Get the most recent triggered camera. + */ + protected _getMostRecentTrigger(): string | null { + const sorted = ( + [...this._triggers.entries()].sort( + (a: [string, Date], b: [string, Date]) => b[1].getTime() - a[1].getTime(), + ) + ); + return sorted.length ? sorted[0][0] : null; + } + /** * Determine if a camera has been triggered. * @param oldHass The old HA object. @@ -972,7 +984,6 @@ export class FrigateCard extends LitElement { const now = new Date(); let changedCamera = false; let triggerChanges = false; - const isTriggered = !!this._triggers.size; for (const [camera, config] of this._cameras?.entries() ?? []) { const triggerEntities = config?.trigger_by_entities ?? []; @@ -985,11 +996,11 @@ export class FrigateCard extends LitElement { ); if (shouldTrigger) { this._triggers.set(camera, now); + triggerChanges = true; } else if (shouldUntrigger && this._triggers.has(camera)) { this._triggers.delete(camera); + triggerChanges = true; } - triggerChanges ||= - (!isTriggered && shouldTrigger) || (isTriggered && !this._triggers.size); } if (triggerChanges && this._isAutomatedViewUpdateAllowed(true)) { @@ -997,18 +1008,9 @@ export class FrigateCard extends LitElement { this._changeView(); changedCamera = true; } else { - let targetCamera: string | null = null; - let targetCameraDate: Date | null = null; - for (const [camera, date] of this._triggers.entries()) { - if (!targetCamera || !targetCameraDate || date > targetCameraDate) { - targetCamera = camera; - targetCameraDate = date; - } - } - if (targetCamera) { - this._changeView( - { view: new View({ view: 'live', camera: targetCamera }) } - ); + const targetCamera = this._getMostRecentTrigger(); + if (targetCamera && (this._view.camera !== targetCamera || !this._view.is('live'))) { + this._changeView({ view: new View({ view: 'live', camera: targetCamera }) }); changedCamera = true; } }