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' {
|
declare module 'issue' {
|
||||||
interface IssueTriggerContext {
|
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;
|
const MEDIA_LOADING_TIMEOUT_SECONDS = 10;
|
||||||
|
|
||||||
// The per-cause presentation (localization key + icon), shared by the
|
// The per-cause presentation (localization key + icon), shared by the
|
||||||
@@ -75,7 +88,7 @@ export class MediaUnavailableIssue implements Issue {
|
|||||||
public readonly key = 'media_unavailable' as const;
|
public readonly key = 'media_unavailable' as const;
|
||||||
|
|
||||||
private _issueActive = false;
|
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.
|
// Timer fires when a target has been loading too long without success.
|
||||||
private _timer = new Timer();
|
private _timer = new Timer();
|
||||||
@@ -107,7 +120,10 @@ export class MediaUnavailableIssue implements Issue {
|
|||||||
// =========================================================================
|
// =========================================================================
|
||||||
|
|
||||||
public trigger(context: IssueTriggerContext['media_unavailable']): void {
|
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 {
|
public getNotification(): Notification {
|
||||||
const targets = new Map(this._erroredTargets);
|
const targets = new Map(this._erroredTargets);
|
||||||
// The pending-load timer's target is a slow initial load that has not yet
|
// The pending-load timer's target is a slow initial load that has not yet
|
||||||
// errored.
|
// errored. Gate on the timer still running: once it is stopped (a hard error
|
||||||
if (this._timerTargetID && !targets.has(this._timerTargetID)) {
|
// on another target took over, or the view moved on), _timerTargetID lingers
|
||||||
targets.set(this._timerTargetID, 'not_loading');
|
// 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 {
|
return {
|
||||||
heading: {
|
heading: {
|
||||||
text: localize('issues.media_unavailable.heading'),
|
text: localize('issues.media_unavailable.heading'),
|
||||||
@@ -183,10 +212,11 @@ export class MediaUnavailableIssue implements Issue {
|
|||||||
text: localize('issues.media_unavailable.text'),
|
text: localize('issues.media_unavailable.text'),
|
||||||
},
|
},
|
||||||
...(targets.size && {
|
...(targets.size && {
|
||||||
metadata: Array.from(targets).map(([id, reason]) =>
|
metadata: Array.from(targets).map(([id, error]) =>
|
||||||
this._getTargetDetail(id, reason),
|
this._getTargetDetail(id, error.reason),
|
||||||
),
|
),
|
||||||
}),
|
}),
|
||||||
|
...(context.length && { context }),
|
||||||
link: {
|
link: {
|
||||||
url: TROUBLESHOOTING_MEDIA_URL,
|
url: TROUBLESHOOTING_MEDIA_URL,
|
||||||
title: localize('issues.troubleshooting_guide'),
|
title: localize('issues.troubleshooting_guide'),
|
||||||
@@ -202,15 +232,22 @@ export class MediaUnavailableIssue implements Issue {
|
|||||||
reason: MediaUnavailableIssueReason,
|
reason: MediaUnavailableIssueReason,
|
||||||
): NotificationDetail {
|
): NotificationDetail {
|
||||||
const isImage = id === IMAGE_VIEW_TARGET_ID_SENTINEL;
|
const isImage = id === IMAGE_VIEW_TARGET_ID_SENTINEL;
|
||||||
const name = isImage
|
|
||||||
? localize('editor.image')
|
|
||||||
: this._api.getCameraManager().getCameraMetadata(id)?.title ?? id;
|
|
||||||
return {
|
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,
|
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.
|
// Retry -- called by the manager to schedule a media reload.
|
||||||
// =========================================================================
|
// =========================================================================
|
||||||
@@ -300,7 +337,7 @@ export class MediaUnavailableIssue implements Issue {
|
|||||||
this._timerTargetID = targetID;
|
this._timerTargetID = targetID;
|
||||||
this._timer.start(MEDIA_LOADING_TIMEOUT_SECONDS, () => {
|
this._timer.start(MEDIA_LOADING_TIMEOUT_SECONDS, () => {
|
||||||
// Record the error on timeout so retry() knows which epoch to bump.
|
// 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._activate();
|
||||||
this._onChange?.();
|
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';
|
import type { LivenessDetector, LivenessVerdict } from '../stream-liveness-controller';
|
||||||
|
|
||||||
const LIVE_ERROR_EVENT = 'advanced-camera-card:live:error';
|
const LIVE_ERROR_EVENT = 'advanced-camera-card:live:error';
|
||||||
@@ -39,9 +39,7 @@ export class ProviderErrorDetector implements LivenessDetector {
|
|||||||
return this._verdict;
|
return this._verdict;
|
||||||
}
|
}
|
||||||
|
|
||||||
private _handler = (
|
private _handler = (ev: CustomEvent<LiveError>): void => {
|
||||||
ev: CustomEvent<MediaUnavailableIssueReason | undefined>,
|
|
||||||
): void => {
|
|
||||||
ev.stopPropagation();
|
ev.stopPropagation();
|
||||||
if (this._verdict.state !== 'not_live') {
|
if (this._verdict.state !== 'not_live') {
|
||||||
// Authoritative: an explicit provider error overrides even direct frame
|
// Authoritative: an explicit provider error overrides even direct frame
|
||||||
@@ -50,7 +48,8 @@ export class ProviderErrorDetector implements LivenessDetector {
|
|||||||
this._verdict = {
|
this._verdict = {
|
||||||
state: 'not_live',
|
state: 'not_live',
|
||||||
authority: 'hard',
|
authority: 'hard',
|
||||||
reason: ev.detail ?? 'playback_error',
|
reason: ev.detail.reason ?? 'playback_error',
|
||||||
|
description: ev.detail.detail,
|
||||||
};
|
};
|
||||||
this._onChange();
|
this._onChange();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -35,6 +35,10 @@ export type LivenessVerdict =
|
|||||||
authority: LivenessAuthority;
|
authority: LivenessAuthority;
|
||||||
reason: MediaUnavailableIssueReason;
|
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
|
// Whether the wrapper should replace the provider with a reconnecting
|
||||||
// placeholder (a silent freeze, e.g. an unavailable camera). Omitted when
|
// placeholder (a silent freeze, e.g. an unavailable camera). Omitted when
|
||||||
// the provider renders its own error and should stay mounted.
|
// the provider renders its own error and should stay mounted.
|
||||||
@@ -50,6 +54,9 @@ export type LivenessVerdict =
|
|||||||
interface StreamFailure {
|
interface StreamFailure {
|
||||||
reason: MediaUnavailableIssueReason;
|
reason: MediaUnavailableIssueReason;
|
||||||
|
|
||||||
|
// Free text naming the specific failure, when known.
|
||||||
|
description?: string;
|
||||||
|
|
||||||
// Whether the wrapper should replace the provider with a reconnecting
|
// Whether the wrapper should replace the provider with a reconnecting
|
||||||
// placeholder (a silent freeze, e.g. an unavailable camera). False when the
|
// placeholder (a silent freeze, e.g. an unavailable camera). False when the
|
||||||
// provider renders its own error and should stay mounted.
|
// provider renders its own error and should stay mounted.
|
||||||
@@ -133,7 +140,11 @@ export class StreamLivenessController implements ReactiveController {
|
|||||||
public getFailure(): StreamFailure | null {
|
public getFailure(): StreamFailure | null {
|
||||||
const verdict = this._getVerdict();
|
const verdict = this._getVerdict();
|
||||||
return verdict.state === 'not_live'
|
return verdict.state === 'not_live'
|
||||||
? { reason: verdict.reason, renderPlaceholder: !!verdict.renderPlaceholder }
|
? {
|
||||||
|
reason: verdict.reason,
|
||||||
|
description: verdict.description,
|
||||||
|
renderPlaceholder: !!verdict.renderPlaceholder,
|
||||||
|
}
|
||||||
: null;
|
: null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -170,14 +181,17 @@ export class StreamLivenessController implements ReactiveController {
|
|||||||
private _onDetectorChange(): void {
|
private _onDetectorChange(): void {
|
||||||
const verdict = this._getVerdict();
|
const verdict = this._getVerdict();
|
||||||
if (verdict.state === 'not_live') {
|
if (verdict.state === 'not_live') {
|
||||||
this._triggerMediaUnavailableIssue(verdict.reason);
|
this._triggerMediaUnavailableIssue(verdict.reason, verdict.description);
|
||||||
}
|
}
|
||||||
this._host.requestUpdate();
|
this._host.requestUpdate();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Tell the issue framework this target's media is not loaded, surfacing the
|
// Tell the issue framework this target's media is not loaded, surfacing the
|
||||||
// media_unavailable issue (status bar + retry) and its throttled reload.
|
// 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();
|
const targetID = this._config.getTargetID();
|
||||||
if (!targetID) {
|
if (!targetID) {
|
||||||
return;
|
return;
|
||||||
@@ -186,6 +200,7 @@ export class StreamLivenessController implements ReactiveController {
|
|||||||
key: 'media_unavailable',
|
key: 'media_unavailable',
|
||||||
targetID,
|
targetID,
|
||||||
reason,
|
reason,
|
||||||
|
description,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,20 +1,24 @@
|
|||||||
import type { MediaUnavailableIssueReason } from '../../../card-controller/issues/issues/media-unavailable';
|
import type { MediaUnavailableIssueReason } from '../../../card-controller/issues/issues/media-unavailable';
|
||||||
import { fireAdvancedCameraCardEvent } from '../../../utils/fire-advanced-camera-card-event';
|
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 {
|
declare global {
|
||||||
interface HTMLElementEventMap {
|
interface HTMLElementEventMap {
|
||||||
'advanced-camera-card:live:error': CustomEvent<
|
'advanced-camera-card:live:error': CustomEvent<LiveError>;
|
||||||
MediaUnavailableIssueReason | undefined
|
|
||||||
>;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// The optional reason lets a provider that knows why it failed drive a specific
|
export function dispatchLiveErrorEvent(element: EventTarget, error?: LiveError): void {
|
||||||
// media-unavailable message; absent, the liveness detector falls back to a
|
fireAdvancedCameraCardEvent(element, 'live:error', error ?? {});
|
||||||
// generic playback error.
|
|
||||||
export function dispatchLiveErrorEvent(
|
|
||||||
element: EventTarget,
|
|
||||||
reason?: MediaUnavailableIssueReason,
|
|
||||||
): void {
|
|
||||||
fireAdvancedCameraCardEvent(element, 'live:error', reason);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
createProxiedEndpointIfNecessary,
|
createProxiedEndpointIfNecessary,
|
||||||
type CreateProxiedEndpointOptions,
|
type CreateProxiedEndpointOptions,
|
||||||
} from '../ha/web-proxy.js';
|
} from '../ha/web-proxy.js';
|
||||||
|
import { localize } from '../localize/localize.js';
|
||||||
import type { Endpoint } from '../types.js';
|
import type { Endpoint } from '../types.js';
|
||||||
import { errorToConsole } from '../utils/basic.js';
|
import { errorToConsole } from '../utils/basic.js';
|
||||||
import { Generation } from '../utils/concurrency/generation.js';
|
import { Generation } from '../utils/concurrency/generation.js';
|
||||||
@@ -30,6 +31,11 @@ interface SignedURLControllerOptions {
|
|||||||
|
|
||||||
type SignedURLErrorType = 'sign' | 'proxy';
|
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 {
|
export class SignedURLController implements ReactiveController {
|
||||||
private _host: ReactiveControllerHost;
|
private _host: ReactiveControllerHost;
|
||||||
private _getOptionsCallback: () => SignedURLControllerOptions;
|
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 { MediaLoadedInfoSourceController } from '../components-lib/media-loaded-info-source-controller.js';
|
||||||
import { ImageMediaPlayerController } from '../components-lib/media-player/image.js';
|
import { ImageMediaPlayerController } from '../components-lib/media-player/image.js';
|
||||||
import { createMediaNotification } from '../components-lib/notification/media.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 { Notification } from '../config/schema/actions/types.js';
|
||||||
import type { CameraConfig } from '../config/schema/cameras.js';
|
import type { CameraConfig } from '../config/schema/cameras.js';
|
||||||
import { type ImageBaseConfig, type ImageMode } from '../config/schema/common/image.js';
|
import { type ImageBaseConfig, type ImageMode } from '../config/schema/common/image.js';
|
||||||
@@ -451,7 +454,7 @@ export class AdvancedCameraCardImageUpdatingPlayer
|
|||||||
const error = this._signedURLController.getError();
|
const error = this._signedURLController.getError();
|
||||||
if (error) {
|
if (error) {
|
||||||
return createMediaNotification({
|
return createMediaNotification({
|
||||||
title: localize(error === 'proxy' ? 'error.failed_proxy' : 'error.failed_sign'),
|
title: getSignedURLErrorText(error),
|
||||||
targetTitle: this.cameraTitle,
|
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 { dispatchLiveErrorEvent } from '../../../../components-lib/live/utils/dispatch-live-error.js';
|
||||||
import { MediaLoadedInfoSourceController } from '../../../../components-lib/media-loaded-info-source-controller.js';
|
import { MediaLoadedInfoSourceController } from '../../../../components-lib/media-loaded-info-source-controller.js';
|
||||||
import { VideoMediaPlayerController } from '../../../../components-lib/media-player/video.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 { MicrophoneConfig } from '../../../../config/schema/live.js';
|
||||||
import type { CardWideConfig } from '../../../../config/schema/types.js';
|
import type { CardWideConfig } from '../../../../config/schema/types.js';
|
||||||
import type { HomeAssistant } from '../../../../ha/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.
|
// the error itself (below); the event drives the liveness verdict + retry.
|
||||||
errorCallback: (reason) => {
|
errorCallback: (reason) => {
|
||||||
this._streamError = mapFailureReasonToIssueReason(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();
|
const error = this._signedURLController.getError();
|
||||||
if (error) {
|
if (error) {
|
||||||
return renderMediaNotification({
|
return renderMediaNotification({
|
||||||
title: localize(error === 'proxy' ? 'error.failed_proxy' : 'error.failed_sign'),
|
title: getSignedURLErrorText(error),
|
||||||
targetTitle: this.cameraTitle,
|
targetTitle: this.cameraTitle,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,7 +11,10 @@ import { customElement, property } from 'lit/decorators.js';
|
|||||||
import type { Camera } from '../../../../camera-manager/camera.js';
|
import type { Camera } from '../../../../camera-manager/camera.js';
|
||||||
import { dispatchLiveErrorEvent } from '../../../../components-lib/live/utils/dispatch-live-error.js';
|
import { dispatchLiveErrorEvent } from '../../../../components-lib/live/utils/dispatch-live-error.js';
|
||||||
import { VideoMediaPlayerController } from '../../../../components-lib/media-player/video.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 { MicrophoneConfig } from '../../../../config/schema/live.js';
|
||||||
import type { HomeAssistant } from '../../../../ha/types.js';
|
import type { HomeAssistant } from '../../../../ha/types.js';
|
||||||
import { localize } from '../../../../localize/localize.js';
|
import { localize } from '../../../../localize/localize.js';
|
||||||
@@ -153,7 +156,7 @@ export class AdvancedCameraCardGo2RTC extends LitElement implements MediaPlayer
|
|||||||
const error = this._signedURLController.getError();
|
const error = this._signedURLController.getError();
|
||||||
if (error) {
|
if (error) {
|
||||||
return renderMediaNotification({
|
return renderMediaNotification({
|
||||||
title: localize(error === 'proxy' ? 'error.failed_proxy' : 'error.failed_sign'),
|
title: getSignedURLErrorText(error),
|
||||||
targetTitle: this.cameraTitle,
|
targetTitle: this.cameraTitle,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -61,7 +61,7 @@ export class AdvancedCameraCardLiveImage extends LitElement implements MediaPlay
|
|||||||
.proxyConfig=${this.camera?.getLiveProxyConfig()}
|
.proxyConfig=${this.camera?.getLiveProxyConfig()}
|
||||||
@advanced-camera-card:image-updating-player:error=${(
|
@advanced-camera-card:image-updating-player:error=${(
|
||||||
ev: CustomEvent<MediaUnavailableIssueReason>,
|
ev: CustomEvent<MediaUnavailableIssueReason>,
|
||||||
) => dispatchLiveErrorEvent(this, ev.detail)}
|
) => dispatchLiveErrorEvent(this, { reason: ev.detail })}
|
||||||
>
|
>
|
||||||
</advanced-camera-card-image-updating-player>
|
</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 { QueryType } from '../../camera-manager/types.js';
|
||||||
import type { ViewManagerEpoch } from '../../card-controller/view/types.js';
|
import type { ViewManagerEpoch } from '../../card-controller/view/types.js';
|
||||||
import { LazyLoadController } from '../../components-lib/lazy-load-controller.js';
|
import { LazyLoadController } from '../../components-lib/lazy-load-controller.js';
|
||||||
import { 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 type { ZoomSettingsObserved } from '../../components-lib/zoom/types.js';
|
||||||
import { handleZoomSettingsObservedEvent } from '../../components-lib/zoom/zoom-view-context.js';
|
import { handleZoomSettingsObservedEvent } from '../../components-lib/zoom/zoom-view-context.js';
|
||||||
import type { CameraConfig } from '../../config/schema/cameras.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 { isHARelativeURL } from '../../ha/is-ha-relative-url.js';
|
||||||
import { resolveMedia, type ResolvedMediaCache } from '../../ha/resolved-media.js';
|
import { resolveMedia, type ResolvedMediaCache } from '../../ha/resolved-media.js';
|
||||||
import type { HomeAssistant, ResolvedMedia } from '../../ha/types.js';
|
import type { HomeAssistant, ResolvedMedia } from '../../ha/types.js';
|
||||||
import { localize } from '../../localize/localize.js';
|
|
||||||
|
|
||||||
import '../../patches/ha-hls-player.js';
|
import '../../patches/ha-hls-player.js';
|
||||||
|
|
||||||
@@ -243,12 +245,9 @@ export class AdvancedCameraCardViewerProvider extends LitElement implements Medi
|
|||||||
const error = this._signedURLController.getError();
|
const error = this._signedURLController.getError();
|
||||||
if (error) {
|
if (error) {
|
||||||
const contentID = this.media?.getContentID();
|
const contentID = this.media?.getContentID();
|
||||||
return renderNotificationBlockFromText(
|
return renderNotificationBlockFromText(getSignedURLErrorText(error), {
|
||||||
localize(error === 'proxy' ? 'error.failed_proxy' : 'error.failed_sign'),
|
|
||||||
{
|
|
||||||
...(contentID && { metadata: [{ text: contentID, icon: 'mdi:identifier' }] }),
|
...(contentID && { metadata: [{ text: contentID, icon: 'mdi:identifier' }] }),
|
||||||
},
|
});
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const url = this._signedURLController.getValue();
|
const url = this._signedURLController.getValue();
|
||||||
|
|||||||
@@ -19,9 +19,11 @@ import {
|
|||||||
} from 'lit';
|
} from 'lit';
|
||||||
import { customElement, property } from 'lit/decorators.js';
|
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 { 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 { MediaLoadedInfoSourceController } from '../components-lib/media-loaded-info-source-controller.js';
|
||||||
|
|
||||||
import '../components/image-player.js';
|
import '../components/image-player.js';
|
||||||
@@ -39,11 +41,11 @@ import './ha-hls-player.js';
|
|||||||
import './ha-web-rtc-player.js';
|
import './ha-web-rtc-player.js';
|
||||||
|
|
||||||
// A failure reported by one of the inner players. Its existence is the failure;
|
// 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`
|
// `error` carries whatever the player knew about it. `dispatched` records
|
||||||
// records whether it has already been announced, so a stream that fails again
|
// whether it has already been announced, so a stream that fails again after
|
||||||
// after recovering is announced again.
|
// recovering is announced again.
|
||||||
interface StreamError {
|
interface StreamError {
|
||||||
reason?: MediaUnavailableIssueReason;
|
error: LiveError;
|
||||||
dispatched: boolean;
|
dispatched: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -173,14 +175,11 @@ void customElements.whenDefined('ha-camera-stream').then(() => {
|
|||||||
this.requestUpdate();
|
this.requestUpdate();
|
||||||
}
|
}
|
||||||
|
|
||||||
private _captureInnerError(
|
private _captureInnerError(stream: StreamType, ev: CustomEvent<LiveError>) {
|
||||||
stream: StreamType,
|
|
||||||
ev: CustomEvent<MediaUnavailableIssueReason | undefined>,
|
|
||||||
) {
|
|
||||||
// Stop the inner-player event at the aggregator boundary; it is
|
// Stop the inner-player event at the aggregator boundary; it is
|
||||||
// re-dispatched from updated() only if this stream is the visible one.
|
// re-dispatched from updated() only if this stream is the visible one.
|
||||||
ev.stopPropagation();
|
ev.stopPropagation();
|
||||||
this._errorPerStream[stream] = { reason: ev.detail, dispatched: false };
|
this._errorPerStream[stream] = { error: ev.detail, dispatched: false };
|
||||||
this.requestUpdate();
|
this.requestUpdate();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -218,9 +217,8 @@ void customElements.whenDefined('ha-camera-stream').then(() => {
|
|||||||
@advanced-camera-card:media:loaded=${(
|
@advanced-camera-card:media:loaded=${(
|
||||||
ev: CustomEvent<MediaLoadedInfoEventDetail>,
|
ev: CustomEvent<MediaLoadedInfoEventDetail>,
|
||||||
) => this._captureInnerLoad(STREAM_TYPE_HLS, ev)}
|
) => this._captureInnerLoad(STREAM_TYPE_HLS, ev)}
|
||||||
@advanced-camera-card:live:error=${(
|
@advanced-camera-card:live:error=${(ev: CustomEvent<LiveError>) =>
|
||||||
ev: CustomEvent<MediaUnavailableIssueReason | undefined>,
|
this._captureInnerError(STREAM_TYPE_HLS, ev)}
|
||||||
) => this._captureInnerError(STREAM_TYPE_HLS, ev)}
|
|
||||||
@streams=${this._handleHlsStreams}
|
@streams=${this._handleHlsStreams}
|
||||||
class="player ${stream.visible ? '' : 'hidden'}"
|
class="player ${stream.visible ? '' : 'hidden'}"
|
||||||
></advanced-camera-card-ha-hls-player>`;
|
></advanced-camera-card-ha-hls-player>`;
|
||||||
@@ -239,9 +237,8 @@ void customElements.whenDefined('ha-camera-stream').then(() => {
|
|||||||
@advanced-camera-card:media:loaded=${(
|
@advanced-camera-card:media:loaded=${(
|
||||||
ev: CustomEvent<MediaLoadedInfoEventDetail>,
|
ev: CustomEvent<MediaLoadedInfoEventDetail>,
|
||||||
) => this._captureInnerLoad(STREAM_TYPE_WEB_RTC, ev)}
|
) => this._captureInnerLoad(STREAM_TYPE_WEB_RTC, ev)}
|
||||||
@advanced-camera-card:live:error=${(
|
@advanced-camera-card:live:error=${(ev: CustomEvent<LiveError>) =>
|
||||||
ev: CustomEvent<MediaUnavailableIssueReason | undefined>,
|
this._captureInnerError(STREAM_TYPE_WEB_RTC, ev)}
|
||||||
) => this._captureInnerError(STREAM_TYPE_WEB_RTC, ev)}
|
|
||||||
@streams=${this._handleWebRtcStreams}
|
@streams=${this._handleWebRtcStreams}
|
||||||
class="player ${stream.visible ? '' : 'hidden'}"
|
class="player ${stream.visible ? '' : 'hidden'}"
|
||||||
></advanced-camera-card-ha-web-rtc-player>`;
|
></advanced-camera-card-ha-web-rtc-player>`;
|
||||||
@@ -307,12 +304,12 @@ void customElements.whenDefined('ha-camera-stream').then(() => {
|
|||||||
// re-evaluated for this update.
|
// re-evaluated for this update.
|
||||||
private _dispatchVisibleStreamError(): void {
|
private _dispatchVisibleStreamError(): void {
|
||||||
const stream = this._visibleStreamType;
|
const stream = this._visibleStreamType;
|
||||||
const error = stream ? this._errorPerStream[stream] : null;
|
const streamError = stream ? this._errorPerStream[stream] : null;
|
||||||
if (!error || error.dispatched) {
|
if (!streamError || streamError.dispatched) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
error.dispatched = true;
|
streamError.dispatched = true;
|
||||||
dispatchLiveErrorEvent(this, error.reason);
|
dispatchLiveErrorEvent(this, streamError.error);
|
||||||
}
|
}
|
||||||
|
|
||||||
static get styles(): CSSResultGroup {
|
static get styles(): CSSResultGroup {
|
||||||
|
|||||||
@@ -131,7 +131,7 @@ void customElements.whenDefined('ha-hls-player').then(() => {
|
|||||||
// and are only logged (see render()).
|
// and are only logged (see render()).
|
||||||
const errored = !!this._error && this._errorIsFatal;
|
const errored = !!this._error && this._errorIsFatal;
|
||||||
if (errored && !this._lastErrored) {
|
if (errored && !this._lastErrored) {
|
||||||
dispatchLiveErrorEvent(this);
|
dispatchLiveErrorEvent(this, { detail: this._error });
|
||||||
}
|
}
|
||||||
this._lastErrored = errored;
|
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.
|
// player can fail more than once and every failure must be reported.
|
||||||
const errored = !!this._error;
|
const errored = !!this._error;
|
||||||
if (errored && !this._lastErrored) {
|
if (errored && !this._lastErrored) {
|
||||||
dispatchLiveErrorEvent(this);
|
dispatchLiveErrorEvent(this, { detail: this._error });
|
||||||
}
|
}
|
||||||
this._lastErrored = errored;
|
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', () => {
|
it('should use camera title when available', () => {
|
||||||
const api = createAPI();
|
const api = createAPI();
|
||||||
vi.mocked(api.getCameraManager().getCameraMetadata).mockReturnValue({
|
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 () => {
|
it('should include a retry control with wired callback', async () => {
|
||||||
const api = createCardAPI();
|
const api = createCardAPI();
|
||||||
const issue = new MediaUnavailableIssue(api);
|
const issue = new MediaUnavailableIssue(api);
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { describe, expect, it, vi } from 'vitest';
|
import { describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
import { ProviderErrorDetector } from '../../../../../src/components-lib/live/liveness/detectors/provider-error';
|
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';
|
const LIVE_ERROR_EVENT = 'advanced-camera-card:live:error';
|
||||||
|
|
||||||
@@ -24,7 +25,7 @@ describe('ProviderErrorDetector', () => {
|
|||||||
const detector = new ProviderErrorDetector(host, onChange);
|
const detector = new ProviderErrorDetector(host, onChange);
|
||||||
detector.subscribe();
|
detector.subscribe();
|
||||||
|
|
||||||
host.dispatchEvent(new Event(LIVE_ERROR_EVENT, { bubbles: true }));
|
dispatchLiveErrorEvent(host);
|
||||||
|
|
||||||
// not_live but no renderPlaceholder: the provider renders its own error.
|
// not_live but no renderPlaceholder: the provider renders its own error.
|
||||||
expect(detector.getVerdict()).toEqual({
|
expect(detector.getVerdict()).toEqual({
|
||||||
@@ -35,19 +36,21 @@ describe('ProviderErrorDetector', () => {
|
|||||||
expect(onChange).toHaveBeenCalledTimes(1);
|
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 host = createHostInDocument();
|
||||||
const detector = new ProviderErrorDetector(host, vi.fn());
|
const detector = new ProviderErrorDetector(host, vi.fn());
|
||||||
detector.subscribe();
|
detector.subscribe();
|
||||||
|
|
||||||
host.dispatchEvent(
|
dispatchLiveErrorEvent(host, {
|
||||||
new CustomEvent(LIVE_ERROR_EVENT, { bubbles: true, detail: 'unsupported' }),
|
reason: 'unsupported',
|
||||||
);
|
detail: 'Codec not supported',
|
||||||
|
});
|
||||||
|
|
||||||
expect(detector.getVerdict()).toEqual({
|
expect(detector.getVerdict()).toEqual({
|
||||||
state: 'not_live',
|
state: 'not_live',
|
||||||
authority: 'hard',
|
authority: 'hard',
|
||||||
reason: 'unsupported',
|
reason: 'unsupported',
|
||||||
|
description: 'Codec not supported',
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -57,8 +60,8 @@ describe('ProviderErrorDetector', () => {
|
|||||||
const detector = new ProviderErrorDetector(host, onChange);
|
const detector = new ProviderErrorDetector(host, onChange);
|
||||||
detector.subscribe();
|
detector.subscribe();
|
||||||
|
|
||||||
host.dispatchEvent(new Event(LIVE_ERROR_EVENT, { bubbles: true }));
|
dispatchLiveErrorEvent(host);
|
||||||
host.dispatchEvent(new Event(LIVE_ERROR_EVENT, { bubbles: true }));
|
dispatchLiveErrorEvent(host);
|
||||||
|
|
||||||
expect(onChange).toHaveBeenCalledTimes(1);
|
expect(onChange).toHaveBeenCalledTimes(1);
|
||||||
});
|
});
|
||||||
@@ -70,7 +73,7 @@ describe('ProviderErrorDetector', () => {
|
|||||||
const detector = new ProviderErrorDetector(host, vi.fn());
|
const detector = new ProviderErrorDetector(host, vi.fn());
|
||||||
detector.subscribe();
|
detector.subscribe();
|
||||||
|
|
||||||
host.dispatchEvent(new Event(LIVE_ERROR_EVENT, { bubbles: true }));
|
dispatchLiveErrorEvent(host);
|
||||||
|
|
||||||
expect(parentListener).not.toHaveBeenCalled();
|
expect(parentListener).not.toHaveBeenCalled();
|
||||||
|
|
||||||
@@ -81,7 +84,7 @@ describe('ProviderErrorDetector', () => {
|
|||||||
const host = createHostInDocument();
|
const host = createHostInDocument();
|
||||||
const detector = new ProviderErrorDetector(host, vi.fn());
|
const detector = new ProviderErrorDetector(host, vi.fn());
|
||||||
detector.subscribe();
|
detector.subscribe();
|
||||||
host.dispatchEvent(new Event(LIVE_ERROR_EVENT, { bubbles: true }));
|
dispatchLiveErrorEvent(host);
|
||||||
expect(detector.getVerdict().state).toBe('not_live');
|
expect(detector.getVerdict().state).toBe('not_live');
|
||||||
|
|
||||||
detector.reset();
|
detector.reset();
|
||||||
@@ -96,7 +99,7 @@ describe('ProviderErrorDetector', () => {
|
|||||||
detector.subscribe();
|
detector.subscribe();
|
||||||
detector.unsubscribe();
|
detector.unsubscribe();
|
||||||
|
|
||||||
host.dispatchEvent(new Event(LIVE_ERROR_EVENT, { bubbles: true }));
|
dispatchLiveErrorEvent(host);
|
||||||
|
|
||||||
expect(detector.getVerdict().state).toBe('unknown');
|
expect(detector.getVerdict().state).toBe('unknown');
|
||||||
expect(onChange).not.toHaveBeenCalled();
|
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 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 { 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 { 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 type { LivenessCallback, MediaPlayerController } from '../../../../src/types';
|
||||||
import {
|
import {
|
||||||
callIntersectionHandler,
|
callIntersectionHandler,
|
||||||
@@ -18,7 +22,6 @@ import {
|
|||||||
IntersectionObserverMock,
|
IntersectionObserverMock,
|
||||||
} from '../../../test-utils';
|
} from '../../../test-utils';
|
||||||
|
|
||||||
const LIVE_ERROR_EVENT = 'advanced-camera-card:live:error';
|
|
||||||
const ISSUE_TRIGGER_EVENT = 'advanced-camera-card:issue:trigger';
|
const ISSUE_TRIGGER_EVENT = 'advanced-camera-card:issue:trigger';
|
||||||
|
|
||||||
const setup = (options?: { targetID?: string | null }) => {
|
const setup = (options?: { targetID?: string | null }) => {
|
||||||
@@ -38,8 +41,8 @@ const setup = (options?: { targetID?: string | null }) => {
|
|||||||
issueTriggers.push((ev as CustomEvent).detail),
|
issueTriggers.push((ev as CustomEvent).detail),
|
||||||
);
|
);
|
||||||
|
|
||||||
const failViaProviderError = (): void => {
|
const failViaProviderError = (error?: LiveError): void => {
|
||||||
host.dispatchEvent(new Event(LIVE_ERROR_EVENT, { bubbles: true }));
|
dispatchLiveErrorEvent(host, error);
|
||||||
};
|
};
|
||||||
|
|
||||||
return { host, controller, issueTriggers, failViaProviderError };
|
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', () => {
|
it('should not fire the issue without a target', () => {
|
||||||
const { host, controller, issueTriggers, failViaProviderError } = setup({
|
const { host, controller, issueTriggers, failViaProviderError } = setup({
|
||||||
targetID: null,
|
targetID: null,
|
||||||
|
|||||||
@@ -3,22 +3,27 @@ import { expect, it, vi } from 'vitest';
|
|||||||
import { dispatchLiveErrorEvent } from '../../../../src/components-lib/live/utils/dispatch-live-error';
|
import { dispatchLiveErrorEvent } from '../../../../src/components-lib/live/utils/dispatch-live-error';
|
||||||
|
|
||||||
// @vitest-environment jsdom
|
// @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 element = document.createElement('div');
|
||||||
const handler = vi.fn();
|
const handler = vi.fn();
|
||||||
element.addEventListener('advanced-camera-card:live:error', handler);
|
element.addEventListener('advanced-camera-card:live:error', handler);
|
||||||
|
|
||||||
dispatchLiveErrorEvent(element);
|
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 element = document.createElement('div');
|
||||||
const handler = vi.fn();
|
const handler = vi.fn();
|
||||||
element.addEventListener('advanced-camera-card:live:error', handler);
|
element.addEventListener('advanced-camera-card:live:error', handler);
|
||||||
|
|
||||||
dispatchLiveErrorEvent(element, 'unsupported');
|
dispatchLiveErrorEvent(element, {
|
||||||
|
reason: 'unsupported',
|
||||||
|
detail: 'Codec not supported',
|
||||||
|
});
|
||||||
expect(handler).toHaveBeenCalledWith(
|
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 { afterEach, describe, expect, it, vi } from 'vitest';
|
||||||
import { mock } from 'vitest-mock-extended';
|
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 { homeAssistantGetSignedURLIfNecessary } from '../../src/ha/sign-path';
|
||||||
import { createProxiedEndpointIfNecessary } from '../../src/ha/web-proxy';
|
import { createProxiedEndpointIfNecessary } from '../../src/ha/web-proxy';
|
||||||
import type { Endpoint } from '../../src/types';
|
import type { Endpoint } from '../../src/types';
|
||||||
@@ -997,3 +1000,13 @@ describe('SignedURLController', () => {
|
|||||||
expect(host.requestUpdate).not.toBeCalled();
|
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