fix: Clear stale media unavailable errors when a camera recovers (#2627)

- Closes: #2576
This commit is contained in:
Dermot Duffy
2026-07-28 21:21:50 -07:00
committed by GitHub
parent 231e3087b7
commit 15b08db3e8
49 changed files with 1128 additions and 229 deletions
+32 -9
View File
@@ -74,7 +74,7 @@ export class CallManager {
.getCameraIDsWithCapability('live')
.has(parentID)
) {
this._notifyError('error.call_invalid_target', inbound);
this._notifyError('error.call_invalid_target', { inbound });
return false;
}
@@ -261,6 +261,22 @@ export class CallManager {
return this.end();
}
// The microphone could not be used for the call, so it is connected but the
// user cannot be heard. `description` is what the reporting layer knows about
// the failure, when it knows anything.
public reportCallMicrophoneError(targetID: string, description?: string): void {
const call = this._call;
// A report that no longer matches the call in progress describes an attempt
// the user has already moved past, e.g. the call ended before the provider
// finished reporting.
if (!call || !call.answered || call.cameraID !== targetID) {
return;
}
this._notifyError('error.call_microphone_failed', { context: description });
}
// Tears down everything `initialize()` set up: stops any in-flight ringtone
// and unanswered timer, drops the active call session, clears the call
// condition state, and de-registers the condition-state listener. Driven by
@@ -367,14 +383,21 @@ export class CallManager {
// Helpers
// =========================================================================
private _notifyError(messageKey: string, inbound: boolean): void {
if (inbound) {
// Don't show errors on inbound calls.
// `context` is a diagnostic the user can quote when reporting the problem.
private _notifyError(
messageKey: string,
options?: { inbound?: boolean; context?: string },
): void {
// An inbound call the user has not answered yet is not something they have
// asked for, so a failure to place it is not worth interrupting them with.
if (options?.inbound) {
return;
}
const context = options?.context;
this._api.getNotificationManager().setNotification(
createNotificationFromText(localize(messageKey), {
heading: { text: localize('error.call_unavailable_heading') },
...(context && { context }),
}),
);
}
@@ -385,7 +408,7 @@ export class CallManager {
const microphoneManager = this._api.getMicrophoneManager();
if (!microphoneManager.isSupported()) {
this._notifyError('error.call_microphone_unsupported', inbound);
this._notifyError('error.call_microphone_unsupported', { inbound });
return false;
}
@@ -394,7 +417,7 @@ export class CallManager {
// there clears the denial, and failing there reports it. An outbound call
// needs the microphone immediately, so a known denial ends it here.
if (!inbound && microphoneManager.isForbidden()) {
this._notifyError('error.call_microphone_forbidden', inbound);
this._notifyError('error.call_microphone_forbidden', { inbound });
return false;
}
@@ -428,7 +451,7 @@ export class CallManager {
}
if (!connected) {
this._notifyError('error.call_microphone_forbidden', false);
this._notifyError('error.call_microphone_forbidden');
return false;
}
return true;
@@ -453,7 +476,7 @@ export class CallManager {
.getStore()
.getAllDependentCameras(cameraID, '2-way-audio');
if (!eligibleCameraIDs.has(streamID)) {
this._notifyError('error.call_invalid_target', inbound);
this._notifyError('error.call_invalid_target', { inbound });
return null;
}
return streamID;
@@ -480,7 +503,7 @@ export class CallManager {
.getAllDependentCameras(parentID, '2-way-audio'),
];
if (!candidates.length) {
this._notifyError('error.call_no_two_way_audio', inbound);
this._notifyError('error.call_no_two_way_audio', { inbound });
return null;
}
return candidates[0];
+12 -1
View File
@@ -1,4 +1,4 @@
import type { IssueTriggerContext } from 'issue';
import type { IssueResolveContext, IssueTriggerContext } from 'issue';
import type { ConditionStateChange } from '../../condition-trigger/conditions/types';
import { contentsChanged, ignoreFunctionIdentity } from '../../utils/basic';
@@ -11,6 +11,7 @@ import type {
IssueKey,
IssuePresence,
IssueReadOnlyState,
IssueResolveContextKey,
IssueTriggerContextKey,
} from './types';
@@ -71,6 +72,16 @@ export class IssueManager {
this.evaluate();
}
// Called by components that observe a problem recovering directly (e.g. a
// stream that is proven to be delivering media again).
public resolve<K extends IssueResolveContextKey>(
key: K,
context: IssueResolveContext[K],
): void {
this._stateManager.resolve(key, context);
this.evaluate();
}
// Evaluate all dynamic issues against current state, re-render the card if
// the issue presence changed, and schedule retries.
//
@@ -1,4 +1,4 @@
import type { IssueTriggerContext } from 'issue';
import type { IssueResolveContext, IssueTriggerContext } from 'issue';
import type { ConditionState } from '../../../condition-trigger/conditions/types.js';
import type {
@@ -23,7 +23,6 @@ export type MediaUnavailableIssueReason =
| 'playback_error'
| 'server_error'
| 'stalled'
| 'two_way_audio_error'
| 'unsupported';
declare module 'issue' {
@@ -37,6 +36,12 @@ declare module 'issue' {
description?: string;
};
}
interface IssueResolveContext {
media_unavailable: {
targetID: string;
};
}
}
// What is known about one target's failure.
@@ -74,10 +79,6 @@ export const MEDIA_UNAVAILABLE_REASONS: Record<
localizationKey: 'issues.media_unavailable.reasons.stalled',
icon: 'mdi:motion-pause',
},
two_way_audio_error: {
localizationKey: 'issues.media_unavailable.reasons.two_way_audio_error',
icon: 'mdi:microphone-off',
},
unsupported: {
localizationKey: 'issues.media_unavailable.reasons.unsupported',
icon: 'mdi:video-off-outline',
@@ -102,21 +103,20 @@ export class MediaUnavailableIssue implements Issue {
this._api = api;
this._onChange = onChange ?? null;
// Clear a target's error on a genuine media (re)load.
// React to a target's media loading; unload / select changes are
// irrelevant here.
this._unsubscribeCallback = this._api
.getMediaLoadedInfoManager()
.subscribe((change) => {
// A reconnect replay (`cached`) did not actually reload the media, and
// unload / select changes are irrelevant here; only a genuine load
// clears the error.
if (change.type === 'load' && !change.cached) {
if (change.type === 'load') {
this._onMediaLoad(change.targetID);
}
});
}
// =========================================================================
// Explicit trigger -- called when a component fires an issue:trigger event.
// Explicit trigger and resolve -- called when a component fires an
// issue:trigger or issue:resolve event.
// =========================================================================
public trigger(context: IssueTriggerContext['media_unavailable']): void {
@@ -126,6 +126,14 @@ export class MediaUnavailableIssue implements Issue {
});
}
// A target is proven to be delivering media again. Stronger evidence than a
// media load, which only says a player attached, so it clears any recorded
// error.
public resolve(context: IssueResolveContext['media_unavailable']): void {
this._erroredTargets.delete(context.targetID);
this._cancelPendingTimer(context.targetID);
}
// =========================================================================
// Detection -- called by the manager on every state change.
// =========================================================================
@@ -139,8 +147,8 @@ export class MediaUnavailableIssue implements Issue {
// A known error for the current target activates immediately, even if its
// (frozen) media still reads as loaded (it might be loaded but then
// reported a playback error that stops playback but leaves the player
// attached). Errors are cleared out-of-band by `_onMediaLoad` on a genuine
// reload.
// attached). Errors are cleared out-of-band, by `resolve` or by
// `_onMediaLoad`.
if (this._hasError(state)) {
this._activate();
return;
@@ -155,9 +163,16 @@ export class MediaUnavailableIssue implements Issue {
this._handlePendingLoad(state);
}
// A genuine media (re)load for a target clears its error.
// A load proves media attached for the target. That ends any wait on it, and
// refutes a `not_loading` error. It is no evidence of recovery for any other
// reason, so those clear only via `resolve`.
private _onMediaLoad(targetID: string): void {
if (this._erroredTargets.delete(targetID)) {
let changed = this._cancelPendingTimer(targetID);
if (this._erroredTargets.get(targetID)?.reason === 'not_loading') {
this._erroredTargets.delete(targetID);
changed = true;
}
if (changed) {
this._onChange?.();
}
}
@@ -183,6 +198,7 @@ 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. 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
@@ -258,10 +274,12 @@ export class MediaUnavailableIssue implements Issue {
public retry(): boolean {
// Build the set of targets to retry: all errored targets plus the
// target the pending timer was tracking (so a user-initiated retry
// works even before the timeout fires).
// target the pending timer is tracking (so a user-initiated retry
// works even before the timeout fires). A stopped timer leaves
// _timerTargetID behind, so gate on it still running: that target may
// since have loaded.
const retryTargets = new Set(this._erroredTargets.keys());
if (this._timerTargetID) {
if (this._timerTargetID && this._timer.isRunning()) {
retryTargets.add(this._timerTargetID);
}
@@ -269,6 +287,8 @@ export class MediaUnavailableIssue implements Issue {
return false;
}
// Bumping a target's mediaEpoch remounts its provider, which is the card's
// only way to rebuild a stream from scratch.
const view = this._api.getViewManager().getView();
const mediaEpoch = { ...(view?.context?.mediaEpoch ?? {}) };
for (const id of retryTargets) {
@@ -276,11 +296,11 @@ export class MediaUnavailableIssue implements Issue {
}
// Intentionally keep _issueActive, _erroredTargets, and the pending
// timer in place. The issue stays visible while the provider
// re-attempts loading underneath. If the retry succeeds, the fresh media
// load clears everything (_onMediaLoad drops the errored target). If it
// fails silently (e.g. bogus stream name), the error stays visible
// immediately -- no new 10s grace period.
// timer in place. The issue stays visible while the provider re-attempts
// loading underneath. If the retry succeeds, the fresh load clears a
// not-loading error and the rebuilt provider's liveness observation
// resolves a stream error. If it fails silently (e.g. bogus stream name),
// the error stays visible immediately -- no new 10s grace period.
this._api.getViewManager().setViewWithMergedContext({ mediaEpoch });
return false;
}
@@ -314,6 +334,17 @@ export class MediaUnavailableIssue implements Issue {
// Private helpers.
// =========================================================================
// Stop waiting on a target's load, if it is the one being waited on. Returns
// whether it was.
private _cancelPendingTimer(targetID: string): boolean {
if (this._timerTargetID !== targetID) {
return false;
}
this._timer.stop();
this._timerTargetID = null;
return true;
}
// Media not yet loaded and no known error: start (or keep) a timeout to catch
// a slow or failed initial load. No targetID means no provider is rendering
// media (e.g. the viewer shows "No media to display"), so there's nothing to
@@ -337,7 +368,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, { reason: 'not_loading' });
this.trigger({ targetID, reason: 'not_loading' });
this._activate();
this._onChange?.();
});
+16 -2
View File
@@ -1,4 +1,4 @@
import type { IssueTriggerContext } from 'issue';
import type { IssueResolveContext, IssueTriggerContext } from 'issue';
import { summarizeNotification } from '../../components-lib/notification/summarize';
import type { ConditionState } from '../../condition-trigger/conditions/types';
@@ -11,6 +11,7 @@ import type {
IssueKey,
IssuePresence,
IssueReadOnlyState,
IssueResolveContextKey,
IssueTriggerContextKey,
KeyedIssueDescription,
} from './types';
@@ -28,7 +29,8 @@ export class IssueStateManager implements IssueReadOnlyState {
}
// =========================================================================
// Detection -- static (one-shot on init) and dynamic (on every state change).
// Detection -- static (one-shot on init), dynamic (on every state change) and
// explicit (triggered or resolved by a component).
// =========================================================================
public async detectStatic(hass: HomeAssistant): Promise<void> {
@@ -56,6 +58,18 @@ export class IssueStateManager implements IssueReadOnlyState {
this._logIfNew(issue);
}
public resolve<K extends IssueResolveContextKey>(
key: K,
context: IssueResolveContext[K],
): void {
const issue = this._issues.get(key);
if (!issue) {
return;
}
issue.resolve?.(context);
this._logIfNew(issue);
}
public detectDynamic(context: ConditionState): void {
for (const issue of this._issues.values()) {
issue.detectDynamic?.(context);
+11 -1
View File
@@ -1,4 +1,4 @@
import type { IssueTriggerContext } from 'issue';
import type { IssueResolveContext, IssueTriggerContext } from 'issue';
import type { ConditionState } from '../../condition-trigger/conditions/types';
import type { Notification } from '../../config/schema/actions/types';
@@ -47,6 +47,11 @@ export type IssueTriggerEventData = {
[K in IssueTriggerContextKey]: { key: K } & IssueTriggerContext[K];
}[IssueTriggerContextKey];
export type IssueResolveContextKey = keyof IssueResolveContext;
export type IssueResolveEventData = {
[K in IssueResolveContextKey]: { key: K } & IssueResolveContext[K];
}[IssueResolveContextKey];
export interface Issue {
readonly key: IssueKey;
@@ -59,6 +64,11 @@ export interface Issue {
// Explicitly trigger this issue with key-specific context.
trigger?(context: IssueTriggerContext[IssueTriggerContextKey]): void;
// The inverse of `trigger`: the context names what recovered, and only that
// part of the issue's state is dropped. Contrast `reset`, which discards
// everything the issue is holding.
resolve?(context: IssueResolveContext[IssueResolveContextKey]): void;
hasIssue(): boolean;
getIssue(): IssueDescription | null;
+5 -15
View File
@@ -17,9 +17,8 @@ interface ActiveEntry {
// A change delivered to subscribers: a target's active media loading or
// unloading, or the selected target changing.
export type MediaLoadedInfoChange =
// A target's media (re)loaded. `cached` marks a replay (a reconnect
// re-dispatch, not an actual reload) rather than a genuine load.
| { type: 'load'; targetID: string; info: MediaLoadedInfo; cached: boolean }
// A target's media (re)loaded, or a reconnect re-dispatched its last load.
| { type: 'load'; targetID: string; info: MediaLoadedInfo }
// A target's media was retired.
| { type: 'unload'; targetID: string }
@@ -68,11 +67,7 @@ export class MediaLoadedInfoManager {
}
}
public set(
mediaLoadedInfo: MediaLoadedInfo,
owner: MediaLoadedInfoOwner,
cached?: boolean,
): void {
public set(mediaLoadedInfo: MediaLoadedInfo, owner: MediaLoadedInfoOwner): void {
if (!isValidMediaLoadedInfo(mediaLoadedInfo) || !mediaLoadedInfo.targetID) {
return;
}
@@ -93,12 +88,7 @@ export class MediaLoadedInfoManager {
// Notify for every target, not just the selected one (e.g. a background
// grid camera).
this._notify({
type: 'load',
targetID,
info: mediaLoadedInfo,
cached: cached ?? false,
});
this._notify({ type: 'load', targetID, info: mediaLoadedInfo });
}
public setSelected(targetID: string | null): void {
@@ -132,7 +122,7 @@ export class MediaLoadedInfoManager {
if (!(owner instanceof HTMLElement) || !targetID) {
return;
}
this.set(ev.detail.info, owner, ev.detail.cached);
this.set(ev.detail.info, owner);
onAbort(ev.detail.signal, () => this._clearTarget(targetID, owner));
}
+16 -1
View File
@@ -16,8 +16,13 @@ import 'web-dialog';
import { actionHandler } from './action-handler-directive.js';
import { ConfigManager } from './card-controller/config/config-manager';
import { CardController } from './card-controller/controller';
import type { IssueKey, IssueTriggerEventData } from './card-controller/issues/types.js';
import type {
IssueKey,
IssueResolveEventData,
IssueTriggerEventData,
} from './card-controller/issues/types.js';
import { resolveAutoHideState, type AutoHideState } from './components-lib/auto-hide.js';
import type { MicrophoneError } from './components-lib/live/utils/dispatch-microphone-error.js';
import { MenuButtonController } from './components-lib/menu-button-controller';
import './components/effects/effects';
@@ -427,6 +432,16 @@ class AdvancedCameraCard extends LitElement {
detail: { key, ...context },
}: CustomEvent<IssueTriggerEventData>) =>
this._controller.getIssueManager().trigger(key, context)}
@advanced-camera-card:issue:resolve=${({
detail: { key, ...context },
}: CustomEvent<IssueResolveEventData>) =>
this._controller.getIssueManager().resolve(key, context)}
@advanced-camera-card:microphone:error=${({
detail,
}: CustomEvent<MicrophoneError>) =>
this._controller
.getCallManager()
.reportCallMicrophoneError(detail.targetID, detail.description)}
@advanced-camera-card:media:loaded=${(
ev: CustomEvent<MediaLoadedInfoEventDetail>,
) => this._controller.getMediaLoadedInfoManager().handleLoadEvent(ev)}
@@ -43,7 +43,8 @@ export class EntityAvailabilityDetector implements LivenessDetector {
public subscribe(): void {
this._active = true;
this._watch();
this._subscribeOrUnsubscribeFromCameraEntity();
this._check();
}
public unsubscribe(): void {
@@ -56,32 +57,34 @@ export class EntityAvailabilityDetector implements LivenessDetector {
}
public reset(): void {
// Re-point the subscription at the (possibly different) camera entity and
// start fresh.
this._verdict = { state: 'unknown' };
this._timer.stop();
this._watch();
// A reset is not a stop: watching continues, only what was learned is
// forgotten. `getCameraEntity` may now name a different entity, so re-point
// the subscription and read that entity now.
this._subscribeOrUnsubscribeFromCameraEntity();
this._check();
}
public getVerdict(): LivenessVerdict {
return this._verdict;
}
// Point the subscription at the current camera entity and re-check its state.
private _watch(): void {
private _subscribeOrUnsubscribeFromCameraEntity(): void {
if (!this._active) {
return;
}
const stateWatcher = this._config.getStateWatcher();
const entityID = this._config.getCameraEntity();
if (entityID !== this._watchedEntity) {
stateWatcher?.unsubscribe(this._onEntityStateChange);
this._watchedEntity = entityID;
if (entityID) {
stateWatcher?.subscribe(this._onEntityStateChange, [entityID]);
}
if (entityID === this._watchedEntity) {
return;
}
stateWatcher?.unsubscribe(this._onEntityStateChange);
this._watchedEntity = entityID;
if (entityID) {
stateWatcher?.subscribe(this._onEntityStateChange, [entityID]);
}
this._check();
}
private _onEntityStateChange = (difference: HassStateDifference): void =>
@@ -93,6 +96,9 @@ export class EntityAvailabilityDetector implements LivenessDetector {
this._evaluate(difference.newState.state);
private _check(): void {
if (!this._active) {
return;
}
const stateObj = this._watchedEntity
? this._config.getHASS()?.states[this._watchedEntity]
: undefined;
@@ -114,6 +114,13 @@ export class MediaPlayerLivenessDetector implements LivenessDetector {
this._watchedPlayer = target;
if (player?.subscribeLiveness) {
// A `live` verdict left over from the last watch means media was flowing
// then, not now. Drop it, so `live` always means something seen during
// this watch. The `not_live` hold below is kept on purpose.
if (this._verdict.state === 'live') {
this._setVerdict({ state: 'unknown' });
}
// Start (or resume) watching; the verdict stays `unknown` until a real
// frame or a stall is observed.
this._unsubscribeLiveness = player.subscribeLiveness((isLive) =>
@@ -49,7 +49,7 @@ export class ProviderErrorDetector implements LivenessDetector {
state: 'not_live',
authority: 'hard',
reason: ev.detail.reason ?? 'playback_error',
description: ev.detail.detail,
description: ev.detail.description,
};
this._onChange();
}
@@ -3,7 +3,10 @@ import type { ReactiveController, ReactiveControllerHost } from 'lit';
import type { Camera } from '../../../camera-manager/camera';
import type { StateWatcherSubscriptionInterface } from '../../../card-controller/hass/state-watcher';
import type { MediaUnavailableIssueReason } from '../../../card-controller/issues/issues/media-unavailable';
import type { IssueTriggerEventData } from '../../../card-controller/issues/types';
import type {
IssueResolveEventData,
IssueTriggerEventData,
} from '../../../card-controller/issues/types';
import type { CameraConfig } from '../../../config/schema/cameras';
import type { HomeAssistant } from '../../../ha/types';
import { fireAdvancedCameraCardEvent } from '../../../utils/fire-advanced-camera-card-event';
@@ -96,6 +99,7 @@ export class StreamLivenessController implements ReactiveController {
private _host: ReactiveControllerHost & HTMLElement;
private _config: StreamLivenessControllerConfig;
private _detectors: LivenessDetector[];
private _resetting = false;
constructor(
host: ReactiveControllerHost & HTMLElement,
@@ -150,7 +154,20 @@ export class StreamLivenessController implements ReactiveController {
// Discard detector state on a stream change (e.g. a stream switch).
public reset(): void {
this._detectors.forEach((detector) => detector.reset?.());
// The detectors are cleared one at a time, and clearing one could cause it
// report a change (e.g. an entity might be marked as having an unknown
// state). Part-way through, some are cleared and some are not, so what they
// add up to is meaningless and this controller needs to not take action
// during this time. Ignore anything detectors say until the reset is
// complete.
this._resetting = true;
try {
this._detectors.forEach((detector) => detector.reset?.());
} finally {
this._resetting = false;
}
this._onDetectorChange();
}
// Reduce the detectors to a single verdict. Direct evidence from the media
@@ -179,9 +196,15 @@ export class StreamLivenessController implements ReactiveController {
}
private _onDetectorChange(): void {
if (this._resetting) {
return;
}
const verdict = this._getVerdict();
if (verdict.state === 'not_live') {
this._triggerMediaUnavailableIssue(verdict.reason, verdict.description);
} else if (verdict.state === 'live') {
this._resolveMediaUnavailableIssue();
}
this._host.requestUpdate();
}
@@ -203,4 +226,15 @@ export class StreamLivenessController implements ReactiveController {
description,
});
}
private _resolveMediaUnavailableIssue(): void {
const targetID = this._config.getTargetID();
if (!targetID) {
return;
}
fireAdvancedCameraCardEvent<IssueResolveEventData>(this._host, 'issue:resolve', {
key: 'media_unavailable',
targetID,
});
}
}
@@ -7,11 +7,11 @@ import { OffscreenImage } from './offscreen-image';
import type { ImageSurface } from './session-controller';
// Liveness: while frames are expected, a gap beyond the window is a stall.
// Omitted -> the surface reports no liveness. `stallWindowSeconds` defaults to
// Omitted -> the surface reports no liveness. `getStallAfterSeconds` defaults to
// the standard frame-stall window.
interface ImageSurfaceLivenessOptions {
isFrameExpected: () => boolean;
stallWindowSeconds?: number;
getStallAfterSeconds?: () => number;
}
interface ImageSurfaceOptions {
@@ -96,7 +96,12 @@ interface Go2RTCSessionCallbacks {
// stream; a higher level should take over (e.g. the card's media-load retry).
// The reason is the most recent source failure, or null when there is none
// (e.g. the socket dropped with no source having reported a cause).
errorCallback: (reason: StreamSourceFailureReason | null) => void;
streamErrorCallback: (reason: StreamSourceFailureReason | null) => void;
// The outbound microphone could not be used, so the camera cannot be talked
// to. The inbound video is unaffected. `error` is what the source knows about
// the failure, when it knows anything.
microphoneErrorCallback: (error?: string) => void;
}
// Injectable platform and factory seams for tests. Every field defaults to
@@ -184,7 +189,7 @@ export class Go2RTCSessionController {
// The most recent source failure on this connection, handed to the error
// callback when the session finally gives up so the card can name the cause.
// Null before any failure and after a healthy commit.
private _lastFailureReason: StreamSourceFailureReason | null = null;
private _lastStreamFailureReason: StreamSourceFailureReason | null = null;
private _retryTimer = new RetryTimer(RECONNECT_INTERVAL_SECONDS);
@@ -227,7 +232,7 @@ export class Go2RTCSessionController {
public reset(): void {
this._retryTimer.reset();
this._lastFailureReason = null;
this._lastStreamFailureReason = null;
this._teardownLanes();
this._channel?.close();
@@ -359,7 +364,7 @@ export class Go2RTCSessionController {
},
failedCallback: (reason: StreamSourceFailureReason) => {
if (source) {
this._lastFailureReason = reason;
this._lastStreamFailureReason = reason;
this._logSourceFailure('binary', reason, mode);
this._handleBinaryFailed(context, source);
}
@@ -448,7 +453,7 @@ export class Go2RTCSessionController {
},
failedCallback: (reason) => {
if (source) {
this._lastFailureReason = reason;
this._lastStreamFailureReason = reason;
this._logSourceFailure('webrtc', reason);
this._handleWebRTCFailed(context, source);
}
@@ -458,6 +463,7 @@ export class Go2RTCSessionController {
source = (this._options?.createWebRTCSource ?? createWebRTCSource)(sourceContext, {
microphoneStream: this._microphoneStream,
microphoneErrorCallback: (error) => this._callbacks.microphoneErrorCallback(error),
createPeerConnection: this._options?.createPeerConnection,
createMediaStream: this._options?.createMediaStream,
});
@@ -628,7 +634,7 @@ export class Go2RTCSessionController {
private _reconnectOrEscalateError(context: ConnectionContext): void {
if (this._retryTimer.getAttempts() >= RECONNECT_MAX_ATTEMPTS) {
this._callbacks.errorCallback(this._lastFailureReason);
this._callbacks.streamErrorCallback(this._lastStreamFailureReason);
return;
}
this._retryTimer.schedule(() => this._connectChannel(context.url, context.surfaces));
@@ -660,7 +666,7 @@ export class Go2RTCSessionController {
this._committedSource = source;
this._retryTimer.reset();
this._lastFailureReason = null;
this._lastStreamFailureReason = null;
if (this._committedSurface && this._committedSurface !== surface) {
this._resetSurface(context, this._committedSurface);
@@ -89,6 +89,7 @@ export interface CreateWebRTCSourceOptions {
createPeerConnection?: PeerConnectionFactory;
createMediaStream?: MediaStreamFactory;
microphoneStream?: MediaStream | null;
microphoneErrorCallback?: (error?: string) => void;
}
export type WebRTCSourceFactory = (
@@ -4,6 +4,7 @@ import type {
UnsubscribeCallback,
} from '../../../../../types';
import { has2WayAudio, hasAudio } from '../../../../../utils/audio';
import { isRecord } from '../../../../../utils/basic';
import { Timer } from '../../../../../utils/timer';
import {
createBrowserPeerConnection,
@@ -38,10 +39,32 @@ const WEBRTC_CONNECT_TIMEOUT_SECONDS = 5;
export type MediaStreamFactory = (tracks: MediaStreamTrack[]) => MediaStream;
// What a thrown value has to say for itself, preferring the browser's sentence
// ("The peer connection is closed") over the bare type name
// ("InvalidStateError"), which means nothing to the person reading it.
//
// DOMException may not inherit from Error, and catch blocks may be handed
// anything, so extract details structurally rather than using `instanceof
// Error`.
const getErrorDescription = (error: unknown): string | null => {
if (!isRecord(error)) {
return null;
}
const message = typeof error.message === 'string' ? error.message : '';
const name = typeof error.name === 'string' ? error.name : '';
return message || name || null;
};
interface WebRTCStreamSourceOptions {
createPeerConnection?: PeerConnectionFactory;
createMediaStream?: MediaStreamFactory;
microphoneStream?: MediaStream | null;
// The outbound microphone track could not be attached. Separate from the
// stream-source failure channel: a microphone that cannot attach says nothing
// about the inbound video which keeps playing. `error` is what the browser
// said went wrong, when it said anything.
microphoneErrorCallback?: (error?: string) => void;
}
export class WebRTCStreamSource implements StreamSource {
@@ -52,6 +75,7 @@ export class WebRTCStreamSource implements StreamSource {
private _createPeerConnection: PeerConnectionFactory;
private _createMediaStream: MediaStreamFactory;
private _microphoneStream: MediaStream | null;
private _microphoneErrorCallback: ((error?: string) => void) | null;
private _microphoneTransceiver: RTCRtpTransceiver | null = null;
@@ -75,6 +99,7 @@ export class WebRTCStreamSource implements StreamSource {
options?.createMediaStream ?? ((tracks) => new MediaStream(tracks));
this._microphoneStream = options?.microphoneStream ?? null;
this._microphoneErrorCallback = options?.microphoneErrorCallback ?? null;
}
public start(): void {
@@ -183,9 +208,7 @@ export class WebRTCStreamSource implements StreamSource {
};
}
// Swap the outbound microphone track without renegotiating. Guards against a
// late rejection from a superseded call (a newer stream, or teardown)
// bringing a retired connection back or overwriting a fresher request.
// Swap the outbound microphone track without renegotiating.
public async setMicrophoneStream(stream: MediaStream | null): Promise<void> {
if (this._microphoneStream === stream) {
return;
@@ -199,17 +222,27 @@ export class WebRTCStreamSource implements StreamSource {
return;
}
// Whether the awaited microphone request is still the one in effect: a newer
// stream, or teardown, retires it, and reporting a retired outcome would
// describe something that is no longer being attempted.
const isCurrentRequest = (
transceiver: RTCRtpTransceiver,
stream: MediaStream | null,
): boolean =>
transceiver === this._microphoneTransceiver &&
this._microphoneStream === stream &&
this._pc !== null;
// A microphone stream carries a single audio track; null detaches the sender.
const desiredTrack = stream?.getAudioTracks()[0] ?? null;
try {
await transceiver.sender.replaceTrack(desiredTrack);
} catch {
const stillCurrent =
transceiver === this._microphoneTransceiver &&
this._microphoneStream === stream &&
this._pc !== null;
if (stillCurrent) {
this._context.callbacks.failedCallback('two_way_audio_error');
} catch (error) {
// Only a failed attach is reported. A failed detach leaves nothing for
// the user to act on: the track stops being transmitted when the peer
// connection closes.
if (desiredTrack && isCurrentRequest(transceiver, stream)) {
this._microphoneErrorCallback?.(getErrorDescription(error) ?? undefined);
}
}
}
@@ -95,7 +95,6 @@ export interface StreamProfile {
}
export type StreamSourceFailureReason =
| 'two_way_audio_error'
| 'buffer_overflow'
| 'connect_timeout'
| 'media_error'
@@ -4,13 +4,12 @@ import type { StreamSourceFailureReason } from '../types';
// The card's media-unavailable causes are user-facing; a source's failure
// reasons are technical. Only a media error and a buffer overflow have no
// distinct user story, so they map to a generic playback error; the rest each
// keep a meaningful cause -- a server rejection, an unsupported stream, a failed
// two-way-audio call, or a timeout that means the stream never got going.
const FAILURE_TO_ISSUE_REASON: Record<
// keep a meaningful cause -- a server rejection, an unsupported stream, or a
// timeout that means the stream never got going.
const STREAM_FAILURE_TO_ISSUE_REASON: Record<
StreamSourceFailureReason,
MediaUnavailableIssueReason
> = {
two_way_audio_error: 'two_way_audio_error',
buffer_overflow: 'playback_error',
connect_timeout: 'not_loading',
media_error: 'playback_error',
@@ -21,7 +20,7 @@ const FAILURE_TO_ISSUE_REASON: Record<
// A null reason is a connection-level failure with no source detail (e.g. the
// socket dropped), which reads as a generic playback error.
export const mapFailureReasonToIssueReason = (
export const mapStreamFailureReasonToIssueReason = (
reason: StreamSourceFailureReason | null,
): MediaUnavailableIssueReason =>
reason === null ? 'playback_error' : FAILURE_TO_ISSUE_REASON[reason];
reason === null ? 'playback_error' : STREAM_FAILURE_TO_ISSUE_REASON[reason];
@@ -10,7 +10,7 @@ export interface LiveError {
// Free text naming the specific failure (e.g. "Failed to start WebRTC stream:
// ..."). Absent when the provider has none.
detail?: string;
description?: string;
}
declare global {
@@ -0,0 +1,27 @@
import { fireAdvancedCameraCardEvent } from '../../../utils/fire-advanced-camera-card-event';
// What a provider knows about its own microphone related failure. Stream
// otherwise not impacted (contrast with `live:error`: which marks the whole
// stream not live).
export interface MicrophoneError {
// The base camera the provider is rendering. Carried because this event is
// handled once for the whole card, unlike `live:error` which is caught and
// stopped on the camera's own provider wrapper and so needs no camera named.
targetID: string;
// Free text naming the specific failure, when the provider has one.
description?: string;
}
declare global {
interface HTMLElementEventMap {
'advanced-camera-card:microphone:error': CustomEvent<MicrophoneError>;
}
}
export function dispatchMicrophoneErrorEvent(
element: EventTarget,
error: MicrophoneError,
): void {
fireAdvancedCameraCardEvent<MicrophoneError>(element, 'microphone:error', error);
}
@@ -38,6 +38,13 @@ type TargetedMediaLoadedInfo = MediaLoadedInfo & { targetID: string };
* without needing the underlying media to re-fire a load (e.g., HaHlsPlayer
* keeps the same `<video>`).
*
* That replay assumes the media outlived the detach. A host that instead
* destroys its media -- whenever it does so, on disconnect or otherwise -- must
* say so with `clear()`, or the replay would describe a player that no longer
* exists and consumers will be told media is loaded when none is. `set` and
* `clear` are the two halves of the same obligation: report what is really
* there.
*
* Aggregator parents (e.g., `ha-camera-stream`) sit on the bubble path; they
* can `stopPropagation` on inner-leaf events and dispatch their own via their
* own source controller, so consumers above the boundary only see the
@@ -68,9 +75,9 @@ export class MediaLoadedInfoSourceController implements ReactiveController {
public hostConnected(): void {
// Two early-returns:
// - `!_lastSet`: nothing to replay -- either the host has never seen a
// media load or the cache was discarded as stale on a prior reconnect
// (see below).
// - `!_lastSet`: nothing to replay -- the host has never seen a media
// load, or destroyed the media it had (`clear`), or the cache was
// discarded as stale on a prior reconnect (see below).
// - `_abort` non-null: a dispatch is already live, meaning we're already
// registered with consumers. Re-firing would orphan the prior
// `AbortController` (no one would ever abort it) and emit a duplicate
@@ -84,8 +91,7 @@ export class MediaLoadedInfoSourceController implements ReactiveController {
// flipped while we were disconnected. Replaying the cached info under a
// stale targetID would misregister with the manager.
if (this._lastSet.targetID === this._config.getTargetID()) {
// A reconnect replay of the last load, not a fresh media load.
this._dispatchLoad(this._lastSet, true);
this._dispatchLoad(this._lastSet);
} else {
this._lastSet = null;
}
@@ -131,16 +137,24 @@ export class MediaLoadedInfoSourceController implements ReactiveController {
this._dispatchLoad(validated);
}
// The media this source announced is gone. Retires the registration through
// the same signal a disconnect uses, and forgets the load so nothing is
// replayed on a later reconnect: the host must `set` again when it has media
// to announce.
public clear(): void {
this._unload();
this._lastSet = null;
}
private _unload(): void {
this._abort?.abort();
this._abort = null;
}
private _dispatchLoad(info: TargetedMediaLoadedInfo, cached?: boolean): void {
private _dispatchLoad(info: TargetedMediaLoadedInfo): void {
this._abort = new AbortController();
fireAdvancedCameraCardEvent<MediaLoadedInfoEventDetail>(this._host, 'media:loaded', {
info,
cached,
signal: this._abort.signal,
});
}
@@ -26,8 +26,10 @@ export interface FrameStallWatchdogConfig {
// Seconds without a frame (while playback is expected) before a stall is
// reported. Defaults to FRAME_STALL_SECONDS; a slow source (e.g. a snapshot
// that refreshes every N seconds) needs a window at least as long as N.
stallAfterSeconds?: number;
// that refreshes every N seconds) needs a window at least as long as N. Read
// each time the timer is armed, so a source whose pace the user can change
// gets the window it has now.
getStallAfterSeconds?: () => number;
}
/**
@@ -44,7 +46,6 @@ export interface FrameStallWatchdogConfig {
*/
export class FrameStallWatchdog {
private _config: FrameStallWatchdogConfig;
private _stallAfterSeconds: number;
private _timer = new Timer();
private _callbacks = new Set<LivenessCallback>();
@@ -57,7 +58,6 @@ export class FrameStallWatchdog {
constructor(config: FrameStallWatchdogConfig) {
this._config = config;
this._stallAfterSeconds = config.stallAfterSeconds ?? FRAME_STALL_SECONDS;
}
public subscribe(callback: LivenessCallback): UnsubscribeCallback {
@@ -65,6 +65,11 @@ export class FrameStallWatchdog {
this._callbacks.add(callback);
if (hadNoSubscribers) {
this._start();
} else if (this._isLive !== null) {
// Someone else is already watching and a frame or a stall has been seen,
// so tell the newcomer what that was rather than leaving it waiting for
// the next one. Nothing went unwatched in between, so it is not stale.
callback(this._isLive);
}
return (): void => {
@@ -81,7 +86,7 @@ export class FrameStallWatchdog {
if (!this._sourceActive) {
return;
}
this._timer.start(this._stallAfterSeconds, () => this._onStall());
this._startStallTimer();
// Notify last: if this recovery notification prompts the final subscriber
// to unsubscribe, `_stop` then clears the timer just armed instead of
@@ -97,10 +102,16 @@ export class FrameStallWatchdog {
// watching begins is still detected. With no source there is nothing to
// arm, so nothing is ever reported.
if (this._sourceActive) {
this._timer.start(this._stallAfterSeconds, () => this._onStall());
this._startStallTimer();
}
}
private _startStallTimer(): void {
this._timer.start(this._config.getStallAfterSeconds?.() ?? FRAME_STALL_SECONDS, () =>
this._onStall(),
);
}
private _stop(): void {
this._timer.stop();
if (this._sourceActive) {
@@ -120,7 +131,7 @@ export class FrameStallWatchdog {
// Legitimately idle (paused / seeking / ended). Re-arm rather than stop: a
// source that later resumes already frozen delivers no frame to kick the
// timer, so a freeze that only becomes actionable later is still caught.
this._timer.start(this._stallAfterSeconds, () => this._onStall());
this._startStallTimer();
}
private _setLive(isLive: boolean): void {
+3 -3
View File
@@ -20,13 +20,13 @@ export interface ImageUpdateControl {
}
// Liveness for an image stream: each <img> `load` is a frame; a gap longer than
// the window while frames are expected is a stall. `stallWindowSeconds`
// the window while frames are expected is a stall. `getStallAfterSeconds`
// defaults to the standard frame-stall window (suits a push-fed stream); a
// timer-refreshed image may set a different one, should be at least its refresh
// interval.
interface ImageLivenessOptions {
isFrameExpected: () => boolean;
stallWindowSeconds?: number;
getStallAfterSeconds?: () => number;
}
// Obtaining a screenshot. Defaults to drawing the current <img>; an image
@@ -81,7 +81,7 @@ export class ImageMediaPlayerController implements MediaPlayerController {
if (livenessOptions) {
const stallWatchdog = new FrameStallWatchdog({
isPlaybackExpected: livenessOptions.isFrameExpected,
stallAfterSeconds: livenessOptions.stallWindowSeconds,
getStallAfterSeconds: livenessOptions.getStallAfterSeconds,
startSource: () => this._startFrameSource(),
stopSource: () => this._stopFrameSource(),
});
+2 -1
View File
@@ -1,5 +1,6 @@
import type {
Notification,
NotificationContextItem,
NotificationDetail,
} from '../../config/schema/actions/types.js';
import type { Link } from '../../config/schema/common/link.js';
@@ -12,7 +13,7 @@ export interface NotificationOptions {
icon?: string;
link?: Link;
metadata?: NotificationDetail[];
context?: object;
context?: NotificationContextItem;
in_progress?: boolean;
}
+12
View File
@@ -16,6 +16,7 @@ import type { MediaUnavailableIssueReason } from '../card-controller/issues/issu
import type { IssueTriggerEventData } from '../card-controller/issues/types.js';
import { CachedValueController } from '../components-lib/cached-value-controller.js';
import { MediaLoadedInfoSourceController } from '../components-lib/media-loaded-info-source-controller.js';
import { FRAME_STALL_SECONDS } from '../components-lib/media-player/frame-stall-watchdog.js';
import { ImageMediaPlayerController } from '../components-lib/media-player/image.js';
import { createMediaNotification } from '../components-lib/notification/media.js';
import {
@@ -176,6 +177,17 @@ export class AdvancedCameraCardImageUpdatingPlayer
isRunning: () => this._cachedValueController.hasTimer(),
},
screenshotProvider: async () => this._cachedValueController.getValue(),
livenessOptions: {
// Frames are only due while the refresh timer runs. A snapshot with
// refreshing switched off shows one picture forever, which is the
// configured behaviour and never a stall.
isFrameExpected: () => this._cachedValueController.hasTimer(),
// Allow a whole refresh interval to pass, plus the standard window, so
// one slow fetch is not mistaken for a stopped camera.
getStallAfterSeconds: () =>
(this._getEffectiveRefreshSeconds() ?? 0) + FRAME_STALL_SECONDS,
},
},
);
@@ -21,8 +21,9 @@ import {
type VideoSurface,
} from '../../../../components-lib/live/providers/go2rtc-experimental/session-controller.js';
import type { SurfaceKind } from '../../../../components-lib/live/providers/go2rtc-experimental/types.js';
import { mapFailureReasonToIssueReason } from '../../../../components-lib/live/providers/go2rtc-experimental/utils/failure-reason.js';
import { mapStreamFailureReasonToIssueReason } from '../../../../components-lib/live/providers/go2rtc-experimental/utils/stream-failure-reason.js';
import { dispatchLiveErrorEvent } from '../../../../components-lib/live/utils/dispatch-live-error.js';
import { dispatchMicrophoneErrorEvent } from '../../../../components-lib/live/utils/dispatch-microphone-error.js';
import { MediaLoadedInfoSourceController } from '../../../../components-lib/media-loaded-info-source-controller.js';
import { VideoMediaPlayerController } from '../../../../components-lib/media-player/video.js';
import {
@@ -163,12 +164,24 @@ export class AdvancedCameraCardGo2RTCExperimental
// failure's user-facing cause) so the card's media-load retry (reconnecting
// indicator, backoff, give-up) runs and can name why. The provider renders
// the error itself (below); the event drives the liveness verdict + retry.
errorCallback: (reason) => {
this._streamError = mapFailureReasonToIssueReason(reason);
streamErrorCallback: (reason) => {
this._streamError = mapStreamFailureReasonToIssueReason(reason);
dispatchLiveErrorEvent(this, { reason: this._streamError });
},
microphoneErrorCallback: (error) => this._reportMicrophoneError(error),
});
private _reportMicrophoneError(error?: string): void {
if (!this.targetID) {
return;
}
dispatchMicrophoneErrorEvent(this, {
targetID: this.targetID,
description: error,
});
}
public async getMediaPlayerController(): Promise<MediaPlayerController | null> {
return this._activeSurface === 'image'
? this._imageSurface.getMediaPlayer()
@@ -186,9 +199,18 @@ export class AdvancedCameraCardGo2RTCExperimental
disconnectedCallback(): void {
// Tear down synchronously so streams (e.g. 2-way audio backchannels)
// release immediately.
this._destroyMedia();
super.disconnectedCallback();
}
// Drop the session and everything it was playing on. The surfaces keep their
// elements but the session has emptied them, so the media that was announced
// no longer exists and must stop being claimed -- otherwise a later reconnect
// would replay it and the card would believe a dead camera was loaded.
private _destroyMedia(): void {
this._session.reset();
this._activeSurface = null;
super.disconnectedCallback();
this._mediaLoadedInfoSourceController.clear();
}
protected willUpdate(changedProps: PropertyValues): void {
@@ -196,8 +218,7 @@ export class AdvancedCameraCardGo2RTCExperimental
// The session is re-established by `updated()` once the new camera's
// signed URL resolves; the next commit picks the live surface. Blank the
// view meanwhile so the previous camera's last frame is not shown.
this._session.reset();
this._activeSurface = null;
this._destroyMedia();
this._streamError = null;
}
@@ -237,8 +258,7 @@ export class AdvancedCameraCardGo2RTCExperimental
// drop the session -- otherwise a later URL, even an identical unsigned
// endpoint, would be skipped by connect()'s identity check and leave the
// session bound to the removed elements.
this._session.reset();
this._activeSurface = null;
this._destroyMedia();
}
}
+6
View File
@@ -171,6 +171,12 @@ export class AdvancedCameraCardLiveJSMPEG extends LitElement implements MediaPla
this._jsmpegCanvasElement.remove();
this._jsmpegCanvasElement = undefined;
}
// The player and the canvas it drew on are gone, so the media announced
// from them no longer exists and must stop being claimed -- otherwise a
// later reconnect would replay it and the card would believe a dead camera
// was loaded.
this._mediaLoadedInfoSourceController.clear();
}
connectedCallback(): void {
@@ -100,6 +100,13 @@ export class AdvancedCameraCardLiveWebRTCCard extends LitElement implements Medi
disconnectedCallback(): void {
this._videoRTC = null;
this._notification = null;
// A reconnect builds a brand new WebRTC element, so the video that was
// announced is gone and must stop being claimed -- otherwise the card would
// be told media is loaded during the window where there is no element at
// all, and would keep believing it if the replacement never loads.
this._mediaLoadedInfoSourceController.clear();
super.disconnectedCallback();
}
+1
View File
@@ -191,6 +191,7 @@ export type NotificationControl = z.infer<typeof notificationControlSchema>;
// A context item is a preformatted string or a structured object that is
// YAML-dumped at render time (see NotificationContextController).
const notificationContextItemSchema = z.union([z.string(), z.custom<object>(isRecord)]);
export type NotificationContextItem = z.infer<typeof notificationContextItemSchema>;
const notificationSchema = z.object({
heading: notificationDetailSchema.optional(),
+2
View File
@@ -11,6 +11,8 @@ declare module 'view' {
declare module 'issue' {
// eslint-disable-next-line @typescript-eslint/no-empty-object-type
interface IssueTriggerContext {}
// eslint-disable-next-line @typescript-eslint/no-empty-object-type
interface IssueResolveContext {}
}
declare module 'action' {
// eslint-disable-next-line @typescript-eslint/no-empty-object-type
+1 -1
View File
@@ -807,6 +807,7 @@
"awaiting_live": "Waiting for live stream to load...",
"awaiting_media": "Waiting for media to load",
"call_invalid_target": "The requested camera or stream is not available to call.",
"call_microphone_failed": "Your microphone could not be connected.",
"call_microphone_forbidden": "Microphone access has been denied for this page. Update your browser permissions and try again.",
"call_microphone_unsupported": "Microphone access is not available in this browser (e.g. requires HTTPS).",
"call_no_two_way_audio": "This camera does not support two-way audio.",
@@ -897,7 +898,6 @@
"playback_error": "Playback error",
"server_error": "Streaming server error",
"stalled": "Stream stalled",
"two_way_audio_error": "Two-way audio error",
"unsupported": "Stream not supported"
},
"text": "The media is not currently available. This can happen for several reasons, for example a camera becoming unavailable, a stream stalling or failing, or media that has not finished loading. The card keeps retrying automatically. For live views, a still image may be shown while a stream is loading (if configured, and by default)"
+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, { detail: this._error });
dispatchLiveErrorEvent(this, { description: this._error });
}
this._lastErrored = errored;
}
-6
View File
@@ -59,12 +59,6 @@ export type MediaLoadedInfoOwner = HTMLElement;
export interface MediaLoadedInfoEventDetail {
info: MediaLoadedInfo;
// Absent (the default, a fresh media load) unless `true`, which marks a
// replay: the source re-dispatched its last load on reconnect without the
// media actually reloading (e.g. preloaded camera in the background when
// brought to the foreground).
cached?: boolean;
// Aborts when the source retires this media. The source aborts on host
// disconnect, and when a subsequent `set()` arrives under a different
// `targetID` (replacing this dispatch). Independent of DOM connectedness, so
@@ -945,6 +945,65 @@ describe('endIf', () => {
});
});
describe('reportCallMicrophoneError', () => {
it('should report a microphone that could not be attached', async () => {
const api = createAPI({ view: createView({ camera: 'camera.office' }) });
const manager = new CallManager(api);
expect(await manager.start()).toBe(true);
vi.mocked(api.getNotificationManager().setNotification).mockClear();
manager.reportCallMicrophoneError('camera.office', 'The peer connection is closed');
expect(api.getNotificationManager().setNotification).toHaveBeenCalledWith({
heading: { text: 'Two-way audio unavailable' },
body: { text: 'Your microphone could not be connected.' },
context: ['The peer connection is closed'],
});
});
it('should omit the context when the browser provided none', async () => {
const api = createAPI({ view: createView({ camera: 'camera.office' }) });
const manager = new CallManager(api);
expect(await manager.start()).toBe(true);
vi.mocked(api.getNotificationManager().setNotification).mockClear();
manager.reportCallMicrophoneError('camera.office');
expect(api.getNotificationManager().setNotification).toHaveBeenCalledWith(
expect.not.objectContaining({ context: expect.anything() }),
);
});
it('should not report without a call', () => {
const api = createAPI({ view: createView({ camera: 'camera.office' }) });
new CallManager(api).reportCallMicrophoneError('camera.office');
expect(api.getNotificationManager().setNotification).not.toHaveBeenCalled();
});
it('should not report while an inbound call is still ringing', async () => {
const api = createAPI({ view: createView({ camera: 'camera.office' }) });
const manager = new CallManager(api);
expect(await manager.start({ inbound: true })).toBe(true);
manager.reportCallMicrophoneError('camera.office');
expect(api.getNotificationManager().setNotification).not.toHaveBeenCalled();
});
it('should not report for a camera the call is not on', async () => {
const api = createAPI({ view: createView({ camera: 'camera.office' }) });
const manager = new CallManager(api);
expect(await manager.start()).toBe(true);
vi.mocked(api.getNotificationManager().setNotification).mockClear();
manager.reportCallMicrophoneError('camera.other');
expect(api.getNotificationManager().setNotification).not.toHaveBeenCalled();
});
});
describe('condition state changes', () => {
it('should end the call when the selected camera changes away', async () => {
const api = createAPI({ view: createView({ camera: 'camera.office' }) });
@@ -265,6 +265,45 @@ describe('IssueManager', () => {
});
});
describe('resolve', () => {
it('should resolve the issue and update the card once it clears', () => {
const api = createCardAPI();
const manager = new IssueManager(api);
const issue = createIssue('media_unavailable', {
hasIssue: vi.fn().mockReturnValue(true),
getIssue: vi.fn().mockReturnValue(createIssueDescription()),
resolve: vi.fn(),
});
manager.addIssue(issue);
// Establish the issue as present, then let resolving remove it.
manager.evaluate();
vi.mocked(api.getCardElementManager().update).mockClear();
vi.mocked(issue.getIssue).mockReturnValue(null);
manager.resolve('media_unavailable', { targetID: 'camera-1' });
expect(issue.resolve).toHaveBeenCalledWith({ targetID: 'camera-1' });
expect(api.getCardElementManager().update).toHaveBeenCalled();
});
it('should stop retrying once resolve removes the last retryable problem', () => {
const { manager, issue } = createRetriableSetup();
assert(issue.resolve);
assert(issue.needsRetry);
// Arm the retry timer while the problem is unresolved.
manager.evaluate();
vi.mocked(issue.needsRetry).mockReturnValue(false);
manager.resolve('media_unavailable', { targetID: 'camera-1' });
vi.advanceTimersByTime(DEFAULT_RETRY_SECONDS * 1000 * 10);
expect(issue.retry).not.toHaveBeenCalled();
});
});
describe('retry', () => {
it('should call retry on the manager and reset the timer', () => {
const { manager, issue } = createRetriableSetup();
@@ -19,18 +19,12 @@ const fireMediaChange = (
vi.mocked(api.getMediaLoadedInfoManager().subscribe).mock.calls[0]?.[0]?.(change);
};
// Simulate a media (re)load for `targetID`. `cached` marks a reconnect replay
// (a re-dispatch of the last load, not an actual reload).
const fireMediaLoad = (
api: ReturnType<typeof createAPI>,
targetID: string,
cached = false,
): void => {
// Simulate a media (re)load for `targetID`.
const fireMediaLoad = (api: ReturnType<typeof createAPI>, targetID: string): void => {
fireMediaChange(api, {
type: 'load',
targetID,
info: createMediaLoadedInfo({ targetID }),
cached,
});
};
@@ -305,15 +299,15 @@ describe('MediaUnavailableIssue', () => {
expect(issue.hasIssue()).toBe(false);
});
it('should clear a target error on a genuine media load', () => {
it('should clear a not-loading error on a media load', () => {
const api = createAPI();
const issue = new MediaUnavailableIssue(api);
issue.trigger({ targetID: 'camera-1', reason: 'stalled' });
issue.trigger({ targetID: 'camera-1', reason: 'not_loading' });
issue.detectDynamic({ targetID: 'camera-1', view: 'live' });
expect(issue.hasIssue()).toBe(true);
// A genuine (re)load for the target clears the error.
// An attached player disproves "media not loading", whoever recorded it.
fireMediaLoad(api, 'camera-1');
// The error is gone, so this unloaded state falls back to the timer
@@ -322,6 +316,21 @@ describe('MediaUnavailableIssue', () => {
expect(issue.hasIssue()).toBe(false);
});
it('should not clear a stream error on a media load', () => {
const api = createAPI();
const issue = new MediaUnavailableIssue(api);
issue.trigger({ targetID: 'camera-1', reason: 'stalled' });
// A load only says a player attached -- for a stream that loaded and then
// froze, that includes a reconnect replay of the frozen player. Stream
// errors clear only via resolve, on real evidence of media flowing.
fireMediaLoad(api, 'camera-1');
issue.detectDynamic({ targetID: 'camera-1', view: 'live' });
expect(issue.hasIssue()).toBe(true);
});
it('should keep an errored target active while its media still reads as loaded', () => {
const issue = new MediaUnavailableIssue(createAPI());
@@ -343,7 +352,7 @@ describe('MediaUnavailableIssue', () => {
const api = createAPI();
const issue = new MediaUnavailableIssue(api);
issue.trigger({ targetID: 'camera-1', reason: 'stalled' });
issue.trigger({ targetID: 'camera-1', reason: 'not_loading' });
// A load for a different target must not clear camera-1's error.
fireMediaLoad(api, 'camera-2');
@@ -352,32 +361,13 @@ describe('MediaUnavailableIssue', () => {
expect(issue.hasIssue()).toBe(true);
});
it('should not clear a target error on a reconnect replay', () => {
const api = createAPI();
const issue = new MediaUnavailableIssue(api);
issue.trigger({ targetID: 'camera-1', reason: 'stalled' });
fireMediaLoad(
api,
'camera-1',
// A cached replay (reconnect re-dispatch) did not actually reload the
// media, so it must not clear the error.
true,
);
issue.detectDynamic({ targetID: 'camera-1', view: 'live' });
expect(issue.hasIssue()).toBe(true);
});
it('should not clear a target error on unload or select changes', () => {
const api = createAPI();
const issue = new MediaUnavailableIssue(api);
issue.trigger({ targetID: 'camera-1', reason: 'stalled' });
issue.trigger({ targetID: 'camera-1', reason: 'not_loading' });
// Only a genuine load clears; unload / select changes are irrelevant.
// Only a load clears; unload / select changes are irrelevant.
fireMediaChange(api, { type: 'unload', targetID: 'camera-1' });
fireMediaChange(api, { type: 'select', targetID: 'camera-1' });
issue.detectDynamic({ targetID: 'camera-1', view: 'live' });
@@ -399,6 +389,78 @@ describe('MediaUnavailableIssue', () => {
});
});
describe('resolve', () => {
it('should clear an errored target', () => {
const issue = new MediaUnavailableIssue(createAPI());
issue.trigger({ targetID: 'camera.office', reason: 'stalled' });
expect(issue.getNotification().metadata).toEqual([
expect.objectContaining({ text: 'camera.office: Stream stalled' }),
]);
issue.resolve({ targetID: 'camera.office' });
expect(issue.getNotification().metadata).toBeUndefined();
});
it('should deactivate a target that is proven to be delivering media again', () => {
const issue = new MediaUnavailableIssue(createAPI());
issue.trigger({ targetID: 'camera-1', reason: 'stalled' });
issue.detectDynamic({
targetID: 'camera-1',
view: 'live',
mediaLoadedInfo: createMediaLoadedInfo({ targetID: 'camera-1' }),
});
expect(issue.hasIssue()).toBe(true);
issue.resolve({ targetID: 'camera-1' });
issue.detectDynamic({
targetID: 'camera-1',
view: 'live',
mediaLoadedInfo: createMediaLoadedInfo({ targetID: 'camera-1' }),
});
expect(issue.hasIssue()).toBe(false);
});
it('should leave other targets errored', () => {
const issue = new MediaUnavailableIssue(createAPI());
issue.trigger({ targetID: 'camera.office', reason: 'stalled' });
issue.resolve({ targetID: 'camera.garden' });
expect(issue.getNotification().metadata).toEqual([
expect.objectContaining({ text: 'camera.office: Stream stalled' }),
]);
});
it('should cancel the pending-load timer for its target', () => {
const onChange = vi.fn();
const issue = new MediaUnavailableIssue(createAPI(), onChange);
issue.detectDynamic({ targetID: 'camera-1', view: 'live' });
issue.resolve({ targetID: 'camera-1' });
vi.advanceTimersByTime(10000);
expect(issue.hasIssue()).toBe(false);
expect(onChange).not.toHaveBeenCalled();
});
it('should leave the pending-load timer alone for a different target', () => {
const issue = new MediaUnavailableIssue(createAPI());
issue.detectDynamic({ targetID: 'camera-1', view: 'live' });
issue.resolve({ targetID: 'camera-2' });
vi.advanceTimersByTime(10000);
expect(issue.hasIssue()).toBe(true);
});
});
describe('getNotification', () => {
it('should return notification regardless of active state', () => {
const issue = new MediaUnavailableIssue(createAPI());
@@ -673,6 +735,28 @@ describe('MediaUnavailableIssue', () => {
});
});
it('should not retry a stale pending-timer target once its timer has stopped', () => {
const api = createAPI();
vi.mocked(api.getViewManager().getView).mockReturnValue(mock<View>());
const issue = new MediaUnavailableIssue(api);
// A slow load arms the pending timer for camera.garden.
issue.detectDynamic({ targetID: 'camera.garden', view: 'live' });
// The view moves to a target that already has a hard error. That path
// activates immediately and stops the timer, but the stale
// _timerTargetID (camera.garden) lingers -- and that target may since
// have loaded, so reloading it would be gratuitous.
issue.trigger({ targetID: 'camera.office', reason: 'playback_error' });
issue.detectDynamic({ targetID: 'camera.office', view: 'live' });
issue.retry();
expect(api.getViewManager().setViewWithMergedContext).toHaveBeenCalledWith({
mediaEpoch: { 'camera.office': 1 },
});
});
it('should keep errored targets and issue state after retry', () => {
const api = createAPI();
vi.mocked(api.getViewManager().getView).mockReturnValue(mock<View>());
@@ -685,8 +769,8 @@ describe('MediaUnavailableIssue', () => {
issue.retry();
// After retry, the issue stays active and the errored target is preserved
// -- no new 10s grace period. A genuine media load would clear everything
// (_onMediaLoad drops the errored target).
// -- no new 10s grace period. Recovery clears it: a load for a
// not-loading error, a resolve for a stream error.
expect(issue.hasIssue()).toBe(true);
issue.detectDynamic({ targetID: 'camera-1', view: 'live' });
expect(issue.hasIssue()).toBe(true);
@@ -714,12 +798,63 @@ describe('MediaUnavailableIssue', () => {
const onChange = vi.fn();
const issue = new MediaUnavailableIssue(api, onChange);
issue.trigger({ targetID: 'camera-1', reason: 'stalled' });
issue.trigger({ targetID: 'camera-1', reason: 'not_loading' });
fireMediaLoad(api, 'camera-1');
expect(onChange).toHaveBeenCalled();
});
it('should not notify onChange when a load changes nothing', () => {
const api = createAPI();
const onChange = vi.fn();
const issue = new MediaUnavailableIssue(api, onChange);
issue.trigger({ targetID: 'camera-1', reason: 'stalled' });
fireMediaLoad(api, 'camera-1');
expect(onChange).not.toHaveBeenCalled();
});
it('should clear a timer-recorded error when the media later loads', () => {
const api = createAPI();
const issue = new MediaUnavailableIssue(api);
// A viewer target has no liveness observer, so a load is the only
// recovery signal it will ever produce.
issue.detectDynamic({ targetID: 'media-1', view: 'clip' });
vi.advanceTimersByTime(10000);
expect(issue.getNotification().metadata).toEqual([
expect.objectContaining({ text: 'media-1: Media not loading' }),
]);
fireMediaLoad(api, 'media-1');
expect(issue.getNotification().metadata).toBeUndefined();
issue.detectDynamic({
targetID: 'media-1',
view: 'clip',
mediaLoadedInfo: createMediaLoadedInfo({ targetID: 'media-1' }),
});
expect(issue.hasIssue()).toBe(false);
});
it('should cancel the pending-load timer when its target genuinely loads', () => {
const api = createAPI();
const issue = new MediaUnavailableIssue(api);
// A target starts loading, arming the pending-load timer.
issue.detectDynamic({ targetID: 'camera-1', view: 'live' });
// It loads in the background, so no further detection pass runs for it
// (detection only ever covers the current target).
fireMediaLoad(api, 'camera-1');
vi.advanceTimersByTime(10000);
expect(issue.hasIssue()).toBe(false);
expect(issue.getNotification().metadata).toBeUndefined();
});
it('should unsubscribe from media loads on destroy', () => {
const api = createAPI();
const unsubscribe = vi.fn();
@@ -116,6 +116,46 @@ describe('IssueStateManager', () => {
});
});
describe('resolve', () => {
it('should call resolve on the matching issue', () => {
const manager = createManager();
manager.resolve('media_unavailable', { targetID: 'cam1' });
assert(mockMediaLoad.resolve);
expect(mockMediaLoad.resolve).toHaveBeenCalledWith({ targetID: 'cam1' });
});
it('should do nothing for unknown key', () => {
const manager = createManager();
manager.resolve('unknown' as never, {} as never);
assert(mockMediaLoad.resolve);
expect(mockMediaLoad.resolve).not.toHaveBeenCalled();
});
it('should log the issue again after resolving cleared it', () => {
const spy = vi.spyOn(console, 'warn').mockReturnValue(undefined);
const manager = createManager([mockMediaLoad]);
vi.mocked(mockMediaLoad.getIssue).mockReturnValue(createIssueDescription());
manager.trigger('media_unavailable', { targetID: 'cam1', reason: 'stalled' });
expect(spy).toHaveBeenCalledTimes(1);
// Resolving clears the issue, which releases the dedupe so the next
// failure is logged as a new episode rather than silently swallowed.
vi.mocked(mockMediaLoad.getIssue).mockReturnValue(null);
manager.resolve('media_unavailable', { targetID: 'cam1' });
vi.mocked(mockMediaLoad.getIssue).mockReturnValue(createIssueDescription());
manager.trigger('media_unavailable', { targetID: 'cam1', reason: 'stalled' });
expect(spy).toHaveBeenCalledTimes(2);
spy.mockRestore();
});
});
describe('detectDynamic', () => {
it('should call detectDynamic on issues with the given state', () => {
const manager = createManager();
@@ -441,25 +441,6 @@ describe('MediaLoadedInfoManager', () => {
type: 'load',
targetID: 'target-1',
info,
cached: false,
});
});
it('should mark a cached load', () => {
const api = createCardAPI();
const manager = new MediaLoadedInfoManager(api);
const owner = document.createElement('div');
const info = createMediaLoadedInfo({ targetID: 'target-1' });
const listener = vi.fn();
manager.subscribe(listener);
manager.set(info, owner, true);
expect(listener).toHaveBeenCalledWith({
type: 'load',
targetID: 'target-1',
info,
cached: true,
});
});
@@ -219,6 +219,22 @@ describe('EntityAvailabilityDetector', () => {
expect(detector.getVerdict()).toEqual({ state: 'unknown' });
});
it('should re-check the entity when reset', () => {
// always_error makes the re-check produce a verdict immediately rather than
// waiting out the grace window.
const { detector, setEntityState } = setup({ alwaysError: true });
detector.subscribe();
// An entity that is already unavailable never fires a state change, so
// resetting must read it rather than wait to be told.
setEntityState('unavailable');
detector.reset();
expect(detector.getVerdict()).toEqual(
expect.objectContaining({ state: 'not_live', authority: 'hard' }),
);
});
it('should do nothing on reset before subscribe', () => {
const { detector, stateWatcher } = setup();
@@ -163,6 +163,26 @@ describe('MediaPlayerLivenessDetector', () => {
expect(onChange).toHaveBeenCalledTimes(1);
});
it('should discard a retained live verdict when watching resumes', async () => {
const { detector, onChange, loadMedia } = setup();
const { player, fireMediaPlayerLiveness } = createPlayer();
detector.subscribe();
loadMedia(player);
await callIntersectionHandler(true);
fireMediaPlayerLiveness(true);
// Away and back with nothing observed in between. The retained `live`
// describes the previous watch, so it must not survive into this one.
detector.unsubscribe();
onChange.mockClear();
detector.subscribe();
loadMedia(player);
await callIntersectionHandler(true);
expect(detector.getVerdict()).toEqual({ state: 'unknown' });
expect(onChange).toHaveBeenCalled();
});
it('should not watch a player without the liveness capability', async () => {
const { detector, onChange, loadMedia } = setup();
const player = mock<MediaPlayerController>();
@@ -13,6 +13,19 @@ const createHostInDocument = (): HTMLElement => {
// @vitest-environment jsdom
describe('ProviderErrorDetector', () => {
it('should not report a change when reset', () => {
const host = createHostInDocument();
const onChange = vi.fn();
const detector = new ProviderErrorDetector(host, onChange);
detector.subscribe();
dispatchLiveErrorEvent(host);
onChange.mockClear();
detector.reset();
expect(onChange).not.toHaveBeenCalled();
});
it('should start unknown', () => {
const detector = new ProviderErrorDetector(document.createElement('div'), vi.fn());
@@ -43,7 +56,7 @@ describe('ProviderErrorDetector', () => {
dispatchLiveErrorEvent(host, {
reason: 'unsupported',
detail: 'Codec not supported',
description: 'Codec not supported',
});
expect(detector.getVerdict()).toEqual({
@@ -23,6 +23,7 @@ import {
} from '../../../test-utils';
const ISSUE_TRIGGER_EVENT = 'advanced-camera-card:issue:trigger';
const ISSUE_RESOLVE_EVENT = 'advanced-camera-card:issue:resolve';
const setup = (options?: { targetID?: string | null }) => {
const host = createLitElement();
@@ -41,11 +42,16 @@ const setup = (options?: { targetID?: string | null }) => {
issueTriggers.push((ev as CustomEvent).detail),
);
const issueResolves: unknown[] = [];
host.addEventListener(ISSUE_RESOLVE_EVENT, (ev) =>
issueResolves.push((ev as CustomEvent).detail),
);
const failViaProviderError = (error?: LiveError): void => {
dispatchLiveErrorEvent(host, error);
};
return { host, controller, issueTriggers, failViaProviderError };
return { host, controller, issueTriggers, issueResolves, failViaProviderError };
};
const createPlayer = (): {
@@ -139,7 +145,9 @@ describe('StreamLivenessController', () => {
const { controller, issueTriggers, failViaProviderError } = setup();
controller.hostConnected();
failViaProviderError({ detail: 'Failed to start WebRTC stream: no candidates' });
failViaProviderError({
description: 'Failed to start WebRTC stream: no candidates',
});
expect(controller.getFailure()).toEqual({
reason: 'playback_error',
@@ -338,6 +346,129 @@ describe('StreamLivenessController', () => {
vi.useRealTimers();
});
describe('resolving the issue', () => {
const setupWithLiveStream = async () => {
const result = setup();
const { player, fireMediaPlayerLiveness } = createPlayer();
result.controller.hostConnected();
result.host.dispatchEvent(
createMediaLoadedInfoEvent({
info: createMediaLoadedInfo({ mediaPlayerController: player }),
}),
);
await callIntersectionHandler(true);
return { ...result, fireMediaPlayerLiveness };
};
it('should resolve when frames confirm the stream is flowing', async () => {
const { issueResolves, fireMediaPlayerLiveness } = await setupWithLiveStream();
fireMediaPlayerLiveness(true);
expect(issueResolves).toEqual([
{ key: 'media_unavailable', targetID: 'camera.office' },
]);
});
it('should not resolve while a hard failure exists', async () => {
const { issueResolves, failViaProviderError, fireMediaPlayerLiveness } =
await setupWithLiveStream();
// A provider has authoritatively condemned the stream. Frames continuing
// to arrive must not talk the card out of it.
failViaProviderError();
fireMediaPlayerLiveness(true);
expect(issueResolves).toEqual([]);
});
it('should not resolve without a target', async () => {
const result = setup({ targetID: null });
const { player, fireMediaPlayerLiveness } = createPlayer();
result.controller.hostConnected();
result.host.dispatchEvent(
createMediaLoadedInfoEvent({
info: createMediaLoadedInfo({ mediaPlayerController: player }),
}),
);
await callIntersectionHandler(true);
fireMediaPlayerLiveness(true);
expect(result.issueResolves).toEqual([]);
});
it('should not resolve on reconnect without fresh evidence', async () => {
const { controller, issueResolves, fireMediaPlayerLiveness } =
await setupWithLiveStream();
fireMediaPlayerLiveness(true);
issueResolves.length = 0;
// Away and back with nothing observed in between: the previous `live` is
// a memory of the old watch, not evidence about the new one.
controller.hostDisconnected();
controller.hostConnected();
expect(issueResolves).toEqual([]);
});
it('should not announce recovery for a stream that was reset away', async () => {
const { controller, issueResolves, fireMediaPlayerLiveness } =
await setupWithLiveStream();
fireMediaPlayerLiveness(true);
issueResolves.length = 0;
// The stream this `live` describes is being torn down, so resetting must
// not report it as recovered.
controller.reset();
expect(issueResolves).toEqual([]);
});
it('should not mix a stale live verdict with a freshly reset one', async () => {
const { controller, host, issueResolves, issueTriggers, failViaProviderError } =
setup();
const { player, fireMediaPlayerLiveness } = createPlayer();
controller.hostConnected();
host.dispatchEvent(
createMediaLoadedInfoEvent({
info: createMediaLoadedInfo({ mediaPlayerController: player }),
}),
);
await callIntersectionHandler(true);
// Frames say live, then a provider error condemns the stream. Both
// verdicts are held at once, by different detectors.
fireMediaPlayerLiveness(true);
failViaProviderError();
issueResolves.length = 0;
issueTriggers.length = 0;
// Resetting clears them in turn. If any detector announced part-way
// through, the cleared provider error would leave the stale `live`
// unopposed and the card would report a recovery that never happened.
controller.reset();
expect(issueResolves).toEqual([]);
expect(issueTriggers).toEqual([]);
});
it('should re-read the detectors once they have all been reset', async () => {
const { controller, host, fireMediaPlayerLiveness } = await setupWithLiveStream();
fireMediaPlayerLiveness(true);
vi.mocked(host.requestUpdate).mockClear();
controller.reset();
// Anything the detectors say while being reset is ignored, so this is the
// single read the controller makes once they are all done.
expect(host.requestUpdate).toHaveBeenCalledTimes(1);
expect(controller.isLive()).toBe(true);
});
});
it('should report not live despite confirmed frames when always_error overrides', async () => {
const host = createLitElement();
document.body.append(host);
@@ -47,7 +47,7 @@ describe('ImageSurfaceController', () => {
it('should have liveness when given liveness options', () => {
const controller = new ImageSurfaceController(createLitElement(), () => null, {
livenessOptions: { isFrameExpected: () => true, stallWindowSeconds: 10 },
livenessOptions: { isFrameExpected: () => true, getStallAfterSeconds: () => 10 },
});
expect(controller.getMediaPlayer().subscribeLiveness).toBeDefined();
@@ -178,7 +178,8 @@ describe('Go2RTCSessionController', () => {
const getControls = vi.fn(() => options?.controls ?? false);
const mediaLoadedCallback = vi.fn();
const surfaceCommittedCallback = vi.fn();
const errorCallback = vi.fn();
const streamErrorCallback = vi.fn();
const microphoneErrorCallback = vi.fn();
const session = new Go2RTCSessionController(
{
@@ -186,7 +187,8 @@ describe('Go2RTCSessionController', () => {
getCardWideConfig: () => options?.cardWideConfig ?? null,
mediaLoadedCallback,
surfaceCommittedCallback,
errorCallback,
streamErrorCallback,
microphoneErrorCallback,
},
{ createWebSocket, createBinarySource, createWebRTCSource, createVideoElement },
);
@@ -201,7 +203,8 @@ describe('Go2RTCSessionController', () => {
createBinarySource,
createWebRTCSource,
createWebSocket,
errorCallback,
streamErrorCallback,
microphoneErrorCallback,
mediaLoadedCallback,
offscreenVideos,
session,
@@ -281,7 +284,8 @@ describe('Go2RTCSessionController', () => {
surfaceCommittedCallback: vi.fn(),
getCardWideConfig: () => null,
mediaLoadedCallback: vi.fn(),
errorCallback: vi.fn(),
streamErrorCallback: vi.fn(),
microphoneErrorCallback: vi.fn(),
});
session.connect('ws://localhost:1/api/ws', createSurfaces().surfaces, ['mse']);
session.reset();
@@ -536,6 +540,30 @@ describe('Go2RTCSessionController', () => {
expect(webRTCOptions[0]?.microphoneStream).toBe(micStream);
});
it('should report a microphone error without disturbing the stream', () => {
const {
session,
surfaces,
websockets,
webRTCOptions,
webRTCSources,
microphoneErrorCallback,
streamErrorCallback,
} = setup();
session.connect('http://host/api/ws?src=camera', surfaces, ['webrtc']);
websockets[0].fireOpen();
webRTCOptions[0]?.microphoneErrorCallback?.('InvalidStateError');
expect(microphoneErrorCallback).toHaveBeenCalledWith('InvalidStateError');
// The inbound video is unaffected by an outbound audio failure, so the
// source keeps running and the session neither escalates nor reconnects.
expect(streamErrorCallback).not.toHaveBeenCalled();
expect(webRTCSources[0].stop).not.toHaveBeenCalled();
expect(websockets).toHaveLength(1);
});
it('should re-dispatch loaded media on an audio mute transition', () => {
const peerConnection = new FakeRTCPeerConnection();
const audioTransceiver = peerConnection.addTransceiver('audio', {
@@ -863,7 +891,8 @@ describe('Go2RTCSessionController', () => {
});
it('should escalate via the error callback after exhausting reconnect attempts', () => {
const { session, surfaces, websockets, createWebSocket, errorCallback } = setup();
const { session, surfaces, websockets, createWebSocket, streamErrorCallback } =
setup();
session.connect('http://host/api/ws?src=camera', surfaces, ['mse']);
// Each fresh connection closes before loading, consuming one reconnect
@@ -878,16 +907,17 @@ describe('Go2RTCSessionController', () => {
websockets[3].fireClose();
expect(createWebSocket).toHaveBeenCalledTimes(4);
expect(errorCallback).toHaveBeenCalledTimes(1);
expect(streamErrorCallback).toHaveBeenCalledTimes(1);
// The socket dropped with no source reporting a cause.
expect(errorCallback).toHaveBeenCalledWith(null);
expect(streamErrorCallback).toHaveBeenCalledWith(null);
vi.advanceTimersByTime(2 * 1000);
expect(createWebSocket).toHaveBeenCalledTimes(4);
});
it('should escalate with the most recent source failure reason', () => {
const { session, surfaces, websockets, binaryContexts, errorCallback } = setup();
const { session, surfaces, websockets, binaryContexts, streamErrorCallback } =
setup();
session.connect('http://host/api/ws?src=camera', surfaces, ['mse']);
// Each attempt: the single binary source fails, which drains the mode
@@ -901,7 +931,7 @@ describe('Go2RTCSessionController', () => {
websockets[3].fireOpen();
binaryContexts[3].callbacks.failedCallback('unsupported');
expect(errorCallback).toHaveBeenCalledWith('unsupported');
expect(streamErrorCallback).toHaveBeenCalledWith('unsupported');
});
it('should reset the reconnect budget after a successful media load', () => {
@@ -911,7 +941,7 @@ describe('Go2RTCSessionController', () => {
websockets,
binaryContexts,
createWebSocket,
errorCallback,
streamErrorCallback,
} = setup();
session.connect('http://host/api/ws?src=camera', surfaces, ['mse']);
@@ -933,7 +963,7 @@ describe('Go2RTCSessionController', () => {
websockets[attempt + 1].fireOpen();
}
expect(errorCallback).not.toHaveBeenCalled();
expect(streamErrorCallback).not.toHaveBeenCalled();
expect(createWebSocket).toHaveBeenCalledTimes(6);
});
@@ -1081,7 +1111,8 @@ describe('Go2RTCSessionController', () => {
surfaceCommittedCallback: vi.fn(),
getCardWideConfig: () => null,
mediaLoadedCallback,
errorCallback: vi.fn(),
streamErrorCallback: vi.fn(),
microphoneErrorCallback: vi.fn(),
},
{ createWebSocket, createBinarySource },
);
@@ -1115,7 +1146,8 @@ describe('Go2RTCSessionController', () => {
surfaceCommittedCallback: vi.fn(),
getCardWideConfig: () => null,
mediaLoadedCallback,
errorCallback: vi.fn(),
streamErrorCallback: vi.fn(),
microphoneErrorCallback: vi.fn(),
},
{ createWebSocket, createWebRTCSource },
);
@@ -1141,7 +1173,8 @@ describe('Go2RTCSessionController', () => {
surfaceCommittedCallback: vi.fn(),
getCardWideConfig: () => null,
mediaLoadedCallback: vi.fn(),
errorCallback: vi.fn(),
streamErrorCallback: vi.fn(),
microphoneErrorCallback: vi.fn(),
},
{ createWebSocket },
);
@@ -1172,7 +1205,8 @@ describe('Go2RTCSessionController', () => {
surfaceCommittedCallback: vi.fn(),
getCardWideConfig: () => null,
mediaLoadedCallback: vi.fn(),
errorCallback: vi.fn(),
streamErrorCallback: vi.fn(),
microphoneErrorCallback: vi.fn(),
},
{ createWebSocket },
);
@@ -1212,7 +1246,8 @@ describe('Go2RTCSessionController', () => {
surfaceCommittedCallback: vi.fn(),
getCardWideConfig: () => null,
mediaLoadedCallback: vi.fn(),
errorCallback: vi.fn(),
streamErrorCallback: vi.fn(),
microphoneErrorCallback: vi.fn(),
},
{ createWebSocket, createBinarySource, createWebRTCSource },
);
@@ -29,11 +29,13 @@ describe('WebRTCStreamSource', () => {
const pc = new FakeRTCPeerConnection();
const createPeerConnection = vi.fn(() => pc.asPeerConnection());
const microphoneErrorCallback = vi.fn();
const source = new WebRTCStreamSource(context, {
createPeerConnection,
createMediaStream: (tracks) =>
new FakeMediaStream(tracks as unknown as FakeMediaStreamTrack[]).asMediaStream(),
microphoneStream: options?.microphoneStream?.asMediaStream() ?? null,
microphoneErrorCallback,
});
return {
@@ -42,6 +44,7 @@ describe('WebRTCStreamSource', () => {
createPeerConnection,
failedCallback,
loadedCallback,
microphoneErrorCallback,
pc,
source,
video,
@@ -506,29 +509,77 @@ describe('WebRTCStreamSource', () => {
});
it('should do nothing before there is a peer connection', async () => {
const { source, failedCallback } = setup();
const { source, microphoneErrorCallback } = setup();
await source.setMicrophoneStream(
new FakeMediaStream([new FakeMediaStreamTrack('audio')]).asMediaStream(),
);
expect(failedCallback).not.toHaveBeenCalled();
expect(microphoneErrorCallback).not.toHaveBeenCalled();
});
it('should fail when a current replaceTrack rejects', async () => {
it.each([
[
'what the browser said when the rejection has a message',
new DOMException('The peer connection is closed', 'InvalidStateError'),
'The peer connection is closed',
],
[
'the rejection type when there is no message to quote',
new DOMException('', 'InvalidStateError'),
'InvalidStateError',
],
['nothing when the rejection is not an object', 'nope', undefined],
[
'nothing when the rejection describes itself with neither',
{ message: 5, name: 7 },
undefined,
],
] as const)(
'should report %s when a current replaceTrack rejects',
async (_summary, rejection, expected) => {
const { source, pc, microphoneErrorCallback } = setup();
source.start();
pc.getMicrophoneTransceiver().sender.replaceTrack.mockRejectedValue(rejection);
await source.setMicrophoneStream(
new FakeMediaStream([new FakeMediaStreamTrack('audio')]).asMediaStream(),
);
expect(microphoneErrorCallback).toHaveBeenCalledWith(expected);
},
);
it('should not fail the stream source when the microphone cannot attach', async () => {
const { source, pc, failedCallback } = setup();
source.start();
pc.getMicrophoneTransceiver().sender.replaceTrack.mockRejectedValue(
new Error('replace failed'),
new DOMException('replace failed', 'InvalidStateError'),
);
await source.setMicrophoneStream(
new FakeMediaStream([new FakeMediaStreamTrack('audio')]).asMediaStream(),
);
expect(failedCallback).toHaveBeenCalledWith('two_way_audio_error');
// The inbound video is unaffected by an outbound audio failure, so the
// source must keep running rather than failing over to another one.
expect(failedCallback).not.toHaveBeenCalled();
});
it('should not report a rejection when detaching the microphone', async () => {
const stream = new FakeMediaStream([new FakeMediaStreamTrack('audio')]);
const { source, pc, microphoneErrorCallback } = setup({
microphoneStream: stream,
});
source.start();
pc.getMicrophoneTransceiver().sender.replaceTrack.mockRejectedValue(
new DOMException('The peer connection is closed', 'InvalidStateError'),
);
await source.setMicrophoneStream(null);
// Ignore the error, the user is not trying to be heard anyway.
expect(microphoneErrorCallback).not.toHaveBeenCalled();
});
it('should ignore a stale replaceTrack rejection after stop', async () => {
const { source, pc, failedCallback } = setup();
const { source, pc, microphoneErrorCallback } = setup();
source.start();
let rejectReplace: (reason: Error) => void = () => {};
pc.getMicrophoneTransceiver().sender.replaceTrack.mockReturnValue(
@@ -543,7 +594,7 @@ describe('WebRTCStreamSource', () => {
rejectReplace(new Error('replace failed'));
await promise;
expect(failedCallback).not.toHaveBeenCalled();
expect(microphoneErrorCallback).not.toHaveBeenCalled();
});
});
});
@@ -1,21 +1,20 @@
import { describe, expect, it } from 'vitest';
import { mapFailureReasonToIssueReason } from '../../../../../../src/components-lib/live/providers/go2rtc-experimental/utils/failure-reason';
import { mapStreamFailureReasonToIssueReason } from '../../../../../../src/components-lib/live/providers/go2rtc-experimental/utils/stream-failure-reason';
describe('mapFailureReasonToIssueReason', () => {
describe('mapStreamFailureReasonToIssueReason', () => {
it.each([
['connect_timeout', 'not_loading'],
['negotiation_timeout', 'not_loading'],
['media_error', 'playback_error'],
['buffer_overflow', 'playback_error'],
['two_way_audio_error', 'two_way_audio_error'],
['server_error', 'server_error'],
['unsupported', 'unsupported'],
] as const)('should map %s to the %s cause', (reason, expected) => {
expect(mapFailureReasonToIssueReason(reason)).toBe(expected);
expect(mapStreamFailureReasonToIssueReason(reason)).toBe(expected);
});
it('should map a null reason to a generic playback error', () => {
expect(mapFailureReasonToIssueReason(null)).toBe('playback_error');
expect(mapStreamFailureReasonToIssueReason(null)).toBe('playback_error');
});
});
@@ -19,11 +19,11 @@ it('should forward the reason and detail as the event detail', () => {
dispatchLiveErrorEvent(element, {
reason: 'unsupported',
detail: 'Codec not supported',
description: 'Codec not supported',
});
expect(handler).toHaveBeenCalledWith(
expect.objectContaining({
detail: { reason: 'unsupported', detail: 'Codec not supported' },
detail: { reason: 'unsupported', description: 'Codec not supported' },
}),
);
});
@@ -60,9 +60,6 @@ describe('MediaLoadedInfoSourceController', () => {
});
expect(ev.detail.signal).toBeInstanceOf(AbortSignal);
expect(ev.detail.signal.aborted).toBe(false);
// A fresh load omits `cached` (only a replay marks it true).
expect(ev.detail.cached).toBeUndefined();
});
it('should dedup structurally-equal info', () => {
@@ -179,10 +176,6 @@ describe('MediaLoadedInfoSourceController', () => {
expect(firstSignal).not.toBe(secondSignal);
expect(firstSignal.aborted).toBe(true);
expect(secondSignal.aborted).toBe(false);
// The fresh load omits `cached`; the reconnect replay marks it `true`.
expect((handler.mock.calls[0][0] as CustomEvent).detail.cached).toBeUndefined();
expect((handler.mock.calls[1][0] as CustomEvent).detail.cached).toBe(true);
});
it('should be a no-op when there is nothing to re-dispatch', () => {
@@ -248,6 +241,79 @@ describe('MediaLoadedInfoSourceController', () => {
});
});
describe('clear', () => {
it('should retire the registration so consumers clean up', () => {
const host = createLitElement();
const controller = new MediaLoadedInfoSourceController(host, {
getTargetID: () => 'target-1',
});
const handler = vi.fn();
host.addEventListener('advanced-camera-card:media:loaded', handler);
controller.set(createMediaLoadedInfo());
const signal = (handler.mock.calls[0][0] as CustomEvent).detail.signal;
const cleanup = vi.fn();
signal.addEventListener('abort', cleanup);
controller.clear();
expect(cleanup).toHaveBeenCalled();
expect(signal.aborted).toBe(true);
});
it('should leave nothing to replay on a later reconnect', () => {
const host = createLitElement();
const controller = new MediaLoadedInfoSourceController(host, {
getTargetID: () => 'target-1',
});
const handler = vi.fn();
host.addEventListener('advanced-camera-card:media:loaded', handler);
controller.set(createMediaLoadedInfo());
handler.mockClear();
// The host destroyed its media, so the reconnect has nothing truthful to
// announce: replaying would describe a player that no longer exists.
controller.clear();
controller.hostDisconnected();
controller.hostConnected();
expect(handler).not.toHaveBeenCalled();
});
it('should announce fresh media after a clear', () => {
const host = createLitElement();
const controller = new MediaLoadedInfoSourceController(host, {
getTargetID: () => 'target-1',
});
const handler = vi.fn();
host.addEventListener('advanced-camera-card:media:loaded', handler);
controller.set(createMediaLoadedInfo());
controller.clear();
handler.mockClear();
// The same info is no longer a duplicate: the dedup compares against a
// load that has been forgotten.
controller.set(createMediaLoadedInfo());
expect(handler).toHaveBeenCalledTimes(1);
});
it('should be safe to call when nothing is active', () => {
const host = createLitElement();
const controller = new MediaLoadedInfoSourceController(host, {
getTargetID: () => 'target-1',
});
// Should not throw.
controller.clear();
});
});
describe('hostDisconnected', () => {
it('should abort the active controller so consumers clean up', () => {
const host = createLitElement();
@@ -28,6 +28,29 @@ describe('FrameStallWatchdog', () => {
});
describe('source lifecycle', () => {
it('should hand a later subscriber what has already been observed', () => {
const watchdog = new FrameStallWatchdog(createConfig());
watchdog.subscribe(vi.fn());
watchdog.notifyFrame();
const later = vi.fn();
watchdog.subscribe(later);
// Observation has been continuous, so the frame just seen is current
// evidence for the newcomer too.
expect(later).toHaveBeenCalledWith(true);
});
it('should tell a later subscriber nothing before anything is observed', () => {
const watchdog = new FrameStallWatchdog(createConfig());
watchdog.subscribe(vi.fn());
const later = vi.fn();
watchdog.subscribe(later);
expect(later).not.toHaveBeenCalled();
});
it('should start the source only on the first subscriber', () => {
const config = createConfig();
const watchdog = new FrameStallWatchdog(config);
@@ -183,6 +206,23 @@ describe('FrameStallWatchdog', () => {
expect(callback).toHaveBeenCalledTimes(1);
expect(callback).toHaveBeenCalledWith(true);
});
it('should use the stall window in force when the timer is armed', () => {
let stallAfterSeconds = 30;
const watchdog = new FrameStallWatchdog(
createConfig({ getStallAfterSeconds: () => stallAfterSeconds }),
);
const callback = vi.fn();
watchdog.subscribe(callback);
// The source slows down: the frame that arrives re-arms with the new,
// shorter window rather than the one the watchdog started with.
stallAfterSeconds = 5;
watchdog.notifyFrame();
vi.advanceTimersByTime(5 * 1000);
expect(callback).toHaveBeenLastCalledWith(false);
});
});
describe('no available source', () => {
@@ -16,10 +16,10 @@ const STALL_MS = STALL_SECONDS * 1000;
const createImageMediaPlayerWithLiveness = (
isFrameExpected: () => boolean,
getImageCallback: () => HTMLImageElement | null,
stallWindowSeconds = STALL_SECONDS,
stallAfterSeconds = STALL_SECONDS,
): ImageMediaPlayerController =>
new ImageMediaPlayerController(createLitElement(), getImageCallback, {
livenessOptions: { isFrameExpected, stallWindowSeconds },
livenessOptions: { isFrameExpected, getStallAfterSeconds: () => stallAfterSeconds },
});
// @vitest-environment jsdom