import type { HassEntity } from 'home-assistant-js-websocket'; import { html, LitElement, unsafeCSS, type CSSResultGroup, type PropertyValues, type TemplateResult, } from 'lit'; import { customElement, property, state } from 'lit/decorators.js'; import { live } from 'lit/directives/live.js'; import { createRef, ref, type Ref } from 'lit/directives/ref.js'; import { getCameraEntityFromConfig } from '../camera-manager/utils/camera-entity-from-config.js'; import type { MediaUnavailableIssueReason } from '../card-controller/issues/issues/media-unavailable.js'; import { CachedValueController } from '../components-lib/cached-value-controller.js'; import { MediaLoadedInfoSourceController } from '../components-lib/media-loaded-info-source-controller.js'; import { FRAME_STALL_SECONDS } from '../components-lib/media-player/frame-stall-watchdog.js'; import { ImageMediaPlayerController } from '../components-lib/media-player/image.js'; import { createMediaNotification } from '../components-lib/notification/media.js'; import { getSignedURLErrorText, SignedURLController, } from '../components-lib/signed-url-controller.js'; import type { Notification } from '../config/schema/actions/types.js'; import type { CameraConfig } from '../config/schema/cameras.js'; import { type ImageBaseConfig, type ImageMode } from '../config/schema/common/image.js'; import type { EnabledProxyConfig } from '../config/schema/common/proxy.js'; import { isHassDifferent } from '../ha/is-hass-different.js'; import type { HomeAssistant } from '../ha/types.js'; import defaultImage from '../images/iris-screensaver.jpg'; import { localize } from '../localize/localize.js'; import imageUpdatingPlayerStyle from '../scss/image-updating-player.scss?inline'; import type { MediaPlayer, MediaPlayerController } from '../types.js'; import { contentsChanged } from '../utils/basic.js'; import { fireAdvancedCameraCardEvent } from '../utils/fire-advanced-camera-card-event.js'; import { createMediaLoadedInfo, dispatchMediaPauseEvent, dispatchMediaPlayEvent, } from '../utils/media-info.js'; import type { View } from '../view/view.js'; import { renderNotificationBlock } from './notification/block.js'; declare global { interface HTMLElementEventMap { // A private signal to the immediate parent that the media failed. // Non-bubbling. 'advanced-camera-card:image-updating-player:error': CustomEvent; } } // 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; const SCREENSAVER_URL = 'https://picsum.photos'; const SCREENSAVER_REFRESH_SECONDS = 60; const SCREENSAVER_DEFAULT_WIDTH = 500; const SCREENSAVER_DEFAULT_HEIGHT = Math.round(SCREENSAVER_DEFAULT_WIDTH / (16 / 9)); // Round requested dimensions to the nearest multiple to avoid fetching a unique // image for every minor size difference. const SCREENSAVER_DIMENSION_BUCKET = 100; export const resolveImageMode = (options?: { imageConfig?: ImageBaseConfig; cameraConfig?: CameraConfig; }): Exclude => { if (!options?.imageConfig?.mode) { return 'default'; } else if (options?.imageConfig?.mode !== 'auto') { return options.imageConfig.mode; } if (options?.imageConfig?.entity) { return 'entity'; } else if (options?.imageConfig?.url) { return 'url'; } else if (getCameraEntityFromConfig(options.cameraConfig)) { return 'camera'; } return 'default'; }; /** * A media player to wrap an image that updates continuously. */ @customElement('advanced-camera-card-image-updating-player') export class AdvancedCameraCardImageUpdatingPlayer extends LitElement implements MediaPlayer { @property({ attribute: false }) public hass?: HomeAssistant; @property({ attribute: false }) public view?: Readonly; @property({ attribute: false }) public cameraConfig?: CameraConfig; @property({ attribute: false }) public targetID?: string; // The camera's title, shown in error messages to identify the camera. @property({ attribute: false }) public cameraTitle?: string; @property({ attribute: false, hasChanged: contentsChanged }) public proxyConfig?: EnabledProxyConfig; // 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?: ImageBaseConfig; @state() private _imageLoadError = false; // Tracks the signed/proxy error so it is reported once on the transition into // failure, not on every update. private _hasSignError = false; private _refImage: Ref = createRef(); // Whether the currently holds the stock image swapped in by // `_forceSafeImage` rather than the intended media. The safe image load must // not be announced as real media arriving, which may otherwise report a // broken camera as healthy. Cleared on the next render, which restores the // real source. private _showingSafeImage = false; private _cachedValueController = new CachedValueController( this, () => this._getEffectiveRefreshSeconds(), () => this._getImageSource(), () => dispatchMediaPlayEvent(this), () => dispatchMediaPauseEvent(this), // Clear image load errors on each timer tick so the next render retries // the -- but only for modes where the underlying URL genuinely // changes between ticks (camera/entity snapshots). For mode: url, the // same static URL will fail the same way every time, so clearing the // error just causes a visible flicker (notification → blank → // notification) every refresh_seconds. URL-mode retries are driven by // mediaEpoch (user-initiated or auto-retry) instead. () => { const mode = resolveImageMode({ imageConfig: this.imageConfig, cameraConfig: this.cameraConfig, }); if (mode !== 'url') { this._imageLoadError = false; } }, ); private _signedURLController = new SignedURLController( this, () => ({ hass: this.hass, endpoint: this.imageConfig?.url ? { endpoint: this.imageConfig.url } : undefined, proxyConfig: this.proxyConfig, }), () => { this._cachedValueController.clearValue(); this._imageLoadError = false; }, ); private _boundVisibilityHandler = this._visibilityHandler.bind(this); // A poll-refreshed snapshot: the cached-value timer is the pausable update // loop, and its cached URL is the screenshot. private _mediaPlayerController = new ImageMediaPlayerController( this, () => this._refImage.value ?? null, { updateControl: { start: () => this._cachedValueController.startTimer(), stop: () => this._cachedValueController.stopTimer(), isRunning: () => this._cachedValueController.hasTimer(), }, screenshotProvider: async () => this._cachedValueController.getValue(), livenessOptions: { // Frames are only due while the refresh timer runs. A snapshot with // refreshing switched off shows one picture forever, which is the // configured behavior and never a stall. isFrameExpected: () => this._cachedValueController.hasTimer(), // Allow a whole refresh interval to pass, plus the standard window, so // one slow fetch is not mistaken for a stopped camera. getStallAfterSeconds: () => (this._getEffectiveRefreshSeconds() ?? 0) + FRAME_STALL_SECONDS, }, }, ); private _mediaLoadedInfoSourceController = new MediaLoadedInfoSourceController(this, { getTargetID: () => this.targetID ?? null, }); public async getMediaPlayerController(): Promise { return this._mediaPlayerController; } /** * 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.isConnected || !this.hass || document.visibilityState !== 'visible') { return false; } const relevantEntity = this._getRelevantEntityForMode( resolveImageMode({ imageConfig: this.imageConfig, cameraConfig: this.cameraConfig, }), ); if (changedProps.has('hass') && changedProps.size == 1 && relevantEntity) { if (isHassDifferent(this.hass, changedProps.get('hass'), [relevantEntity])) { // 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 !this.hasUpdated; } return true; } /** * Ensure there is a cached value before an update. * @param _changedProps The changed properties */ protected willUpdate(changedProps: PropertyValues): void { // The render (below) restores the real source (via `live()`), so its load // is the real media again. this._showingSafeImage = false; const relevantEntity = this._getRelevantEntityForMode( resolveImageMode({ imageConfig: this.imageConfig, cameraConfig: this.cameraConfig, }), ); // 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('imageConfig') || changedProps.has('cameraConfig') || changedProps.has('proxyConfig') || changedProps.has('view') || (relevantEntity && !this._getAcceptableState(relevantEntity)) ) { this._cachedValueController?.clearValue(); this._imageLoadError = false; } if (!this._cachedValueController?.getValue()) { this._cachedValueController?.updateValue(); } const hasSignError = !!this._signedURLController.getError(); if (hasSignError && !this._hasSignError) { this._dispatchError('server_error'); } this._hasSignError = hasSignError; } private _dispatchError(reason: MediaUnavailableIssueReason): void { // An image request can fail after this player has left the DOM. Ignore // such failures: they describe media that is no longer shown. if (!this.isConnected) { return; } fireAdvancedCameraCardEvent(this, 'image-updating-player:error', reason, { bubbles: false, composed: false, }); } /** * 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/advanced-camera-card/issues/398 . * @param entity The entity. * @returns The state or null if not acceptable. */ private _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(); this._imageLoadError = false; document.removeEventListener('visibilitychange', this._boundVisibilityHandler); super.disconnectedCallback(); } private _getScreensaverURL(): string { const bucket = (value: number, fallback: number): number => Math.ceil((value || fallback) / SCREENSAVER_DIMENSION_BUCKET) * SCREENSAVER_DIMENSION_BUCKET; const w = bucket(this.clientWidth, SCREENSAVER_DEFAULT_WIDTH); const h = bucket(this.clientHeight, SCREENSAVER_DEFAULT_HEIGHT); const urlObj = new URL(`${SCREENSAVER_URL}/${w}/${h}`); return this._buildCacheBustURL(urlObj, 'query-string'); } /** * Handle document visibility changes. */ private _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/advanced-camera-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 an image URL that the browser will not cache. Supports two modes: * - 'query-string': Appends a `_t` parameter. This is the most robust way to * defeat caching (it bypasses HTTP caches) but it changes the path sent to * the server and so can invalidate signed URLs. * - 'fragment': Appends a `_t` fragment. This is less robust (the browser * might still serve from its HTTP cache) but it does not change the URL * sent to the server so it is safe for signed URLs. * @param url The input URL. * @param mode The cache-busting mode. * @returns The cache-busted URL string. */ private _buildCacheBustURL(url: URL, mode: 'query-string' | 'fragment'): string { if (mode === 'query-string') { url.searchParams.append('_t', String(Date.now())); } else { url.hash = `_t=${Date.now()}`; } return url.toString(); } private _addQueryParametersToURL(url: URL, parameters?: string): URL { if (parameters) { const searchParams = new URLSearchParams(parameters); for (const [key, value] of searchParams.entries()) { url.searchParams.append(key, value); } } return url; } private _getEffectiveRefreshSeconds(): number | null { const seconds = this.imageConfig?.refresh_seconds ?? null; if (seconds !== 'auto') { return seconds; } const mode = resolveImageMode({ imageConfig: this.imageConfig, cameraConfig: this.cameraConfig, }); return mode === 'screensaver' ? SCREENSAVER_REFRESH_SECONDS : 1; } private _getRelevantEntityForMode(mode: Exclude): string | null { return mode === 'camera' ? getCameraEntityFromConfig(this.cameraConfig) : mode === 'entity' ? this.imageConfig?.entity ?? null : null; } private _getImageSource(): string { const mode = resolveImageMode({ imageConfig: this.imageConfig, cameraConfig: this.cameraConfig, }); if (this.hass && mode === 'camera') { const state = this._getAcceptableState( getCameraEntityFromConfig(this.cameraConfig), ); if (state?.attributes.entity_picture) { const urlObj = new URL(state.attributes.entity_picture, document.baseURI); this._addQueryParametersToURL(urlObj, this.imageConfig?.entity_parameters); return this._buildCacheBustURL(urlObj, 'query-string'); } } if (this.hass && mode === 'entity' && this.imageConfig?.entity) { const state = this._getAcceptableState(this.imageConfig?.entity); if (state?.attributes.entity_picture) { const urlObj = new URL(state.attributes.entity_picture, document.baseURI); this._addQueryParametersToURL(urlObj, this.imageConfig?.entity_parameters); return this._buildCacheBustURL(urlObj, 'query-string'); } } if (mode === 'url' && this.imageConfig?.url) { const url = this._signedURLController.getValue(); if (url) { const urlObj = new URL(url, document.baseURI); if (this.proxyConfig?.enabled) { // Use a fragment for cache-busting proxied URLs, as this does not // change the path and thus preserves the validity of the signed URL. return this._buildCacheBustURL(urlObj, 'fragment'); } return this._buildCacheBustURL(urlObj, 'query-string'); } } if (mode === 'screensaver') { return this._getScreensaverURL(); } return defaultImage; } /** * Force the img element to a safe image. */ private _forceSafeImage(stockOnly?: boolean): void { if (this._refImage.value) { this._showingSafeImage = true; // Avoid restoring the raw configured URL when proxying is enabled, since // that would bypass the proxied/signed URL path on visibility changes. const configuredURL = !stockOnly && !this.proxyConfig?.enabled ? this.imageConfig?.url ?? null : null; this._refImage.value.src = configuredURL ?? defaultImage; } } private _getDisplayNotification(): Notification | null { const error = this._signedURLController.getError(); if (error) { return createMediaNotification({ title: getSignedURLErrorText(error), targetTitle: this.cameraTitle, }); } if (this._imageLoadError) { return createMediaNotification({ title: localize('error.image_load_error'), targetTitle: this.cameraTitle, }); } return null; } protected render(): TemplateResult | void { const notification = this._getDisplayNotification(); if (notification) { return renderNotificationBlock(notification); } const src = this._cachedValueController?.getValue(); // 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` { if (this._showingSafeImage) { return; } const mediaLoadedInfo = createMediaLoadedInfo(ev, { mediaPlayerController: this._mediaPlayerController, capabilities: { supportsPause: !!this._getEffectiveRefreshSeconds(), }, }); if (mediaLoadedInfo) { this._mediaLoadedInfoSourceController.set(mediaLoadedInfo); } }} @error=${() => { const mode = resolveImageMode({ imageConfig: this.imageConfig, cameraConfig: this.cameraConfig, }); if (mode === 'camera' || mode === 'entity' || mode === 'screensaver') { this._forceSafeImage(true); } else if (mode === 'url') { this._imageLoadError = true; } this._dispatchError('server_error'); }} /> ` : html``; } static get styles(): CSSResultGroup { return unsafeCSS(imageUpdatingPlayerStyle); } } declare global { interface HTMLElementTagNameMap { 'advanced-camera-card-image-updating-player': AdvancedCameraCardImageUpdatingPlayer; } }