fix: Clear stale media unavailable errors when a camera recovers (#2627)
- Closes: #2576
This commit is contained in:
@@ -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];
|
||||
|
||||
@@ -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?.();
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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
@@ -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'
|
||||
|
||||
+5
-6
@@ -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 {
|
||||
|
||||
@@ -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(),
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
@@ -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(),
|
||||
|
||||
Vendored
+2
@@ -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
|
||||
|
||||
@@ -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)"
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
+1
-1
@@ -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();
|
||||
|
||||
+51
-16
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
+4
-5
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user