diff --git a/src/cached-value-controller.ts b/src/cached-value-controller.ts index b5d7902d..80119e76 100644 --- a/src/cached-value-controller.ts +++ b/src/cached-value-controller.ts @@ -1,8 +1,7 @@ import { ReactiveController, ReactiveControllerHost } from 'lit'; export class CachedValueController implements ReactiveController { - public value?: T; - + protected _value?: T; protected _host: ReactiveControllerHost; protected _timerSeconds: number; protected _callback: () => T; @@ -21,38 +20,46 @@ export class CachedValueController implements ReactiveController { this._host.removeController(this); } + /** + * Get the value. + */ + get value(): T | undefined { + return this._value; + } + /** * Update the cached value (and reset the timer). */ public updateValue(): void { - this.value = this._callback(); - this._setTimer(); + this._value = this._callback(); + this._startTimer(); } /** - * Update the value and render it. + * Clear the cached value. */ - protected _updateValueAndRender(): void { - this.updateValue(); - this._host.requestUpdate(); + public clearValue(): void { + this._value = undefined; + this._stopTimer(); } /** - * Remove the timer. + * Disable the timer. */ - protected _removeTimer(): void { + protected _stopTimer(): void { clearInterval(this._timerID); this._timerID = undefined; } /** - * Set the timer. + * Enable the timer. Repeated calls will have no effect. */ - protected _setTimer(): void { - clearInterval(this._timerID); + protected _startTimer(): void { + this._stopTimer(); if (this._timerSeconds > 0) { this._timerID = window.setInterval(() => { - this._updateValueAndRender(); + this.updateValue(); + this._host.requestUpdate(); }, this._timerSeconds * 1000); } } @@ -61,13 +68,14 @@ export class CachedValueController implements ReactiveController { * Host has connected to the cache. */ hostConnected(): void { - this._updateValueAndRender(); + this.updateValue(); + this._host.requestUpdate(); } /** * Host has disconnected from the cache. */ hostDisconnected(): void { - this._removeTimer(); + this.clearValue(); } } diff --git a/src/common.ts b/src/common.ts index fd3c799f..c602a803 100644 --- a/src/common.ts +++ b/src/common.ts @@ -233,18 +233,15 @@ export function shouldUpdateBasedOnHass( if (!newHass || !entities || !entities.length) { return false; } + if (!oldHass) { + return true; + } - if (oldHass) { - for (let i = 0; i < entities.length; i++) { - const entity = entities[i]; - if (!entity) { - continue; - } - if (oldHass.states[entity] !== newHass.states[entity]) { - return true; - } + for (let i = 0; i < entities.length; i++) { + const entity = entities[i]; + if (entity && oldHass.states[entity] !== newHass.states[entity]) { + return true; } - return false; } return false; } @@ -591,4 +588,4 @@ export const frigateCardHasAction = ( */ export const stopEventFromActivatingCardWideActions = (ev: Event): void => { ev.stopPropagation(); -} \ No newline at end of file +}; diff --git a/src/components/image.ts b/src/components/image.ts index da0eb9f4..8794bf71 100644 --- a/src/components/image.ts +++ b/src/components/image.ts @@ -7,7 +7,7 @@ import { unsafeCSS, } from 'lit'; import { HomeAssistant } from 'custom-card-helpers'; -import { customElement, property, state } from 'lit/decorators.js'; +import { customElement, property, query, state } from 'lit/decorators.js'; import { CachedValueController } from '../cached-value-controller.js'; import { CameraConfig, ImageViewConfig } from '../types.js'; @@ -17,6 +17,9 @@ import defaultImage from '../images/frigate-bird-in-sky.jpg'; import imageStyle from '../scss/image.scss'; +// See: https://github.com/home-assistant/core/blob/dev/homeassistant/components/camera/__init__.py#L101 +const HASS_REJECTION_CUTOFF_MS = 5 * 60 * 1000; + @customElement('frigate-card-image') export class FrigateCardImage extends LitElement { @property({ attribute: false }) @@ -31,8 +34,11 @@ export class FrigateCardImage extends LitElement { @state() protected _imageConfig?: ImageViewConfig; - protected _cachedValueController?: CachedValueController; + @query('img') + protected _image?: HTMLImageElement; + protected _cachedValueController?: CachedValueController; + protected _boundVisibilityHandler = this._visibilityHandler.bind(this); /** * Set the image configuration. */ @@ -62,21 +68,96 @@ export class FrigateCardImage extends LitElement { * @returns `true` if the element should be updated. */ protected shouldUpdate(changedProps: PropertyValues): boolean { - const oldHass = changedProps.get('hass') as HomeAssistant | undefined; - let shouldUpdate = !oldHass || changedProps.size != 1; - - // Image needs to update if the image view is in camera mode and the camera - // entity changes, as this could be a security token change. - if (oldHass && this._imageConfig?.mode === 'camera') { - const cameraEntity = this._getCameraEntity(); - if ( - shouldUpdateBasedOnHass(this.hass, oldHass, cameraEntity ? [cameraEntity] : []) - ) { - shouldUpdate ||= true; - this._cachedValueController?.updateValue(); + if (!this.hass || document.visibilityState !== 'visible') { + return false; + } + + // If camera mode is enabled, reject all updates if hass is older than + // HASS_REJECTION_CUTOFF_MS or if HASS is not currently connected. By using + // an older hass (even if it is not the property being updated), we run the + // risk that the JS has an old access token for the camera, and that results + // in a notification on the HA UI about a failed login. See + // https://github.com/dermotduffy/frigate-hass-card/issues/398 . + const cameraEntity = this._getCameraEntity(); + const state = cameraEntity ? this.hass.states[cameraEntity] : undefined; + if ( + this._imageConfig?.mode === 'camera' && + (!this.hass.connected || + !state || + Date.now() - Date.parse(state.last_updated) >= HASS_REJECTION_CUTOFF_MS) + ) { + return false; + } + + if ( + changedProps.has('hass') && + changedProps.size == 1 && + this._imageConfig?.mode === 'camera' && + cameraEntity + ) { + if (shouldUpdateBasedOnHass(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. + this._cachedValueController?.clearValue(); + return true; } + return false; + } + return true; + } + + /** + * Ensure there is a cached value before an update. + * @param _changedProps The changed properties + */ + // eslint-disable-next-line @typescript-eslint/no-unused-vars + protected willUpdate(_changedProps: PropertyValues): void { + if (!this._cachedValueController?.value) { + this._cachedValueController?.updateValue(); + } + } + + /** + * Component connected callback. + */ + connectedCallback(): void { + super.connectedCallback(); + document.addEventListener('visibilitychange', this._boundVisibilityHandler); + } + + /** + * Component disconnected callback. + */ + disconnectedCallback(): void { + document.removeEventListener('visibilitychange', this._boundVisibilityHandler); + super.disconnectedCallback(); + } + + /** + * Handle document visibility changes. + */ + protected _visibilityHandler(): void { + if (!this._image) { + return; + } + if (document.visibilityState === 'hidden') { + // Set the image to default when the document is hidden. This is to avoid + // some browsers (e.g. Firefox) eagerly re-loading the old image when the + // document regains visibility -- for some images (e.g. camera mode) the + // image may be using an old-expired token and re-use prior to + // re-generation of a new URL would generate an unauthorized request + // (401), see: + // https://github.com/dermotduffy/frigate-hass-card/issues/398 + this._cachedValueController?.clearValue(); + this._image.src = defaultImage; + } else { + // If the document is freshly re-visible, immediately re-render it to + // restore the image src. If the HASS object is old (i.e. browser tab was + // inactive for some time) this update request may be (correctly) + // rejected. + this.requestUpdate(); } - return shouldUpdate; } /** diff --git a/src/editor.ts b/src/editor.ts index 74e15753..30afefa1 100644 --- a/src/editor.ts +++ b/src/editor.ts @@ -1,7 +1,6 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ import { CSSResultGroup, LitElement, TemplateResult, html, unsafeCSS } from 'lit'; import { customElement, property, state } from 'lit/decorators.js'; -import { ifDefined } from 'lit/directives/if-defined.js'; import { HomeAssistant, LovelaceCardEditor, fireEvent } from 'custom-card-helpers'; import { localize } from './localize/localize.js'; @@ -95,12 +94,6 @@ interface EditorOptions { [setName: string]: EditorOptionsSet; } -interface ConfigValueTarget { - configValue: string; - checked?: boolean; - value?: string; -} - interface EditorCameraTarget { cameraIndex: number; }