fix: Clear stale media unavailable errors when a camera recovers (#2627)
- Closes: #2576
This commit is contained in:
@@ -74,7 +74,7 @@ export class CallManager {
|
||||
.getCameraIDsWithCapability('live')
|
||||
.has(parentID)
|
||||
) {
|
||||
this._notifyError('error.call_invalid_target', inbound);
|
||||
this._notifyError('error.call_invalid_target', { inbound });
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -261,6 +261,22 @@ export class CallManager {
|
||||
return this.end();
|
||||
}
|
||||
|
||||
// The microphone could not be used for the call, so it is connected but the
|
||||
// user cannot be heard. `description` is what the reporting layer knows about
|
||||
// the failure, when it knows anything.
|
||||
public reportCallMicrophoneError(targetID: string, description?: string): void {
|
||||
const call = this._call;
|
||||
|
||||
// A report that no longer matches the call in progress describes an attempt
|
||||
// the user has already moved past, e.g. the call ended before the provider
|
||||
// finished reporting.
|
||||
if (!call || !call.answered || call.cameraID !== targetID) {
|
||||
return;
|
||||
}
|
||||
|
||||
this._notifyError('error.call_microphone_failed', { context: description });
|
||||
}
|
||||
|
||||
// Tears down everything `initialize()` set up: stops any in-flight ringtone
|
||||
// and unanswered timer, drops the active call session, clears the call
|
||||
// condition state, and de-registers the condition-state listener. Driven by
|
||||
@@ -367,14 +383,21 @@ export class CallManager {
|
||||
// Helpers
|
||||
// =========================================================================
|
||||
|
||||
private _notifyError(messageKey: string, inbound: boolean): void {
|
||||
if (inbound) {
|
||||
// Don't show errors on inbound calls.
|
||||
// `context` is a diagnostic the user can quote when reporting the problem.
|
||||
private _notifyError(
|
||||
messageKey: string,
|
||||
options?: { inbound?: boolean; context?: string },
|
||||
): void {
|
||||
// An inbound call the user has not answered yet is not something they have
|
||||
// asked for, so a failure to place it is not worth interrupting them with.
|
||||
if (options?.inbound) {
|
||||
return;
|
||||
}
|
||||
const context = options?.context;
|
||||
this._api.getNotificationManager().setNotification(
|
||||
createNotificationFromText(localize(messageKey), {
|
||||
heading: { text: localize('error.call_unavailable_heading') },
|
||||
...(context && { context }),
|
||||
}),
|
||||
);
|
||||
}
|
||||
@@ -385,7 +408,7 @@ export class CallManager {
|
||||
const microphoneManager = this._api.getMicrophoneManager();
|
||||
|
||||
if (!microphoneManager.isSupported()) {
|
||||
this._notifyError('error.call_microphone_unsupported', inbound);
|
||||
this._notifyError('error.call_microphone_unsupported', { inbound });
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -394,7 +417,7 @@ export class CallManager {
|
||||
// there clears the denial, and failing there reports it. An outbound call
|
||||
// needs the microphone immediately, so a known denial ends it here.
|
||||
if (!inbound && microphoneManager.isForbidden()) {
|
||||
this._notifyError('error.call_microphone_forbidden', inbound);
|
||||
this._notifyError('error.call_microphone_forbidden', { inbound });
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -428,7 +451,7 @@ export class CallManager {
|
||||
}
|
||||
|
||||
if (!connected) {
|
||||
this._notifyError('error.call_microphone_forbidden', false);
|
||||
this._notifyError('error.call_microphone_forbidden');
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
@@ -453,7 +476,7 @@ export class CallManager {
|
||||
.getStore()
|
||||
.getAllDependentCameras(cameraID, '2-way-audio');
|
||||
if (!eligibleCameraIDs.has(streamID)) {
|
||||
this._notifyError('error.call_invalid_target', inbound);
|
||||
this._notifyError('error.call_invalid_target', { inbound });
|
||||
return null;
|
||||
}
|
||||
return streamID;
|
||||
@@ -480,7 +503,7 @@ export class CallManager {
|
||||
.getAllDependentCameras(parentID, '2-way-audio'),
|
||||
];
|
||||
if (!candidates.length) {
|
||||
this._notifyError('error.call_no_two_way_audio', inbound);
|
||||
this._notifyError('error.call_no_two_way_audio', { inbound });
|
||||
return null;
|
||||
}
|
||||
return candidates[0];
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { IssueTriggerContext } from 'issue';
|
||||
import type { IssueResolveContext, IssueTriggerContext } from 'issue';
|
||||
|
||||
import type { ConditionStateChange } from '../../condition-trigger/conditions/types';
|
||||
import { contentsChanged, ignoreFunctionIdentity } from '../../utils/basic';
|
||||
@@ -11,6 +11,7 @@ import type {
|
||||
IssueKey,
|
||||
IssuePresence,
|
||||
IssueReadOnlyState,
|
||||
IssueResolveContextKey,
|
||||
IssueTriggerContextKey,
|
||||
} from './types';
|
||||
|
||||
@@ -71,6 +72,16 @@ export class IssueManager {
|
||||
this.evaluate();
|
||||
}
|
||||
|
||||
// Called by components that observe a problem recovering directly (e.g. a
|
||||
// stream that is proven to be delivering media again).
|
||||
public resolve<K extends IssueResolveContextKey>(
|
||||
key: K,
|
||||
context: IssueResolveContext[K],
|
||||
): void {
|
||||
this._stateManager.resolve(key, context);
|
||||
this.evaluate();
|
||||
}
|
||||
|
||||
// Evaluate all dynamic issues against current state, re-render the card if
|
||||
// the issue presence changed, and schedule retries.
|
||||
//
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { IssueTriggerContext } from 'issue';
|
||||
import type { IssueResolveContext, IssueTriggerContext } from 'issue';
|
||||
|
||||
import type { ConditionState } from '../../../condition-trigger/conditions/types.js';
|
||||
import type {
|
||||
@@ -23,7 +23,6 @@ export type MediaUnavailableIssueReason =
|
||||
| 'playback_error'
|
||||
| 'server_error'
|
||||
| 'stalled'
|
||||
| 'two_way_audio_error'
|
||||
| 'unsupported';
|
||||
|
||||
declare module 'issue' {
|
||||
@@ -37,6 +36,12 @@ declare module 'issue' {
|
||||
description?: string;
|
||||
};
|
||||
}
|
||||
|
||||
interface IssueResolveContext {
|
||||
media_unavailable: {
|
||||
targetID: string;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// What is known about one target's failure.
|
||||
@@ -74,10 +79,6 @@ export const MEDIA_UNAVAILABLE_REASONS: Record<
|
||||
localizationKey: 'issues.media_unavailable.reasons.stalled',
|
||||
icon: 'mdi:motion-pause',
|
||||
},
|
||||
two_way_audio_error: {
|
||||
localizationKey: 'issues.media_unavailable.reasons.two_way_audio_error',
|
||||
icon: 'mdi:microphone-off',
|
||||
},
|
||||
unsupported: {
|
||||
localizationKey: 'issues.media_unavailable.reasons.unsupported',
|
||||
icon: 'mdi:video-off-outline',
|
||||
@@ -102,21 +103,20 @@ export class MediaUnavailableIssue implements Issue {
|
||||
this._api = api;
|
||||
this._onChange = onChange ?? null;
|
||||
|
||||
// Clear a target's error on a genuine media (re)load.
|
||||
// React to a target's media loading; unload / select changes are
|
||||
// irrelevant here.
|
||||
this._unsubscribeCallback = this._api
|
||||
.getMediaLoadedInfoManager()
|
||||
.subscribe((change) => {
|
||||
// A reconnect replay (`cached`) did not actually reload the media, and
|
||||
// unload / select changes are irrelevant here; only a genuine load
|
||||
// clears the error.
|
||||
if (change.type === 'load' && !change.cached) {
|
||||
if (change.type === 'load') {
|
||||
this._onMediaLoad(change.targetID);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Explicit trigger -- called when a component fires an issue:trigger event.
|
||||
// Explicit trigger and resolve -- called when a component fires an
|
||||
// issue:trigger or issue:resolve event.
|
||||
// =========================================================================
|
||||
|
||||
public trigger(context: IssueTriggerContext['media_unavailable']): void {
|
||||
@@ -126,6 +126,14 @@ export class MediaUnavailableIssue implements Issue {
|
||||
});
|
||||
}
|
||||
|
||||
// A target is proven to be delivering media again. Stronger evidence than a
|
||||
// media load, which only says a player attached, so it clears any recorded
|
||||
// error.
|
||||
public resolve(context: IssueResolveContext['media_unavailable']): void {
|
||||
this._erroredTargets.delete(context.targetID);
|
||||
this._cancelPendingTimer(context.targetID);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Detection -- called by the manager on every state change.
|
||||
// =========================================================================
|
||||
@@ -139,8 +147,8 @@ export class MediaUnavailableIssue implements Issue {
|
||||
// A known error for the current target activates immediately, even if its
|
||||
// (frozen) media still reads as loaded (it might be loaded but then
|
||||
// reported a playback error that stops playback but leaves the player
|
||||
// attached). Errors are cleared out-of-band by `_onMediaLoad` on a genuine
|
||||
// reload.
|
||||
// attached). Errors are cleared out-of-band, by `resolve` or by
|
||||
// `_onMediaLoad`.
|
||||
if (this._hasError(state)) {
|
||||
this._activate();
|
||||
return;
|
||||
@@ -155,9 +163,16 @@ export class MediaUnavailableIssue implements Issue {
|
||||
this._handlePendingLoad(state);
|
||||
}
|
||||
|
||||
// A genuine media (re)load for a target clears its error.
|
||||
// A load proves media attached for the target. That ends any wait on it, and
|
||||
// refutes a `not_loading` error. It is no evidence of recovery for any other
|
||||
// reason, so those clear only via `resolve`.
|
||||
private _onMediaLoad(targetID: string): void {
|
||||
if (this._erroredTargets.delete(targetID)) {
|
||||
let changed = this._cancelPendingTimer(targetID);
|
||||
if (this._erroredTargets.get(targetID)?.reason === 'not_loading') {
|
||||
this._erroredTargets.delete(targetID);
|
||||
changed = true;
|
||||
}
|
||||
if (changed) {
|
||||
this._onChange?.();
|
||||
}
|
||||
}
|
||||
@@ -183,6 +198,7 @@ export class MediaUnavailableIssue implements Issue {
|
||||
|
||||
public getNotification(): Notification {
|
||||
const targets = new Map(this._erroredTargets);
|
||||
|
||||
// The pending-load timer's target is a slow initial load that has not yet
|
||||
// errored. Gate on the timer still running: once it is stopped (a hard error
|
||||
// on another target took over, or the view moved on), _timerTargetID lingers
|
||||
@@ -258,10 +274,12 @@ export class MediaUnavailableIssue implements Issue {
|
||||
|
||||
public retry(): boolean {
|
||||
// Build the set of targets to retry: all errored targets plus the
|
||||
// target the pending timer was tracking (so a user-initiated retry
|
||||
// works even before the timeout fires).
|
||||
// target the pending timer is tracking (so a user-initiated retry
|
||||
// works even before the timeout fires). A stopped timer leaves
|
||||
// _timerTargetID behind, so gate on it still running: that target may
|
||||
// since have loaded.
|
||||
const retryTargets = new Set(this._erroredTargets.keys());
|
||||
if (this._timerTargetID) {
|
||||
if (this._timerTargetID && this._timer.isRunning()) {
|
||||
retryTargets.add(this._timerTargetID);
|
||||
}
|
||||
|
||||
@@ -269,6 +287,8 @@ export class MediaUnavailableIssue implements Issue {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Bumping a target's mediaEpoch remounts its provider, which is the card's
|
||||
// only way to rebuild a stream from scratch.
|
||||
const view = this._api.getViewManager().getView();
|
||||
const mediaEpoch = { ...(view?.context?.mediaEpoch ?? {}) };
|
||||
for (const id of retryTargets) {
|
||||
@@ -276,11 +296,11 @@ export class MediaUnavailableIssue implements Issue {
|
||||
}
|
||||
|
||||
// Intentionally keep _issueActive, _erroredTargets, and the pending
|
||||
// timer in place. The issue stays visible while the provider
|
||||
// re-attempts loading underneath. If the retry succeeds, the fresh media
|
||||
// load clears everything (_onMediaLoad drops the errored target). If it
|
||||
// fails silently (e.g. bogus stream name), the error stays visible
|
||||
// immediately -- no new 10s grace period.
|
||||
// timer in place. The issue stays visible while the provider re-attempts
|
||||
// loading underneath. If the retry succeeds, the fresh load clears a
|
||||
// not-loading error and the rebuilt provider's liveness observation
|
||||
// resolves a stream error. If it fails silently (e.g. bogus stream name),
|
||||
// the error stays visible immediately -- no new 10s grace period.
|
||||
this._api.getViewManager().setViewWithMergedContext({ mediaEpoch });
|
||||
return false;
|
||||
}
|
||||
@@ -314,6 +334,17 @@ export class MediaUnavailableIssue implements Issue {
|
||||
// Private helpers.
|
||||
// =========================================================================
|
||||
|
||||
// Stop waiting on a target's load, if it is the one being waited on. Returns
|
||||
// whether it was.
|
||||
private _cancelPendingTimer(targetID: string): boolean {
|
||||
if (this._timerTargetID !== targetID) {
|
||||
return false;
|
||||
}
|
||||
this._timer.stop();
|
||||
this._timerTargetID = null;
|
||||
return true;
|
||||
}
|
||||
|
||||
// Media not yet loaded and no known error: start (or keep) a timeout to catch
|
||||
// a slow or failed initial load. No targetID means no provider is rendering
|
||||
// media (e.g. the viewer shows "No media to display"), so there's nothing to
|
||||
@@ -337,7 +368,7 @@ export class MediaUnavailableIssue implements Issue {
|
||||
this._timerTargetID = targetID;
|
||||
this._timer.start(MEDIA_LOADING_TIMEOUT_SECONDS, () => {
|
||||
// Record the error on timeout so retry() knows which epoch to bump.
|
||||
this._erroredTargets.set(targetID, { reason: 'not_loading' });
|
||||
this.trigger({ targetID, reason: 'not_loading' });
|
||||
this._activate();
|
||||
this._onChange?.();
|
||||
});
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { IssueTriggerContext } from 'issue';
|
||||
import type { IssueResolveContext, IssueTriggerContext } from 'issue';
|
||||
|
||||
import { summarizeNotification } from '../../components-lib/notification/summarize';
|
||||
import type { ConditionState } from '../../condition-trigger/conditions/types';
|
||||
@@ -11,6 +11,7 @@ import type {
|
||||
IssueKey,
|
||||
IssuePresence,
|
||||
IssueReadOnlyState,
|
||||
IssueResolveContextKey,
|
||||
IssueTriggerContextKey,
|
||||
KeyedIssueDescription,
|
||||
} from './types';
|
||||
@@ -28,7 +29,8 @@ export class IssueStateManager implements IssueReadOnlyState {
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Detection -- static (one-shot on init) and dynamic (on every state change).
|
||||
// Detection -- static (one-shot on init), dynamic (on every state change) and
|
||||
// explicit (triggered or resolved by a component).
|
||||
// =========================================================================
|
||||
|
||||
public async detectStatic(hass: HomeAssistant): Promise<void> {
|
||||
@@ -56,6 +58,18 @@ export class IssueStateManager implements IssueReadOnlyState {
|
||||
this._logIfNew(issue);
|
||||
}
|
||||
|
||||
public resolve<K extends IssueResolveContextKey>(
|
||||
key: K,
|
||||
context: IssueResolveContext[K],
|
||||
): void {
|
||||
const issue = this._issues.get(key);
|
||||
if (!issue) {
|
||||
return;
|
||||
}
|
||||
issue.resolve?.(context);
|
||||
this._logIfNew(issue);
|
||||
}
|
||||
|
||||
public detectDynamic(context: ConditionState): void {
|
||||
for (const issue of this._issues.values()) {
|
||||
issue.detectDynamic?.(context);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { IssueTriggerContext } from 'issue';
|
||||
import type { IssueResolveContext, IssueTriggerContext } from 'issue';
|
||||
|
||||
import type { ConditionState } from '../../condition-trigger/conditions/types';
|
||||
import type { Notification } from '../../config/schema/actions/types';
|
||||
@@ -47,6 +47,11 @@ export type IssueTriggerEventData = {
|
||||
[K in IssueTriggerContextKey]: { key: K } & IssueTriggerContext[K];
|
||||
}[IssueTriggerContextKey];
|
||||
|
||||
export type IssueResolveContextKey = keyof IssueResolveContext;
|
||||
export type IssueResolveEventData = {
|
||||
[K in IssueResolveContextKey]: { key: K } & IssueResolveContext[K];
|
||||
}[IssueResolveContextKey];
|
||||
|
||||
export interface Issue {
|
||||
readonly key: IssueKey;
|
||||
|
||||
@@ -59,6 +64,11 @@ export interface Issue {
|
||||
// Explicitly trigger this issue with key-specific context.
|
||||
trigger?(context: IssueTriggerContext[IssueTriggerContextKey]): void;
|
||||
|
||||
// The inverse of `trigger`: the context names what recovered, and only that
|
||||
// part of the issue's state is dropped. Contrast `reset`, which discards
|
||||
// everything the issue is holding.
|
||||
resolve?(context: IssueResolveContext[IssueResolveContextKey]): void;
|
||||
|
||||
hasIssue(): boolean;
|
||||
getIssue(): IssueDescription | null;
|
||||
|
||||
|
||||
@@ -17,9 +17,8 @@ interface ActiveEntry {
|
||||
// A change delivered to subscribers: a target's active media loading or
|
||||
// unloading, or the selected target changing.
|
||||
export type MediaLoadedInfoChange =
|
||||
// A target's media (re)loaded. `cached` marks a replay (a reconnect
|
||||
// re-dispatch, not an actual reload) rather than a genuine load.
|
||||
| { type: 'load'; targetID: string; info: MediaLoadedInfo; cached: boolean }
|
||||
// A target's media (re)loaded, or a reconnect re-dispatched its last load.
|
||||
| { type: 'load'; targetID: string; info: MediaLoadedInfo }
|
||||
|
||||
// A target's media was retired.
|
||||
| { type: 'unload'; targetID: string }
|
||||
@@ -68,11 +67,7 @@ export class MediaLoadedInfoManager {
|
||||
}
|
||||
}
|
||||
|
||||
public set(
|
||||
mediaLoadedInfo: MediaLoadedInfo,
|
||||
owner: MediaLoadedInfoOwner,
|
||||
cached?: boolean,
|
||||
): void {
|
||||
public set(mediaLoadedInfo: MediaLoadedInfo, owner: MediaLoadedInfoOwner): void {
|
||||
if (!isValidMediaLoadedInfo(mediaLoadedInfo) || !mediaLoadedInfo.targetID) {
|
||||
return;
|
||||
}
|
||||
@@ -93,12 +88,7 @@ export class MediaLoadedInfoManager {
|
||||
|
||||
// Notify for every target, not just the selected one (e.g. a background
|
||||
// grid camera).
|
||||
this._notify({
|
||||
type: 'load',
|
||||
targetID,
|
||||
info: mediaLoadedInfo,
|
||||
cached: cached ?? false,
|
||||
});
|
||||
this._notify({ type: 'load', targetID, info: mediaLoadedInfo });
|
||||
}
|
||||
|
||||
public setSelected(targetID: string | null): void {
|
||||
@@ -132,7 +122,7 @@ export class MediaLoadedInfoManager {
|
||||
if (!(owner instanceof HTMLElement) || !targetID) {
|
||||
return;
|
||||
}
|
||||
this.set(ev.detail.info, owner, ev.detail.cached);
|
||||
this.set(ev.detail.info, owner);
|
||||
onAbort(ev.detail.signal, () => this._clearTarget(targetID, owner));
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user