From 448fa2d9f18c006ed0c9d206ab03970ed1a8ea47 Mon Sep 17 00:00:00 2001 From: Dermot Duffy Date: Wed, 22 Jul 2026 20:45:49 -0700 Subject: [PATCH] feat: Surface the provider's error cause in the media_unavailable notification (#2599) - Closes: #2592 --- .../issues/issues/media-unavailable.ts | 63 +++++++++++++++---- .../live/liveness/detectors/provider-error.ts | 9 ++- .../liveness/stream-liveness-controller.ts | 21 ++++++- .../live/utils/dispatch-live-error.ts | 26 ++++---- src/components-lib/signed-url-controller.ts | 6 ++ src/components/image-updating-player.ts | 7 ++- .../providers/go2rtc-experimental/index.ts | 9 ++- src/components/live/providers/go2rtc/index.ts | 7 ++- src/components/live/providers/image.ts | 2 +- src/components/viewer/provider.ts | 15 +++-- src/patches/ha-camera-stream.ts | 39 ++++++------ src/patches/ha-hls-player.ts | 2 +- src/patches/ha-web-rtc-player.ts | 2 +- .../issues/issues/media-unavailable.test.ts | 54 ++++++++++++++++ .../liveness/detectors/provider-error.test.ts | 23 ++++--- .../stream-liveness-controller.test.ts | 30 ++++++++- .../live/utils/dispatch-live-error.test.ts | 15 +++-- .../signed-url-controller.test.ts | 15 ++++- 18 files changed, 255 insertions(+), 90 deletions(-) diff --git a/src/card-controller/issues/issues/media-unavailable.ts b/src/card-controller/issues/issues/media-unavailable.ts index f5002460..1b8507ba 100644 --- a/src/card-controller/issues/issues/media-unavailable.ts +++ b/src/card-controller/issues/issues/media-unavailable.ts @@ -28,10 +28,23 @@ export type MediaUnavailableIssueReason = declare module 'issue' { interface IssueTriggerContext { - media_unavailable: { targetID: string; reason: MediaUnavailableIssueReason }; + media_unavailable: { + targetID: string; + reason: MediaUnavailableIssueReason; + + // Free text naming the specific failure (e.g. the message a player + // reported), when the trigger source knew it. + description?: string; + }; } } +// What is known about one target's failure. +interface TargetError { + reason: MediaUnavailableIssueReason; + description?: string; +} + const MEDIA_LOADING_TIMEOUT_SECONDS = 10; // The per-cause presentation (localization key + icon), shared by the @@ -75,7 +88,7 @@ export class MediaUnavailableIssue implements Issue { public readonly key = 'media_unavailable' as const; private _issueActive = false; - private _erroredTargets = new Map(); + private _erroredTargets = new Map(); // Timer fires when a target has been loading too long without success. private _timer = new Timer(); @@ -107,7 +120,10 @@ export class MediaUnavailableIssue implements Issue { // ========================================================================= public trigger(context: IssueTriggerContext['media_unavailable']): void { - this._erroredTargets.set(context.targetID, context.reason); + this._erroredTargets.set(context.targetID, { + reason: context.reason, + description: context.description, + }); } // ========================================================================= @@ -168,11 +184,24 @@ export class MediaUnavailableIssue implements Issue { public getNotification(): Notification { const targets = new Map(this._erroredTargets); // The pending-load timer's target is a slow initial load that has not yet - // errored. - if (this._timerTargetID && !targets.has(this._timerTargetID)) { - targets.set(this._timerTargetID, 'not_loading'); + // 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 + // lines, which stay short enough to scan when several cameras fail at once. + const context = Array.from(targets) + .filter(([, error]) => error.description) + .map(([id, error]) => `${this._getTargetName(id)}: ${error.description}`); + return { heading: { text: localize('issues.media_unavailable.heading'), @@ -183,10 +212,11 @@ export class MediaUnavailableIssue implements Issue { text: localize('issues.media_unavailable.text'), }, ...(targets.size && { - metadata: Array.from(targets).map(([id, reason]) => - this._getTargetDetail(id, reason), + metadata: Array.from(targets).map(([id, error]) => + this._getTargetDetail(id, error.reason), ), }), + ...(context.length && { context }), link: { url: TROUBLESHOOTING_MEDIA_URL, title: localize('issues.troubleshooting_guide'), @@ -202,15 +232,22 @@ export class MediaUnavailableIssue implements Issue { reason: MediaUnavailableIssueReason, ): NotificationDetail { const isImage = id === IMAGE_VIEW_TARGET_ID_SENTINEL; - const name = isImage - ? localize('editor.image') - : this._api.getCameraManager().getCameraMetadata(id)?.title ?? id; return { - text: `${name}: ${localize(MEDIA_UNAVAILABLE_REASONS[reason].localizationKey)}`, + text: `${this._getTargetName(id)}: ${localize( + MEDIA_UNAVAILABLE_REASONS[reason].localizationKey, + )}`, icon: isImage ? 'mdi:image' : MEDIA_UNAVAILABLE_REASONS[reason].icon, }; } + // The user-facing name of a target: a camera's title, or the label for the + // image view (which may have no camera behind it). + private _getTargetName(id: string): string { + return id === IMAGE_VIEW_TARGET_ID_SENTINEL + ? localize('editor.image') + : this._api.getCameraManager().getCameraMetadata(id)?.title ?? id; + } + // ========================================================================= // Retry -- called by the manager to schedule a media reload. // ========================================================================= @@ -300,7 +337,7 @@ export class MediaUnavailableIssue implements Issue { this._timerTargetID = targetID; this._timer.start(MEDIA_LOADING_TIMEOUT_SECONDS, () => { // Record the error on timeout so retry() knows which epoch to bump. - this._erroredTargets.set(targetID, 'not_loading'); + this._erroredTargets.set(targetID, { reason: 'not_loading' }); this._activate(); this._onChange?.(); }); diff --git a/src/components-lib/live/liveness/detectors/provider-error.ts b/src/components-lib/live/liveness/detectors/provider-error.ts index 67d6e7b7..a3814c12 100644 --- a/src/components-lib/live/liveness/detectors/provider-error.ts +++ b/src/components-lib/live/liveness/detectors/provider-error.ts @@ -1,4 +1,4 @@ -import type { MediaUnavailableIssueReason } from '../../../../card-controller/issues/issues/media-unavailable'; +import type { LiveError } from '../../utils/dispatch-live-error'; import type { LivenessDetector, LivenessVerdict } from '../stream-liveness-controller'; const LIVE_ERROR_EVENT = 'advanced-camera-card:live:error'; @@ -39,9 +39,7 @@ export class ProviderErrorDetector implements LivenessDetector { return this._verdict; } - private _handler = ( - ev: CustomEvent, - ): void => { + private _handler = (ev: CustomEvent): void => { ev.stopPropagation(); if (this._verdict.state !== 'not_live') { // Authoritative: an explicit provider error overrides even direct frame @@ -50,7 +48,8 @@ export class ProviderErrorDetector implements LivenessDetector { this._verdict = { state: 'not_live', authority: 'hard', - reason: ev.detail ?? 'playback_error', + reason: ev.detail.reason ?? 'playback_error', + description: ev.detail.detail, }; this._onChange(); } diff --git a/src/components-lib/live/liveness/stream-liveness-controller.ts b/src/components-lib/live/liveness/stream-liveness-controller.ts index c6fc6eaa..d6327062 100644 --- a/src/components-lib/live/liveness/stream-liveness-controller.ts +++ b/src/components-lib/live/liveness/stream-liveness-controller.ts @@ -35,6 +35,10 @@ export type LivenessVerdict = authority: LivenessAuthority; reason: MediaUnavailableIssueReason; + // Free text naming the specific failure, when the detector's source knew + // it. Omitted when only the categorical reason is known. + description?: string; + // Whether the wrapper should replace the provider with a reconnecting // placeholder (a silent freeze, e.g. an unavailable camera). Omitted when // the provider renders its own error and should stay mounted. @@ -50,6 +54,9 @@ export type LivenessVerdict = interface StreamFailure { reason: MediaUnavailableIssueReason; + // Free text naming the specific failure, when known. + description?: string; + // Whether the wrapper should replace the provider with a reconnecting // placeholder (a silent freeze, e.g. an unavailable camera). False when the // provider renders its own error and should stay mounted. @@ -133,7 +140,11 @@ export class StreamLivenessController implements ReactiveController { public getFailure(): StreamFailure | null { const verdict = this._getVerdict(); return verdict.state === 'not_live' - ? { reason: verdict.reason, renderPlaceholder: !!verdict.renderPlaceholder } + ? { + reason: verdict.reason, + description: verdict.description, + renderPlaceholder: !!verdict.renderPlaceholder, + } : null; } @@ -170,14 +181,17 @@ export class StreamLivenessController implements ReactiveController { private _onDetectorChange(): void { const verdict = this._getVerdict(); if (verdict.state === 'not_live') { - this._triggerMediaUnavailableIssue(verdict.reason); + this._triggerMediaUnavailableIssue(verdict.reason, verdict.description); } this._host.requestUpdate(); } // Tell the issue framework this target's media is not loaded, surfacing the // media_unavailable issue (status bar + retry) and its throttled reload. - private _triggerMediaUnavailableIssue(reason: MediaUnavailableIssueReason): void { + private _triggerMediaUnavailableIssue( + reason: MediaUnavailableIssueReason, + description?: string, + ): void { const targetID = this._config.getTargetID(); if (!targetID) { return; @@ -186,6 +200,7 @@ export class StreamLivenessController implements ReactiveController { key: 'media_unavailable', targetID, reason, + description, }); } } diff --git a/src/components-lib/live/utils/dispatch-live-error.ts b/src/components-lib/live/utils/dispatch-live-error.ts index 77108fd4..b0616588 100644 --- a/src/components-lib/live/utils/dispatch-live-error.ts +++ b/src/components-lib/live/utils/dispatch-live-error.ts @@ -1,20 +1,24 @@ import type { MediaUnavailableIssueReason } from '../../../card-controller/issues/issues/media-unavailable'; import { fireAdvancedCameraCardEvent } from '../../../utils/fire-advanced-camera-card-event'; +// What a provider knows about its own failure. +export interface LiveError { + // The cause, drawn from the fixed set the card can describe and illustrate. + // Absent when the provider cannot narrow it down, in which case the liveness + // detector falls back to a generic playback error. + reason?: MediaUnavailableIssueReason; + + // Free text naming the specific failure (e.g. "Failed to start WebRTC stream: + // ..."). Absent when the provider has none. + detail?: string; +} + declare global { interface HTMLElementEventMap { - 'advanced-camera-card:live:error': CustomEvent< - MediaUnavailableIssueReason | undefined - >; + 'advanced-camera-card:live:error': CustomEvent; } } -// The optional reason lets a provider that knows why it failed drive a specific -// media-unavailable message; absent, the liveness detector falls back to a -// generic playback error. -export function dispatchLiveErrorEvent( - element: EventTarget, - reason?: MediaUnavailableIssueReason, -): void { - fireAdvancedCameraCardEvent(element, 'live:error', reason); +export function dispatchLiveErrorEvent(element: EventTarget, error?: LiveError): void { + fireAdvancedCameraCardEvent(element, 'live:error', error ?? {}); } diff --git a/src/components-lib/signed-url-controller.ts b/src/components-lib/signed-url-controller.ts index ab273833..734ad17c 100644 --- a/src/components-lib/signed-url-controller.ts +++ b/src/components-lib/signed-url-controller.ts @@ -8,6 +8,7 @@ import { createProxiedEndpointIfNecessary, type CreateProxiedEndpointOptions, } from '../ha/web-proxy.js'; +import { localize } from '../localize/localize.js'; import type { Endpoint } from '../types.js'; import { errorToConsole } from '../utils/basic.js'; import { Generation } from '../utils/concurrency/generation.js'; @@ -30,6 +31,11 @@ interface SignedURLControllerOptions { type SignedURLErrorType = 'sign' | 'proxy'; +// The user-facing description of a signed-URL failure, so every surface that +// reports one names it identically. +export const getSignedURLErrorText = (error: SignedURLErrorType): string => + localize(error === 'proxy' ? 'error.failed_proxy' : 'error.failed_sign'); + export class SignedURLController implements ReactiveController { private _host: ReactiveControllerHost; private _getOptionsCallback: () => SignedURLControllerOptions; diff --git a/src/components/image-updating-player.ts b/src/components/image-updating-player.ts index 119bceb1..89ed82df 100644 --- a/src/components/image-updating-player.ts +++ b/src/components/image-updating-player.ts @@ -18,7 +18,10 @@ import { CachedValueController } from '../components-lib/cached-value-controller import { MediaLoadedInfoSourceController } from '../components-lib/media-loaded-info-source-controller.js'; import { ImageMediaPlayerController } from '../components-lib/media-player/image.js'; import { createMediaNotification } from '../components-lib/notification/media.js'; -import { SignedURLController } from '../components-lib/signed-url-controller.js'; +import { + getSignedURLErrorText, + SignedURLController, +} from '../components-lib/signed-url-controller.js'; import type { Notification } from '../config/schema/actions/types.js'; import type { CameraConfig } from '../config/schema/cameras.js'; import { type ImageBaseConfig, type ImageMode } from '../config/schema/common/image.js'; @@ -451,7 +454,7 @@ export class AdvancedCameraCardImageUpdatingPlayer const error = this._signedURLController.getError(); if (error) { return createMediaNotification({ - title: localize(error === 'proxy' ? 'error.failed_proxy' : 'error.failed_sign'), + title: getSignedURLErrorText(error), targetTitle: this.cameraTitle, }); } diff --git a/src/components/live/providers/go2rtc-experimental/index.ts b/src/components/live/providers/go2rtc-experimental/index.ts index bd6c4fa1..b21ac4f0 100644 --- a/src/components/live/providers/go2rtc-experimental/index.ts +++ b/src/components/live/providers/go2rtc-experimental/index.ts @@ -25,7 +25,10 @@ import { mapFailureReasonToIssueReason } from '../../../../components-lib/live/p import { dispatchLiveErrorEvent } from '../../../../components-lib/live/utils/dispatch-live-error.js'; import { MediaLoadedInfoSourceController } from '../../../../components-lib/media-loaded-info-source-controller.js'; import { VideoMediaPlayerController } from '../../../../components-lib/media-player/video.js'; -import { SignedURLController } from '../../../../components-lib/signed-url-controller.js'; +import { + getSignedURLErrorText, + SignedURLController, +} from '../../../../components-lib/signed-url-controller.js'; import type { MicrophoneConfig } from '../../../../config/schema/live.js'; import type { CardWideConfig } from '../../../../config/schema/types.js'; import type { HomeAssistant } from '../../../../ha/types.js'; @@ -162,7 +165,7 @@ export class AdvancedCameraCardGo2RTCExperimental // the error itself (below); the event drives the liveness verdict + retry. errorCallback: (reason) => { this._streamError = mapFailureReasonToIssueReason(reason); - dispatchLiveErrorEvent(this, this._streamError); + dispatchLiveErrorEvent(this, { reason: this._streamError }); }, }); @@ -243,7 +246,7 @@ export class AdvancedCameraCardGo2RTCExperimental const error = this._signedURLController.getError(); if (error) { return renderMediaNotification({ - title: localize(error === 'proxy' ? 'error.failed_proxy' : 'error.failed_sign'), + title: getSignedURLErrorText(error), targetTitle: this.cameraTitle, }); } diff --git a/src/components/live/providers/go2rtc/index.ts b/src/components/live/providers/go2rtc/index.ts index 9aa2d20a..228b4ace 100644 --- a/src/components/live/providers/go2rtc/index.ts +++ b/src/components/live/providers/go2rtc/index.ts @@ -11,7 +11,10 @@ import { customElement, property } from 'lit/decorators.js'; import type { Camera } from '../../../../camera-manager/camera.js'; import { dispatchLiveErrorEvent } from '../../../../components-lib/live/utils/dispatch-live-error.js'; import { VideoMediaPlayerController } from '../../../../components-lib/media-player/video.js'; -import { SignedURLController } from '../../../../components-lib/signed-url-controller.js'; +import { + getSignedURLErrorText, + SignedURLController, +} from '../../../../components-lib/signed-url-controller.js'; import type { MicrophoneConfig } from '../../../../config/schema/live.js'; import type { HomeAssistant } from '../../../../ha/types.js'; import { localize } from '../../../../localize/localize.js'; @@ -153,7 +156,7 @@ export class AdvancedCameraCardGo2RTC extends LitElement implements MediaPlayer const error = this._signedURLController.getError(); if (error) { return renderMediaNotification({ - title: localize(error === 'proxy' ? 'error.failed_proxy' : 'error.failed_sign'), + title: getSignedURLErrorText(error), targetTitle: this.cameraTitle, }); } diff --git a/src/components/live/providers/image.ts b/src/components/live/providers/image.ts index a5f2b7d1..7867e5df 100644 --- a/src/components/live/providers/image.ts +++ b/src/components/live/providers/image.ts @@ -61,7 +61,7 @@ export class AdvancedCameraCardLiveImage extends LitElement implements MediaPlay .proxyConfig=${this.camera?.getLiveProxyConfig()} @advanced-camera-card:image-updating-player:error=${( ev: CustomEvent, - ) => dispatchLiveErrorEvent(this, ev.detail)} + ) => dispatchLiveErrorEvent(this, { reason: ev.detail })} > `; diff --git a/src/components/viewer/provider.ts b/src/components/viewer/provider.ts index 22b01b6e..034ec88e 100644 --- a/src/components/viewer/provider.ts +++ b/src/components/viewer/provider.ts @@ -14,7 +14,10 @@ import type { CameraManager } from '../../camera-manager/manager.js'; import { QueryType } from '../../camera-manager/types.js'; import type { ViewManagerEpoch } from '../../card-controller/view/types.js'; import { LazyLoadController } from '../../components-lib/lazy-load-controller.js'; -import { SignedURLController } from '../../components-lib/signed-url-controller.js'; +import { + getSignedURLErrorText, + SignedURLController, +} from '../../components-lib/signed-url-controller.js'; import type { ZoomSettingsObserved } from '../../components-lib/zoom/types.js'; import { handleZoomSettingsObservedEvent } from '../../components-lib/zoom/zoom-view-context.js'; import type { CameraConfig } from '../../config/schema/cameras.js'; @@ -24,7 +27,6 @@ import { canonicalizeHAURL } from '../../ha/canonical-url.js'; import { isHARelativeURL } from '../../ha/is-ha-relative-url.js'; import { resolveMedia, type ResolvedMediaCache } from '../../ha/resolved-media.js'; import type { HomeAssistant, ResolvedMedia } from '../../ha/types.js'; -import { localize } from '../../localize/localize.js'; import '../../patches/ha-hls-player.js'; @@ -243,12 +245,9 @@ export class AdvancedCameraCardViewerProvider extends LitElement implements Medi const error = this._signedURLController.getError(); if (error) { const contentID = this.media?.getContentID(); - return renderNotificationBlockFromText( - localize(error === 'proxy' ? 'error.failed_proxy' : 'error.failed_sign'), - { - ...(contentID && { metadata: [{ text: contentID, icon: 'mdi:identifier' }] }), - }, - ); + return renderNotificationBlockFromText(getSignedURLErrorText(error), { + ...(contentID && { metadata: [{ text: contentID, icon: 'mdi:identifier' }] }), + }); } const url = this._signedURLController.getValue(); diff --git a/src/patches/ha-camera-stream.ts b/src/patches/ha-camera-stream.ts index 97db1e20..a9df66b2 100644 --- a/src/patches/ha-camera-stream.ts +++ b/src/patches/ha-camera-stream.ts @@ -19,9 +19,11 @@ import { } from 'lit'; import { customElement, property } from 'lit/decorators.js'; -import type { MediaUnavailableIssueReason } from '../card-controller/issues/issues/media-unavailable.js'; import { HA_CAMERA_STREAM_MUTE_CHANGE_EVENT } from '../components-lib/live/ha-stream-mute-controller.js'; -import { dispatchLiveErrorEvent } from '../components-lib/live/utils/dispatch-live-error.js'; +import { + dispatchLiveErrorEvent, + type LiveError, +} from '../components-lib/live/utils/dispatch-live-error.js'; import { MediaLoadedInfoSourceController } from '../components-lib/media-loaded-info-source-controller.js'; import '../components/image-player.js'; @@ -39,11 +41,11 @@ import './ha-hls-player.js'; import './ha-web-rtc-player.js'; // A failure reported by one of the inner players. Its existence is the failure; -// `reason` is present only when the player named a specific cause. `dispatched` -// records whether it has already been announced, so a stream that fails again -// after recovering is announced again. +// `error` carries whatever the player knew about it. `dispatched` records +// whether it has already been announced, so a stream that fails again after +// recovering is announced again. interface StreamError { - reason?: MediaUnavailableIssueReason; + error: LiveError; dispatched: boolean; } @@ -173,14 +175,11 @@ void customElements.whenDefined('ha-camera-stream').then(() => { this.requestUpdate(); } - private _captureInnerError( - stream: StreamType, - ev: CustomEvent, - ) { + private _captureInnerError(stream: StreamType, ev: CustomEvent) { // Stop the inner-player event at the aggregator boundary; it is // re-dispatched from updated() only if this stream is the visible one. ev.stopPropagation(); - this._errorPerStream[stream] = { reason: ev.detail, dispatched: false }; + this._errorPerStream[stream] = { error: ev.detail, dispatched: false }; this.requestUpdate(); } @@ -218,9 +217,8 @@ void customElements.whenDefined('ha-camera-stream').then(() => { @advanced-camera-card:media:loaded=${( ev: CustomEvent, ) => this._captureInnerLoad(STREAM_TYPE_HLS, ev)} - @advanced-camera-card:live:error=${( - ev: CustomEvent, - ) => this._captureInnerError(STREAM_TYPE_HLS, ev)} + @advanced-camera-card:live:error=${(ev: CustomEvent) => + this._captureInnerError(STREAM_TYPE_HLS, ev)} @streams=${this._handleHlsStreams} class="player ${stream.visible ? '' : 'hidden'}" >`; @@ -239,9 +237,8 @@ void customElements.whenDefined('ha-camera-stream').then(() => { @advanced-camera-card:media:loaded=${( ev: CustomEvent, ) => this._captureInnerLoad(STREAM_TYPE_WEB_RTC, ev)} - @advanced-camera-card:live:error=${( - ev: CustomEvent, - ) => this._captureInnerError(STREAM_TYPE_WEB_RTC, ev)} + @advanced-camera-card:live:error=${(ev: CustomEvent) => + this._captureInnerError(STREAM_TYPE_WEB_RTC, ev)} @streams=${this._handleWebRtcStreams} class="player ${stream.visible ? '' : 'hidden'}" >`; @@ -307,12 +304,12 @@ void customElements.whenDefined('ha-camera-stream').then(() => { // re-evaluated for this update. private _dispatchVisibleStreamError(): void { const stream = this._visibleStreamType; - const error = stream ? this._errorPerStream[stream] : null; - if (!error || error.dispatched) { + const streamError = stream ? this._errorPerStream[stream] : null; + if (!streamError || streamError.dispatched) { return; } - error.dispatched = true; - dispatchLiveErrorEvent(this, error.reason); + streamError.dispatched = true; + dispatchLiveErrorEvent(this, streamError.error); } static get styles(): CSSResultGroup { diff --git a/src/patches/ha-hls-player.ts b/src/patches/ha-hls-player.ts index d23d2620..be64c667 100644 --- a/src/patches/ha-hls-player.ts +++ b/src/patches/ha-hls-player.ts @@ -131,7 +131,7 @@ void customElements.whenDefined('ha-hls-player').then(() => { // and are only logged (see render()). const errored = !!this._error && this._errorIsFatal; if (errored && !this._lastErrored) { - dispatchLiveErrorEvent(this); + dispatchLiveErrorEvent(this, { detail: this._error }); } this._lastErrored = errored; } diff --git a/src/patches/ha-web-rtc-player.ts b/src/patches/ha-web-rtc-player.ts index 4b1bed07..5208211b 100644 --- a/src/patches/ha-web-rtc-player.ts +++ b/src/patches/ha-web-rtc-player.ts @@ -166,7 +166,7 @@ void customElements.whenDefined('ha-web-rtc-player').then(() => { // player can fail more than once and every failure must be reported. const errored = !!this._error; if (errored && !this._lastErrored) { - dispatchLiveErrorEvent(this); + dispatchLiveErrorEvent(this, { detail: this._error }); } this._lastErrored = errored; } diff --git a/tests/card-controller/issues/issues/media-unavailable.test.ts b/tests/card-controller/issues/issues/media-unavailable.test.ts index 285f522e..b0ed0f4d 100644 --- a/tests/card-controller/issues/issues/media-unavailable.test.ts +++ b/tests/card-controller/issues/issues/media-unavailable.test.ts @@ -431,6 +431,29 @@ describe('MediaUnavailableIssue', () => { ]); }); + it('should drop a stale pending-timer target once its timer has stopped', () => { + const issue = new MediaUnavailableIssue(createAPI()); + + // A slow load arms the pending timer for camera.garden. + issue.detectDynamic({ targetID: 'camera.garden', view: 'live' }); + + // The view moves to a different target that already has a hard error. + // That path activates immediately and stops the timer, but the stale + // _timerTargetID (camera.garden) lingers. + issue.trigger({ targetID: 'camera.office', reason: 'playback_error' }); + issue.detectDynamic({ targetID: 'camera.office', view: 'live' }); + + // Only the real error shows; the stale, no-longer-running pending target + // must not paint a "not loading" line. + const notification = issue.getNotification(); + expect(notification.metadata).not.toContainEqual( + expect.objectContaining({ text: 'camera.garden: Media not loading' }), + ); + expect(notification.metadata).toEqual([ + expect.objectContaining({ text: 'camera.office: Playback error' }), + ]); + }); + it('should use camera title when available', () => { const api = createAPI(); vi.mocked(api.getCameraManager().getCameraMetadata).mockReturnValue({ @@ -471,6 +494,37 @@ describe('MediaUnavailableIssue', () => { ]); }); + it('should render the free-text cause as context, keyed by camera title', () => { + const api = createAPI(); + vi.mocked(api.getCameraManager().getCameraMetadata).mockReturnValue({ + title: 'Office', + icon: { icon: 'mdi:cctv' }, + }); + const issue = new MediaUnavailableIssue(api); + issue.trigger({ + targetID: 'camera.office', + reason: 'playback_error', + description: 'Failed to start WebRTC stream: no candidates', + }); + + const notification = issue.getNotification(); + + // The metadata line stays scannable; the long cause sits below it. + expect(notification.metadata).toEqual([ + expect.objectContaining({ text: 'Office: Playback error' }), + ]); + expect(notification.context).toEqual([ + 'Office: Failed to start WebRTC stream: no candidates', + ]); + }); + + it('should omit context for targets without a free-text cause', () => { + const issue = new MediaUnavailableIssue(createAPI()); + issue.trigger({ targetID: 'camera.office', reason: 'stalled' }); + + expect(issue.getNotification().context).toBeUndefined(); + }); + it('should include a retry control with wired callback', async () => { const api = createCardAPI(); const issue = new MediaUnavailableIssue(api); diff --git a/tests/components-lib/live/liveness/detectors/provider-error.test.ts b/tests/components-lib/live/liveness/detectors/provider-error.test.ts index fe5e6ec3..7d001998 100644 --- a/tests/components-lib/live/liveness/detectors/provider-error.test.ts +++ b/tests/components-lib/live/liveness/detectors/provider-error.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it, vi } from 'vitest'; import { ProviderErrorDetector } from '../../../../../src/components-lib/live/liveness/detectors/provider-error'; +import { dispatchLiveErrorEvent } from '../../../../../src/components-lib/live/utils/dispatch-live-error'; const LIVE_ERROR_EVENT = 'advanced-camera-card:live:error'; @@ -24,7 +25,7 @@ describe('ProviderErrorDetector', () => { const detector = new ProviderErrorDetector(host, onChange); detector.subscribe(); - host.dispatchEvent(new Event(LIVE_ERROR_EVENT, { bubbles: true })); + dispatchLiveErrorEvent(host); // not_live but no renderPlaceholder: the provider renders its own error. expect(detector.getVerdict()).toEqual({ @@ -35,19 +36,21 @@ describe('ProviderErrorDetector', () => { expect(onChange).toHaveBeenCalledTimes(1); }); - it('should adopt the specific reason carried on the event', () => { + it('should adopt the specific reason and description carried on the event', () => { const host = createHostInDocument(); const detector = new ProviderErrorDetector(host, vi.fn()); detector.subscribe(); - host.dispatchEvent( - new CustomEvent(LIVE_ERROR_EVENT, { bubbles: true, detail: 'unsupported' }), - ); + dispatchLiveErrorEvent(host, { + reason: 'unsupported', + detail: 'Codec not supported', + }); expect(detector.getVerdict()).toEqual({ state: 'not_live', authority: 'hard', reason: 'unsupported', + description: 'Codec not supported', }); }); @@ -57,8 +60,8 @@ describe('ProviderErrorDetector', () => { const detector = new ProviderErrorDetector(host, onChange); detector.subscribe(); - host.dispatchEvent(new Event(LIVE_ERROR_EVENT, { bubbles: true })); - host.dispatchEvent(new Event(LIVE_ERROR_EVENT, { bubbles: true })); + dispatchLiveErrorEvent(host); + dispatchLiveErrorEvent(host); expect(onChange).toHaveBeenCalledTimes(1); }); @@ -70,7 +73,7 @@ describe('ProviderErrorDetector', () => { const detector = new ProviderErrorDetector(host, vi.fn()); detector.subscribe(); - host.dispatchEvent(new Event(LIVE_ERROR_EVENT, { bubbles: true })); + dispatchLiveErrorEvent(host); expect(parentListener).not.toHaveBeenCalled(); @@ -81,7 +84,7 @@ describe('ProviderErrorDetector', () => { const host = createHostInDocument(); const detector = new ProviderErrorDetector(host, vi.fn()); detector.subscribe(); - host.dispatchEvent(new Event(LIVE_ERROR_EVENT, { bubbles: true })); + dispatchLiveErrorEvent(host); expect(detector.getVerdict().state).toBe('not_live'); detector.reset(); @@ -96,7 +99,7 @@ describe('ProviderErrorDetector', () => { detector.subscribe(); detector.unsubscribe(); - host.dispatchEvent(new Event(LIVE_ERROR_EVENT, { bubbles: true })); + dispatchLiveErrorEvent(host); expect(detector.getVerdict().state).toBe('unknown'); expect(onChange).not.toHaveBeenCalled(); diff --git a/tests/components-lib/live/liveness/stream-liveness-controller.test.ts b/tests/components-lib/live/liveness/stream-liveness-controller.test.ts index 0c1b016b..378539fb 100644 --- a/tests/components-lib/live/liveness/stream-liveness-controller.test.ts +++ b/tests/components-lib/live/liveness/stream-liveness-controller.test.ts @@ -5,6 +5,10 @@ import type { Camera } from '../../../../src/camera-manager/camera'; import type { StateWatcherSubscriptionInterface } from '../../../../src/card-controller/hass/state-watcher'; import { LIVENESS_ENTITY_UNAVAILABLE_GRACE_SECONDS } from '../../../../src/components-lib/live/liveness/detectors/entity-availability'; import { StreamLivenessController } from '../../../../src/components-lib/live/liveness/stream-liveness-controller'; +import { + dispatchLiveErrorEvent, + type LiveError, +} from '../../../../src/components-lib/live/utils/dispatch-live-error'; import type { LivenessCallback, MediaPlayerController } from '../../../../src/types'; import { callIntersectionHandler, @@ -18,7 +22,6 @@ import { IntersectionObserverMock, } from '../../../test-utils'; -const LIVE_ERROR_EVENT = 'advanced-camera-card:live:error'; const ISSUE_TRIGGER_EVENT = 'advanced-camera-card:issue:trigger'; const setup = (options?: { targetID?: string | null }) => { @@ -38,8 +41,8 @@ const setup = (options?: { targetID?: string | null }) => { issueTriggers.push((ev as CustomEvent).detail), ); - const failViaProviderError = (): void => { - host.dispatchEvent(new Event(LIVE_ERROR_EVENT, { bubbles: true })); + const failViaProviderError = (error?: LiveError): void => { + dispatchLiveErrorEvent(host, error); }; return { host, controller, issueTriggers, failViaProviderError }; @@ -132,6 +135,27 @@ describe('StreamLivenessController', () => { }); }); + it("should carry the provider's error description into the failure and the issue", () => { + const { controller, issueTriggers, failViaProviderError } = setup(); + controller.hostConnected(); + + failViaProviderError({ detail: 'Failed to start WebRTC stream: no candidates' }); + + expect(controller.getFailure()).toEqual({ + reason: 'playback_error', + description: 'Failed to start WebRTC stream: no candidates', + renderPlaceholder: false, + }); + expect(issueTriggers).toEqual([ + { + key: 'media_unavailable', + targetID: 'camera.office', + reason: 'playback_error', + description: 'Failed to start WebRTC stream: no candidates', + }, + ]); + }); + it('should not fire the issue without a target', () => { const { host, controller, issueTriggers, failViaProviderError } = setup({ targetID: null, diff --git a/tests/components-lib/live/utils/dispatch-live-error.test.ts b/tests/components-lib/live/utils/dispatch-live-error.test.ts index 33926f4c..86792c63 100644 --- a/tests/components-lib/live/utils/dispatch-live-error.test.ts +++ b/tests/components-lib/live/utils/dispatch-live-error.test.ts @@ -3,22 +3,27 @@ import { expect, it, vi } from 'vitest'; import { dispatchLiveErrorEvent } from '../../../../src/components-lib/live/utils/dispatch-live-error'; // @vitest-environment jsdom -it('should dispatch live error event', () => { +it('should dispatch live error event with an empty error when none is given', () => { const element = document.createElement('div'); const handler = vi.fn(); element.addEventListener('advanced-camera-card:live:error', handler); dispatchLiveErrorEvent(element); - expect(handler).toBeCalled(); + expect(handler).toHaveBeenCalledWith(expect.objectContaining({ detail: {} })); }); -it('should forward the reason as the event detail', () => { +it('should forward the reason and detail as the event detail', () => { const element = document.createElement('div'); const handler = vi.fn(); element.addEventListener('advanced-camera-card:live:error', handler); - dispatchLiveErrorEvent(element, 'unsupported'); + dispatchLiveErrorEvent(element, { + reason: 'unsupported', + detail: 'Codec not supported', + }); expect(handler).toHaveBeenCalledWith( - expect.objectContaining({ detail: 'unsupported' }), + expect.objectContaining({ + detail: { reason: 'unsupported', detail: 'Codec not supported' }, + }), ); }); diff --git a/tests/components-lib/signed-url-controller.test.ts b/tests/components-lib/signed-url-controller.test.ts index fec31004..6a9523ca 100644 --- a/tests/components-lib/signed-url-controller.test.ts +++ b/tests/components-lib/signed-url-controller.test.ts @@ -2,7 +2,10 @@ import type { ReactiveControllerHost } from 'lit'; import { afterEach, describe, expect, it, vi } from 'vitest'; import { mock } from 'vitest-mock-extended'; -import { SignedURLController } from '../../src/components-lib/signed-url-controller'; +import { + getSignedURLErrorText, + SignedURLController, +} from '../../src/components-lib/signed-url-controller'; import { homeAssistantGetSignedURLIfNecessary } from '../../src/ha/sign-path'; import { createProxiedEndpointIfNecessary } from '../../src/ha/web-proxy'; import type { Endpoint } from '../../src/types'; @@ -997,3 +1000,13 @@ describe('SignedURLController', () => { expect(host.requestUpdate).not.toBeCalled(); }); }); + +describe('getSignedURLErrorText', () => { + it('should describe a signing failure', () => { + expect(getSignedURLErrorText('sign')).toBe('Could not sign Home Assistant URL'); + }); + + it('should describe a proxy failure', () => { + expect(getSignedURLErrorText('proxy')).toBe('Could not proxy via Home Assistant'); + }); +});