fix: Report and retry media failures for every camera in a grid (#2658)

- Closes: #2637
 - Related: #2099
This commit is contained in:
Dermot Duffy
2026-08-05 21:35:47 -07:00
committed by GitHub
parent 9f88aacefe
commit c137f4c00c
31 changed files with 1719 additions and 1000 deletions
+1 -1
View File
@@ -32,7 +32,7 @@ export const createIssueManager = (
manager.addIssue(new InitializationIssue(api)); manager.addIssue(new InitializationIssue(api));
manager.addIssue(new LegacyResourceIssue(changeCallback)); manager.addIssue(new LegacyResourceIssue(changeCallback));
manager.addIssue(new MediaQueryIssue(api)); manager.addIssue(new MediaQueryIssue(api));
manager.addIssue(new MediaUnavailableIssue(api, changeCallback)); manager.addIssue(new MediaUnavailableIssue(api));
return manager; return manager;
}; };
@@ -1,16 +1,13 @@
import type { IssueResolveContext, IssueTriggerContext } from 'issue'; import type { IssueResolveContext, IssueTriggerContext } from 'issue';
import type { ConditionState } from '../../../condition-trigger/conditions/types.js';
import type { import type {
Notification, Notification,
NotificationDetail, NotificationDetail,
} from '../../../config/schema/actions/types.js'; } from '../../../config/schema/actions/types.js';
import { TROUBLESHOOTING_MEDIA_URL } from '../../../const.js'; import { TROUBLESHOOTING_MEDIA_URL } from '../../../const.js';
import { localize } from '../../../localize/localize.js'; import { localize } from '../../../localize/localize.js';
import type { UnsubscribeCallback } from '../../../types.js'; import { getDisplayedTargetIDs } from '../../../view/layout.js';
import { Timer } from '../../../utils/timer.js';
import { IMAGE_VIEW_TARGET_ID_SENTINEL } from '../../../view/target-id.js'; import { IMAGE_VIEW_TARGET_ID_SENTINEL } from '../../../view/target-id.js';
import { isAnyMediaViewName } from '../../../view/view.js';
import type { CardIssueManagerAPI } from '../../types.js'; import type { CardIssueManagerAPI } from '../../types.js';
import { createRetryControl } from '../retry-control.js'; import { createRetryControl } from '../retry-control.js';
import type { Issue, IssueDescription } from '../types.js'; import type { Issue, IssueDescription } from '../types.js';
@@ -40,6 +37,9 @@ declare module 'issue' {
interface IssueResolveContext { interface IssueResolveContext {
media_unavailable: { media_unavailable: {
targetID: string; targetID: string;
// Optionally limits the clearing to one kind of failure.
reason?: MediaUnavailableIssueReason;
}; };
} }
} }
@@ -50,8 +50,6 @@ interface TargetError {
description?: string; description?: string;
} }
export const MEDIA_LOADING_TIMEOUT_SECONDS = 10;
// The per-cause presentation (localization key + icon), shared by the // The per-cause presentation (localization key + icon), shared by the
// notification metadata and the reconnecting placeholder so each cause is // notification metadata and the reconnecting placeholder so each cause is
// described in exactly one place. // described in exactly one place.
@@ -85,33 +83,20 @@ export const MEDIA_UNAVAILABLE_REASONS: Record<
}, },
}; };
// Reports media failures for the targets the user can currently see. Failures
// are raised and cleared by the components observing the media (e.g. providers)
// -- player errors and stalls via the liveness detectors, and a load that never
// arrives via each media host's load watchdog. This issue scopes them to the
// displayed targets and drives the throttled reload that retries them.
export class MediaUnavailableIssue implements Issue { export class MediaUnavailableIssue implements Issue {
public readonly key = 'media_unavailable' as const; public readonly key = 'media_unavailable' as const;
private _issueActive = false;
private _erroredTargets = new Map<string, TargetError>(); private _erroredTargets = new Map<string, TargetError>();
// Timer fires when a target has been loading too long without success.
private _timer = new Timer();
private _timerTargetID: string | null = null;
private _api: CardIssueManagerAPI; private _api: CardIssueManagerAPI;
private _onChange: (() => void) | null;
private _unsubscribeCallback: UnsubscribeCallback;
constructor(api: CardIssueManagerAPI, onChange?: () => void) { constructor(api: CardIssueManagerAPI) {
this._api = api; this._api = api;
this._onChange = onChange ?? null;
// React to a target's media loading; unload / select changes are
// irrelevant here.
this._unsubscribeCallback = this._api
.getMediaLoadedInfoManager()
.subscribe((change) => {
if (change.type === 'load') {
this._onMediaLoad(change.targetID);
}
});
} }
// ========================================================================= // =========================================================================
@@ -126,67 +111,29 @@ export class MediaUnavailableIssue implements Issue {
}); });
} }
// A target is proven to be delivering media again. Stronger evidence than a
// media load, which only says a player attached, so it clears any recorded
// error.
public resolve(context: IssueResolveContext['media_unavailable']): void { public resolve(context: IssueResolveContext['media_unavailable']): void {
const error = this._erroredTargets.get(context.targetID);
if (!error || (context.reason && context.reason !== error.reason)) {
return;
}
this._erroredTargets.delete(context.targetID); this._erroredTargets.delete(context.targetID);
this._cancelPendingTimer(context.targetID);
}
// =========================================================================
// Detection -- called by the manager on every state change.
// =========================================================================
public detectDynamic(state: ConditionState): void {
if (!isAnyMediaViewName(state.view)) {
this._deactivate();
return;
}
// A known error for the current target activates immediately, even if its
// (frozen) media still reads as loaded (it might be loaded but then
// reported a playback error that stops playback but leaves the player
// attached). Errors are cleared out-of-band, by `resolve` or by
// `_onMediaLoad`.
if (this._hasError(state)) {
this._activate();
return;
}
if (state.mediaLoadedInfo) {
// Loaded with no known error: healthy.
this._deactivate();
return;
}
this._handlePendingLoad(state);
}
// A load proves media attached for the target. That ends any wait on it, and
// refutes a `not_loading` error. It is no evidence of recovery for any other
// reason, so those clear only via `resolve`.
private _onMediaLoad(targetID: string): void {
let changed = this._cancelPendingTimer(targetID);
if (this._erroredTargets.get(targetID)?.reason === 'not_loading') {
this._erroredTargets.delete(targetID);
changed = true;
}
if (changed) {
this._onChange?.();
}
} }
// ========================================================================= // =========================================================================
// State queries -- called by the manager to read current state. // State queries -- called by the manager to read current state.
// ========================================================================= // =========================================================================
// Reported exactly when a target on screen has a known failure, even if its
// (frozen) media still reads as loaded (it might be loaded but then reported
// a playback error that stops playback but leaves the player attached).
// Failures are cleared out-of-band, by `resolve`.
public hasIssue(): boolean { public hasIssue(): boolean {
return this._issueActive; return !!this._getDisplayedErrors().size;
} }
public getIssue(): IssueDescription | null { public getIssue(): IssueDescription | null {
if (!this._issueActive) { if (!this.hasIssue()) {
return null; return null;
} }
return { return {
@@ -197,20 +144,7 @@ export class MediaUnavailableIssue implements Issue {
} }
public getNotification(): Notification { public getNotification(): Notification {
const targets = new Map(this._erroredTargets); const targets = this._getDisplayedErrors();
// The pending-load timer's target is a slow initial load that has not yet
// errored. Gate on the timer still running: once it is stopped (a hard error
// on another target took over, or the view moved on), _timerTargetID lingers
// and would otherwise paint a stale "not loading" line for a target that has
// since loaded.
if (
this._timerTargetID &&
this._timer.isRunning() &&
!targets.has(this._timerTargetID)
) {
targets.set(this._timerTargetID, { reason: 'not_loading' });
}
// The free-text causes go in the context block rather than on the metadata // The free-text causes go in the context block rather than on the metadata
// lines, which stay short enough to scan when several cameras fail at once. // lines, which stay short enough to scan when several cameras fail at once.
@@ -269,20 +203,11 @@ export class MediaUnavailableIssue implements Issue {
// ========================================================================= // =========================================================================
public needsRetry(): boolean { public needsRetry(): boolean {
return this._issueActive; return this.hasIssue();
} }
public retry(): boolean { public retry(): boolean {
// Build the set of targets to retry: all errored targets plus the const retryTargets = this._getDisplayedErrors();
// target the pending timer is tracking (so a user-initiated retry
// works even before the timeout fires). A stopped timer leaves
// _timerTargetID behind, so gate on it still running: that target may
// since have loaded.
const retryTargets = new Set(this._erroredTargets.keys());
if (this._timerTargetID && this._timer.isRunning()) {
retryTargets.add(this._timerTargetID);
}
if (!retryTargets.size) { if (!retryTargets.size) {
return false; return false;
} }
@@ -291,16 +216,15 @@ export class MediaUnavailableIssue implements Issue {
// only way to rebuild a stream from scratch. // only way to rebuild a stream from scratch.
const view = this._api.getViewManager().getView(); const view = this._api.getViewManager().getView();
const mediaEpoch = { ...(view?.context?.mediaEpoch ?? {}) }; const mediaEpoch = { ...(view?.context?.mediaEpoch ?? {}) };
for (const id of retryTargets) { for (const id of retryTargets.keys()) {
mediaEpoch[id] = (mediaEpoch[id] ?? 0) + 1; mediaEpoch[id] = (mediaEpoch[id] ?? 0) + 1;
} }
// Intentionally keep _issueActive, _erroredTargets, and the pending // Intentionally keep _erroredTargets in place. The issue stays visible
// timer in place. The issue stays visible while the provider re-attempts // while the provider re-attempts loading underneath. If the retry succeeds,
// loading underneath. If the retry succeeds, the fresh load clears a // the fresh load clears a not-loading error and the rebuilt provider's
// not-loading error and the rebuilt provider's liveness observation // liveness observation resolves a stream error. If it fails silently (e.g.
// resolves a stream error. If it fails silently (e.g. bogus stream name), // bogus stream name), the error stays visible immediately.
// the error stays visible immediately -- no new 10s grace period.
this._api.getViewManager().setViewWithMergedContext({ mediaEpoch }); this._api.getViewManager().setViewWithMergedContext({ mediaEpoch });
return false; return false;
} }
@@ -310,83 +234,27 @@ export class MediaUnavailableIssue implements Issue {
// ========================================================================= // =========================================================================
public reset(): void { public reset(): void {
this._deactivate();
this._erroredTargets.clear(); this._erroredTargets.clear();
} }
// Stop reacting to media loads at end of life.
public destroy(): void {
this._unsubscribeCallback();
}
// Stop the pending-load timer so offscreen time doesn't count toward the
// 10s threshold. Preserve _issueActive, _erroredTargets, and
// _timerTargetID: already-visible errors remain visible on reattach, and
// retaining _timerTargetID lets the existing active/target-mismatch guard
// in _handlePendingLoad avoid spuriously deactivating the preserved
// issue when the same target is still loading on resume. The timer is
// re-armed with a fresh window by the next detectDynamic pass.
public suspend(): void {
this._timer.stop();
}
// ========================================================================= // =========================================================================
// Private helpers. // Private helpers.
// ========================================================================= // =========================================================================
// Stop waiting on a target's load, if it is the one being waited on. Returns // The errored targets the user can currently see. An error recorded for a
// whether it was. // target that has since left the screen names something they cannot look at,
private _cancelPendingTimer(targetID: string): boolean { // and reloading it would achieve nothing. Read fresh rather than remembered:
if (this._timerTargetID !== targetID) { // a change in conditions can re-evaluate an override that replaces the
return false; // configured cameras, leaving the view exactly as it was.
} private _getDisplayedErrors(): Map<string, TargetError> {
this._timer.stop(); const view = this._api.getViewManager().getView();
this._timerTargetID = null; if (!view) {
return true; return new Map();
} }
// Media not yet loaded and no known error: start (or keep) a timeout to catch const displayedTargetIDs = getDisplayedTargetIDs(view, this._api.getCameraManager());
// a slow or failed initial load. No targetID means no provider is rendering return new Map(
// media (e.g. the viewer shows "No media to display"), so there's nothing to [...this._erroredTargets].filter(([targetID]) => displayedTargetIDs.has(targetID)),
// wait for. );
private _handlePendingLoad(state: ConditionState): void {
if (!state.targetID) {
this._deactivate();
return;
}
const targetID = state.targetID;
// When the target changes, clear the active state so the new target gets
// its own timeout window instead of inheriting the previous target's.
if (this._issueActive && this._timerTargetID !== targetID) {
this._deactivate();
}
// Start (or restart) the timer for this target.
if (!this._timer.isRunning() || this._timerTargetID !== targetID) {
this._timerTargetID = targetID;
this._timer.start(MEDIA_LOADING_TIMEOUT_SECONDS, () => {
// Record the error on timeout so retry() knows which epoch to bump.
this.trigger({ targetID, reason: 'not_loading' });
this._activate();
this._onChange?.();
});
}
}
private _hasError(state: ConditionState): boolean {
return !!state.targetID && this._erroredTargets.has(state.targetID);
}
private _activate(): void {
this._timer.stop();
this._issueActive = true;
}
private _deactivate(): void {
this._timer.stop();
this._timerTargetID = null;
this._issueActive = false;
} }
} }
@@ -0,0 +1,219 @@
import type { ReactiveController, ReactiveControllerHost } from 'lit';
import type {
IssueResolveEventData,
IssueTriggerEventData,
} from '../card-controller/issues/types';
import type { MediaLoadedInfoEventDetail } from '../types';
import { onAbort } from '../utils/abort-signal';
import { Generation } from '../utils/concurrency/generation';
import { fireAdvancedCameraCardEvent } from '../utils/fire-advanced-camera-card-event';
import { Timer } from '../utils/timer';
const MEDIA_LOADED_EVENT = 'advanced-camera-card:media:loaded';
export const MEDIA_LOADING_TIMEOUT_SECONDS = 10;
interface MediaLoadWatchdogConfig {
getTargetID: () => string | null;
// Whether the host is currently trying to load media. False when loading is
// held back (e.g. lazy loading), or when the host is showing a failure of its
// own rather than waiting for media.
isLoadExpected: () => boolean;
// Changes when the media underneath is rebuilt without the host itself being
// rebuilt (e.g. a retry re-keying a player below it), so the wait starts
// again. Omitted by a host that a retry replaces outright.
getAttemptID?: () => unknown;
}
/**
* Watches a media host's load: when the host expects media but none arrives
* within the window, raises a `media_unavailable` issue with reason
* `not_loading`, and clears it again once media does arrive.
*
* Loads are observed from the host's own bubble path, so a `media:loaded`
* dispatched by any descendant player ends the wait, and that media going away
* (its event's abort signal) restarts it if a load is still expected.
*
* A host may be reused for a succession of targets (a viewer slide swiped from
* one media item to the next), so everything learned is scoped to the target it
* was learned about. A target change starts a fresh wait and resolves any
* failure reported for the target left behind.
*/
export class MediaLoadWatchdogController implements ReactiveController {
private _host: ReactiveControllerHost & HTMLElement;
private _config: MediaLoadWatchdogConfig;
private _timer = new Timer();
// What the state below describes: the target, and which attempt at loading it
// (see `getAttemptID`). A change in either means everything known is about
// media the host has moved on from.
private _targetID: string | null = null;
private _attemptID: unknown = undefined;
private _mediaLoaded = false;
// Identifies which load `_mediaLoaded` describes. One player can replace
// another for the same target before the first has gone away, and the first
// going away must not be read as the current media being lost.
private _loadGeneration = new Generation();
// Whether the timeout has fired for the current attempt, so a hung load is
// reported only once. A retry is a new attempt and is waited on afresh.
private _fired = false;
// The target this watchdog has an outstanding failure reported for, so that
// failure can be resolved if the host moves on to another target. It
// outlives the attempt that reported it, which keeps the failure on screen
// while a retry runs underneath.
private _reportedTargetID: string | null = null;
// Media loads, and the media going away, are observed through listeners and
// abort signals that can outlive a disconnect, and a detached host must not be
// waited on.
private _connected = false;
constructor(
host: ReactiveControllerHost & HTMLElement,
config: MediaLoadWatchdogConfig,
) {
this._host = host;
this._config = config;
host.addController(this);
}
public hostConnected(): void {
this._connected = true;
this._host.addEventListener(MEDIA_LOADED_EVENT, this._onMediaLoaded);
this._evaluate();
}
public hostDisconnected(): void {
this._connected = false;
this._host.removeEventListener(MEDIA_LOADED_EVENT, this._onMediaLoaded);
this._timer.stop();
}
public hostUpdated(): void {
this._evaluate();
}
private _forgetLoad(): void {
this._mediaLoaded = false;
this._fired = false;
this._loadGeneration.invalidate();
this._timer.stop();
}
private _onMediaLoaded = (ev: CustomEvent<MediaLoadedInfoEventDetail>): void => {
const targetID = ev.detail.info.targetID;
// The host's own target is read fresh so a load arriving as the host
// switches is attributed to whichever target it actually describes.
if (!targetID || targetID !== this._config.getTargetID()) {
return;
}
// This load replaces whatever was being followed, which may be a different
// target the host has just moved off.
this._syncAttempt();
this._forgetLoad();
this._mediaLoaded = true;
const generation = this._loadGeneration.next();
// Media arriving disproves a not-loading failure whoever reported it.
this._resolveFailure(targetID);
// Media going away is not itself a failure, but a load that is still
// expected afterwards needs a fresh wait. This is recorded whether or not
// the host is attached, since a detached host has no media either.
onAbort(ev.detail.signal, () => {
if (targetID !== this._targetID || !this._loadGeneration.isCurrent(generation)) {
return;
}
this._mediaLoaded = false;
this._evaluate();
});
};
// Clear a target's not-loading failure, whichever component reported it.
// Naming the reason leaves a failure reported for any other reason alone.
private _resolveFailure(targetID: string): void {
if (this._reportedTargetID === targetID) {
this._reportedTargetID = null;
}
fireAdvancedCameraCardEvent<IssueResolveEventData>(this._host, 'issue:resolve', {
key: 'media_unavailable',
targetID,
reason: 'not_loading',
});
}
// Discard everything learned about a previous target, or a previous attempt
// at the same one.
private _syncAttempt(): void {
const targetID = this._config.getTargetID();
const attemptID = this._config.getAttemptID?.();
if (targetID === this._targetID && attemptID === this._attemptID) {
return;
}
// If an abandoned target was previously reported, resolve it so it's not
// stuck forever
if (this._reportedTargetID && this._reportedTargetID !== targetID) {
this._resolveFailure(this._reportedTargetID);
}
this._targetID = targetID;
this._attemptID = attemptID;
this._forgetLoad();
}
private _evaluate(): void {
if (!this._connected) {
return;
}
this._syncAttempt();
if (!this._targetID || this._mediaLoaded || !this._config.isLoadExpected()) {
this._fired = false;
this._timer.stop();
return;
}
if (!this._fired && !this._timer.isRunning()) {
this._timer.start(MEDIA_LOADING_TIMEOUT_SECONDS, () => this._onTimeout());
}
}
private _onTimeout(): void {
const targetID = this._targetID;
// Conditions may have changed while the timer matured.
if (
!this._connected ||
!targetID ||
targetID !== this._config.getTargetID() ||
this._attemptID !== this._config.getAttemptID?.() ||
this._mediaLoaded ||
!this._config.isLoadExpected()
) {
return;
}
this._fired = true;
this._reportedTargetID = targetID;
fireAdvancedCameraCardEvent<IssueTriggerEventData>(this._host, 'issue:trigger', {
key: 'media_unavailable',
targetID,
reason: 'not_loading',
});
}
}
+16
View File
@@ -126,6 +126,13 @@ export class AdvancedCameraCardImageUpdatingPlayer
private _refImage: Ref<HTMLImageElement> = createRef(); 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( private _cachedValueController = new CachedValueController(
this, this,
() => this._getEffectiveRefreshSeconds(), () => this._getEffectiveRefreshSeconds(),
@@ -234,6 +241,10 @@ export class AdvancedCameraCardImageUpdatingPlayer
* @param _changedProps The changed properties * @param _changedProps The changed properties
*/ */
protected willUpdate(changedProps: PropertyValues): void { 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( const relevantEntity = this._getRelevantEntityForMode(
resolveImageMode({ resolveImageMode({
imageConfig: this.imageConfig, imageConfig: this.imageConfig,
@@ -454,6 +465,7 @@ export class AdvancedCameraCardImageUpdatingPlayer
*/ */
private _forceSafeImage(stockOnly?: boolean): void { private _forceSafeImage(stockOnly?: boolean): void {
if (this._refImage.value) { if (this._refImage.value) {
this._showingSafeImage = true;
// Avoid restoring the raw configured URL when proxying is enabled, since // Avoid restoring the raw configured URL when proxying is enabled, since
// that would bypass the proxied/signed URL path on visibility changes. // that would bypass the proxied/signed URL path on visibility changes.
const configuredURL = const configuredURL =
@@ -495,6 +507,10 @@ export class AdvancedCameraCardImageUpdatingPlayer
${ref(this._refImage)} ${ref(this._refImage)}
src=${live(src)} src=${live(src)}
@load=${(ev: Event) => { @load=${(ev: Event) => {
if (this._showingSafeImage) {
return;
}
const mediaLoadedInfo = createMediaLoadedInfo(ev, { const mediaLoadedInfo = createMediaLoadedInfo(ev, {
mediaPlayerController: this._mediaPlayerController, mediaPlayerController: this._mediaPlayerController,
capabilities: { capabilities: {
+39 -10
View File
@@ -12,6 +12,7 @@ import { createRef, ref, type Ref } from 'lit/directives/ref.js';
import type { CameraManager } from '../camera-manager/manager'; import type { CameraManager } from '../camera-manager/manager';
import type { ViewManagerEpoch } from '../card-controller/view/types'; import type { ViewManagerEpoch } from '../card-controller/view/types';
import { MediaLoadWatchdogController } from '../components-lib/media-load-watchdog-controller';
import type { ZoomSettingsObserved } from '../components-lib/zoom/types'; import type { ZoomSettingsObserved } from '../components-lib/zoom/types';
import { handleZoomSettingsObservedEvent } from '../components-lib/zoom/zoom-view-context'; import { handleZoomSettingsObservedEvent } from '../components-lib/zoom/zoom-view-context';
import type { CameraConfig } from '../config/schema/cameras'; import type { CameraConfig } from '../config/schema/cameras';
@@ -59,6 +60,40 @@ export class AdvancedCameraCardImage extends LitElement implements MediaPlayer {
private _refImage: Ref<MediaPlayerElement> = createRef(); private _refImage: Ref<MediaPlayerElement> = createRef();
constructor() {
super();
// No lazy loading: Reports the image as a media_unavailable issue if it
// never arrives.
new MediaLoadWatchdogController(this, {
getTargetID: () => IMAGE_VIEW_TARGET_ID_SENTINEL,
isLoadExpected: () =>
// A misconfigured image already shows why nothing can be drawn, and
// reloading it cannot change the configuration.
!!this.hass && !this._getConfigurationError(),
// Player is keyed on epoch.
getAttemptID: () => this._getMediaEpoch(),
});
}
private _getMediaEpoch(): number {
const view = this.viewManagerEpoch?.manager.getView();
return view?.context?.mediaEpoch?.[IMAGE_VIEW_TARGET_ID_SENTINEL] ?? 0;
}
// Returns the reason no image can be shown at all, or null when one can.
// `camera` mode has nothing to draw from without a camera.
private _getConfigurationError(): string | null {
const mode = resolveImageMode({
imageConfig: this.imageConfig,
cameraConfig: this.cameraConfig,
});
return mode === 'camera' && !this.cameraConfig
? localize('error.no_camera_for_image')
: null;
}
public async getMediaPlayerController(): Promise<MediaPlayerController | null> { public async getMediaPlayerController(): Promise<MediaPlayerController | null> {
await this.updateComplete; await this.updateComplete;
return (await this._refImage.value?.getMediaPlayerController()) ?? null; return (await this._refImage.value?.getMediaPlayerController()) ?? null;
@@ -118,24 +153,18 @@ export class AdvancedCameraCardImage extends LitElement implements MediaPlayer {
return; return;
} }
// Determine if this image mode requires a camera const configurationError = this._getConfigurationError();
const mode = resolveImageMode({ if (configurationError) {
imageConfig: this.imageConfig, return renderNotificationBlockFromText(configurationError, {
cameraConfig: this.cameraConfig,
});
if (mode === 'camera' && !this.cameraConfig) {
return renderNotificationBlockFromText(localize('error.no_camera_for_image'), {
icon: 'mdi:camera-off', icon: 'mdi:camera-off',
}); });
} }
const view = this.viewManagerEpoch?.manager.getView(); const view = this.viewManagerEpoch?.manager.getView();
const mediaEpoch = view?.context?.mediaEpoch?.[IMAGE_VIEW_TARGET_ID_SENTINEL] ?? 0;
return this._renderContainer(html` return this._renderContainer(html`
${keyed( ${keyed(
mediaEpoch, this._getMediaEpoch(),
html` html`
<advanced-camera-card-image-updating-player <advanced-camera-card-image-updating-player
${ref(this._refImage)} ${ref(this._refImage)}
+8 -11
View File
@@ -20,6 +20,7 @@ import type { CardWideConfig } from '../../config/schema/types.js';
import type { HomeAssistant } from '../../ha/types.js'; import type { HomeAssistant } from '../../ha/types.js';
import liveGridStyle from '../../scss/live-grid.scss?inline'; import liveGridStyle from '../../scss/live-grid.scss?inline';
import { contentsChanged } from '../../utils/basic.js'; import { contentsChanged } from '../../utils/basic.js';
import { getLiveGridCameraIDs } from '../../view/layout.js';
import './carousel.js'; import './carousel.js';
@@ -97,26 +98,22 @@ export class AdvancedCameraCardLiveGrid extends LitElement {
}); });
} }
private _needsGrid(): boolean { private _getGridCameraIDs(): Set<string> | null {
const cameraIDs = this.cameraManager?.getStore().getCameraIDsWithCapability('live');
const view = this.viewManagerEpoch?.manager.getView(); const view = this.viewManagerEpoch?.manager.getView();
return ( return view && this.cameraManager
!!view?.isGrid() && ? getLiveGridCameraIDs(view, this.cameraManager)
!!view?.supportsMultipleDisplayModes() && : null;
!!cameraIDs &&
cameraIDs.size > 1
);
} }
protected willUpdate(changedProps: PropertyValues): void { protected willUpdate(changedProps: PropertyValues): void {
if (changedProps.has('viewManagerEpoch') && this._needsGrid()) { if (changedProps.has('viewManagerEpoch') && this._getGridCameraIDs()) {
void import('../media-grid.js'); void import('../media-grid.js');
} }
} }
protected render(): TemplateResult | void { protected render(): TemplateResult | void {
const cameraIDs = this.cameraManager?.getStore().getCameraIDsWithCapability('live'); const cameraIDs = this._getGridCameraIDs();
if (!cameraIDs?.size || !this._needsGrid()) { if (!cameraIDs) {
return this._renderCarousel(); return this._renderCarousel();
} }
+50 -21
View File
@@ -17,6 +17,7 @@ import { MEDIA_UNAVAILABLE_REASONS } from '../../card-controller/issues/issues/m
import { LazyLoadController } from '../../components-lib/lazy-load-controller.js'; import { LazyLoadController } from '../../components-lib/lazy-load-controller.js';
import { isAudioIntendedOnLoad } from '../../components-lib/live/audio-intent.js'; import { isAudioIntendedOnLoad } from '../../components-lib/live/audio-intent.js';
import { StreamLivenessController } from '../../components-lib/live/liveness/stream-liveness-controller.js'; import { StreamLivenessController } from '../../components-lib/live/liveness/stream-liveness-controller.js';
import { MediaLoadWatchdogController } from '../../components-lib/media-load-watchdog-controller.js';
import { MediaLoadedInfoSinkController } from '../../components-lib/media-loaded-info-sink-controller.js'; import { MediaLoadedInfoSinkController } from '../../components-lib/media-loaded-info-sink-controller.js';
import type { PartialZoomSettings } from '../../components-lib/zoom/types.js'; import type { PartialZoomSettings } from '../../components-lib/zoom/types.js';
import type { LiveConfig } from '../../config/schema/live.js'; import type { LiveConfig } from '../../config/schema/live.js';
@@ -110,6 +111,24 @@ export class AdvancedCameraCardLiveProvider extends LitElement implements MediaP
private _lazyLoadController: LazyLoadController = new LazyLoadController(this); private _lazyLoadController: LazyLoadController = new LazyLoadController(this);
constructor() {
super();
// Watch for media that fails to load. The constructor registers it as a
// controller on this host.
new MediaLoadWatchdogController(this, {
getTargetID: () => this.targetID ?? null,
isLoadExpected: () =>
this._shouldLoad() &&
// Don't report media unavailable for configuration errors as retries
// cannot possible help, and a message is already rendered.
!this._getConfigurationError() &&
// Specific > generic: Don't replace existing failures flagged with
// liveness detectors.
!this._streamLivenessController.getFailure(),
});
}
// A note on dynamic imports: // A note on dynamic imports:
// //
// We gather the dynamic live provider import promises and do not consider the // We gather the dynamic live provider import promises and do not consider the
@@ -181,6 +200,33 @@ export class AdvancedCameraCardLiveProvider extends LitElement implements MediaP
} }
} }
// Whether this provider should be loading media at all.
private _shouldLoad(): boolean {
return this._lazyLoadController.isLoaded();
}
// Returns the reason this camera cannot stream at all, or null when it can.
private _getConfigurationError(): string | null {
const cameraConfig = this.camera?.getConfig();
const provider = getResolvedLiveProvider(cameraConfig);
if (
provider !== 'ha' &&
provider !== 'image' &&
!(cameraConfig?.camera_entity && cameraConfig.always_error_if_entity_unavailable)
) {
return null;
}
if (!cameraConfig?.camera_entity) {
return localize('error.no_live_camera');
}
if (!this.hass?.states[cameraConfig.camera_entity]) {
return localize('error.live_camera_not_found');
}
return null;
}
override async getUpdateComplete(): Promise<boolean> { override async getUpdateComplete(): Promise<boolean> {
// See 'A note on dynamic imports' above for explanation of why this is // See 'A note on dynamic imports' above for explanation of why this is
// necessary. // necessary.
@@ -234,7 +280,7 @@ export class AdvancedCameraCardLiveProvider extends LitElement implements MediaP
protected render(): TemplateResult | void { protected render(): TemplateResult | void {
const cameraConfig = this.camera?.getConfig(); const cameraConfig = this.camera?.getConfig();
if ( if (
!this._lazyLoadController?.isLoaded() || !this._shouldLoad() ||
!this.hass || !this.hass ||
!this.liveConfig || !this.liveConfig ||
!this.camera || !this.camera ||
@@ -262,32 +308,15 @@ export class AdvancedCameraCardLiveProvider extends LitElement implements MediaP
const provider = getResolvedLiveProvider(this.camera?.getConfig()); const provider = getResolvedLiveProvider(this.camera?.getConfig());
// `ha`/`image` cannot stream without a camera entity, so validate that const configurationError = this._getConfigurationError();
// here. Entity *availability* (including the always_error immediate path) if (configurationError) {
// is owned by the liveness controller's EntityAvailabilityDetector and
// surfaces via getFailure() below, for all providers.
if (
provider === 'ha' ||
provider === 'image' ||
(cameraConfig?.camera_entity && cameraConfig.always_error_if_entity_unavailable)
) {
if (!cameraConfig?.camera_entity) {
return renderMediaNotification({ return renderMediaNotification({
icon: 'mdi:camera', icon: 'mdi:camera',
title: localize('error.configuration_error'), title: localize('error.configuration_error'),
detail: localize('error.no_live_camera'), detail: configurationError,
targetTitle: this.cameraTitle, targetTitle: this.cameraTitle,
}); });
} }
if (!this.hass.states[cameraConfig.camera_entity]) {
return renderMediaNotification({
icon: 'mdi:camera',
title: localize('error.configuration_error'),
detail: localize('error.live_camera_not_found'),
targetTitle: this.cameraTitle,
});
}
}
const failure = this._streamLivenessController.getFailure(); const failure = this._streamLivenessController.getFailure();
+7 -12
View File
@@ -17,6 +17,7 @@ import type { CardWideConfig } from '../../config/schema/types.js';
import type { ViewerConfig } from '../../config/schema/viewer.js'; import type { ViewerConfig } from '../../config/schema/viewer.js';
import type { ResolvedMediaCache } from '../../ha/resolved-media.js'; import type { ResolvedMediaCache } from '../../ha/resolved-media.js';
import type { HomeAssistant } from '../../ha/types.js'; import type { HomeAssistant } from '../../ha/types.js';
import { getViewerGridCameraIDs } from '../../view/layout.js';
import '../../patches/ha-hls-player.js'; import '../../patches/ha-hls-player.js';
@@ -75,19 +76,14 @@ export class AdvancedCameraCardViewerGrid extends LitElement {
} }
protected willUpdate(changedProps: PropertyValues): void { protected willUpdate(changedProps: PropertyValues): void {
if (changedProps.has('viewManagerEpoch') && this._needsGrid()) { if (changedProps.has('viewManagerEpoch') && this._getGridCameraIDs()) {
void import('../media-grid.js'); void import('../media-grid.js');
} }
} }
private _needsGrid(): boolean { private _getGridCameraIDs(): Set<string> | null {
const view = this.viewManagerEpoch?.manager.getView(); const view = this.viewManagerEpoch?.manager.getView();
const cameraIDs = view?.queryResults?.getCameraIDs(); return view ? getViewerGridCameraIDs(view) : null;
return (
!!view?.isGrid() &&
!!view?.supportsMultipleDisplayModes() &&
(cameraIDs?.size ?? 0) > 1
);
} }
private _gridSelectCamera(cameraID: string): void { private _gridSelectCamera(cameraID: string): void {
@@ -103,15 +99,14 @@ export class AdvancedCameraCardViewerGrid extends LitElement {
} }
protected render(): TemplateResult { protected render(): TemplateResult {
const view = this.viewManagerEpoch?.manager.getView(); const cameraIDs = this._getGridCameraIDs();
const cameraIDs = view?.queryResults?.getCameraIDs(); if (!cameraIDs) {
if (!cameraIDs || !this._needsGrid()) {
return this._renderCarousel(); return this._renderCarousel();
} }
return html` return html`
<advanced-camera-card-media-grid <advanced-camera-card-media-grid
.selected=${view?.camera} .selected=${this.viewManagerEpoch?.manager.getView()?.camera}
.displayConfig=${this.viewerConfig?.display} .displayConfig=${this.viewerConfig?.display}
@advanced-camera-card:media-grid:selected=${( @advanced-camera-card:media-grid:selected=${(
ev: CustomEvent<MediaGridSelected>, ev: CustomEvent<MediaGridSelected>,
+27 -8
View File
@@ -14,6 +14,7 @@ import type { CameraManager } from '../../camera-manager/manager.js';
import { QueryType } from '../../camera-manager/types.js'; import { QueryType } from '../../camera-manager/types.js';
import type { ViewManagerEpoch } from '../../card-controller/view/types.js'; import type { ViewManagerEpoch } from '../../card-controller/view/types.js';
import { LazyLoadController } from '../../components-lib/lazy-load-controller.js'; import { LazyLoadController } from '../../components-lib/lazy-load-controller.js';
import { MediaLoadWatchdogController } from '../../components-lib/media-load-watchdog-controller.js';
import { import {
getSignedURLErrorText, getSignedURLErrorText,
SignedURLController, SignedURLController,
@@ -36,6 +37,7 @@ import type {
MediaPlayerController, MediaPlayerController,
MediaPlayerElement, MediaPlayerElement,
} from '../../types.js'; } from '../../types.js';
import { Generation } from '../../utils/concurrency/generation.js';
import { classifyMimeType } from '../../utils/mime-type.js'; import { classifyMimeType } from '../../utils/mime-type.js';
import { ViewItemClassifier } from '../../view/item-classifier.js'; import { ViewItemClassifier } from '../../view/item-classifier.js';
import type { ViewMedia } from '../../view/item.js'; import type { ViewMedia } from '../../view/item.js';
@@ -77,6 +79,9 @@ export class AdvancedCameraCardViewerProvider extends LitElement implements Medi
private _resolvedMedia: ResolvedMedia | null = null; private _resolvedMedia: ResolvedMedia | null = null;
// Drops a slow resolution for media the provider has since moved off.
private _resolveGeneration = new Generation();
private _signedURLController = new SignedURLController(this, () => { private _signedURLController = new SignedURLController(this, () => {
if (!this.hass || !this._resolvedMedia) { if (!this.hass || !this._resolvedMedia) {
return {}; return {};
@@ -99,6 +104,12 @@ export class AdvancedCameraCardViewerProvider extends LitElement implements Medi
constructor() { constructor() {
super(); super();
this._lazyLoadController.addListener((loaded) => loaded && this._resolveURL()); this._lazyLoadController.addListener((loaded) => loaded && this._resolveURL());
// Watch for media load failure (including resolving media ID and signing).
new MediaLoadWatchdogController(this, {
getTargetID: () => this.media?.getID() ?? null,
isLoadExpected: () => this._shouldLoad(),
});
} }
public async getMediaPlayerController(): Promise<MediaPlayerController | null> { public async getMediaPlayerController(): Promise<MediaPlayerController | null> {
@@ -140,19 +151,28 @@ export class AdvancedCameraCardViewerProvider extends LitElement implements Medi
private async _resolveURL(): Promise<void> { private async _resolveURL(): Promise<void> {
const contentID = this.media?.getContentID(); const contentID = this.media?.getContentID();
if (!contentID || !this.hass || !this._lazyLoadController?.isLoaded()) { if (!contentID || !this.hass || !this._shouldLoad()) {
this._resolveGeneration.invalidate();
this._resolvedMedia = null; this._resolvedMedia = null;
return; return;
} }
const generation = this._resolveGeneration.next();
// Clear immediately so the SignedURLController doesn't see a stale URL // Clear immediately so the SignedURLController doesn't see a stale URL
// from the previous media item during the async gap. // from the previous media item during the async gap.
this._resolvedMedia = null; this._resolvedMedia = null;
this._resolvedMedia = const resolved =
this.resolvedMediaCache?.get(contentID) ?? this.resolvedMediaCache?.get(contentID) ??
(await resolveMedia(this.hass, contentID, this.resolvedMediaCache)) ?? (await resolveMedia(this.hass, contentID, this.resolvedMediaCache)) ??
null; null;
if (!this._resolveGeneration.isCurrent(generation)) {
return;
}
this._resolvedMedia = resolved;
this.requestUpdate(); this.requestUpdate();
} }
@@ -177,6 +197,10 @@ export class AdvancedCameraCardViewerProvider extends LitElement implements Medi
} }
} }
private _shouldLoad(): boolean {
return this._lazyLoadController.isLoaded();
}
private _getRelevantCameraConfig(): CameraConfig | null { private _getRelevantCameraConfig(): CameraConfig | null {
const cameraID = this.media?.getCameraID(); const cameraID = this.media?.getCameraID();
return cameraID return cameraID
@@ -233,12 +257,7 @@ export class AdvancedCameraCardViewerProvider extends LitElement implements Medi
} }
protected render(): TemplateResult | void { protected render(): TemplateResult | void {
if ( if (!this._shouldLoad() || !this.media || !this.hass || !this.viewerConfig) {
!this._lazyLoadController?.isLoaded() ||
!this.media ||
!this.hass ||
!this.viewerConfig
) {
return; return;
} }
+1 -1
View File
@@ -41,7 +41,7 @@ export interface ConditionState {
// Generic media target identifier. See @view/target-id for details. // Generic media target identifier. See @view/target-id for details.
targetID?: string; targetID?: string;
triggered?: Set<string>; triggered?: ReadonlySet<string>;
userAgent?: string; userAgent?: string;
view?: AdvancedCameraCardView; view?: AdvancedCameraCardView;
} }
+6
View File
@@ -37,12 +37,18 @@
// not change in size (even border-box sizing appears to allow size to change // not change in size (even border-box sizing appears to allow size to change
// when the element has a non-fixed height). // when the element has a non-fixed height).
border: var(--advanced-camera-card-grid-border-size) solid transparent; border: var(--advanced-camera-card-grid-border-size) solid transparent;
// Unselected cells cannot be usefully scrolled, so ensure notifications do
// not show scrollbars.
--advanced-camera-card-notification-block-overflow-y: hidden;
} }
::slotted([selected]) { ::slotted([selected]) {
border: var(--advanced-camera-card-grid-border-size) solid border: var(--advanced-camera-card-grid-border-size) solid
var(--advanced-camera-card-grid-selected-border-color); var(--advanced-camera-card-grid-selected-border-color);
--advanced-camera-card-notification-block-overflow-y: auto;
// Selected width = columns × selection factor, capped at 100%. // Selected width = columns × selection factor, capped at 100%.
width: min( width: min(
100%, 100%,
+3 -1
View File
@@ -29,7 +29,9 @@
padding: 24px; padding: 24px;
max-width: min(92%, 500px); max-width: min(92%, 500px);
max-height: 100%; max-height: 100%;
overflow-y: auto;
// A container that cannot usefully be scrolled can set this to 'hidden'.
overflow-y: var(--advanced-camera-card-notification-block-overflow-y, auto);
scrollbar-width: thin; scrollbar-width: thin;
text-align: center; text-align: center;
} }
+85
View File
@@ -0,0 +1,85 @@
import type { CameraManager } from '../camera-manager/manager';
import { getViewTargetID } from './target-id';
import type { View } from './view';
const isGridLayout = (view: View): boolean =>
// This reflects whether the view 'asks' for a grid. This is inherited as
// the user changes view.
view.isGrid() &&
// This reflects whether the current view actually *supports* a grid.
view.supportsMultipleDisplayModes();
// The cameras a live grid lays out, one cell each, or null when live is laid
// out as a single carousel instead (incl. when there's <= 1 camera).
export const getLiveGridCameraIDs = (
view: View,
cameraManager: CameraManager,
): Set<string> | null => {
if (!isGridLayout(view)) {
return null;
}
const cameraIDs = cameraManager.getStore().getCameraIDsWithCapability('live');
return cameraIDs.size > 1 ? cameraIDs : null;
};
// The cameras a viewer grid lays out, one cell each, or null when the viewer is
// laid out as a single carousel instead (incl. when there's <= 1 camera). The
// cameras come from the query results, so only a camera with media to show gets
// a cell.
export const getViewerGridCameraIDs = (view: View): Set<string> | null => {
if (!isGridLayout(view)) {
return null;
}
const cameraIDs = view.queryResults?.getCameraIDs();
return cameraIDs && cameraIDs.size > 1 ? cameraIDs : null;
};
// The target shown in each cell of a grid, or null when the view is not laid
// out as a grid.
const getGridTargetIDs = (
view: View,
cameraManager: CameraManager,
): Set<string> | null => {
if (view.is('live')) {
// A live cell shows its camera, so the camera is the target.
return getLiveGridCameraIDs(view, cameraManager);
}
if (view.isViewerView()) {
const cameraIDs = getViewerGridCameraIDs(view);
if (!cameraIDs) {
return null;
}
// A viewer cell shows one media item belonging to its camera.
const targetIDs = new Set<string>();
for (const cameraID of cameraIDs) {
const targetID = view.queryResults?.getSelectedResult(cameraID)?.getID();
if (targetID) {
targetIDs.add(targetID);
}
}
return targetIDs;
}
return null;
};
// Every target the current view lays out, one per cell. A grid shows several
// targets at once, so all of them count even when some are scrolled out of the
// viewport: the user chose to put them on screen. Every other layout shows a
// single target at a time, which is the one the view identifies.
export const getDisplayedTargetIDs = (
view: View,
cameraManager: CameraManager,
): Set<string> => {
const gridTargetIDs = getGridTargetIDs(view, cameraManager);
if (gridTargetIDs) {
return gridTargetIDs;
}
const targetID = getViewTargetID(view);
return targetID ? new Set([targetID]) : new Set();
};
+1 -1
View File
@@ -70,7 +70,7 @@ const isGalleryViewName = (view?: AdvancedCameraCardView): boolean =>
const isAnyFolderViewName = (view?: AdvancedCameraCardView): boolean => const isAnyFolderViewName = (view?: AdvancedCameraCardView): boolean =>
!!view && FOLDER_VIEW_NAMES.includes(view); !!view && FOLDER_VIEW_NAMES.includes(view);
export const isAnyMediaViewName = (view?: AdvancedCameraCardView): boolean => const isAnyMediaViewName = (view?: AdvancedCameraCardView): boolean =>
isViewerViewName(view) || view === 'live' || view === 'image'; isViewerViewName(view) || view === 'live' || view === 'image';
export class View { export class View {
+22 -1
View File
@@ -607,10 +607,31 @@ export class MountedCard {
control.click(); control.click();
} }
/**
* Click the control that steps a carousel one item along.
*
* These carry the name of whatever they move to rather than a name of their
* own, which several other controls also carry, so they are reached by the
* side they sit on instead. Which side moves forward depends on the reading
* direction of the page, exactly as it does for the user.
*/
public async clickNextPreviousControl(side: 'left' | 'right'): Promise<void> {
const control = await this.waitForRender(
() => deepQuery<HTMLElement>(this.card, `ha-icon-button.controls.${side}`),
`the ${side} carousel control`,
);
await clickElement(control);
}
private async _findControl(name: string): Promise<HTMLElement> { private async _findControl(name: string): Promise<HTMLElement> {
return await this.waitForRender(() => { return await this.waitForRender(() => {
const found = deepQueryAll(this.card, '*').find( const found = deepQueryAll(this.card, '*').find(
(element) => getControlName(element) === name, (element) =>
getControlName(element) === name &&
// A control the user can press occupies space. An element that only
// wraps one can carry the same name while having no box of its own.
!!element.getBoundingClientRect().width,
); );
return found instanceof HTMLElement ? found : null; return found instanceof HTMLElement ? found : null;
}, `a control named ${name}`); }, `a control named ${name}`);
+2 -2
View File
@@ -90,7 +90,7 @@ export const createUnansweredMediaURL = (): string => createMediaURL([]);
*/ */
export const createStallingMediaURL = (): string => createMediaURL([HTTP_OK]); export const createStallingMediaURL = (): string => createMediaURL([HTTP_OK]);
export interface StillCameraHASSOptions { export interface CameraHASSOptions {
// Camera entities beyond the default one. // Camera entities beyond the default one.
cameras?: string[]; cameras?: string[];
@@ -105,7 +105,7 @@ export interface StillCameraHASSOptions {
* A Home Assistant holding the cameras a card is about to be given, which is * A Home Assistant holding the cameras a card is about to be given, which is
* the minimum any browser test needs before it can mount anything. * the minimum any browser test needs before it can mount anything.
*/ */
export const createStillCameraHASS = (options?: StillCameraHASSOptions): FakeHASS => { export const createCameraHASS = (options?: CameraHASSOptions): FakeHASS => {
const cameras = [STILL_CAMERA_ENTITY, ...(options?.cameras ?? [])]; const cameras = [STILL_CAMERA_ENTITY, ...(options?.cameras ?? [])];
return new FakeHASS({ return new FakeHASS({
+3 -3
View File
@@ -3,7 +3,7 @@ import { describe, expect, it, vi } from 'vitest';
import type { RawAdvancedCameraCardConfig } from '../../src/config/types'; import type { RawAdvancedCameraCardConfig } from '../../src/config/types';
import { MountedCardFactory, type MountedCard } from '../browser/mounted-card'; import { MountedCardFactory, type MountedCard } from '../browser/mounted-card';
import { import {
createStillCameraHASS, createCameraHASS,
createStillImageCameraConfig, createStillImageCameraConfig,
createStillImageCardConfig, createStillImageCardConfig,
getBlockNotificationText, getBlockNotificationText,
@@ -43,7 +43,7 @@ describe('CameraManager', () => {
], ],
view: { issues: { retry_seconds: 0 } }, view: { issues: { retry_seconds: 0 } },
}), }),
createStillCameraHASS({ createCameraHASS({
cameras: [TRIGGERING_CAMERA_ENTITY, OTHER_TRIGGERING_CAMERA_ENTITY], cameras: [TRIGGERING_CAMERA_ENTITY, OTHER_TRIGGERING_CAMERA_ENTITY],
}), }),
); );
@@ -64,7 +64,7 @@ describe('CameraManager', () => {
createStillImageCardConfig({ createStillImageCardConfig({
cameras: [createSubscribingCameraConfig(TRIGGERING_CAMERA_ENTITY)], cameras: [createSubscribingCameraConfig(TRIGGERING_CAMERA_ENTITY)],
}), }),
createStillCameraHASS({ cameras: [TRIGGERING_CAMERA_ENTITY] }), createCameraHASS({ cameras: [TRIGGERING_CAMERA_ENTITY] }),
); );
await vi.waitFor(() => expect(card.getOpenEventSubscriptionCount()).toBe(1)); await vi.waitFor(() => expect(card.getOpenEventSubscriptionCount()).toBe(1));
@@ -1,15 +1,12 @@
import { describe, expect, it } from 'vitest'; import { describe, expect, it } from 'vitest';
import { MountedCardFactory, type MountedCard } from '../browser/mounted-card'; import { MountedCardFactory, type MountedCard } from '../browser/mounted-card';
import { import { createCameraHASS, createStillImageCardConfig } from '../browser/test-utils';
createStillCameraHASS,
createStillImageCardConfig,
} from '../browser/test-utils';
const TRIGGER_ENTITY = 'input_boolean.zoom'; const TRIGGER_ENTITY = 'input_boolean.zoom';
const mount = async (): Promise<MountedCard> => { const mount = async (): Promise<MountedCard> => {
const hass = createStillCameraHASS({ entities: { [TRIGGER_ENTITY]: 'off' } }); const hass = createCameraHASS({ entities: { [TRIGGER_ENTITY]: 'off' } });
return await MountedCardFactory.createFromSource( return await MountedCardFactory.createFromSource(
createStillImageCardConfig({ createStillImageCardConfig({
automations: [ automations: [
@@ -4,8 +4,8 @@ import { createLogAction } from '../../src/utils/action';
import { MountedCardFactory, type MountedCard } from '../browser/mounted-card'; import { MountedCardFactory, type MountedCard } from '../browser/mounted-card';
import { import {
CARD_INITIALIZED_MESSAGE, CARD_INITIALIZED_MESSAGE,
createCameraHASS,
createInitializedAutomation, createInitializedAutomation,
createStillCameraHASS,
createStillImageCardConfig, createStillImageCardConfig,
deepQueryAll, deepQueryAll,
getFocusedElement, getFocusedElement,
@@ -28,7 +28,7 @@ const mountCard = async (): Promise<MountedCard> => {
}, },
], ],
}), }),
createStillCameraHASS(), createCameraHASS(),
); );
await card.events.waitForFirst('advanced-camera-card:media:loaded'); await card.events.waitForFirst('advanced-camera-card:media:loaded');
@@ -4,8 +4,8 @@ import type { RawAdvancedCameraCardConfig } from '../../../src/config/types';
import { MountedCardFactory, type MountedCard } from '../../browser/mounted-card'; import { MountedCardFactory, type MountedCard } from '../../browser/mounted-card';
import { import {
CARD_INITIALIZED_MESSAGE, CARD_INITIALIZED_MESSAGE,
createCameraHASS,
createInitializedAutomation, createInitializedAutomation,
createStillCameraHASS,
createStillImageCameraConfig, createStillImageCameraConfig,
createStillImageCardConfig, createStillImageCardConfig,
isMediaLoadedInfoEventDetail, isMediaLoadedInfoEventDetail,
@@ -26,7 +26,7 @@ const mount = async (
): Promise<MountedCard> => ): Promise<MountedCard> =>
await MountedCardFactory.createFromSource( await MountedCardFactory.createFromSource(
createConfig(overrides), createConfig(overrides),
createStillCameraHASS({ cameras: [OTHER_CAMERA_ENTITY] }), createCameraHASS({ cameras: [OTHER_CAMERA_ENTITY] }),
); );
// The cameras the card has actually loaded media for, in order. Media that // The cameras the card has actually loaded media for, in order. Media that
+33 -6
View File
@@ -5,6 +5,7 @@ import { createIssueManager } from '../../../src/card-controller/issues/factory'
import { IssueManager } from '../../../src/card-controller/issues/issue-manager'; import { IssueManager } from '../../../src/card-controller/issues/issue-manager';
import { ConditionStateManager } from '../../../src/condition-trigger/conditions/state-manager'; import { ConditionStateManager } from '../../../src/condition-trigger/conditions/state-manager';
import { createCardAPI } from '../../test-utils'; import { createCardAPI } from '../../test-utils';
import { createView } from '../../view/test-utils';
import { createSubscriptionHealth } from '../test-utils'; import { createSubscriptionHealth } from '../test-utils';
describe('createIssueManager', () => { describe('createIssueManager', () => {
@@ -77,23 +78,49 @@ describe('createIssueManager', () => {
]); ]);
}); });
it('should wire changeCallback so timer-based issues activate via evaluate', () => { it('should wire changeCallback so an issue can ask for a re-evaluation', () => {
const api = createCardAPI();
vi.mocked(api.getConditionStateManager).mockReturnValue(new ConditionStateManager());
const health = createSubscriptionHealth();
const manager = createIssueManager(api, health);
vi.mocked(api.getCardElementManager().update).mockClear();
// A subscription starts failing. Nothing has asked the IssueManager to
// re-evaluate, so it does not know yet.
health.getFailures.mockReturnValue([{ key: 'camera-1', error: 'failed' }]);
expect(api.getCardElementManager().update).not.toHaveBeenCalled();
// The callback EventSubscriptionIssue registered is what asks it to
// re-evaluate.
const changeCallback = health.addListener.mock.calls[0][0];
changeCallback();
expect(manager.getStateManager().getIssuePresence().has('event_subscription')).toBe(
true,
);
expect(api.getCardElementManager().update).toHaveBeenCalled();
});
it('should report a media failure raised by a component', () => {
const api = createCardAPI(); const api = createCardAPI();
const stateManager = new ConditionStateManager(); const stateManager = new ConditionStateManager();
vi.mocked(api.getConditionStateManager).mockReturnValue(stateManager); vi.mocked(api.getConditionStateManager).mockReturnValue(stateManager);
vi.mocked(api.getViewManager().getView).mockReturnValue(
createView({ view: 'live', camera: 'camera-1' }),
);
const manager = createIssueManager(api, createSubscriptionHealth()); const manager = createIssueManager(api, createSubscriptionHealth());
// Setting view starts the media_unavailable timer (via the condition state
// listener → evaluate → detectDynamic).
stateManager.setState({ targetID: 'camera-1', view: 'live' }); stateManager.setState({ targetID: 'camera-1', view: 'live' });
expect(manager.getStateManager().getIssuePresence().has('media_unavailable')).toBe( expect(manager.getStateManager().getIssuePresence().has('media_unavailable')).toBe(
false, false,
); );
// After the timeout, the changeCallback fires evaluate which manager.trigger('media_unavailable', {
// updates the card element. targetID: 'camera-1',
vi.advanceTimersByTime(10000); reason: 'not_loading',
});
expect(manager.getStateManager().getIssuePresence().has('media_unavailable')).toBe( expect(manager.getStateManager().getIssuePresence().has('media_unavailable')).toBe(
true, true,
@@ -24,6 +24,7 @@ import {
createMediaLoadedInfo, createMediaLoadedInfo,
flushPromises, flushPromises,
} from '../../test-utils'; } from '../../test-utils';
import { createView } from '../../view/test-utils';
const DEFAULT_RETRY_SECONDS = 1; const DEFAULT_RETRY_SECONDS = 1;
@@ -120,6 +121,9 @@ describe('IssueManager', () => {
const api = createCardAPI(); const api = createCardAPI();
const conditionStateManager = new ConditionStateManager(); const conditionStateManager = new ConditionStateManager();
vi.mocked(api.getConditionStateManager).mockReturnValue(conditionStateManager); vi.mocked(api.getConditionStateManager).mockReturnValue(conditionStateManager);
vi.mocked(api.getViewManager().getView).mockReturnValue(
createView({ view: 'live', camera: 'camera.office' }),
);
const manager = new IssueManager(api); const manager = new IssueManager(api);
const issue = new MediaUnavailableIssue(api); const issue = new MediaUnavailableIssue(api);
@@ -3,8 +3,8 @@ import { describe, expect, it, vi } from 'vitest';
import { MountedCardFactory, type MountedCard } from '../../../browser/mounted-card'; import { MountedCardFactory, type MountedCard } from '../../../browser/mounted-card';
import { import {
CARD_INITIALIZED_MESSAGE, CARD_INITIALIZED_MESSAGE,
createCameraHASS,
createInitializedAutomation, createInitializedAutomation,
createStillCameraHASS,
createStillImageCameraConfig, createStillImageCameraConfig,
createStillImageCardConfig, createStillImageCardConfig,
getBlockNotificationText, getBlockNotificationText,
@@ -40,7 +40,7 @@ const mountBrokenCard = async (): Promise<MountedCard> =>
view: { issues: { retry_seconds: 0 } }, view: { issues: { retry_seconds: 0 } },
automations: [createInitializedAutomation()], automations: [createInitializedAutomation()],
}), }),
createStillCameraHASS(), createCameraHASS(),
); );
describe('InitializationIssue', () => { describe('InitializationIssue', () => {
@@ -1,8 +1,8 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { afterEach, assert, beforeEach, describe, expect, it, vi } from 'vitest';
import { RETRY_EXPONENTIAL_BASE_SECONDS } from '../../../../src/card-controller/issues/issue-manager'; import { RETRY_EXPONENTIAL_BASE_SECONDS } from '../../../../src/card-controller/issues/issue-manager';
import { MEDIA_LOADING_TIMEOUT_SECONDS } from '../../../../src/card-controller/issues/issues/media-unavailable';
import { LIVENESS_ENTITY_UNAVAILABLE_GRACE_SECONDS } from '../../../../src/components-lib/live/liveness/detectors/entity-availability'; import { LIVENESS_ENTITY_UNAVAILABLE_GRACE_SECONDS } from '../../../../src/components-lib/live/liveness/detectors/entity-availability';
import { MEDIA_LOADING_TIMEOUT_SECONDS } from '../../../../src/components-lib/media-load-watchdog-controller';
import { FRAME_STALL_SECONDS } from '../../../../src/components-lib/media-player/frame-stall-watchdog'; import { FRAME_STALL_SECONDS } from '../../../../src/components-lib/media-player/frame-stall-watchdog';
import type { RawAdvancedCameraCardConfig } from '../../../../src/config/types'; import type { RawAdvancedCameraCardConfig } from '../../../../src/config/types';
import { import {
@@ -12,9 +12,9 @@ import {
} from '../../../browser/mounted-card'; } from '../../../browser/mounted-card';
import { useTestMedia } from '../../../browser/test-media'; import { useTestMedia } from '../../../browser/test-media';
import { import {
createCameraHASS,
createFailingMediaURL, createFailingMediaURL,
createStallingMediaURL, createStallingMediaURL,
createStillCameraHASS,
createStillImageCameraConfig, createStillImageCameraConfig,
createStillImageCardConfig, createStillImageCardConfig,
createTemporarilyFailingMediaURL, createTemporarilyFailingMediaURL,
@@ -24,10 +24,13 @@ import {
getBlockNotificationText, getBlockNotificationText,
isLiveMediaShowing, isLiveMediaShowing,
STILL_CAMERA_ENTITY, STILL_CAMERA_ENTITY,
type CameraHASSOptions,
} from '../../../browser/test-utils'; } from '../../../browser/test-utils';
const SECOND_CAMERA_ENTITY = 'camera.hallway'; const SECOND_CAMERA_ENTITY = 'camera.hallway';
const OVERRIDE_ENTITY = 'input_boolean.override';
const MEDIA_ISSUE_TITLE = 'Media unavailable'; const MEDIA_ISSUE_TITLE = 'Media unavailable';
// Holding this reaches the diagnostics view, the only view showing no media // Holding this reaches the diagnostics view, the only view showing no media
@@ -41,6 +44,17 @@ const findIssue = (card: MountedCard): Element | null =>
const isIssueReported = (card: MountedCard): boolean => !!findIssue(card); const isIssueReported = (card: MountedCard): boolean => !!findIssue(card);
// Resolve once `image` holds a picture that actually loaded. A failed load also
// marks the element complete, so the decoded size is what separates the two.
const waitForImageLoaded = async (image: HTMLImageElement): Promise<void> =>
await new Promise<void>((resolve) => {
if (image.complete && image.naturalWidth > 0) {
resolve();
} else {
image.addEventListener('load', () => resolve(), { once: true });
}
});
const waitForIssueReported = async (card: MountedCard): Promise<void> => { const waitForIssueReported = async (card: MountedCard): Promise<void> => {
await card.waitForRender( await card.waitForRender(
() => findIssue(card), () => findIssue(card),
@@ -48,9 +62,14 @@ const waitForIssueReported = async (card: MountedCard): Promise<void> => {
); );
}; };
interface MountCardOptions extends MountOptions { const waitForIssueCleared = async (card: MountedCard): Promise<void> => {
cameras?: string[]; await card.waitForRender(
} () => !findIssue(card) || null,
`the ${MEDIA_ISSUE_TITLE} issue being cleared`,
);
};
interface MountCardOptions extends MountOptions, CameraHASSOptions {}
/** /**
* Every test here needs the status bar rendered, since that is where an issue * Every test here needs the status bar rendered, since that is where an issue
@@ -60,11 +79,11 @@ const mountCard = async (
config?: Partial<RawAdvancedCameraCardConfig>, config?: Partial<RawAdvancedCameraCardConfig>,
options?: MountCardOptions, options?: MountCardOptions,
): Promise<MountedCard> => { ): Promise<MountedCard> => {
const { cameras, ...mountOptions } = options ?? {}; const { cameras, entities, language, ...mountOptions } = options ?? {};
return await MountedCardFactory.createFromSource( return await MountedCardFactory.createFromSource(
createStillImageCardConfig({ status_bar: { style: 'outside' }, ...config }), createStillImageCardConfig({ status_bar: { style: 'outside' }, ...config }),
createStillCameraHASS({ cameras }), createCameraHASS({ cameras, entities, language }),
mountOptions, mountOptions,
); );
}; };
@@ -77,17 +96,22 @@ const mountCardSingleCamera = async (): Promise<MountedCard> => {
return card; return card;
}; };
const mountCardDualCameras = async (): Promise<MountedCard> => { /**
const card = await mountCard( * A grid of the two standard cameras, which differ only in how they are
{ * configured to behave.
live: { display: { mode: 'grid' } }, */
cameras: [ const mountCardGrid = async (
createStillImageCameraConfig(), cameras: RawAdvancedCameraCardConfig[],
createStillImageCameraConfig(SECOND_CAMERA_ENTITY), options?: {
], config?: Partial<RawAdvancedCameraCardConfig>;
entities?: CameraHASSOptions['entities'];
}, },
): Promise<MountedCard> =>
await mountCard(
{ live: { display: { mode: 'grid' } }, cameras, ...options?.config },
{ {
cameras: [SECOND_CAMERA_ENTITY], cameras: [SECOND_CAMERA_ENTITY],
...(options?.entities && { entities: options.entities }),
// The grid observes both its own size and its cells', so a cell resize // The grid observes both its own size and its cells', so a cell resize
// can resize the host and vice versa. Chromium reports each round it has // can resize the host and vice versa. Chromium reports each round it has
@@ -100,6 +124,12 @@ const mountCardDualCameras = async (): Promise<MountedCard> => {
}, },
); );
const mountCardDualCameras = async (): Promise<MountedCard> => {
const card = await mountCardGrid([
createStillImageCameraConfig(),
createStillImageCameraConfig(SECOND_CAMERA_ENTITY),
]);
await card.events.waitForFirst('advanced-camera-card:media:loaded'); await card.events.waitForFirst('advanced-camera-card:media:loaded');
return card; return card;
@@ -182,6 +212,149 @@ describe('MediaUnavailableIssue', () => {
expect(isLiveMediaShowing(card.card)).toBe(true); expect(isLiveMediaShowing(card.card)).toBe(true);
}); });
it('should offer no scrollbar on a failed grid camera that is not selected', async () => {
const card = await mountCardDualCameras();
card.setEntityState(SECOND_CAMERA_ENTITY, 'unavailable');
await card.advanceSeconds(LIVENESS_ENTITY_UNAVAILABLE_GRACE_SECONDS);
const block = await card.waitForSelector('advanced-camera-card-notification-block');
// Unselected cells cannot usefully be scrolled, so a scrollbar is not offered.
const content = block.shadowRoot?.querySelector('.content');
assert(content);
expect(getComputedStyle(content).overflowY).toBe('hidden');
});
it('should trigger an issue for an unselected grid camera and recover it', async () => {
// Every camera in a grid is on screen, so any of them failing triggers an
// issue, not only the camera that happens to be selected.
// See: https://github.com/dermotduffy/advanced-camera-card/issues/2637
const card = await mountCardGrid([
createStillImageCameraConfig(),
createStillImageCameraConfig(
SECOND_CAMERA_ENTITY,
createTemporarilyFailingMediaURL(1),
),
]);
await card.events.waitForFirst('advanced-camera-card:issue:trigger');
await waitForIssueReported(card);
expect(getBlockNotificationText(card.card)).toContain(SECOND_CAMERA_ENTITY);
// Nothing here asks the card to try again: the retry runs on its own.
await card.advanceSeconds(RETRY_EXPONENTIAL_BASE_SECONDS);
await waitForIssueCleared(card);
expect(isLiveMediaShowing(card.card)).toBe(true);
});
it('should trigger an issue for a camera added to a grid by an override', async () => {
// Overrides don't recreate views. If an override changes the cameras,
// issues should be created for newly added cameras.
const card = await mountCardGrid([createStillImageCameraConfig()], {
entities: { [OVERRIDE_ENTITY]: 'off' },
config: {
overrides: [
{
conditions: [{ condition: 'state', entity: OVERRIDE_ENTITY, state: 'on' }],
set: {
cameras: [
createStillImageCameraConfig(),
createStillImageCameraConfig(
SECOND_CAMERA_ENTITY,
createFailingMediaURL(),
),
],
},
},
],
},
});
await card.events.waitForFirst('advanced-camera-card:media:loaded');
expect(isIssueReported(card)).toBe(false);
card.setEntityState(OVERRIDE_ENTITY, 'on');
await card.events.waitForFirst('advanced-camera-card:issue:trigger');
await waitForIssueReported(card);
expect(getBlockNotificationText(card.card)).toContain(SECOND_CAMERA_ENTITY);
});
it('should stay silent about a carousel camera that is not on screen', async () => {
// A carousel camera that has loaded and failed triggers no issue while it
// is off screen. Lazy loading is off so that it does load and fail, rather
// than the test passing because nothing was watching it.
const card = await mountCard(
{
live: { lazy_load: false },
cameras: [
createStillImageCameraConfig(),
createStillImageCameraConfig(SECOND_CAMERA_ENTITY),
],
},
{ cameras: [SECOND_CAMERA_ENTITY] },
);
await card.events.waitForFirst('advanced-camera-card:media:loaded');
card.setEntityState(SECOND_CAMERA_ENTITY, 'unavailable');
await card.advanceSeconds(LIVENESS_ENTITY_UNAVAILABLE_GRACE_SECONDS * 4);
expect(isIssueReported(card)).toBe(false);
// Step the carousel on to that same camera, which reports the failure it
// already had. The silence above was the scoping, not an absence of
// anything watching.
await card.clickNextPreviousControl('right');
await waitForIssueReported(card);
expect(getBlockNotificationText(card.card)).toContain(SECOND_CAMERA_ENTITY);
});
it('should keep an issue across a detach and re-attach', async () => {
const card = await mountCardDualCameras();
card.setEntityState(SECOND_CAMERA_ENTITY, 'unavailable');
await card.advanceSeconds(LIVENESS_ENTITY_UNAVAILABLE_GRACE_SECONDS);
await waitForIssueReported(card);
card.detach();
card.attach();
await card.updateComplete;
// The clock has not moved, so a failure found all over again could not have
// been reported yet. This is the issue from before the detach.
expect(isIssueReported(card)).toBe(true);
});
it('should trigger an issue for a grid camera that goes quiet without failing', async () => {
// No active errors, just an unanswered load on an unselected camera.
const card = await mountCardGrid([
createStillImageCameraConfig(),
createStillImageCameraConfig(SECOND_CAMERA_ENTITY, createUnansweredMediaURL()),
]);
// Nothing is waited on until the cell has a player asking for media.
await card.waitForSelector('advanced-camera-card-live-image');
await card.advanceSeconds(MEDIA_LOADING_TIMEOUT_SECONDS - 1);
expect(isIssueReported(card)).toBe(false);
await card.advanceSeconds(1);
await waitForIssueReported(card);
// The cell itself is still showing that it is waiting, so which camera has
// given up is only knowable from the issue.
await card.clickControl(MEDIA_ISSUE_TITLE);
const notification = await card.waitForSelector('advanced-camera-card-notification');
expect(notification.shadowRoot?.textContent).toContain(SECOND_CAMERA_ENTITY);
expect(notification.shadowRoot?.textContent).toContain('Media not loading');
});
it('should report a camera whose media fails to load', async () => { it('should report a camera whose media fails to load', async () => {
const card = await mountCard({ const card = await mountCard({
cameras: [ cameras: [
@@ -392,6 +565,44 @@ describe('MediaUnavailableIssue', () => {
expect(getBlockNotificationText(card.card)).toContain('Stream stalled'); expect(getBlockNotificationText(card.card)).toContain('Stream stalled');
}); });
it('should trigger an issue for a camera picture that falls back to a stock image', async () => {
// A camera-mode image that cannot be fetched swaps in a bundled stock
// picture, so something is always on screen. That substitute must not be
// announced as the camera's media arriving, or the failure it replaced
// would be reported as recovered.
const card = await MountedCardFactory.createFromSource(
createStillImageCardConfig({
status_bar: { style: 'outside' },
cameras: [
{
camera_entity: STILL_CAMERA_ENTITY,
live_provider: 'image',
image: { mode: 'camera' },
},
],
}),
createCameraHASS({
entities: {
[STILL_CAMERA_ENTITY]: {
state: 'idle',
attributes: { entity_picture: createFailingMediaURL() },
},
},
}),
);
const image = await card.waitForSelector<HTMLImageElement>('img');
await card.events.waitForFirst('advanced-camera-card:issue:trigger');
// The stock picture is being swapped into the same element. Wait for that
// load specifically: it is the one that could be mistaken for the camera's
// media arriving.
await waitForImageLoaded(image);
expect(isIssueReported(card)).toBe(true);
expect(card.events.getEntries('advanced-camera-card:media:loaded')).toHaveLength(0);
});
it('should report a player that reports a playback error', async () => { it('should report a player that reports a playback error', async () => {
const card = await mountCard({ const card = await mountCard({
// A provider given nothing to play. It reports that it failed without // A provider given nothing to play. It reports that it failed without
File diff suppressed because it is too large Load Diff
@@ -10,8 +10,8 @@ import {
import { import {
CARD_INITIALIZED_MESSAGE, CARD_INITIALIZED_MESSAGE,
clickElement, clickElement,
createCameraHASS,
createInitializedAutomation, createInitializedAutomation,
createStillCameraHASS,
createStillImageCardConfig, createStillImageCardConfig,
dispatchPointerDown, dispatchPointerDown,
getFocusedElement, getFocusedElement,
@@ -106,7 +106,7 @@ const mountCard = async (options?: MountCardOptions): Promise<MountedCard> => {
}, },
], ],
}), }),
createStillCameraHASS({ entities: { [ZOOM_ENTITY]: 'off' } }), createCameraHASS({ entities: { [ZOOM_ENTITY]: 'off' } }),
mountOptions, mountOptions,
); );
@@ -1,10 +1,7 @@
import { describe, expect, it } from 'vitest'; import { describe, expect, it } from 'vitest';
import { MountedCardFactory } from '../browser/mounted-card'; import { MountedCardFactory } from '../browser/mounted-card';
import { import { createCameraHASS, createStillImageCardConfig } from '../browser/test-utils';
createStillCameraHASS,
createStillImageCardConfig,
} from '../browser/test-utils';
// A colour the dark theme sets and the card carries no other way. Reading it // A colour the dark theme sets and the card carries no other way. Reading it
// back proves the stylesheet reached the card rather than merely compiling. // back proves the stylesheet reached the card rather than merely compiling.
@@ -14,7 +11,7 @@ const DARK_PRIMARY_BACKGROUND = '#111111';
const mountThemed = async (themes: string[]) => const mountThemed = async (themes: string[]) =>
await MountedCardFactory.createFromSource( await MountedCardFactory.createFromSource(
createStillImageCardConfig({ view: { theme: { themes } } }), createStillImageCardConfig({ view: { theme: { themes } } }),
createStillCameraHASS(), createCameraHASS(),
); );
describe('themes', () => { describe('themes', () => {
@@ -0,0 +1,455 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import type {
IssueResolveEventData,
IssueTriggerEventData,
} from '../../src/card-controller/issues/types';
import {
MEDIA_LOADING_TIMEOUT_SECONDS,
MediaLoadWatchdogController,
} from '../../src/components-lib/media-load-watchdog-controller';
import { createLitElement, createMediaLoadedInfo } from '../test-utils';
const TIMEOUT_MS = MEDIA_LOADING_TIMEOUT_SECONDS * 1000;
const createHarness = (options?: {
targetID?: string | null;
loadExpected?: boolean;
}) => {
const host = createLitElement();
let targetID = options?.targetID === undefined ? 'camera-1' : options.targetID;
let loadExpected = options?.loadExpected ?? true;
let attemptID = 0;
const triggerRequests: IssueTriggerEventData[] = [];
host.addEventListener('advanced-camera-card:issue:trigger', (ev: Event) => {
triggerRequests.push((ev as CustomEvent<IssueTriggerEventData>).detail);
});
const resolveRequests: IssueResolveEventData[] = [];
host.addEventListener('advanced-camera-card:issue:resolve', (ev: Event) => {
resolveRequests.push((ev as CustomEvent<IssueResolveEventData>).detail);
});
const controller = new MediaLoadWatchdogController(host, {
getTargetID: () => targetID,
isLoadExpected: () => loadExpected,
getAttemptID: () => attemptID,
});
return {
host,
controller,
triggerRequests,
resolveRequests,
setTargetID: (value: string | null): void => {
targetID = value;
},
setLoadExpected: (value: boolean): void => {
loadExpected = value;
},
retryMedia: (): void => {
attemptID++;
},
connect: (): void => controller.hostConnected(),
disconnect: (): void => controller.hostDisconnected(),
update: (): void => controller.hostUpdated(),
// Deliver a `media:loaded` as a descendant player would, returning the
// controller that makes that media go away.
mediaLoaded: (loadedTargetID: string): AbortController => {
const abort = new AbortController();
host.dispatchEvent(
new CustomEvent('advanced-camera-card:media:loaded', {
bubbles: true,
composed: true,
detail: {
info: createMediaLoadedInfo({ targetID: loadedTargetID }),
signal: abort.signal,
},
}),
);
return abort;
},
};
};
// @vitest-environment jsdom
describe('MediaLoadWatchdogController', () => {
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
});
it('should register itself with the host', () => {
const harness = createHarness();
expect(harness.host.addController).toHaveBeenCalledWith(harness.controller);
});
it('should report a load that never arrives', () => {
const harness = createHarness();
harness.connect();
vi.advanceTimersByTime(TIMEOUT_MS);
expect(harness.triggerRequests).toEqual([
{ key: 'media_unavailable', targetID: 'camera-1', reason: 'not_loading' },
]);
});
it('should not report a load that arrives in time', () => {
const harness = createHarness();
harness.connect();
vi.advanceTimersByTime(TIMEOUT_MS - 1);
harness.mediaLoaded('camera-1');
vi.advanceTimersByTime(TIMEOUT_MS);
expect(harness.triggerRequests).toEqual([]);
});
it('should report only once for a target that stays hung', () => {
const harness = createHarness();
harness.connect();
vi.advanceTimersByTime(TIMEOUT_MS);
harness.update();
vi.advanceTimersByTime(TIMEOUT_MS * 5);
expect(harness.triggerRequests).toHaveLength(1);
});
it('should not wait when no load is expected', () => {
const harness = createHarness({ loadExpected: false });
harness.connect();
vi.advanceTimersByTime(TIMEOUT_MS);
expect(harness.triggerRequests).toEqual([]);
});
it('should not wait without a target', () => {
const harness = createHarness({ targetID: null });
harness.connect();
vi.advanceTimersByTime(TIMEOUT_MS);
expect(harness.triggerRequests).toEqual([]);
});
it('should give a fresh window to a load that becomes expected again', () => {
const harness = createHarness({ loadExpected: false });
harness.connect();
vi.advanceTimersByTime(TIMEOUT_MS);
harness.setLoadExpected(true);
harness.update();
vi.advanceTimersByTime(TIMEOUT_MS - 1);
expect(harness.triggerRequests).toEqual([]);
vi.advanceTimersByTime(1);
expect(harness.triggerRequests).toHaveLength(1);
});
it('should not report once the host stops expecting a load', () => {
const harness = createHarness();
harness.connect();
vi.advanceTimersByTime(TIMEOUT_MS - 1);
harness.setLoadExpected(false);
vi.advanceTimersByTime(1);
expect(harness.triggerRequests).toEqual([]);
});
describe('clearing a reported failure', () => {
it('should clear the failure when media arrives', () => {
const harness = createHarness();
harness.connect();
vi.advanceTimersByTime(TIMEOUT_MS);
harness.mediaLoaded('camera-1');
expect(harness.resolveRequests).toEqual([
{ key: 'media_unavailable', targetID: 'camera-1', reason: 'not_loading' },
]);
});
it('should clear a failure it never reported itself', () => {
const harness = createHarness();
harness.connect();
// Another component can report the same kind of failure for this target.
harness.mediaLoaded('camera-1');
expect(harness.resolveRequests).toEqual([
{ key: 'media_unavailable', targetID: 'camera-1', reason: 'not_loading' },
]);
});
it('should not clear anything for a load belonging to another target', () => {
const harness = createHarness();
harness.connect();
harness.mediaLoaded('camera-2');
expect(harness.resolveRequests).toEqual([]);
});
});
describe('media going away', () => {
it('should wait again when loaded media goes away', () => {
const harness = createHarness();
harness.connect();
const abort = harness.mediaLoaded('camera-1');
vi.advanceTimersByTime(TIMEOUT_MS * 2);
expect(harness.triggerRequests).toEqual([]);
abort.abort();
vi.advanceTimersByTime(TIMEOUT_MS);
expect(harness.triggerRequests).toHaveLength(1);
});
it('should ignore an older load going away after a newer one', () => {
const harness = createHarness();
harness.connect();
const stale = harness.mediaLoaded('camera-1');
harness.mediaLoaded('camera-1');
stale.abort();
vi.advanceTimersByTime(TIMEOUT_MS);
expect(harness.triggerRequests).toEqual([]);
});
it('should wait again when media went away while the host was disconnected', () => {
const harness = createHarness();
harness.connect();
const abort = harness.mediaLoaded('camera-1');
harness.disconnect();
abort.abort();
// A detached host is not waited on, so nothing is reported yet.
vi.advanceTimersByTime(TIMEOUT_MS);
expect(harness.triggerRequests).toEqual([]);
// The media that went away is gone on return, so the wait resumes rather
// than the host being taken to still have it.
harness.connect();
vi.advanceTimersByTime(TIMEOUT_MS);
expect(harness.triggerRequests).toEqual([
{ key: 'media_unavailable', targetID: 'camera-1', reason: 'not_loading' },
]);
});
});
describe('a host reused for another target', () => {
it('should not treat a load for another target as its own', () => {
const harness = createHarness();
harness.connect();
harness.mediaLoaded('camera-2');
vi.advanceTimersByTime(TIMEOUT_MS);
expect(harness.triggerRequests).toEqual([
{ key: 'media_unavailable', targetID: 'camera-1', reason: 'not_loading' },
]);
});
it('should not carry a previous load over to the next target', () => {
const harness = createHarness();
harness.connect();
harness.mediaLoaded('camera-1');
harness.setTargetID('camera-2');
harness.update();
vi.advanceTimersByTime(TIMEOUT_MS);
expect(harness.triggerRequests).toEqual([
{ key: 'media_unavailable', targetID: 'camera-2', reason: 'not_loading' },
]);
});
it('should give the next target a full window rather than what remained', () => {
const harness = createHarness();
harness.connect();
vi.advanceTimersByTime(TIMEOUT_MS - 1);
harness.setTargetID('camera-2');
harness.update();
vi.advanceTimersByTime(TIMEOUT_MS - 1);
expect(harness.triggerRequests).toEqual([]);
vi.advanceTimersByTime(1);
expect(harness.triggerRequests).toEqual([
{ key: 'media_unavailable', targetID: 'camera-2', reason: 'not_loading' },
]);
});
it('should report the next target after the previous one was reported', () => {
const harness = createHarness();
harness.connect();
vi.advanceTimersByTime(TIMEOUT_MS);
harness.setTargetID('camera-2');
harness.update();
vi.advanceTimersByTime(TIMEOUT_MS);
expect(harness.triggerRequests).toEqual([
{ key: 'media_unavailable', targetID: 'camera-1', reason: 'not_loading' },
{ key: 'media_unavailable', targetID: 'camera-2', reason: 'not_loading' },
]);
});
it('should resolve a failure it reported for the previous target', () => {
const harness = createHarness();
harness.connect();
vi.advanceTimersByTime(TIMEOUT_MS);
harness.setTargetID('camera-2');
harness.update();
// Nothing watches camera-1 now, so nothing could ever say it recovered.
expect(harness.resolveRequests).toEqual([
{ key: 'media_unavailable', targetID: 'camera-1', reason: 'not_loading' },
]);
});
it('should resolve a failure when the next target loads immediately', () => {
const harness = createHarness();
harness.connect();
vi.advanceTimersByTime(TIMEOUT_MS);
harness.setTargetID('camera-2');
harness.mediaLoaded('camera-2');
expect(harness.resolveRequests).toEqual([
{ key: 'media_unavailable', targetID: 'camera-1', reason: 'not_loading' },
{ key: 'media_unavailable', targetID: 'camera-2', reason: 'not_loading' },
]);
});
it('should resolve nothing when it reported no failure', () => {
const harness = createHarness();
harness.connect();
harness.setTargetID('camera-2');
harness.update();
expect(harness.resolveRequests).toEqual([]);
});
});
describe('connection', () => {
it('should not wait while disconnected', () => {
const harness = createHarness();
harness.connect();
vi.advanceTimersByTime(TIMEOUT_MS - 1);
harness.disconnect();
vi.advanceTimersByTime(TIMEOUT_MS);
expect(harness.triggerRequests).toEqual([]);
});
it('should give a fresh window on reconnect', () => {
const harness = createHarness();
harness.connect();
harness.disconnect();
harness.connect();
vi.advanceTimersByTime(TIMEOUT_MS - 1);
expect(harness.triggerRequests).toEqual([]);
vi.advanceTimersByTime(1);
expect(harness.triggerRequests).toHaveLength(1);
});
it('should ignore a host update while disconnected', () => {
const harness = createHarness();
harness.update();
vi.advanceTimersByTime(TIMEOUT_MS);
expect(harness.triggerRequests).toEqual([]);
});
});
describe('a rebuilt attempt at the same target', () => {
it('should wait again after the media underneath is rebuilt', () => {
const harness = createHarness();
harness.connect();
harness.mediaLoaded('camera-1');
harness.retryMedia();
harness.update();
vi.advanceTimersByTime(TIMEOUT_MS);
expect(harness.triggerRequests).toHaveLength(1);
});
it('should report again after a retry of a target already reported', () => {
const harness = createHarness();
harness.connect();
vi.advanceTimersByTime(TIMEOUT_MS);
harness.retryMedia();
harness.update();
vi.advanceTimersByTime(TIMEOUT_MS);
expect(harness.triggerRequests).toHaveLength(2);
});
it('should not report an attempt the retry has already replaced', () => {
const harness = createHarness();
harness.connect();
// The retry lands while the window for the previous attempt is still
// maturing, and the host has not been updated yet.
vi.advanceTimersByTime(TIMEOUT_MS - 1);
harness.retryMedia();
vi.advanceTimersByTime(1);
expect(harness.triggerRequests).toEqual([]);
});
it('should keep a reported failure visible while the retry runs', () => {
const harness = createHarness();
harness.connect();
vi.advanceTimersByTime(TIMEOUT_MS);
harness.retryMedia();
harness.update();
// The target is unchanged, so the failure still describes it until the
// retried media either arrives or hangs in its turn.
expect(harness.resolveRequests).toEqual([]);
});
it('should give the rebuilt attempt a full window', () => {
const harness = createHarness();
harness.connect();
vi.advanceTimersByTime(TIMEOUT_MS - 1);
harness.retryMedia();
harness.update();
vi.advanceTimersByTime(TIMEOUT_MS - 1);
expect(harness.triggerRequests).toEqual([]);
vi.advanceTimersByTime(1);
expect(harness.triggerRequests).toHaveLength(1);
});
});
});
@@ -3,7 +3,7 @@ import { describe, expect, it } from 'vitest';
import type { MediaLoadedInfoEventDetail } from '../../src/types'; import type { MediaLoadedInfoEventDetail } from '../../src/types';
import { MountedCardFactory, type MountedCard } from '../browser/mounted-card'; import { MountedCardFactory, type MountedCard } from '../browser/mounted-card';
import { import {
createStillCameraHASS, createCameraHASS,
createStillImageCardConfig, createStillImageCardConfig,
isMediaLoadedInfoEventDetail, isMediaLoadedInfoEventDetail,
STILL_CAMERA_ENTITY, STILL_CAMERA_ENTITY,
@@ -22,7 +22,7 @@ interface RenderedElement extends Element {
} }
const mount = async (): Promise<MountedCard> => { const mount = async (): Promise<MountedCard> => {
const hass = createStillCameraHASS({ entities: { [UNRELATED_ENTITY]: 'off' } }); const hass = createCameraHASS({ entities: { [UNRELATED_ENTITY]: 'off' } });
return await MountedCardFactory.createFromSource(createStillImageCardConfig(), hass); return await MountedCardFactory.createFromSource(createStillImageCardConfig(), hass);
}; };
+3 -3
View File
@@ -11,7 +11,7 @@ import {
type MountOptions, type MountOptions,
} from '../browser/mounted-card'; } from '../browser/mounted-card';
import { import {
createStillCameraHASS, createCameraHASS,
createStillImageCardConfig, createStillImageCardConfig,
isLiveMediaShowing, isLiveMediaShowing,
} from '../browser/test-utils'; } from '../browser/test-utils';
@@ -99,7 +99,7 @@ class BuildMountedCardFactory extends MountedCardFactory {
* rather than from `src/`. * rather than from `src/`.
*/ */
const mountBuiltCard = async ( const mountBuiltCard = async (
hass: FakeHASS = createStillCameraHASS(), hass: FakeHASS = createCameraHASS(),
): Promise<MountedCard> => ): Promise<MountedCard> =>
await BuildMountedCardFactory.createFromBuild( await BuildMountedCardFactory.createFromBuild(
`/${PUBLIC_ENTRY}?hacstag=${HACSTAG}`, `/${PUBLIC_ENTRY}?hacstag=${HACSTAG}`,
@@ -243,7 +243,7 @@ describe('the built card', () => {
// Clear resource timings to only measure the impact of mounting the card. // Clear resource timings to only measure the impact of mounting the card.
performance.clearResourceTimings(); performance.clearResourceTimings();
const mounted = await mountBuiltCard(createStillCameraHASS({ language: 'de' })); const mounted = await mountBuiltCard(createCameraHASS({ language: 'de' }));
await mounted.events.waitForFirst('advanced-camera-card:media:loaded'); await mounted.events.waitForFirst('advanced-camera-card:media:loaded');
expect(getLanguageChunks()).toEqual([expect.stringMatching(/^lang-de-/)]); expect(getLanguageChunks()).toEqual([expect.stringMatching(/^lang-de-/)]);
+191
View File
@@ -0,0 +1,191 @@
import { describe, expect, it } from 'vitest';
import { mock } from 'vitest-mock-extended';
import type { CameraManager } from '../../src/camera-manager/manager';
import type { CameraManagerReadOnlyConfigStore } from '../../src/camera-manager/store';
import {
getDisplayedTargetIDs,
getLiveGridCameraIDs,
getViewerGridCameraIDs,
} from '../../src/view/layout';
import { QueryResults } from '../../src/view/query-results';
import { IMAGE_VIEW_TARGET_ID_SENTINEL } from '../../src/view/target-id';
import { createView, generateViewMediaArray } from './test-utils';
// A camera manager whose store reports the given live-capable cameras.
const createCameraManager = (cameraIDs: string[]): CameraManager => {
const cameraManager = mock<CameraManager>();
const store = mock<CameraManagerReadOnlyConfigStore>();
store.getCameraIDsWithCapability.mockReturnValue(new Set(cameraIDs));
cameraManager.getStore.mockReturnValue(store);
return cameraManager;
};
// Query results spanning `cameraIDs`, each camera with its own media.
const createQueryResults = (cameraIDs: string[]): QueryResults =>
new QueryResults({
results: generateViewMediaArray({ cameraIDs, count: 1 }),
selectedIndex: 0,
});
describe('getLiveGridCameraIDs', () => {
it('should return every live camera when laid out as a grid', () => {
const view = createView({ view: 'live', displayMode: 'grid' });
expect(
getLiveGridCameraIDs(view, createCameraManager(['kitchen', 'office'])),
).toEqual(new Set(['kitchen', 'office']));
});
it('should return null when not laid out as a grid', () => {
const view = createView({ view: 'live', displayMode: 'single' });
expect(
getLiveGridCameraIDs(view, createCameraManager(['kitchen', 'office'])),
).toBeNull();
});
it('should return null for a view that inherited a grid it cannot use', () => {
const view = createView({ view: 'image', displayMode: 'grid' });
expect(
getLiveGridCameraIDs(view, createCameraManager(['kitchen', 'office'])),
).toBeNull();
});
});
describe('getViewerGridCameraIDs', () => {
it('should return every camera with media when laid out as a grid', () => {
const view = createView({
view: 'media',
displayMode: 'grid',
queryResults: createQueryResults(['kitchen', 'office']),
});
expect(getViewerGridCameraIDs(view)).toEqual(new Set(['kitchen', 'office']));
});
it('should return null when not laid out as a grid', () => {
const view = createView({
view: 'media',
displayMode: 'single',
queryResults: createQueryResults(['kitchen', 'office']),
});
expect(getViewerGridCameraIDs(view)).toBeNull();
});
it('should return null for a view that inherited a grid it cannot use', () => {
const view = createView({
view: 'image',
displayMode: 'grid',
queryResults: createQueryResults(['kitchen', 'office']),
});
expect(getViewerGridCameraIDs(view)).toBeNull();
});
});
describe('getDisplayedTargetIDs', () => {
it('should return every camera of a live grid', () => {
const view = createView({ view: 'live', displayMode: 'grid' });
expect(
getDisplayedTargetIDs(view, createCameraManager(['kitchen', 'office'])),
).toEqual(new Set(['kitchen', 'office']));
});
it('should return the selected camera of a live carousel', () => {
const view = createView({
view: 'live',
camera: 'kitchen',
displayMode: 'single',
});
expect(
getDisplayedTargetIDs(view, createCameraManager(['kitchen', 'office'])),
).toEqual(new Set(['kitchen']));
});
it('should return the selected media of every camera of a viewer grid', () => {
const view = createView({
view: 'media',
displayMode: 'grid',
queryResults: createQueryResults(['kitchen', 'office']),
});
expect(getDisplayedTargetIDs(view, createCameraManager([]))).toEqual(
new Set(['id-kitchen-0', 'id-office-0']),
);
});
it('should return the selected media of a viewer carousel', () => {
const view = createView({
view: 'media',
displayMode: 'single',
queryResults: createQueryResults(['kitchen', 'office']),
});
expect(getDisplayedTargetIDs(view, createCameraManager([]))).toEqual(
new Set(['id-kitchen-0']),
);
});
it('should skip a viewer grid camera without a selected media', () => {
const queryResults = createQueryResults(['kitchen', 'office']);
queryResults.resetSelectedResult('office');
const view = createView({
view: 'media',
displayMode: 'grid',
queryResults,
});
expect(getDisplayedTargetIDs(view, createCameraManager([]))).toEqual(
new Set(['id-kitchen-0']),
);
});
it('should return the lone camera of a live grid that needs no layout', () => {
const view = createView({
view: 'live',
camera: 'kitchen',
displayMode: 'grid',
});
expect(getDisplayedTargetIDs(view, createCameraManager(['kitchen']))).toEqual(
new Set(['kitchen']),
);
});
it('should return the lone media of a viewer grid that needs no layout', () => {
const view = createView({
view: 'media',
displayMode: 'grid',
queryResults: createQueryResults(['kitchen']),
});
expect(getDisplayedTargetIDs(view, createCameraManager([]))).toEqual(
new Set(['id-kitchen-0']),
);
});
it('should return nothing for a viewer grid without query results', () => {
const view = createView({ view: 'media', displayMode: 'grid' });
expect(getDisplayedTargetIDs(view, createCameraManager([]))).toEqual(new Set());
});
it('should return the sentinel for the image view', () => {
const view = createView({ view: 'image' });
expect(getDisplayedTargetIDs(view, createCameraManager([]))).toEqual(
new Set([IMAGE_VIEW_TARGET_ID_SENTINEL]),
);
});
it('should return nothing for a view that displays no media', () => {
const view = createView({ view: 'timeline' });
expect(getDisplayedTargetIDs(view, createCameraManager([]))).toEqual(new Set());
});
});