fix: Clear stale media unavailable errors when a camera recovers (#2627)
- Closes: #2576
This commit is contained in:
@@ -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);
|
||||
}
|
||||
Reference in New Issue
Block a user