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>
`;
+6 -7
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'),
{
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;
}
@@ -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);
@@ -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();
@@ -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,
@@ -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' },
}),
);
});
@@ -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');
});
});