import { HomeAssistant } from 'custom-card-helpers'; import { HassEntity } from 'home-assistant-js-websocket'; import { CSSResultGroup, html, LitElement, PropertyValues, TemplateResult, unsafeCSS, } from 'lit'; import { customElement, property } from 'lit/decorators.js'; import { live } from 'lit/directives/live.js'; import { createRef, ref, Ref } from 'lit/directives/ref.js'; import { CachedValueController } from '../cached-value-controller.js'; import defaultImage from '../images/frigate-bird-in-sky.jpg'; import { localize } from '../localize/localize.js'; import imageStyle from '../scss/image.scss'; import { CameraConfig, ImageViewConfig, MediaLoadedInfo } from '../types.js'; import { isHassDifferent } from '../utils/ha'; import { updateElementStyleFromMediaLayoutConfig } from '../utils/media-layout.js'; import { createMediaLoadedInfo, dispatchExistingMediaLoadedInfoAsEvent, } from '../utils/media-info.js'; import { View } from '../view/view.js'; import { dispatchErrorMessageEvent } from './message.js'; import { contentsChanged } from '../utils/basic.js'; import isEqual from 'lodash-es/isEqual'; // See TOKEN_CHANGE_INTERVAL in https://github.com/home-assistant/core/blob/dev/homeassistant/components/camera/__init__.py . const HASS_REJECTION_CUTOFF_MS = 5 * 60 * 1000; @customElement('frigate-card-image') export class FrigateCardImage extends LitElement { @property({ attribute: false }) public hass?: HomeAssistant; @property({ attribute: false }) public view?: Readonly; @property({ attribute: false }) public cameraConfig?: CameraConfig; // Using contentsChanged to ensure overridden configs (e.g. when the // 'show_image_during_load' option is true for live views, an overridden // config may be used here). @property({ attribute: false, hasChanged: contentsChanged }) public imageConfig?: ImageViewConfig; protected _refImage: Ref = createRef(); protected _cachedValueController?: CachedValueController; protected _boundVisibilityHandler = this._visibilityHandler.bind(this); protected _mediaLoadedInfo: MediaLoadedInfo | null = null; /** * Get the camera entity for the current camera configuration. * @returns The entity or undefined if no camera entity is available. */ protected _getCameraEntity(): string | null { return ( (this.cameraConfig?.camera_entity || this.cameraConfig?.webrtc_card?.entity) ?? null ); } /** * Determine whether the element should be updated. * @param changedProps The changed properties if any. * @returns `true` if the element should be updated. */ protected shouldUpdate(changedProps: PropertyValues): boolean { if (!this.hass || document.visibilityState !== 'visible') { return false; } const cameraEntity = this._getCameraEntity(); if ( changedProps.has('hass') && changedProps.size == 1 && this.imageConfig?.mode === 'camera' && 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. this._cachedValueController?.clearValue(); return true; } return false; } return true; } /** * Ensure there is a cached value before an update. * @param _changedProps The changed properties */ protected willUpdate(changedProps: PropertyValues): void { if (changedProps.has('imageConfig')) { if (this._cachedValueController) { this._cachedValueController.removeController(); } if (this.imageConfig) { this._cachedValueController = new CachedValueController( this, this.imageConfig.refresh_seconds, this._getImageSource.bind(this), ); } updateElementStyleFromMediaLayoutConfig(this, this.imageConfig?.layout); } // If the camera or view changed, immediately discard the old value (view to // allow pressing of the image button to fetch a fresh image). Likewise, if // the state is not acceptable, discard the old value (to allow a stock or // backup image to be displayed). if ( changedProps.has('cameraConfig') || changedProps.has('view') || (this.imageConfig?.mode === 'camera' && !this._getAcceptableState(this._getCameraEntity())) ) { this._cachedValueController?.clearValue(); } if (!this._cachedValueController?.value) { this._cachedValueController?.updateValue(); } } /** * Determine if a given entity is acceptable as the basis for an image render * (detects old or disconnected states). Using an old state is problematic as * it runs 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 . * @param entity The entity. * @returns The state or null if not acceptable. */ protected _getAcceptableState(entity: string | null): HassEntity | null { const state = (entity ? this.hass?.states[entity] : null) ?? null; return !!this.hass && this.hass.connected && !!state && Date.now() - Date.parse(state.last_updated) < HASS_REJECTION_CUTOFF_MS ? state : null; } /** * Component connected callback. */ connectedCallback(): void { super.connectedCallback(); document.addEventListener('visibilitychange', this._boundVisibilityHandler); this._cachedValueController?.startTimer(); } /** * Component disconnected callback. */ disconnectedCallback(): void { this._cachedValueController?.stopTimer(); document.removeEventListener('visibilitychange', this._boundVisibilityHandler); super.disconnectedCallback(); } /** * Handle document visibility changes. */ protected _visibilityHandler(): void { if (!this._refImage.value) { 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?.stopTimer(); this._cachedValueController?.clearValue(); this._forceSafeImage(); } 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._cachedValueController?.startTimer(); this.requestUpdate(); } } /** * Build a working absolute image URL that the browser will not cache. * @param url An input URL (may be relative to document origin) * @returns A new URL (absolute, will not be browser cached). */ protected _buildImageURL(url: string): string { const urlObj = new URL(url, document.baseURI); urlObj.searchParams.append('_t', String(Date.now())); return urlObj.toString(); } protected _getImageSource(): string { if (this.hass && this.imageConfig?.mode === 'camera') { const state = this._getAcceptableState(this._getCameraEntity()); if (state?.attributes.entity_picture) { return this._buildImageURL(state.attributes.entity_picture); } } if (this.imageConfig?.mode !== 'screensaver' && this.imageConfig?.url) { return this._buildImageURL(this.imageConfig.url); } return defaultImage; } /** * Force the img element to a safe image. */ protected _forceSafeImage(stockOnly?: boolean): void { if (this._refImage.value) { this._refImage.value.src = !stockOnly && this.imageConfig?.url ? this.imageConfig.url : defaultImage; } } protected render(): TemplateResult | void { const src = this._cachedValueController?.value; // Note the use of live() below to ensure the update will restore the image // src if it's been changed via _forceSafeImage(). return src ? html` { const mediaLoadedInfo = createMediaLoadedInfo(ev); // Avoid the media being reported as repeatedly loading unless the // media info changes. if (mediaLoadedInfo && !isEqual(this._mediaLoadedInfo, mediaLoadedInfo)) { this._mediaLoadedInfo = mediaLoadedInfo; dispatchExistingMediaLoadedInfoAsEvent(this, mediaLoadedInfo); } }} @error=${() => { if (this.imageConfig?.mode === 'camera') { // In camera mode, the user has likely not made an error, but HA // may be unavailble, so show the stock image. Don't let the URL // override the stock image in this case, as this could create an // error loop if that URL subsequently failed to load. this._forceSafeImage(true); } else if (this.imageConfig?.mode === 'url') { // In url mode, the user likely specified a URL that cannot be // resolved. Show an error message. dispatchErrorMessageEvent(this, localize('error.image_load_error'), { context: this.imageConfig, }); } }} />` : html``; } static get styles(): CSSResultGroup { return unsafeCSS(imageStyle); } } declare global { interface HTMLElementTagNameMap { 'frigate-card-image': FrigateCardImage; } }