feat: Surface the provider's error cause in the media_unavailable notification (#2599)
- Closes: #2592
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
`;
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user