fix: Report media failures once per failure (#2666)

This commit is contained in:
Dermot Duffy
2026-08-09 10:48:17 -07:00
committed by GitHub
parent 8d8d486afa
commit 5e199b5612
22 changed files with 484 additions and 123 deletions
@@ -35,12 +35,22 @@ declare module 'issue' {
}
interface IssueResolveContext {
media_unavailable: {
targetID: string;
// Optionally limits the clearing to one kind of failure.
reason?: MediaUnavailableIssueReason;
};
// Either a resolve scoped to (at most) one named kind of failure, or the
// statement that the target's media has loaded, which resolves whichever
// failures have `resetOnLoad`. Only real media is ever announced as loaded:
// the card's substitute pictures (a loading or stock image) are not, so
// they can never make this statement.
media_unavailable: { targetID: string } & (
| {
// Optionally limits the clearing to one kind of failure.
reason?: MediaUnavailableIssueReason;
cause?: never;
}
| {
reason?: never;
cause: 'media-loaded';
}
);
}
}
@@ -52,34 +62,46 @@ interface TargetError {
// The per-cause presentation (localization key + icon), shared by the
// notification metadata and the reconnecting placeholder so each cause is
// described in exactly one place.
// described in exactly one place. `resetOnLoad` means a media load will reset
// this issue reason.
export const MEDIA_UNAVAILABLE_REASONS: Record<
MediaUnavailableIssueReason,
{ localizationKey: string; icon: string }
{ localizationKey: string; icon: string; resetOnLoad: boolean }
> = {
entity_unavailable: {
localizationKey: 'issues.media_unavailable.reasons.entity_unavailable',
icon: 'mdi:cctv-off',
resetOnLoad: false,
},
not_loading: {
localizationKey: 'issues.media_unavailable.reasons.not_loading',
icon: 'mdi:progress-helper',
resetOnLoad: true,
},
playback_error: {
localizationKey: 'issues.media_unavailable.reasons.playback_error',
icon: 'mdi:alert-circle',
// A player can load media and still fail to play it.
resetOnLoad: false,
},
server_error: {
localizationKey: 'issues.media_unavailable.reasons.server_error',
icon: 'mdi:server-network-off',
resetOnLoad: true,
},
stalled: {
localizationKey: 'issues.media_unavailable.reasons.stalled',
icon: 'mdi:motion-pause',
resetOnLoad: false,
},
unsupported: {
localizationKey: 'issues.media_unavailable.reasons.unsupported',
icon: 'mdi:video-off-outline',
// Substitute pictures are never announced as loaded media, so a load means
// the requested media was delivered in some supported way after all.
resetOnLoad: true,
},
};
@@ -113,7 +135,15 @@ export class MediaUnavailableIssue implements Issue {
public resolve(context: IssueResolveContext['media_unavailable']): void {
const error = this._erroredTargets.get(context.targetID);
if (!error || (context.reason && context.reason !== error.reason)) {
if (!error) {
return;
}
if (context.cause === 'media-loaded') {
if (!MEDIA_UNAVAILABLE_REASONS[error.reason].resetOnLoad) {
return;
}
} else if (context.reason && context.reason !== error.reason) {
return;
}
@@ -3,7 +3,11 @@ import { isEqual } from 'lodash-es';
import type { StateWatcherSubscriptionInterface } from '../../../../card-controller/hass/state-watcher';
import type { HassStateDifference, HomeAssistant } from '../../../../ha/types';
import { Timer } from '../../../../utils/timer';
import type { LivenessDetector, LivenessVerdict } from '../stream-liveness-controller';
import type {
LivenessDetector,
LivenessInvalidationCause,
LivenessVerdict,
} from '../stream-liveness-controller';
// A camera entity must stay `unavailable` this long before the stream is
// treated as lost. Shorter blips (e.g. during PTZ, see issue #2124) are
@@ -56,13 +60,17 @@ export class EntityAvailabilityDetector implements LivenessDetector {
this._timer.stop();
}
public reset(): void {
public invalidate(cause: LivenessInvalidationCause): void {
if (cause !== 'stream-changed') {
// Only changing the entity under detection can invalidate the verdict.
return;
}
this._verdict = { state: 'unknown' };
this._timer.stop();
// 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.
// `getCameraEntity` may now name a different entity, so re-point the
// subscription and read that entity now.
this._subscribeOrUnsubscribeFromCameraEntity();
this._check();
}
@@ -7,7 +7,11 @@ import type {
} from '../../../../types';
import { onAbort } from '../../../../utils/abort-signal';
import { VisibilityObserver } from '../../../visibility-observer';
import type { LivenessDetector, LivenessVerdict } from '../stream-liveness-controller';
import type {
LivenessDetector,
LivenessInvalidationCause,
LivenessVerdict,
} from '../stream-liveness-controller';
const MEDIA_LOADED_EVENT = 'advanced-camera-card:media:loaded';
@@ -61,19 +65,22 @@ export class MediaPlayerLivenessDetector implements LivenessDetector {
}
public unsubscribe(): void {
// Retain the verdict so a reconnect resumes where it left off; use reset()
// to discard it.
// Retain the verdict so a reconnect resumes where it left off; use
// invalidate() to discard it.
this._host.removeEventListener(MEDIA_LOADED_EVENT, this._onMediaLoaded);
this._visibilityObserver?.destroy();
this._visibilityObserver = null;
this._unwatch();
}
public reset(): void {
// The underlying stream changed (e.g. a substream switch): discard the
// verdict and re-evaluate against the new media.
this._unwatch();
this._setVerdict({ state: 'unknown' });
public invalidate(cause: LivenessInvalidationCause): void {
// A stall is a failure of media that had already loaded, so an invalidation
// with cause 'media-loaded' proves nothing as far as this detector is
// concerned. Only a changed stream/camera discards the verdict.
if (cause === 'stream-changed') {
this._unwatch();
this._setVerdict({ state: 'unknown' });
}
}
public getVerdict(): LivenessVerdict {
@@ -1,5 +1,9 @@
import type { LiveError } from '../../utils/dispatch-live-error';
import type { LivenessDetector, LivenessVerdict } from '../stream-liveness-controller';
import type {
LivenessDetector,
LivenessInvalidationCause,
LivenessVerdict,
} from '../stream-liveness-controller';
const LIVE_ERROR_EVENT = 'advanced-camera-card:live:error';
@@ -31,7 +35,10 @@ export class ProviderErrorDetector implements LivenessDetector {
this._host.removeEventListener(LIVE_ERROR_EVENT, this._handler);
}
public reset(): void {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
public invalidate(_cause: LivenessInvalidationCause): void {
// No matter the cause the provider error is disproven: the stream the error
// was about has been replaced, or media is demonstrably being delivered.
this._verdict = { state: 'unknown' };
}
@@ -3,17 +3,18 @@ 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 {
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';
import {
resolveMediaUnavailableIssue,
triggerMediaUnavailableIssue,
} from '../../media-unavailable-issue';
import { EntityAvailabilityDetector } from './detectors/entity-availability';
import { MediaPlayerLivenessDetector } from './detectors/media-player-liveness';
import { ProviderErrorDetector } from './detectors/provider-error';
const MEDIA_LOADED_EVENT = 'advanced-camera-card:media:loaded';
// How far a verdict's evidence is trusted, so direct observation of the media
// outweighs an indirect signal:
// - `direct`: observed from the media itself (e.g. frames arriving or
@@ -66,17 +67,26 @@ interface StreamFailure {
renderPlaceholder: boolean;
}
// A prior detection can be invalidated for one of two reasons:
// - `stream-changed`: the media underneath was replaced (e.g. a different camera or
// provider, or a retry rebuilding it), so nothing observed about the old one
// still applies.
// - `media-loaded`: media has loaded, which can disprove a failure to deliver
// it. Whether it does is each detector's own call, since the media that
// loaded is not always the media it was watching.
export type LivenessInvalidationCause = 'stream-changed' | 'media-loaded';
export interface LivenessDetector {
// Start observing the signal.
subscribe(): void;
// Stop observing (e.g. on disconnect). Accumulated state is retained so a
// later reconnect resumes where it left off; use reset() to discard it.
// later reconnect resumes where it left off; use invalidate() to discard it.
unsubscribe(): void;
// Discard accumulated state because the underlying stream changed (e.g. a
// substream switch), so detection restarts from scratch.
reset?(): void;
// Notify the detector that `cause` occurred; the detector decides whether
// its verdict still holds.
invalidate(cause: LivenessInvalidationCause): void;
// Reports the stream's current liveness, calling `onChange` (passed at
// construction) whenever that verdict changes.
@@ -99,7 +109,7 @@ export class StreamLivenessController implements ReactiveController {
private _host: ReactiveControllerHost & HTMLElement;
private _config: StreamLivenessControllerConfig;
private _detectors: LivenessDetector[];
private _resetting = false;
private _invalidating = false;
constructor(
host: ReactiveControllerHost & HTMLElement,
@@ -128,10 +138,12 @@ export class StreamLivenessController implements ReactiveController {
}
public hostConnected(): void {
this._host.addEventListener(MEDIA_LOADED_EVENT, this._onMediaLoaded);
this._detectors.forEach((detector) => detector.subscribe());
}
public hostDisconnected(): void {
this._host.removeEventListener(MEDIA_LOADED_EVENT, this._onMediaLoaded);
this._detectors.forEach((detector) => detector.unsubscribe());
}
@@ -154,17 +166,23 @@ export class StreamLivenessController implements ReactiveController {
// Discard detector state on a stream change (e.g. a stream switch).
public reset(): void {
// 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;
this._invalidate('stream-changed');
}
private _onMediaLoaded = (): void => this._invalidate('media-loaded');
private _invalidate(cause: LivenessInvalidationCause): void {
// The detectors are invalidated one at a time, and invalidating one can
// make it call back with a changed verdict (e.g. an entity dropping back to
// an unknown state). Part way through, some have been invalidated and some
// have 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 invalidation is complete.
this._invalidating = true;
try {
this._detectors.forEach((detector) => detector.reset?.());
this._detectors.forEach((detector) => detector.invalidate(cause));
} finally {
this._resetting = false;
this._invalidating = false;
}
this._onDetectorChange();
@@ -196,7 +214,7 @@ export class StreamLivenessController implements ReactiveController {
}
private _onDetectorChange(): void {
if (this._resetting) {
if (this._invalidating) {
return;
}
@@ -219,12 +237,7 @@ export class StreamLivenessController implements ReactiveController {
if (!targetID) {
return;
}
fireAdvancedCameraCardEvent<IssueTriggerEventData>(this._host, 'issue:trigger', {
key: 'media_unavailable',
targetID,
reason,
description,
});
triggerMediaUnavailableIssue(this._host, { targetID, reason, description });
}
private _resolveMediaUnavailableIssue(): void {
@@ -232,9 +245,6 @@ export class StreamLivenessController implements ReactiveController {
if (!targetID) {
return;
}
fireAdvancedCameraCardEvent<IssueResolveEventData>(this._host, 'issue:resolve', {
key: 'media_unavailable',
targetID,
});
resolveMediaUnavailableIssue(this._host, { targetID });
}
}
@@ -1,14 +1,13 @@
import type { ReactiveController, ReactiveControllerHost } from 'lit';
import type {
IssueResolveEventData,
IssueTriggerEventData,
} from '../card-controller/issues/types';
import type { MediaLoadedInfoEventDetail } from '../types';
import { onAbort } from '../utils/abort-signal';
import { Generation } from '../utils/concurrency/generation';
import { fireAdvancedCameraCardEvent } from '../utils/fire-advanced-camera-card-event';
import { Timer } from '../utils/timer';
import {
resolveMediaUnavailableIssue,
triggerMediaUnavailableIssue,
} from './media-unavailable-issue';
const MEDIA_LOADED_EVENT = 'advanced-camera-card:media:loaded';
@@ -125,8 +124,10 @@ export class MediaLoadWatchdogController implements ReactiveController {
this._mediaLoaded = true;
const generation = this._loadGeneration.next();
// Media arriving disproves a not-loading failure whoever reported it.
this._resolveFailure(targetID);
if (this._reportedTargetID === targetID) {
this._reportedTargetID = null;
}
resolveMediaUnavailableIssue(this._host, { targetID, cause: 'media-loaded' });
// Media going away is not itself a failure, but a load that is still
// expected afterwards needs a fresh wait. This is recorded whether or not
@@ -141,20 +142,6 @@ export class MediaLoadWatchdogController implements ReactiveController {
});
};
// Clear a target's not-loading failure, whichever component reported it.
// Naming the reason leaves a failure reported for any other reason alone.
private _resolveFailure(targetID: string): void {
if (this._reportedTargetID === targetID) {
this._reportedTargetID = null;
}
fireAdvancedCameraCardEvent<IssueResolveEventData>(this._host, 'issue:resolve', {
key: 'media_unavailable',
targetID,
reason: 'not_loading',
});
}
// Discard everything learned about a previous target, or a previous attempt
// at the same one.
private _syncAttempt(): void {
@@ -164,10 +151,15 @@ export class MediaLoadWatchdogController implements ReactiveController {
return;
}
// If an abandoned target was previously reported, resolve it so it's not
// stuck forever
// If an issue was triggered for a target since abandoned, resolve it so it
// is not stuck forever, scoped to not-loading since that is all this
// watchdog triggers.
if (this._reportedTargetID && this._reportedTargetID !== targetID) {
this._resolveFailure(this._reportedTargetID);
resolveMediaUnavailableIssue(this._host, {
targetID: this._reportedTargetID,
reason: 'not_loading',
});
this._reportedTargetID = null;
}
this._targetID = targetID;
@@ -210,10 +202,6 @@ export class MediaLoadWatchdogController implements ReactiveController {
this._fired = true;
this._reportedTargetID = targetID;
fireAdvancedCameraCardEvent<IssueTriggerEventData>(this._host, 'issue:trigger', {
key: 'media_unavailable',
targetID,
reason: 'not_loading',
});
triggerMediaUnavailableIssue(this._host, { targetID, reason: 'not_loading' });
}
}
@@ -0,0 +1,47 @@
import type { IssueResolveContext, IssueTriggerContext } from 'issue';
import type {
IssueResolveEventData,
IssueTriggerEventData,
} from '../card-controller/issues/types';
import { fireAdvancedCameraCardEvent } from '../utils/fire-advanced-camera-card-event';
// The `media_unavailable` issue holds one failure per target, and several
// components trigger and resolve it for the same target. These events are
// statements of observed fact, not paired open/close operations:
//
// - A statement must be true when made: trigger only a failure that was
// observed, and resolve only what the observed evidence disproves, naming a
// `reason` to narrow the resolve to exactly that.
// - Statements are idempotent. Two components observing the same recovery may
// both resolve; the second changes nothing.
// - A failure is not necessarily resolved by the component that triggered it.
// That component may since have been replaced (e.g. a retry rebuilding a
// provider), so recovery is stated by whichever component observes it.
/**
* Trigger the `media_unavailable` issue for a target, naming what went wrong.
*/
export function triggerMediaUnavailableIssue(
element: EventTarget,
context: IssueTriggerContext['media_unavailable'],
): void {
fireAdvancedCameraCardEvent<IssueTriggerEventData>(element, 'issue:trigger', {
key: 'media_unavailable',
...context,
});
}
/**
* Resolve a target's `media_unavailable` issue. Naming a reason leaves an issue
* triggered for any other reason in place.
*/
export function resolveMediaUnavailableIssue(
element: EventTarget,
context: IssueResolveContext['media_unavailable'],
): void {
fireAdvancedCameraCardEvent<IssueResolveEventData>(element, 'issue:resolve', {
key: 'media_unavailable',
...context,
});
}
+9 -18
View File
@@ -13,7 +13,6 @@ import { createRef, ref, type Ref } from 'lit/directives/ref.js';
import { getCameraEntityFromConfig } from '../camera-manager/utils/camera-entity-from-config.js';
import type { MediaUnavailableIssueReason } from '../card-controller/issues/issues/media-unavailable.js';
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';
@@ -212,7 +211,7 @@ export class AdvancedCameraCardImageUpdatingPlayer
* @returns `true` if the element should be updated.
*/
protected shouldUpdate(changedProps: PropertyValues): boolean {
if (!this.hass || document.visibilityState !== 'visible') {
if (!this.isConnected || !this.hass || document.visibilityState !== 'visible') {
return false;
}
@@ -279,6 +278,12 @@ export class AdvancedCameraCardImageUpdatingPlayer
}
private _dispatchError(reason: MediaUnavailableIssueReason): void {
// An image request can fail after this player has left the DOM. Ignore
// such failures: they describe media that is no longer shown.
if (!this.isConnected) {
return;
}
fireAdvancedCameraCardEvent(this, 'image-updating-player:error', reason, {
bubbles: false,
composed: false,
@@ -530,23 +535,9 @@ export class AdvancedCameraCardImageUpdatingPlayer
this._forceSafeImage(true);
} else if (mode === 'url') {
this._imageLoadError = true;
}
// Report the failure to the parent. A live context marks the
// stream not-live so its wrapper stops covering the error with a
// loading overlay; the plain image view ignores it.
this._dispatchError('not_loading');
}
if (this.targetID) {
fireAdvancedCameraCardEvent<IssueTriggerEventData>(
this,
'issue:trigger',
{
key: 'media_unavailable',
targetID: this.targetID,
reason: 'not_loading',
},
);
}
this._dispatchError('not_loading');
}}
/>
`
+13
View File
@@ -11,8 +11,10 @@ import { keyed } from 'lit/directives/keyed.js';
import { createRef, ref, type Ref } from 'lit/directives/ref.js';
import type { CameraManager } from '../camera-manager/manager';
import type { MediaUnavailableIssueReason } from '../card-controller/issues/issues/media-unavailable';
import type { ViewManagerEpoch } from '../card-controller/view/types';
import { MediaLoadWatchdogController } from '../components-lib/media-load-watchdog-controller';
import { triggerMediaUnavailableIssue } from '../components-lib/media-unavailable-issue';
import type { ZoomSettingsObserved } from '../components-lib/zoom/types';
import { handleZoomSettingsObservedEvent } from '../components-lib/zoom/zoom-view-context';
import type { CameraConfig } from '../config/schema/cameras';
@@ -175,6 +177,17 @@ export class AdvancedCameraCardImage extends LitElement implements MediaPlayer {
.targetID=${IMAGE_VIEW_TARGET_ID_SENTINEL}
.proxyConfig=${this._resolveProxyConfig(this.imageConfig?.proxy) ??
undefined}
@advanced-camera-card:image-updating-player:error=${(
ev: CustomEvent<MediaUnavailableIssueReason>,
) =>
// The image view has no liveness detector, so the view triggers
// the issue itself. An image's failures are all failures to load.
// The load watchdog above resolves them once media eventually
// loads.
triggerMediaUnavailableIssue(this, {
targetID: IMAGE_VIEW_TARGET_ID_SENTINEL,
reason: ev.detail,
})}
>
</advanced-camera-card-image-updating-player>
`,
+6 -2
View File
@@ -209,7 +209,7 @@ export class AdvancedCameraCardViewerCarousel extends LitElement {
for (let i = 0; i < this._media.length; ++i) {
const media = this._media[i];
if (media) {
const slide = this._renderMediaItem(media);
const slide = this._renderMediaItem(media, i === this._selected);
if (slide) {
slides[i] = slide;
}
@@ -459,7 +459,10 @@ export class AdvancedCameraCardViewerCarousel extends LitElement {
}
}
private _renderMediaItem(media: ViewMedia): TemplateResult | null {
private _renderMediaItem(
media: ViewMedia,
isSelected: boolean,
): TemplateResult | null {
const view = this.viewManagerEpoch?.manager.getView();
if (!this.hass || !view || !this.viewerConfig) {
return null;
@@ -479,6 +482,7 @@ export class AdvancedCameraCardViewerCarousel extends LitElement {
.resolvedMediaCache=${this.resolvedMediaCache}
.cameraManager=${this.cameraManager}
.cardWideConfig=${this.cardWideConfig}
.forceSelected=${isSelected}
></advanced-camera-card-viewer-provider>`,
)}
</div>`;
+11 -1
View File
@@ -74,6 +74,15 @@ export class AdvancedCameraCardViewerProvider extends LitElement implements Medi
@property({ attribute: false })
public cardWideConfig?: CardWideConfig;
// Whether to force this slide to behave as if it is selected and
// intersecting. Set by the carousel on its currently-selected slide. This is
// necessary: `render` below draws nothing until the slide has loaded, and a
// slide drawing nothing can have no height for IntersectionObserver to see.
// Left to the observer only, the slide would wait to be seen before drawing
// anything there was to see. See `LazyLoadConfiguration.forceSelected`.
@property({ attribute: false })
public forceSelected = false;
private _refProvider: Ref<MediaPlayerElement> = createRef();
private _lazyLoadController: LazyLoadController = new LazyLoadController(this);
@@ -177,9 +186,10 @@ export class AdvancedCameraCardViewerProvider extends LitElement implements Medi
}
protected willUpdate(changedProps: PropertyValues): void {
if (changedProps.has('viewerConfig')) {
if (changedProps.has('viewerConfig') || changedProps.has('forceSelected')) {
this._lazyLoadController.setConfiguration({
lazyLoad: this.viewerConfig?.lazy_load,
forceSelected: this.forceSelected,
});
}
+1 -1
View File
@@ -804,7 +804,7 @@
}
},
"error": {
"awaiting_live": "Waiting for live stream to load...",
"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.",