From ab3202c9ac1c82c87e7aeb244be7ff8e0e1886a6 Mon Sep 17 00:00:00 2001 From: Dermot Duffy Date: Tue, 17 May 2022 22:09:30 -0700 Subject: [PATCH] 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); +};