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

- Closes: #2576
This commit is contained in:
Dermot Duffy
2026-07-28 21:21:50 -07:00
committed by GitHub
parent 231e3087b7
commit 15b08db3e8
49 changed files with 1128 additions and 229 deletions
+32 -9
View File
@@ -74,7 +74,7 @@ export class CallManager {
.getCameraIDsWithCapability('live')
.has(parentID)
) {
this._notifyError('error.call_invalid_target', inbound);
this._notifyError('error.call_invalid_target', { inbound });
return false;
}
@@ -261,6 +261,22 @@ export class CallManager {
return this.end();
}
// The microphone could not be used for the call, so it is connected but the
// user cannot be heard. `description` is what the reporting layer knows about
// the failure, when it knows anything.
public reportCallMicrophoneError(targetID: string, description?: string): void {
const call = this._call;
// A report that no longer matches the call in progress describes an attempt
// the user has already moved past, e.g. the call ended before the provider
// finished reporting.
if (!call || !call.answered || call.cameraID !== targetID) {
return;
}
this._notifyError('error.call_microphone_failed', { context: description });
}
// Tears down everything `initialize()` set up: stops any in-flight ringtone
// and unanswered timer, drops the active call session, clears the call
// condition state, and de-registers the condition-state listener. Driven by
@@ -367,14 +383,21 @@ export class CallManager {
// Helpers
// =========================================================================
private _notifyError(messageKey: string, inbound: boolean): void {
if (inbound) {
// Don't show errors on inbound calls.
// `context` is a diagnostic the user can quote when reporting the problem.
private _notifyError(
messageKey: string,
options?: { inbound?: boolean; context?: string },
): void {
// An inbound call the user has not answered yet is not something they have
// asked for, so a failure to place it is not worth interrupting them with.
if (options?.inbound) {
return;
}
const context = options?.context;
this._api.getNotificationManager().setNotification(
createNotificationFromText(localize(messageKey), {
heading: { text: localize('error.call_unavailable_heading') },
...(context && { context }),
}),
);
}
@@ -385,7 +408,7 @@ export class CallManager {
const microphoneManager = this._api.getMicrophoneManager();
if (!microphoneManager.isSupported()) {
this._notifyError('error.call_microphone_unsupported', inbound);
this._notifyError('error.call_microphone_unsupported', { inbound });
return false;
}
@@ -394,7 +417,7 @@ export class CallManager {
// there clears the denial, and failing there reports it. An outbound call
// needs the microphone immediately, so a known denial ends it here.
if (!inbound && microphoneManager.isForbidden()) {
this._notifyError('error.call_microphone_forbidden', inbound);
this._notifyError('error.call_microphone_forbidden', { inbound });
return false;
}
@@ -428,7 +451,7 @@ export class CallManager {
}
if (!connected) {
this._notifyError('error.call_microphone_forbidden', false);
this._notifyError('error.call_microphone_forbidden');
return false;
}
return true;
@@ -453,7 +476,7 @@ export class CallManager {
.getStore()
.getAllDependentCameras(cameraID, '2-way-audio');
if (!eligibleCameraIDs.has(streamID)) {
this._notifyError('error.call_invalid_target', inbound);
this._notifyError('error.call_invalid_target', { inbound });
return null;
}
return streamID;
@@ -480,7 +503,7 @@ export class CallManager {
.getAllDependentCameras(parentID, '2-way-audio'),
];
if (!candidates.length) {
this._notifyError('error.call_no_two_way_audio', inbound);
this._notifyError('error.call_no_two_way_audio', { inbound });
return null;
}
return candidates[0];
+12 -1
View File
@@ -1,4 +1,4 @@
import type { IssueTriggerContext } from 'issue';
import type { IssueResolveContext, IssueTriggerContext } from 'issue';
import type { ConditionStateChange } from '../../condition-trigger/conditions/types';
import { contentsChanged, ignoreFunctionIdentity } from '../../utils/basic';
@@ -11,6 +11,7 @@ import type {
IssueKey,
IssuePresence,
IssueReadOnlyState,
IssueResolveContextKey,
IssueTriggerContextKey,
} from './types';
@@ -71,6 +72,16 @@ export class IssueManager {
this.evaluate();
}
// Called by components that observe a problem recovering directly (e.g. a
// stream that is proven to be delivering media again).
public resolve<K extends IssueResolveContextKey>(
key: K,
context: IssueResolveContext[K],
): void {
this._stateManager.resolve(key, context);
this.evaluate();
}
// Evaluate all dynamic issues against current state, re-render the card if
// the issue presence changed, and schedule retries.
//
@@ -1,4 +1,4 @@
import type { IssueTriggerContext } from 'issue';
import type { IssueResolveContext, IssueTriggerContext } from 'issue';
import type { ConditionState } from '../../../condition-trigger/conditions/types.js';
import type {
@@ -23,7 +23,6 @@ export type MediaUnavailableIssueReason =
| 'playback_error'
| 'server_error'
| 'stalled'
| 'two_way_audio_error'
| 'unsupported';
declare module 'issue' {
@@ -37,6 +36,12 @@ declare module 'issue' {
description?: string;
};
}
interface IssueResolveContext {
media_unavailable: {
targetID: string;
};
}
}
// What is known about one target's failure.
@@ -74,10 +79,6 @@ export const MEDIA_UNAVAILABLE_REASONS: Record<
localizationKey: 'issues.media_unavailable.reasons.stalled',
icon: 'mdi:motion-pause',
},
two_way_audio_error: {
localizationKey: 'issues.media_unavailable.reasons.two_way_audio_error',
icon: 'mdi:microphone-off',
},
unsupported: {
localizationKey: 'issues.media_unavailable.reasons.unsupported',
icon: 'mdi:video-off-outline',
@@ -102,21 +103,20 @@ export class MediaUnavailableIssue implements Issue {
this._api = api;
this._onChange = onChange ?? null;
// Clear a target's error on a genuine media (re)load.
// React to a target's media loading; unload / select changes are
// irrelevant here.
this._unsubscribeCallback = this._api
.getMediaLoadedInfoManager()
.subscribe((change) => {
// A reconnect replay (`cached`) did not actually reload the media, and
// unload / select changes are irrelevant here; only a genuine load
// clears the error.
if (change.type === 'load' && !change.cached) {
if (change.type === 'load') {
this._onMediaLoad(change.targetID);
}
});
}
// =========================================================================
// Explicit trigger -- called when a component fires an issue:trigger event.
// Explicit trigger and resolve -- called when a component fires an
// issue:trigger or issue:resolve event.
// =========================================================================
public trigger(context: IssueTriggerContext['media_unavailable']): void {
@@ -126,6 +126,14 @@ export class MediaUnavailableIssue implements Issue {
});
}
// A target is proven to be delivering media again. Stronger evidence than a
// media load, which only says a player attached, so it clears any recorded
// error.
public resolve(context: IssueResolveContext['media_unavailable']): void {
this._erroredTargets.delete(context.targetID);
this._cancelPendingTimer(context.targetID);
}
// =========================================================================
// Detection -- called by the manager on every state change.
// =========================================================================
@@ -139,8 +147,8 @@ export class MediaUnavailableIssue implements Issue {
// A known error for the current target activates immediately, even if its
// (frozen) media still reads as loaded (it might be loaded but then
// reported a playback error that stops playback but leaves the player
// attached). Errors are cleared out-of-band by `_onMediaLoad` on a genuine
// reload.
// attached). Errors are cleared out-of-band, by `resolve` or by
// `_onMediaLoad`.
if (this._hasError(state)) {
this._activate();
return;
@@ -155,9 +163,16 @@ export class MediaUnavailableIssue implements Issue {
this._handlePendingLoad(state);
}
// A genuine media (re)load for a target clears its error.
// A load proves media attached for the target. That ends any wait on it, and
// refutes a `not_loading` error. It is no evidence of recovery for any other
// reason, so those clear only via `resolve`.
private _onMediaLoad(targetID: string): void {
if (this._erroredTargets.delete(targetID)) {
let changed = this._cancelPendingTimer(targetID);
if (this._erroredTargets.get(targetID)?.reason === 'not_loading') {
this._erroredTargets.delete(targetID);
changed = true;
}
if (changed) {
this._onChange?.();
}
}
@@ -183,6 +198,7 @@ export class MediaUnavailableIssue implements Issue {
public getNotification(): Notification {
const targets = new Map(this._erroredTargets);
// The pending-load timer's target is a slow initial load that has not yet
// errored. Gate on the timer still running: once it is stopped (a hard error
// on another target took over, or the view moved on), _timerTargetID lingers
@@ -258,10 +274,12 @@ export class MediaUnavailableIssue implements Issue {
public retry(): boolean {
// Build the set of targets to retry: all errored targets plus the
// target the pending timer was tracking (so a user-initiated retry
// works even before the timeout fires).
// target the pending timer is tracking (so a user-initiated retry
// works even before the timeout fires). A stopped timer leaves
// _timerTargetID behind, so gate on it still running: that target may
// since have loaded.
const retryTargets = new Set(this._erroredTargets.keys());
if (this._timerTargetID) {
if (this._timerTargetID && this._timer.isRunning()) {
retryTargets.add(this._timerTargetID);
}
@@ -269,6 +287,8 @@ export class MediaUnavailableIssue implements Issue {
return false;
}
// Bumping a target's mediaEpoch remounts its provider, which is the card's
// only way to rebuild a stream from scratch.
const view = this._api.getViewManager().getView();
const mediaEpoch = { ...(view?.context?.mediaEpoch ?? {}) };
for (const id of retryTargets) {
@@ -276,11 +296,11 @@ export class MediaUnavailableIssue implements Issue {
}
// Intentionally keep _issueActive, _erroredTargets, and the pending
// timer in place. The issue stays visible while the provider
// re-attempts loading underneath. If the retry succeeds, the fresh media
// load clears everything (_onMediaLoad drops the errored target). If it
// fails silently (e.g. bogus stream name), the error stays visible
// immediately -- no new 10s grace period.
// timer in place. The issue stays visible while the provider re-attempts
// loading underneath. If the retry succeeds, the fresh load clears a
// not-loading error and the rebuilt provider's liveness observation
// resolves a stream error. If it fails silently (e.g. bogus stream name),
// the error stays visible immediately -- no new 10s grace period.
this._api.getViewManager().setViewWithMergedContext({ mediaEpoch });
return false;
}
@@ -314,6 +334,17 @@ export class MediaUnavailableIssue implements Issue {
// Private helpers.
// =========================================================================
// Stop waiting on a target's load, if it is the one being waited on. Returns
// whether it was.
private _cancelPendingTimer(targetID: string): boolean {
if (this._timerTargetID !== targetID) {
return false;
}
this._timer.stop();
this._timerTargetID = null;
return true;
}
// Media not yet loaded and no known error: start (or keep) a timeout to catch
// a slow or failed initial load. No targetID means no provider is rendering
// media (e.g. the viewer shows "No media to display"), so there's nothing to
@@ -337,7 +368,7 @@ export class MediaUnavailableIssue implements Issue {
this._timerTargetID = targetID;
this._timer.start(MEDIA_LOADING_TIMEOUT_SECONDS, () => {
// Record the error on timeout so retry() knows which epoch to bump.
this._erroredTargets.set(targetID, { reason: 'not_loading' });
this.trigger({ targetID, reason: 'not_loading' });
this._activate();
this._onChange?.();
});
+16 -2
View File
@@ -1,4 +1,4 @@
import type { IssueTriggerContext } from 'issue';
import type { IssueResolveContext, IssueTriggerContext } from 'issue';
import { summarizeNotification } from '../../components-lib/notification/summarize';
import type { ConditionState } from '../../condition-trigger/conditions/types';
@@ -11,6 +11,7 @@ import type {
IssueKey,
IssuePresence,
IssueReadOnlyState,
IssueResolveContextKey,
IssueTriggerContextKey,
KeyedIssueDescription,
} from './types';
@@ -28,7 +29,8 @@ export class IssueStateManager implements IssueReadOnlyState {
}
// =========================================================================
// Detection -- static (one-shot on init) and dynamic (on every state change).
// Detection -- static (one-shot on init), dynamic (on every state change) and
// explicit (triggered or resolved by a component).
// =========================================================================
public async detectStatic(hass: HomeAssistant): Promise<void> {
@@ -56,6 +58,18 @@ export class IssueStateManager implements IssueReadOnlyState {
this._logIfNew(issue);
}
public resolve<K extends IssueResolveContextKey>(
key: K,
context: IssueResolveContext[K],
): void {
const issue = this._issues.get(key);
if (!issue) {
return;
}
issue.resolve?.(context);
this._logIfNew(issue);
}
public detectDynamic(context: ConditionState): void {
for (const issue of this._issues.values()) {
issue.detectDynamic?.(context);
+11 -1
View File
@@ -1,4 +1,4 @@
import type { IssueTriggerContext } from 'issue';
import type { IssueResolveContext, IssueTriggerContext } from 'issue';
import type { ConditionState } from '../../condition-trigger/conditions/types';
import type { Notification } from '../../config/schema/actions/types';
@@ -47,6 +47,11 @@ export type IssueTriggerEventData = {
[K in IssueTriggerContextKey]: { key: K } & IssueTriggerContext[K];
}[IssueTriggerContextKey];
export type IssueResolveContextKey = keyof IssueResolveContext;
export type IssueResolveEventData = {
[K in IssueResolveContextKey]: { key: K } & IssueResolveContext[K];
}[IssueResolveContextKey];
export interface Issue {
readonly key: IssueKey;
@@ -59,6 +64,11 @@ export interface Issue {
// Explicitly trigger this issue with key-specific context.
trigger?(context: IssueTriggerContext[IssueTriggerContextKey]): void;
// The inverse of `trigger`: the context names what recovered, and only that
// part of the issue's state is dropped. Contrast `reset`, which discards
// everything the issue is holding.
resolve?(context: IssueResolveContext[IssueResolveContextKey]): void;
hasIssue(): boolean;
getIssue(): IssueDescription | null;
+5 -15
View File
@@ -17,9 +17,8 @@ interface ActiveEntry {
// A change delivered to subscribers: a target's active media loading or
// unloading, or the selected target changing.
export type MediaLoadedInfoChange =
// A target's media (re)loaded. `cached` marks a replay (a reconnect
// re-dispatch, not an actual reload) rather than a genuine load.
| { type: 'load'; targetID: string; info: MediaLoadedInfo; cached: boolean }
// A target's media (re)loaded, or a reconnect re-dispatched its last load.
| { type: 'load'; targetID: string; info: MediaLoadedInfo }
// A target's media was retired.
| { type: 'unload'; targetID: string }
@@ -68,11 +67,7 @@ export class MediaLoadedInfoManager {
}
}
public set(
mediaLoadedInfo: MediaLoadedInfo,
owner: MediaLoadedInfoOwner,
cached?: boolean,
): void {
public set(mediaLoadedInfo: MediaLoadedInfo, owner: MediaLoadedInfoOwner): void {
if (!isValidMediaLoadedInfo(mediaLoadedInfo) || !mediaLoadedInfo.targetID) {
return;
}
@@ -93,12 +88,7 @@ export class MediaLoadedInfoManager {
// Notify for every target, not just the selected one (e.g. a background
// grid camera).
this._notify({
type: 'load',
targetID,
info: mediaLoadedInfo,
cached: cached ?? false,
});
this._notify({ type: 'load', targetID, info: mediaLoadedInfo });
}
public setSelected(targetID: string | null): void {
@@ -132,7 +122,7 @@ export class MediaLoadedInfoManager {
if (!(owner instanceof HTMLElement) || !targetID) {
return;
}
this.set(ev.detail.info, owner, ev.detail.cached);
this.set(ev.detail.info, owner);
onAbort(ev.detail.signal, () => this._clearTarget(targetID, owner));
}
+16 -1
View File
@@ -16,8 +16,13 @@ import 'web-dialog';
import { actionHandler } from './action-handler-directive.js';
import { ConfigManager } from './card-controller/config/config-manager';
import { CardController } from './card-controller/controller';
import type { IssueKey, IssueTriggerEventData } from './card-controller/issues/types.js';
import type {
IssueKey,
IssueResolveEventData,
IssueTriggerEventData,
} from './card-controller/issues/types.js';
import { resolveAutoHideState, type AutoHideState } from './components-lib/auto-hide.js';
import type { MicrophoneError } from './components-lib/live/utils/dispatch-microphone-error.js';
import { MenuButtonController } from './components-lib/menu-button-controller';
import './components/effects/effects';
@@ -427,6 +432,16 @@ class AdvancedCameraCard extends LitElement {
detail: { key, ...context },
}: CustomEvent<IssueTriggerEventData>) =>
this._controller.getIssueManager().trigger(key, context)}
@advanced-camera-card:issue:resolve=${({
detail: { key, ...context },
}: CustomEvent<IssueResolveEventData>) =>
this._controller.getIssueManager().resolve(key, context)}
@advanced-camera-card:microphone:error=${({
detail,
}: CustomEvent<MicrophoneError>) =>
this._controller
.getCallManager()
.reportCallMicrophoneError(detail.targetID, detail.description)}
@advanced-camera-card:media:loaded=${(
ev: CustomEvent<MediaLoadedInfoEventDetail>,
) => this._controller.getMediaLoadedInfoManager().handleLoadEvent(ev)}
@@ -43,7 +43,8 @@ export class EntityAvailabilityDetector implements LivenessDetector {
public subscribe(): void {
this._active = true;
this._watch();
this._subscribeOrUnsubscribeFromCameraEntity();
this._check();
}
public unsubscribe(): void {
@@ -56,32 +57,34 @@ export class EntityAvailabilityDetector implements LivenessDetector {
}
public reset(): void {
// Re-point the subscription at the (possibly different) camera entity and
// start fresh.
this._verdict = { state: 'unknown' };
this._timer.stop();
this._watch();
// A reset is not a stop: watching continues, only what was learned is
// forgotten. `getCameraEntity` may now name a different entity, so re-point
// the subscription and read that entity now.
this._subscribeOrUnsubscribeFromCameraEntity();
this._check();
}
public getVerdict(): LivenessVerdict {
return this._verdict;
}
// Point the subscription at the current camera entity and re-check its state.
private _watch(): void {
private _subscribeOrUnsubscribeFromCameraEntity(): void {
if (!this._active) {
return;
}
const stateWatcher = this._config.getStateWatcher();
const entityID = this._config.getCameraEntity();
if (entityID !== this._watchedEntity) {
stateWatcher?.unsubscribe(this._onEntityStateChange);
this._watchedEntity = entityID;
if (entityID) {
stateWatcher?.subscribe(this._onEntityStateChange, [entityID]);
}
if (entityID === this._watchedEntity) {
return;
}
stateWatcher?.unsubscribe(this._onEntityStateChange);
this._watchedEntity = entityID;
if (entityID) {
stateWatcher?.subscribe(this._onEntityStateChange, [entityID]);
}
this._check();
}
private _onEntityStateChange = (difference: HassStateDifference): void =>
@@ -93,6 +96,9 @@ export class EntityAvailabilityDetector implements LivenessDetector {
this._evaluate(difference.newState.state);
private _check(): void {
if (!this._active) {
return;
}
const stateObj = this._watchedEntity
? this._config.getHASS()?.states[this._watchedEntity]
: undefined;
@@ -114,6 +114,13 @@ export class MediaPlayerLivenessDetector implements LivenessDetector {
this._watchedPlayer = target;
if (player?.subscribeLiveness) {
// A `live` verdict left over from the last watch means media was flowing
// then, not now. Drop it, so `live` always means something seen during
// this watch. The `not_live` hold below is kept on purpose.
if (this._verdict.state === 'live') {
this._setVerdict({ state: 'unknown' });
}
// Start (or resume) watching; the verdict stays `unknown` until a real
// frame or a stall is observed.
this._unsubscribeLiveness = player.subscribeLiveness((isLive) =>
@@ -49,7 +49,7 @@ export class ProviderErrorDetector implements LivenessDetector {
state: 'not_live',
authority: 'hard',
reason: ev.detail.reason ?? 'playback_error',
description: ev.detail.detail,
description: ev.detail.description,
};
this._onChange();
}
@@ -3,7 +3,10 @@ import type { ReactiveController, ReactiveControllerHost } from 'lit';
import type { Camera } from '../../../camera-manager/camera';
import type { StateWatcherSubscriptionInterface } from '../../../card-controller/hass/state-watcher';
import type { MediaUnavailableIssueReason } from '../../../card-controller/issues/issues/media-unavailable';
import type { IssueTriggerEventData } from '../../../card-controller/issues/types';
import type {
IssueResolveEventData,
IssueTriggerEventData,
} from '../../../card-controller/issues/types';
import type { CameraConfig } from '../../../config/schema/cameras';
import type { HomeAssistant } from '../../../ha/types';
import { fireAdvancedCameraCardEvent } from '../../../utils/fire-advanced-camera-card-event';
@@ -96,6 +99,7 @@ export class StreamLivenessController implements ReactiveController {
private _host: ReactiveControllerHost & HTMLElement;
private _config: StreamLivenessControllerConfig;
private _detectors: LivenessDetector[];
private _resetting = false;
constructor(
host: ReactiveControllerHost & HTMLElement,
@@ -150,7 +154,20 @@ export class StreamLivenessController implements ReactiveController {
// Discard detector state on a stream change (e.g. a stream switch).
public reset(): void {
this._detectors.forEach((detector) => detector.reset?.());
// The detectors are cleared one at a time, and clearing one could cause it
// report a change (e.g. an entity might be marked as having an unknown
// state). Part-way through, some are cleared and some are not, so what they
// add up to is meaningless and this controller needs to not take action
// during this time. Ignore anything detectors say until the reset is
// complete.
this._resetting = true;
try {
this._detectors.forEach((detector) => detector.reset?.());
} finally {
this._resetting = false;
}
this._onDetectorChange();
}
// Reduce the detectors to a single verdict. Direct evidence from the media
@@ -179,9 +196,15 @@ export class StreamLivenessController implements ReactiveController {
}
private _onDetectorChange(): void {
if (this._resetting) {
return;
}
const verdict = this._getVerdict();
if (verdict.state === 'not_live') {
this._triggerMediaUnavailableIssue(verdict.reason, verdict.description);
} else if (verdict.state === 'live') {
this._resolveMediaUnavailableIssue();
}
this._host.requestUpdate();
}
@@ -203,4 +226,15 @@ export class StreamLivenessController implements ReactiveController {
description,
});
}
private _resolveMediaUnavailableIssue(): void {
const targetID = this._config.getTargetID();
if (!targetID) {
return;
}
fireAdvancedCameraCardEvent<IssueResolveEventData>(this._host, 'issue:resolve', {
key: 'media_unavailable',
targetID,
});
}
}
@@ -7,11 +7,11 @@ import { OffscreenImage } from './offscreen-image';
import type { ImageSurface } from './session-controller';
// Liveness: while frames are expected, a gap beyond the window is a stall.
// Omitted -> the surface reports no liveness. `stallWindowSeconds` defaults to
// Omitted -> the surface reports no liveness. `getStallAfterSeconds` defaults to
// the standard frame-stall window.
interface ImageSurfaceLivenessOptions {
isFrameExpected: () => boolean;
stallWindowSeconds?: number;
getStallAfterSeconds?: () => number;
}
interface ImageSurfaceOptions {
@@ -96,7 +96,12 @@ interface Go2RTCSessionCallbacks {
// stream; a higher level should take over (e.g. the card's media-load retry).
// The reason is the most recent source failure, or null when there is none
// (e.g. the socket dropped with no source having reported a cause).
errorCallback: (reason: StreamSourceFailureReason | null) => void;
streamErrorCallback: (reason: StreamSourceFailureReason | null) => void;
// The outbound microphone could not be used, so the camera cannot be talked
// to. The inbound video is unaffected. `error` is what the source knows about
// the failure, when it knows anything.
microphoneErrorCallback: (error?: string) => void;
}
// Injectable platform and factory seams for tests. Every field defaults to
@@ -184,7 +189,7 @@ export class Go2RTCSessionController {
// The most recent source failure on this connection, handed to the error
// callback when the session finally gives up so the card can name the cause.
// Null before any failure and after a healthy commit.
private _lastFailureReason: StreamSourceFailureReason | null = null;
private _lastStreamFailureReason: StreamSourceFailureReason | null = null;
private _retryTimer = new RetryTimer(RECONNECT_INTERVAL_SECONDS);
@@ -227,7 +232,7 @@ export class Go2RTCSessionController {
public reset(): void {
this._retryTimer.reset();
this._lastFailureReason = null;
this._lastStreamFailureReason = null;
this._teardownLanes();
this._channel?.close();
@@ -359,7 +364,7 @@ export class Go2RTCSessionController {
},
failedCallback: (reason: StreamSourceFailureReason) => {
if (source) {
this._lastFailureReason = reason;
this._lastStreamFailureReason = reason;
this._logSourceFailure('binary', reason, mode);
this._handleBinaryFailed(context, source);
}
@@ -448,7 +453,7 @@ export class Go2RTCSessionController {
},
failedCallback: (reason) => {
if (source) {
this._lastFailureReason = reason;
this._lastStreamFailureReason = reason;
this._logSourceFailure('webrtc', reason);
this._handleWebRTCFailed(context, source);
}
@@ -458,6 +463,7 @@ export class Go2RTCSessionController {
source = (this._options?.createWebRTCSource ?? createWebRTCSource)(sourceContext, {
microphoneStream: this._microphoneStream,
microphoneErrorCallback: (error) => this._callbacks.microphoneErrorCallback(error),
createPeerConnection: this._options?.createPeerConnection,
createMediaStream: this._options?.createMediaStream,
});
@@ -628,7 +634,7 @@ export class Go2RTCSessionController {
private _reconnectOrEscalateError(context: ConnectionContext): void {
if (this._retryTimer.getAttempts() >= RECONNECT_MAX_ATTEMPTS) {
this._callbacks.errorCallback(this._lastFailureReason);
this._callbacks.streamErrorCallback(this._lastStreamFailureReason);
return;
}
this._retryTimer.schedule(() => this._connectChannel(context.url, context.surfaces));
@@ -660,7 +666,7 @@ export class Go2RTCSessionController {
this._committedSource = source;
this._retryTimer.reset();
this._lastFailureReason = null;
this._lastStreamFailureReason = null;
if (this._committedSurface && this._committedSurface !== surface) {
this._resetSurface(context, this._committedSurface);
@@ -89,6 +89,7 @@ export interface CreateWebRTCSourceOptions {
createPeerConnection?: PeerConnectionFactory;
createMediaStream?: MediaStreamFactory;
microphoneStream?: MediaStream | null;
microphoneErrorCallback?: (error?: string) => void;
}
export type WebRTCSourceFactory = (
@@ -4,6 +4,7 @@ import type {
UnsubscribeCallback,
} from '../../../../../types';
import { has2WayAudio, hasAudio } from '../../../../../utils/audio';
import { isRecord } from '../../../../../utils/basic';
import { Timer } from '../../../../../utils/timer';
import {
createBrowserPeerConnection,
@@ -38,10 +39,32 @@ const WEBRTC_CONNECT_TIMEOUT_SECONDS = 5;
export type MediaStreamFactory = (tracks: MediaStreamTrack[]) => MediaStream;
// What a thrown value has to say for itself, preferring the browser's sentence
// ("The peer connection is closed") over the bare type name
// ("InvalidStateError"), which means nothing to the person reading it.
//
// DOMException may not inherit from Error, and catch blocks may be handed
// anything, so extract details structurally rather than using `instanceof
// Error`.
const getErrorDescription = (error: unknown): string | null => {
if (!isRecord(error)) {
return null;
}
const message = typeof error.message === 'string' ? error.message : '';
const name = typeof error.name === 'string' ? error.name : '';
return message || name || null;
};
interface WebRTCStreamSourceOptions {
createPeerConnection?: PeerConnectionFactory;
createMediaStream?: MediaStreamFactory;
microphoneStream?: MediaStream | null;
// The outbound microphone track could not be attached. Separate from the
// stream-source failure channel: a microphone that cannot attach says nothing
// about the inbound video which keeps playing. `error` is what the browser
// said went wrong, when it said anything.
microphoneErrorCallback?: (error?: string) => void;
}
export class WebRTCStreamSource implements StreamSource {
@@ -52,6 +75,7 @@ export class WebRTCStreamSource implements StreamSource {
private _createPeerConnection: PeerConnectionFactory;
private _createMediaStream: MediaStreamFactory;
private _microphoneStream: MediaStream | null;
private _microphoneErrorCallback: ((error?: string) => void) | null;
private _microphoneTransceiver: RTCRtpTransceiver | null = null;
@@ -75,6 +99,7 @@ export class WebRTCStreamSource implements StreamSource {
options?.createMediaStream ?? ((tracks) => new MediaStream(tracks));
this._microphoneStream = options?.microphoneStream ?? null;
this._microphoneErrorCallback = options?.microphoneErrorCallback ?? null;
}
public start(): void {
@@ -183,9 +208,7 @@ export class WebRTCStreamSource implements StreamSource {
};
}
// Swap the outbound microphone track without renegotiating. Guards against a
// late rejection from a superseded call (a newer stream, or teardown)
// bringing a retired connection back or overwriting a fresher request.
// Swap the outbound microphone track without renegotiating.
public async setMicrophoneStream(stream: MediaStream | null): Promise<void> {
if (this._microphoneStream === stream) {
return;
@@ -199,17 +222,27 @@ export class WebRTCStreamSource implements StreamSource {
return;
}
// Whether the awaited microphone request is still the one in effect: a newer
// stream, or teardown, retires it, and reporting a retired outcome would
// describe something that is no longer being attempted.
const isCurrentRequest = (
transceiver: RTCRtpTransceiver,
stream: MediaStream | null,
): boolean =>
transceiver === this._microphoneTransceiver &&
this._microphoneStream === stream &&
this._pc !== null;
// A microphone stream carries a single audio track; null detaches the sender.
const desiredTrack = stream?.getAudioTracks()[0] ?? null;
try {
await transceiver.sender.replaceTrack(desiredTrack);
} catch {
const stillCurrent =
transceiver === this._microphoneTransceiver &&
this._microphoneStream === stream &&
this._pc !== null;
if (stillCurrent) {
this._context.callbacks.failedCallback('two_way_audio_error');
} catch (error) {
// Only a failed attach is reported. A failed detach leaves nothing for
// the user to act on: the track stops being transmitted when the peer
// connection closes.
if (desiredTrack && isCurrentRequest(transceiver, stream)) {
this._microphoneErrorCallback?.(getErrorDescription(error) ?? undefined);
}
}
}
@@ -95,7 +95,6 @@ export interface StreamProfile {
}
export type StreamSourceFailureReason =
| 'two_way_audio_error'
| 'buffer_overflow'
| 'connect_timeout'
| 'media_error'
@@ -4,13 +4,12 @@ import type { StreamSourceFailureReason } from '../types';
// The card's media-unavailable causes are user-facing; a source's failure
// reasons are technical. Only a media error and a buffer overflow have no
// distinct user story, so they map to a generic playback error; the rest each
// keep a meaningful cause -- a server rejection, an unsupported stream, a failed
// two-way-audio call, or a timeout that means the stream never got going.
const FAILURE_TO_ISSUE_REASON: Record<
// keep a meaningful cause -- a server rejection, an unsupported stream, or a
// timeout that means the stream never got going.
const STREAM_FAILURE_TO_ISSUE_REASON: Record<
StreamSourceFailureReason,
MediaUnavailableIssueReason
> = {
two_way_audio_error: 'two_way_audio_error',
buffer_overflow: 'playback_error',
connect_timeout: 'not_loading',
media_error: 'playback_error',
@@ -21,7 +20,7 @@ const FAILURE_TO_ISSUE_REASON: Record<
// A null reason is a connection-level failure with no source detail (e.g. the
// socket dropped), which reads as a generic playback error.
export const mapFailureReasonToIssueReason = (
export const mapStreamFailureReasonToIssueReason = (
reason: StreamSourceFailureReason | null,
): MediaUnavailableIssueReason =>
reason === null ? 'playback_error' : FAILURE_TO_ISSUE_REASON[reason];
reason === null ? 'playback_error' : STREAM_FAILURE_TO_ISSUE_REASON[reason];
@@ -10,7 +10,7 @@ export interface LiveError {
// Free text naming the specific failure (e.g. "Failed to start WebRTC stream:
// ..."). Absent when the provider has none.
detail?: string;
description?: string;
}
declare global {
@@ -0,0 +1,27 @@
import { fireAdvancedCameraCardEvent } from '../../../utils/fire-advanced-camera-card-event';
// What a provider knows about its own microphone related failure. Stream
// otherwise not impacted (contrast with `live:error`: which marks the whole
// stream not live).
export interface MicrophoneError {
// The base camera the provider is rendering. Carried because this event is
// handled once for the whole card, unlike `live:error` which is caught and
// stopped on the camera's own provider wrapper and so needs no camera named.
targetID: string;
// Free text naming the specific failure, when the provider has one.
description?: string;
}
declare global {
interface HTMLElementEventMap {
'advanced-camera-card:microphone:error': CustomEvent<MicrophoneError>;
}
}
export function dispatchMicrophoneErrorEvent(
element: EventTarget,
error: MicrophoneError,
): void {
fireAdvancedCameraCardEvent<MicrophoneError>(element, 'microphone:error', error);
}
@@ -38,6 +38,13 @@ type TargetedMediaLoadedInfo = MediaLoadedInfo & { targetID: string };
* without needing the underlying media to re-fire a load (e.g., HaHlsPlayer
* keeps the same `<video>`).
*
* That replay assumes the media outlived the detach. A host that instead
* destroys its media -- whenever it does so, on disconnect or otherwise -- must
* say so with `clear()`, or the replay would describe a player that no longer
* exists and consumers will be told media is loaded when none is. `set` and
* `clear` are the two halves of the same obligation: report what is really
* there.
*
* Aggregator parents (e.g., `ha-camera-stream`) sit on the bubble path; they
* can `stopPropagation` on inner-leaf events and dispatch their own via their
* own source controller, so consumers above the boundary only see the
@@ -68,9 +75,9 @@ export class MediaLoadedInfoSourceController implements ReactiveController {
public hostConnected(): void {
// Two early-returns:
// - `!_lastSet`: nothing to replay -- either the host has never seen a
// media load or the cache was discarded as stale on a prior reconnect
// (see below).
// - `!_lastSet`: nothing to replay -- the host has never seen a media
// load, or destroyed the media it had (`clear`), or the cache was
// discarded as stale on a prior reconnect (see below).
// - `_abort` non-null: a dispatch is already live, meaning we're already
// registered with consumers. Re-firing would orphan the prior
// `AbortController` (no one would ever abort it) and emit a duplicate
@@ -84,8 +91,7 @@ export class MediaLoadedInfoSourceController implements ReactiveController {
// flipped while we were disconnected. Replaying the cached info under a
// stale targetID would misregister with the manager.
if (this._lastSet.targetID === this._config.getTargetID()) {
// A reconnect replay of the last load, not a fresh media load.
this._dispatchLoad(this._lastSet, true);
this._dispatchLoad(this._lastSet);
} else {
this._lastSet = null;
}
@@ -131,16 +137,24 @@ export class MediaLoadedInfoSourceController implements ReactiveController {
this._dispatchLoad(validated);
}
// The media this source announced is gone. Retires the registration through
// the same signal a disconnect uses, and forgets the load so nothing is
// replayed on a later reconnect: the host must `set` again when it has media
// to announce.
public clear(): void {
this._unload();
this._lastSet = null;
}
private _unload(): void {
this._abort?.abort();
this._abort = null;
}
private _dispatchLoad(info: TargetedMediaLoadedInfo, cached?: boolean): void {
private _dispatchLoad(info: TargetedMediaLoadedInfo): void {
this._abort = new AbortController();
fireAdvancedCameraCardEvent<MediaLoadedInfoEventDetail>(this._host, 'media:loaded', {
info,
cached,
signal: this._abort.signal,
});
}
@@ -26,8 +26,10 @@ export interface FrameStallWatchdogConfig {
// Seconds without a frame (while playback is expected) before a stall is
// reported. Defaults to FRAME_STALL_SECONDS; a slow source (e.g. a snapshot
// that refreshes every N seconds) needs a window at least as long as N.
stallAfterSeconds?: number;
// that refreshes every N seconds) needs a window at least as long as N. Read
// each time the timer is armed, so a source whose pace the user can change
// gets the window it has now.
getStallAfterSeconds?: () => number;
}
/**
@@ -44,7 +46,6 @@ export interface FrameStallWatchdogConfig {
*/
export class FrameStallWatchdog {
private _config: FrameStallWatchdogConfig;
private _stallAfterSeconds: number;
private _timer = new Timer();
private _callbacks = new Set<LivenessCallback>();
@@ -57,7 +58,6 @@ export class FrameStallWatchdog {
constructor(config: FrameStallWatchdogConfig) {
this._config = config;
this._stallAfterSeconds = config.stallAfterSeconds ?? FRAME_STALL_SECONDS;
}
public subscribe(callback: LivenessCallback): UnsubscribeCallback {
@@ -65,6 +65,11 @@ export class FrameStallWatchdog {
this._callbacks.add(callback);
if (hadNoSubscribers) {
this._start();
} else if (this._isLive !== null) {
// Someone else is already watching and a frame or a stall has been seen,
// so tell the newcomer what that was rather than leaving it waiting for
// the next one. Nothing went unwatched in between, so it is not stale.
callback(this._isLive);
}
return (): void => {
@@ -81,7 +86,7 @@ export class FrameStallWatchdog {
if (!this._sourceActive) {
return;
}
this._timer.start(this._stallAfterSeconds, () => this._onStall());
this._startStallTimer();
// Notify last: if this recovery notification prompts the final subscriber
// to unsubscribe, `_stop` then clears the timer just armed instead of
@@ -97,10 +102,16 @@ export class FrameStallWatchdog {
// watching begins is still detected. With no source there is nothing to
// arm, so nothing is ever reported.
if (this._sourceActive) {
this._timer.start(this._stallAfterSeconds, () => this._onStall());
this._startStallTimer();
}
}
private _startStallTimer(): void {
this._timer.start(this._config.getStallAfterSeconds?.() ?? FRAME_STALL_SECONDS, () =>
this._onStall(),
);
}
private _stop(): void {
this._timer.stop();
if (this._sourceActive) {
@@ -120,7 +131,7 @@ export class FrameStallWatchdog {
// Legitimately idle (paused / seeking / ended). Re-arm rather than stop: a
// source that later resumes already frozen delivers no frame to kick the
// timer, so a freeze that only becomes actionable later is still caught.
this._timer.start(this._stallAfterSeconds, () => this._onStall());
this._startStallTimer();
}
private _setLive(isLive: boolean): void {
+3 -3
View File
@@ -20,13 +20,13 @@ export interface ImageUpdateControl {
}
// Liveness for an image stream: each <img> `load` is a frame; a gap longer than
// the window while frames are expected is a stall. `stallWindowSeconds`
// the window while frames are expected is a stall. `getStallAfterSeconds`
// defaults to the standard frame-stall window (suits a push-fed stream); a
// timer-refreshed image may set a different one, should be at least its refresh
// interval.
interface ImageLivenessOptions {
isFrameExpected: () => boolean;
stallWindowSeconds?: number;
getStallAfterSeconds?: () => number;
}
// Obtaining a screenshot. Defaults to drawing the current <img>; an image
@@ -81,7 +81,7 @@ export class ImageMediaPlayerController implements MediaPlayerController {
if (livenessOptions) {
const stallWatchdog = new FrameStallWatchdog({
isPlaybackExpected: livenessOptions.isFrameExpected,
stallAfterSeconds: livenessOptions.stallWindowSeconds,
getStallAfterSeconds: livenessOptions.getStallAfterSeconds,
startSource: () => this._startFrameSource(),
stopSource: () => this._stopFrameSource(),
});
+2 -1
View File
@@ -1,5 +1,6 @@
import type {
Notification,
NotificationContextItem,
NotificationDetail,
} from '../../config/schema/actions/types.js';
import type { Link } from '../../config/schema/common/link.js';
@@ -12,7 +13,7 @@ export interface NotificationOptions {
icon?: string;
link?: Link;
metadata?: NotificationDetail[];
context?: object;
context?: NotificationContextItem;
in_progress?: boolean;
}
+12
View File
@@ -16,6 +16,7 @@ import type { MediaUnavailableIssueReason } from '../card-controller/issues/issu
import type { IssueTriggerEventData } from '../card-controller/issues/types.js';
import { CachedValueController } from '../components-lib/cached-value-controller.js';
import { MediaLoadedInfoSourceController } from '../components-lib/media-loaded-info-source-controller.js';
import { FRAME_STALL_SECONDS } from '../components-lib/media-player/frame-stall-watchdog.js';
import { ImageMediaPlayerController } from '../components-lib/media-player/image.js';
import { createMediaNotification } from '../components-lib/notification/media.js';
import {
@@ -176,6 +177,17 @@ export class AdvancedCameraCardImageUpdatingPlayer
isRunning: () => this._cachedValueController.hasTimer(),
},
screenshotProvider: async () => this._cachedValueController.getValue(),
livenessOptions: {
// Frames are only due while the refresh timer runs. A snapshot with
// refreshing switched off shows one picture forever, which is the
// configured behaviour and never a stall.
isFrameExpected: () => this._cachedValueController.hasTimer(),
// Allow a whole refresh interval to pass, plus the standard window, so
// one slow fetch is not mistaken for a stopped camera.
getStallAfterSeconds: () =>
(this._getEffectiveRefreshSeconds() ?? 0) + FRAME_STALL_SECONDS,
},
},
);
@@ -21,8 +21,9 @@ import {
type VideoSurface,
} from '../../../../components-lib/live/providers/go2rtc-experimental/session-controller.js';
import type { SurfaceKind } from '../../../../components-lib/live/providers/go2rtc-experimental/types.js';
import { mapFailureReasonToIssueReason } from '../../../../components-lib/live/providers/go2rtc-experimental/utils/failure-reason.js';
import { mapStreamFailureReasonToIssueReason } from '../../../../components-lib/live/providers/go2rtc-experimental/utils/stream-failure-reason.js';
import { dispatchLiveErrorEvent } from '../../../../components-lib/live/utils/dispatch-live-error.js';
import { dispatchMicrophoneErrorEvent } from '../../../../components-lib/live/utils/dispatch-microphone-error.js';
import { MediaLoadedInfoSourceController } from '../../../../components-lib/media-loaded-info-source-controller.js';
import { VideoMediaPlayerController } from '../../../../components-lib/media-player/video.js';
import {
@@ -163,12 +164,24 @@ export class AdvancedCameraCardGo2RTCExperimental
// failure's user-facing cause) so the card's media-load retry (reconnecting
// indicator, backoff, give-up) runs and can name why. The provider renders
// the error itself (below); the event drives the liveness verdict + retry.
errorCallback: (reason) => {
this._streamError = mapFailureReasonToIssueReason(reason);
streamErrorCallback: (reason) => {
this._streamError = mapStreamFailureReasonToIssueReason(reason);
dispatchLiveErrorEvent(this, { reason: this._streamError });
},
microphoneErrorCallback: (error) => this._reportMicrophoneError(error),
});
private _reportMicrophoneError(error?: string): void {
if (!this.targetID) {
return;
}
dispatchMicrophoneErrorEvent(this, {
targetID: this.targetID,
description: error,
});
}
public async getMediaPlayerController(): Promise<MediaPlayerController | null> {
return this._activeSurface === 'image'
? this._imageSurface.getMediaPlayer()
@@ -186,9 +199,18 @@ export class AdvancedCameraCardGo2RTCExperimental
disconnectedCallback(): void {
// Tear down synchronously so streams (e.g. 2-way audio backchannels)
// release immediately.
this._destroyMedia();
super.disconnectedCallback();
}
// Drop the session and everything it was playing on. The surfaces keep their
// elements but the session has emptied them, so the media that was announced
// no longer exists and must stop being claimed -- otherwise a later reconnect
// would replay it and the card would believe a dead camera was loaded.
private _destroyMedia(): void {
this._session.reset();
this._activeSurface = null;
super.disconnectedCallback();
this._mediaLoadedInfoSourceController.clear();
}
protected willUpdate(changedProps: PropertyValues): void {
@@ -196,8 +218,7 @@ export class AdvancedCameraCardGo2RTCExperimental
// The session is re-established by `updated()` once the new camera's
// signed URL resolves; the next commit picks the live surface. Blank the
// view meanwhile so the previous camera's last frame is not shown.
this._session.reset();
this._activeSurface = null;
this._destroyMedia();
this._streamError = null;
}
@@ -237,8 +258,7 @@ export class AdvancedCameraCardGo2RTCExperimental
// drop the session -- otherwise a later URL, even an identical unsigned
// endpoint, would be skipped by connect()'s identity check and leave the
// session bound to the removed elements.
this._session.reset();
this._activeSurface = null;
this._destroyMedia();
}
}
+6
View File
@@ -171,6 +171,12 @@ export class AdvancedCameraCardLiveJSMPEG extends LitElement implements MediaPla
this._jsmpegCanvasElement.remove();
this._jsmpegCanvasElement = undefined;
}
// The player and the canvas it drew on are gone, so the media announced
// from them no longer exists and must stop being claimed -- otherwise a
// later reconnect would replay it and the card would believe a dead camera
// was loaded.
this._mediaLoadedInfoSourceController.clear();
}
connectedCallback(): void {
@@ -100,6 +100,13 @@ export class AdvancedCameraCardLiveWebRTCCard extends LitElement implements Medi
disconnectedCallback(): void {
this._videoRTC = null;
this._notification = null;
// A reconnect builds a brand new WebRTC element, so the video that was
// announced is gone and must stop being claimed -- otherwise the card would
// be told media is loaded during the window where there is no element at
// all, and would keep believing it if the replacement never loads.
this._mediaLoadedInfoSourceController.clear();
super.disconnectedCallback();
}
+1
View File
@@ -191,6 +191,7 @@ export type NotificationControl = z.infer<typeof notificationControlSchema>;
// A context item is a preformatted string or a structured object that is
// YAML-dumped at render time (see NotificationContextController).
const notificationContextItemSchema = z.union([z.string(), z.custom<object>(isRecord)]);
export type NotificationContextItem = z.infer<typeof notificationContextItemSchema>;
const notificationSchema = z.object({
heading: notificationDetailSchema.optional(),
+2
View File
@@ -11,6 +11,8 @@ declare module 'view' {
declare module 'issue' {
// eslint-disable-next-line @typescript-eslint/no-empty-object-type
interface IssueTriggerContext {}
// eslint-disable-next-line @typescript-eslint/no-empty-object-type
interface IssueResolveContext {}
}
declare module 'action' {
// eslint-disable-next-line @typescript-eslint/no-empty-object-type
+1 -1
View File
@@ -807,6 +807,7 @@
"awaiting_live": "Waiting for live stream to load...",
"awaiting_media": "Waiting for media to load",
"call_invalid_target": "The requested camera or stream is not available to call.",
"call_microphone_failed": "Your microphone could not be connected.",
"call_microphone_forbidden": "Microphone access has been denied for this page. Update your browser permissions and try again.",
"call_microphone_unsupported": "Microphone access is not available in this browser (e.g. requires HTTPS).",
"call_no_two_way_audio": "This camera does not support two-way audio.",
@@ -897,7 +898,6 @@
"playback_error": "Playback error",
"server_error": "Streaming server error",
"stalled": "Stream stalled",
"two_way_audio_error": "Two-way audio error",
"unsupported": "Stream not supported"
},
"text": "The media is not currently available. This can happen for several reasons, for example a camera becoming unavailable, a stream stalling or failing, or media that has not finished loading. The card keeps retrying automatically. For live views, a still image may be shown while a stream is loading (if configured, and by default)"
+1 -1
View File
@@ -131,7 +131,7 @@ void customElements.whenDefined('ha-hls-player').then(() => {
// and are only logged (see render()).
const errored = !!this._error && this._errorIsFatal;
if (errored && !this._lastErrored) {
dispatchLiveErrorEvent(this, { detail: this._error });
dispatchLiveErrorEvent(this, { description: this._error });
}
this._lastErrored = errored;
}
-6
View File
@@ -59,12 +59,6 @@ export type MediaLoadedInfoOwner = HTMLElement;
export interface MediaLoadedInfoEventDetail {
info: MediaLoadedInfo;
// Absent (the default, a fresh media load) unless `true`, which marks a
// replay: the source re-dispatched its last load on reconnect without the
// media actually reloading (e.g. preloaded camera in the background when
// brought to the foreground).
cached?: boolean;
// Aborts when the source retires this media. The source aborts on host
// disconnect, and when a subsequent `set()` arrives under a different
// `targetID` (replacing this dispatch). Independent of DOM connectedness, so