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
@@ -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);
}