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