557 lines
20 KiB
TypeScript
557 lines
20 KiB
TypeScript
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<MediaUnavailableIssueReason>;
|
|
}
|
|
}
|
|
|
|
// 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<ImageMode, 'auto'> => {
|
|
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<View>;
|
|
|
|
@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<HTMLImageElement> = createRef();
|
|
|
|
// Whether the <img> 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 <img> -- 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 <img> →
|
|
// 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<MediaPlayerController | null> {
|
|
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<ImageMode, 'auto'>): 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`
|
|
<img
|
|
${ref(this._refImage)}
|
|
src=${live(src)}
|
|
@load=${(ev: Event) => {
|
|
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;
|
|
}
|
|
}
|