feat: Surface the provider's error cause in the media_unavailable notification (#2599)

- Closes: #2592
This commit is contained in:
Dermot Duffy
2026-07-22 20:45:49 -07:00
committed by GitHub
parent 5a549c0326
commit 448fa2d9f1
18 changed files with 255 additions and 90 deletions
@@ -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<string, MediaUnavailableIssueReason>();
private _erroredTargets = new Map<string, TargetError>();
// 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?.();
});
@@ -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<MediaUnavailableIssueReason | undefined>,
): void => {
private _handler = (ev: CustomEvent<LiveError>): 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();
}
@@ -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,
});
}
}
@@ -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<LiveError>;
}
}
// 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 ?? {});
}
@@ -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;
+5 -2
View File
@@ -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,
});
}
@@ -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,
});
}
@@ -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,
});
}
+1 -1
View File
@@ -61,7 +61,7 @@ export class AdvancedCameraCardLiveImage extends LitElement implements MediaPlay
.proxyConfig=${this.camera?.getLiveProxyConfig()}
@advanced-camera-card:image-updating-player:error=${(
ev: CustomEvent<MediaUnavailableIssueReason>,
) => dispatchLiveErrorEvent(this, ev.detail)}
) => dispatchLiveErrorEvent(this, { reason: ev.detail })}
>
</advanced-camera-card-image-updating-player>
`;
+7 -8
View File
@@ -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();
+18 -21
View File
@@ -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<MediaUnavailableIssueReason | undefined>,
) {
private _captureInnerError(stream: StreamType, ev: CustomEvent<LiveError>) {
// 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<MediaLoadedInfoEventDetail>,
) => this._captureInnerLoad(STREAM_TYPE_HLS, ev)}
@advanced-camera-card:live:error=${(
ev: CustomEvent<MediaUnavailableIssueReason | undefined>,
) => this._captureInnerError(STREAM_TYPE_HLS, ev)}
@advanced-camera-card:live:error=${(ev: CustomEvent<LiveError>) =>
this._captureInnerError(STREAM_TYPE_HLS, ev)}
@streams=${this._handleHlsStreams}
class="player ${stream.visible ? '' : 'hidden'}"
></advanced-camera-card-ha-hls-player>`;
@@ -239,9 +237,8 @@ void customElements.whenDefined('ha-camera-stream').then(() => {
@advanced-camera-card:media:loaded=${(
ev: CustomEvent<MediaLoadedInfoEventDetail>,
) => this._captureInnerLoad(STREAM_TYPE_WEB_RTC, ev)}
@advanced-camera-card:live:error=${(
ev: CustomEvent<MediaUnavailableIssueReason | undefined>,
) => this._captureInnerError(STREAM_TYPE_WEB_RTC, ev)}
@advanced-camera-card:live:error=${(ev: CustomEvent<LiveError>) =>
this._captureInnerError(STREAM_TYPE_WEB_RTC, ev)}
@streams=${this._handleWebRtcStreams}
class="player ${stream.visible ? '' : 'hidden'}"
></advanced-camera-card-ha-web-rtc-player>`;
@@ -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 {
+1 -1
View File
@@ -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;
}
+1 -1
View File
@@ -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;
}