feat: Add hardened error handling and retries (#2451)

- Closes #1830
 - Closes #2099
This commit is contained in:
Dermot Duffy
2026-06-30 17:45:12 -07:00
committed by dermotduffy
parent 4bc787e2b7
commit 47bcce93d3
182 changed files with 7877 additions and 4043 deletions
+30
View File
@@ -0,0 +1,30 @@
import { CardIssueManagerAPI } from '../types';
import { IssueManager } from './issue-manager';
import { ConfigErrorIssue } from './issues/config-error';
import { ConfigUpgradeIssue } from './issues/config-upgrade';
import { ConnectionIssue } from './issues/connection';
import { InitializationIssue } from './issues/initialization';
import { LegacyResourceIssue } from './issues/legacy-resource';
import { MediaLoadIssue } from './issues/media-load';
import { MediaQueryIssue } from './issues/media-query';
import { ViewIncompatibleIssue } from './issues/view-incompatible';
export const createIssueManager = (api: CardIssueManagerAPI): IssueManager => {
const manager = new IssueManager(api);
const changeCallback = () => manager.evaluate();
// Registration order determines both retry priority and full-card display
// priority. For retries, issues are retried in order and an exclusive retry
// stops the loop. For display, getFullCardIssue() returns the first active
// full-card issue. Register broader/more critical issues first.
manager.addIssue(new ConfigErrorIssue());
manager.addIssue(new ConfigUpgradeIssue(api));
manager.addIssue(new ViewIncompatibleIssue(api));
manager.addIssue(new ConnectionIssue());
manager.addIssue(new InitializationIssue(api));
manager.addIssue(new LegacyResourceIssue(changeCallback));
manager.addIssue(new MediaQueryIssue(api));
manager.addIssue(new MediaLoadIssue(api, changeCallback));
return manager;
};
+246
View File
@@ -0,0 +1,246 @@
import type { IssueTriggerContext } from 'issue';
import { ConditionStateChange } from '../../conditions/types';
import { isActionAllowedBasedOnInteractionState } from '../../utils/interaction-mode';
import { Timer } from '../../utils/timer';
import { CardIssueManagerAPI } from '../types';
import { IssueStateManager } from './state-manager';
import { Issue, IssueKey, IssueReadOnlyState, IssueTriggerContextKey } from './types';
// Exponential backoff schedule for 'auto' retry. The base is set above the
// per-media retry threshold (~10s) so the issue-level backoff kicks in *after*
// lower-level recovery has had a chance to work, not in parallel with it.
export const RETRY_EXPONENTIAL_BASE_SECONDS = 30;
export const RETRY_EXPONENTIAL_MAX_SECONDS = 600;
const RETRY_EXPONENTIAL_JITTER_MIN = 0.5;
const RETRY_EXPONENTIAL_JITTER_MAX = 1.0;
// Wraps the passive IssueStateManager with reaction logic. A single
// condition-state listener drives everything: it runs one-shot static
// detection when mandatory-init completes (`initialized` transitions to
// true), then evaluates dynamic issues on every subsequent state change,
// schedules retries, and updates the card. Full-card issues are rendered by
// card.ts via getStateManager().getFullCardIssue(). Non-full-card issue
// notifications are shown on demand via showNotification().
export class IssueManager {
private _api: CardIssueManagerAPI;
private _stateManager = new IssueStateManager();
private _retryTimer = new Timer();
private _retryAttempt = 0;
private _suspended = false;
// Reentrancy guard: evaluate() calls setState() on the condition state
// manager, which fires listeners synchronously — including the one
// registered in this constructor. Without this guard, detectDynamic()
// and presence computation would run twice per evaluation.
private _evaluating = false;
constructor(api: CardIssueManagerAPI) {
this._api = api;
api.getConditionStateManager().addListener((change) => this._onStateChange(change));
}
// =========================================================================
// Setup.
// =========================================================================
public addIssue(issue: Issue): void {
this._stateManager.addIssue(issue);
}
public getStateManager(): IssueReadOnlyState {
return this._stateManager;
}
// =========================================================================
// Detection & reaction.
// =========================================================================
// Called by components that detect an issue directly (e.g. a provider
// error event), bypassing the condition-state polling loop.
public trigger<K extends IssueTriggerContextKey>(
key: K,
context: IssueTriggerContext[K],
): void {
this._stateManager.trigger(key, context);
this.evaluate();
}
// Evaluate all dynamic issues against current state, then react to any
// changes: notify, update condition state, and schedule retries.
//
// Detection of "anything changed" is delegated to the condition state
// manager: IssuePresence is a Map<IssueKey, IssueDescription>, so its
// deep equality check naturally catches both presence-set churn (issues
// appearing/disappearing) and content-level churn (an issue swapping
// sub-states without changing its key, e.g. ConnectionIssue going from
// 'lost' to 'starting').
public evaluate(): void {
if (this._suspended || this._evaluating) {
return;
}
this._evaluating = true;
try {
const state = this._api.getConditionStateManager().getState();
this._stateManager.detectDynamic(state);
if (
this._api.getConditionStateManager().setState({
issues: this._stateManager.getIssuePresence(),
})
) {
this._api.getCardElementManager().update();
}
this._scheduleRetryIfNeeded();
} finally {
this._evaluating = false;
}
}
// Attempts a retry for the given issue. Pass `force = true` for user-
// initiated retries (e.g. clicking the retry button on a notification): it
// bypasses the `needsRetry()` gate that scheduled auto-retries must
// respect, so even an issue that doesn't currently want a retry will run
// its `retry()` method. Also stops the pending auto-retry timer so the
// user action resets the backoff schedule.
public retry(key: IssueKey, force?: boolean): void {
this._stateManager.retry(key, force);
this._retryTimer.stop();
this.evaluate();
}
// Show the notification for an issue on demand (e.g. user clicks a loading
// icon) regardless of whether the issue is currently active.
public showNotification(key: IssueKey): void {
const notification = this._stateManager.getNotification(key);
if (notification) {
this._api.getNotificationManager().setNotification(notification);
}
}
// =========================================================================
// Lifecycle.
// =========================================================================
public reset(key?: IssueKey): void {
// When resetting a specific key that has no active issue, skip the
// reset+evaluate cycle entirely to avoid unnecessary work.
if (key && !this._stateManager.getIssuePresence().has(key)) {
return;
}
this._stateManager.reset(key);
this.evaluate();
}
// Gate evaluation while the card is detached so timers don't arm or mature
// offscreen. Issue state is preserved (including full-card issues like
// config_error). Issue-internal timers are stopped via Issue.suspend so
// offscreen time doesn't count against age-based thresholds (e.g. media
// loading timeout). Evaluation resumes on resume().
public suspend(): void {
this._suspended = true;
this._retryTimer.stop();
this._stateManager.suspend();
}
public resume(): void {
this._suspended = false;
this.evaluate();
}
public destroy(): void {
this._retryTimer.stop();
this._stateManager.destroy();
}
// =========================================================================
// Private helpers.
// =========================================================================
// Drives both one-shot static detection (on mandatory-init completion) and
// normal re-evaluation (on any condition-state change).
//
// `initialized: true` in the change payload means mandatory initialization
// just finished — see InitializationManager._initializeMandatory. That's
// also the earliest point at which the full HASS object is guaranteed
// ready for websocket calls (e.g. LegacyResourceIssue's lovelace/resources
// fetch). Because `initialized` is latched (its comment notes it never
// changes again), this block fires exactly once per IssueManager life.
private _onStateChange(change: ConditionStateChange): void {
if (change.change.initialized === true && change.new.hass) {
/* async */ this._stateManager
.detectStatic(change.new.hass)
.then(() => this.evaluate());
}
this.evaluate();
}
private _scheduleRetryIfNeeded(): void {
if (!this._stateManager.needsRetry()) {
this._retryTimer.stop();
this._retryAttempt = 0;
return;
}
if (this._retryTimer.isRunning()) {
return;
}
const config = this._api.getConfigManager().getConfig();
if (!config) {
this._retryAttempt = 0;
return;
}
const delaySeconds = this._nextRetryDelaySeconds(config.view.issues.retry_seconds);
if (delaySeconds === null) {
this._retryAttempt = 0;
return;
}
this._retryTimer.start(delaySeconds, () => {
if (!this._stateManager.needsRetry()) {
this._retryAttempt = 0;
return;
}
if (this._isScheduledRetryAllowed()) {
this._stateManager.retry();
this._retryAttempt++;
// evaluate() re-arms the timer via _scheduleRetryIfNeeded.
this.evaluate();
} else {
// Retry was gated (e.g. user interaction). This isn't a failed attempt
// so don't increment — re-arm at the same delay.
this._scheduleRetryIfNeeded();
}
});
}
private _nextRetryDelaySeconds(retryConfig: 'auto' | number): number | null {
if (typeof retryConfig === 'number') {
return retryConfig === 0 ? null : retryConfig;
}
// 'auto': exponential backoff, capped, with jitter to avoid thundering-herd
// when multiple cards retry the same backend in lockstep.
const exp = Math.min(
RETRY_EXPONENTIAL_MAX_SECONDS,
RETRY_EXPONENTIAL_BASE_SECONDS * 2 ** this._retryAttempt,
);
const jitter =
RETRY_EXPONENTIAL_JITTER_MIN +
Math.random() * (RETRY_EXPONENTIAL_JITTER_MAX - RETRY_EXPONENTIAL_JITTER_MIN);
return exp * jitter;
}
private _isScheduledRetryAllowed(): boolean {
const interactionMode = this._api.getConfigManager().getConfig()?.view
.issues.interaction_mode;
return (
!!interactionMode &&
isActionAllowedBasedOnInteractionState(
interactionMode,
this._api.getInteractionManager().hasInteraction(),
)
);
}
}
@@ -0,0 +1,30 @@
import { Issue, IssueDescription, IssueKey } from '../types.js';
// Shared base for issues whose only state is a single triggered error. Subclasses
// define the key and how an error renders; the base handles trigger/reset and
// gates getIssue() on error presence.
export abstract class AbstractErrorIssue implements Issue {
public abstract readonly key: IssueKey;
protected _error: unknown = null;
public trigger(context: { error: unknown }): void {
this._error = context.error ?? null;
}
public hasIssue(): boolean {
return this._error !== null;
}
public getIssue(): IssueDescription | null {
if (this._error == null) {
return null;
}
return this._buildDescription(this._error);
}
public reset(): void {
this._error = null;
}
protected abstract _buildDescription(error: NonNullable<unknown>): IssueDescription;
}
@@ -0,0 +1,28 @@
import { createNotificationFromError } from '../../../components-lib/notification/factory.js';
import { localize } from '../../../localize/localize.js';
import { IssueDescription } from '../types.js';
import { AbstractErrorIssue } from './abstract-error-issue.js';
declare module 'issue' {
interface IssueTriggerContext {
config_error: { error: unknown };
}
}
export class ConfigErrorIssue extends AbstractErrorIssue {
public readonly key = 'config_error' as const;
public isFullCardIssue(): boolean {
return true;
}
protected _buildDescription(error: NonNullable<unknown>): IssueDescription {
return {
icon: 'mdi:alert',
severity: 'high',
notification: createNotificationFromError(error, {
heading: { text: localize('issues.config_error.heading') },
}),
};
}
}
@@ -0,0 +1,47 @@
import { isConfigUpgradeable } from '../../../config/management.js';
import { TROUBLESHOOTING_CONFIG_UPGRADE_URL } from '../../../const.js';
import { localize } from '../../../localize/localize.js';
import { CardIssueManagerAPI } from '../../types';
import { Issue, IssueDescription } from '../types';
export class ConfigUpgradeIssue implements Issue {
public readonly key = 'config_upgrade' as const;
private _api: CardIssueManagerAPI;
private _upgradeable = false;
constructor(api: CardIssueManagerAPI) {
this._api = api;
}
public async detectStatic(): Promise<void> {
const rawConfig = this._api.getConfigManager().getRawConfig();
this._upgradeable = !!rawConfig && isConfigUpgradeable(rawConfig);
}
public hasIssue(): boolean {
return this._upgradeable;
}
public getIssue(): IssueDescription | null {
if (!this._upgradeable) {
return null;
}
return {
icon: 'mdi:update',
severity: 'medium',
notification: {
heading: {
text: localize('issues.config_upgrade.heading'),
icon: 'mdi:update',
severity: 'medium',
},
body: { text: localize('issues.config_upgrade.text') },
link: {
url: TROUBLESHOOTING_CONFIG_UPGRADE_URL,
title: localize('issues.troubleshooting_guide'),
},
},
};
}
}
@@ -0,0 +1,79 @@
import { STATE_RUNNING } from 'home-assistant-js-websocket';
import { ConditionState } from '../../../conditions/types.js';
import { localize } from '../../../localize/localize.js';
import { Issue, IssueDescription } from '../types.js';
// Tracks "is HA fully ready to talk to". Active in two sub-states:
// - 'lost' : the WebSocket is disconnected
// - 'starting' : the WebSocket is connected but HA hasn't finished loading
// integrations yet (hass.config.state !== STATE_RUNNING)
// Both sub-states render as a full-card notification with a spinner. The card
// only attempts re-initialization when the issue clears (i.e. HA is fully
// ready), so integration-specific WS calls (e.g. Frigate event subscriptions)
// don't fail with "Unknown command" against a half-loaded HA.
type ConnectionState = 'ready' | 'lost' | 'starting';
export class ConnectionIssue implements Issue {
public readonly key = 'connection' as const;
private _state: ConnectionState = 'ready';
public detectDynamic(state: ConditionState): void {
// Before HASS is ever provided, leave state untouched — undefined hass is
// not a disconnection, just "not yet initialized".
if (state.hass === undefined) {
return;
}
if (!state.hass.connected) {
this._state = 'lost';
} else if (state.hass.config?.state !== STATE_RUNNING) {
this._state = 'starting';
} else {
this._state = 'ready';
}
}
public hasIssue(): boolean {
return this._state !== 'ready';
}
public isFullCardIssue(): boolean {
return true;
}
public getIssue(): IssueDescription | null {
return this._state === 'lost'
? {
icon: 'mdi:lan-disconnect',
severity: 'high',
notification: {
heading: {
text: localize('issues.connection.lost.heading'),
icon: 'mdi:lan-disconnect',
severity: 'high',
},
body: { text: localize('issues.connection.lost.text') },
in_progress: true,
},
}
: this._state === 'starting'
? {
icon: 'mdi:home-assistant',
severity: 'medium',
notification: {
heading: {
text: localize('issues.connection.starting.heading'),
icon: 'mdi:home-assistant',
severity: 'medium',
},
body: { text: localize('issues.connection.starting.text') },
in_progress: true,
},
}
: null;
}
public reset(): void {
this._state = 'ready';
}
}
@@ -0,0 +1,68 @@
import { createNotificationFromError } from '../../../components-lib/notification/factory.js';
import { localize } from '../../../localize/localize.js';
import { CardIssueManagerAPI } from '../../types';
import { createRetryControl } from '../retry-control.js';
import { IssueDescription } from '../types';
import { AbstractErrorIssue } from './abstract-error-issue.js';
declare module 'issue' {
interface IssueTriggerContext {
initialization: { error: unknown };
}
}
export class InitializationIssue extends AbstractErrorIssue {
public readonly key = 'initialization' as const;
private _api: CardIssueManagerAPI;
constructor(api: CardIssueManagerAPI) {
super();
this._api = api;
}
public detectDynamic(): void {
if (
this._error !== null &&
this._api.getInitializationManager().isInitializedMandatory()
) {
this._error = null;
}
}
public needsRetry(): boolean {
return this._error !== null;
}
public retry(): boolean {
// Clear the error so the full-card issue is removed and shouldUpdate()
// no longer short-circuits before initializeMandatory().
this._error = null;
// Reset init state so initializeMandatory() re-attempts on the next
// render cycle. destroy() releases the existing CameraManager's held
// resources (WebSocket subscriptions, listeners) before the CAMERAS
// init aspect replaces the instance via createCameraManager().
this._api.getInitializationManager().uninitializeMandatory();
this._api.getCameraManager().destroy();
return false;
}
public isFullCardIssue(): boolean {
return true;
}
protected _buildDescription(error: NonNullable<unknown>): IssueDescription {
const notification = createNotificationFromError(error, {
heading: { text: localize('issues.initialization.heading') },
});
return {
icon: 'mdi:alert',
severity: 'high',
notification: {
...notification,
controls: [createRetryControl(this.key)],
},
};
}
}
@@ -0,0 +1,169 @@
import { z } from 'zod';
import { TROUBLESHOOTING_LEGACY_RESOURCE_URL } from '../../../const.js';
import { HomeAssistant } from '../../../ha/types';
import { localize } from '../../../localize/localize';
import { createInternalCallbackAction } from '../../../utils/action';
import { CardActionsAPI } from '../../types';
import { Issue, IssueDescription } from '../types';
const LEGACY_RESOURCE_FILENAME = 'frigate-hass-card.js';
const ADVANCED_CAMERA_CARD_PATTERN = 'advanced-camera-card.js';
const getResourcePath = (url: string, baseURL: string): string => {
try {
return new URL(url, baseURL).pathname;
} catch {
// Fallback: strip query string manually.
const queryIndex = url.indexOf('?');
return queryIndex >= 0 ? url.slice(0, queryIndex) : url;
}
};
const resourcesSchema = z.array(
z.object({
id: z.string(),
type: z.string(),
url: z.string(),
}),
);
export class LegacyResourceIssue implements Issue {
public readonly key = 'legacy_resource' as const;
private _legacyResourceIDs: string[] = [];
private _hasCorrectResource = false;
private _checked = false;
private _changeCallback: (() => void) | null;
constructor(changeCallback?: () => void) {
this._changeCallback = changeCallback ?? null;
}
public async detectStatic(hass: HomeAssistant): Promise<void> {
// Only admin users can view/modify dashboard resources.
if (!hass.user?.is_admin) {
return;
}
try {
const rawResources = await hass.callWS({
type: 'lovelace/resources',
});
const parseResult = resourcesSchema.safeParse(rawResources);
if (!parseResult.success) {
return;
}
this._legacyResourceIDs = [];
this._hasCorrectResource = false;
for (const resource of parseResult.data) {
const path = getResourcePath(resource.url, hass.hassUrl());
if (path.endsWith(LEGACY_RESOURCE_FILENAME)) {
this._legacyResourceIDs.push(resource.id);
}
if (path.endsWith(ADVANCED_CAMERA_CARD_PATTERN)) {
this._hasCorrectResource = true;
}
}
this._checked = true;
} catch {
// Silently ignore WS failures (e.g. non-admin, connection issues).
}
}
public hasIssue(): boolean {
return this._checked && this._legacyResourceIDs.length > 0;
}
public getIssue(): IssueDescription | null {
if (!this.hasIssue()) {
return null;
}
const text = this._hasCorrectResource
? localize('issues.legacy_resource.text_both')
: localize('issues.legacy_resource.text_only_legacy');
return {
icon: 'mdi:alert',
severity: 'high',
notification: {
heading: {
text: localize('issues.legacy_resource.heading'),
icon: 'mdi:alert',
severity: 'high',
},
body: { text },
link: {
url: TROUBLESHOOTING_LEGACY_RESOURCE_URL,
title: localize('issues.troubleshooting_guide'),
},
...(this._hasCorrectResource
? {
controls: [
{
tooltip: localize('issues.legacy_resource.remove'),
icon: 'mdi:delete',
severity: 'high',
actions: {
tap_action: createInternalCallbackAction(
async (api: CardActionsAPI) => {
const hass = api.getHASSManager().getHASS();
if (hass) {
await this.fix(hass);
}
},
),
},
dismiss: true,
},
],
}
: {}),
},
};
}
public async fix(hass: HomeAssistant): Promise<boolean> {
if (
!hass.user?.is_admin ||
!this._hasCorrectResource ||
!this._legacyResourceIDs.length
) {
return false;
}
try {
await Promise.all(
this._legacyResourceIDs.map((id) =>
hass.callWS({
type: 'lovelace/resources/delete',
resource_id: id,
}),
),
);
// Re-detect to verify removal.
this._checked = false;
await this.detectStatic(hass);
// Success requires: detection completed cleanly AND found zero legacy
// resources. detectStatic swallows WS / schema failures and leaves
// _checked false; treating that as "issue gone" would let a failed
// verification fetch masquerade as a successful fix. Demand the
// positive signal instead.
const fixed = this._checked && this._legacyResourceIDs.length === 0;
if (fixed) {
this._changeCallback?.();
}
return fixed;
} catch {
return false;
}
}
}
@@ -0,0 +1,238 @@
import type { IssueTriggerContext } from 'issue';
import { ConditionState } from '../../../conditions/types.js';
import { Notification } from '../../../config/schema/actions/types.js';
import { TROUBLESHOOTING_MEDIA_URL } from '../../../const.js';
import { localize } from '../../../localize/localize.js';
import { Timer } from '../../../utils/timer.js';
import { IMAGE_VIEW_TARGET_ID_SENTINEL } from '../../../view/target-id.js';
import { isAnyMediaViewName } from '../../../view/view.js';
import { CardIssueManagerAPI } from '../../types.js';
import { createRetryControl } from '../retry-control.js';
import { Issue, IssueDescription } from '../types.js';
declare module 'issue' {
interface IssueTriggerContext {
media_load: { targetID: string };
}
}
const MEDIA_LOADING_TIMEOUT_SECONDS = 10;
export class MediaLoadIssue implements Issue {
public readonly key = 'media_load' as const;
private _issueActive = false;
private _erroredTargetIDs = new Set<string>();
// Timer fires when a target has been loading too long without success.
private _timer = new Timer();
private _timerTargetID: string | null = null;
private _api: CardIssueManagerAPI;
private _onChange: (() => void) | null;
constructor(api: CardIssueManagerAPI, onChange?: () => void) {
this._api = api;
this._onChange = onChange ?? null;
}
// =========================================================================
// Explicit trigger — called when a component fires an issue:trigger event.
// =========================================================================
public trigger(context: IssueTriggerContext['media_load']): void {
this._erroredTargetIDs.add(context.targetID);
}
// =========================================================================
// Detection — called by the manager on every state change.
// =========================================================================
public detectDynamic(state: ConditionState): void {
if (!isAnyMediaViewName(state.view)) {
this._deactivate();
return;
}
if (state.mediaLoadedInfo) {
this._handleMediaLoaded(state);
} else {
this._handleMediaNotLoaded(state);
}
}
// =========================================================================
// State queries — called by the manager to read current state.
// =========================================================================
public hasIssue(): boolean {
return this._issueActive;
}
public getIssue(): IssueDescription | null {
if (!this._issueActive) {
return null;
}
return {
icon: 'mdi:cctv-off',
severity: 'high',
notification: this.getNotification(),
};
}
public getNotification(): Notification {
const targets = new Set(this._erroredTargetIDs);
if (this._timerTargetID) {
targets.add(this._timerTargetID);
}
return {
heading: {
text: localize('issues.media_load.heading'),
icon: 'mdi:cctv-off',
severity: 'high' as const,
},
body: {
text: localize('issues.media_load.text'),
},
...(targets.size && {
metadata: Array.from(targets).map((id) => ({
text:
id === IMAGE_VIEW_TARGET_ID_SENTINEL
? localize('editor.image')
: this._api.getCameraManager().getCameraMetadata(id)?.title ?? id,
icon: id === IMAGE_VIEW_TARGET_ID_SENTINEL ? 'mdi:image' : 'mdi:cctv',
})),
}),
link: {
url: TROUBLESHOOTING_MEDIA_URL,
title: localize('issues.troubleshooting_guide'),
},
controls: [createRetryControl(this.key)],
};
}
// =========================================================================
// Retry — called by the manager to schedule a media reload.
// =========================================================================
public needsRetry(): boolean {
return this._issueActive;
}
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).
const retryTargets = new Set(this._erroredTargetIDs);
if (this._timerTargetID) {
retryTargets.add(this._timerTargetID);
}
if (!retryTargets.size) {
return false;
}
const view = this._api.getViewManager().getView();
const mediaEpoch = { ...(view?.context?.mediaEpoch ?? {}) };
for (const id of retryTargets) {
mediaEpoch[id] = (mediaEpoch[id] ?? 0) + 1;
}
// Intentionally keep _issueActive, _erroredTargetIDs, and the pending
// timer in place. The issue stays visible while the provider
// re-attempts loading underneath. If the retry succeeds,
// _handleMediaLoaded will clear everything when media:loaded fires. 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;
}
// =========================================================================
// Lifecycle.
// =========================================================================
public reset(): void {
this._deactivate();
this._erroredTargetIDs.clear();
}
// Stop the pending-load timer so offscreen time doesn't count toward the
// 10s threshold. Preserve _issueActive, _erroredTargetIDs, and
// _timerTargetID: already-visible errors remain visible on reattach, and
// retaining _timerTargetID lets the existing active/target-mismatch guard
// in _handleMediaNotLoaded avoid spuriously deactivating the preserved
// issue when the same target is still loading on resume. The timer is
// re-armed with a fresh window by the next detectDynamic pass.
public suspend(): void {
this._timer.stop();
}
// =========================================================================
// Private helpers.
// =========================================================================
// Media loaded successfully: deactivate and clear the error for this target
// so it won't immediately re-trigger on the next evaluation.
private _handleMediaLoaded(state: ConditionState): void {
this._deactivate();
if (state.targetID) {
this._erroredTargetIDs.delete(state.targetID);
}
}
// Media not yet loaded: activate immediately if there is a known provider
// error for this target, otherwise start a timeout to detect slow loads.
private _handleMediaNotLoaded(state: ConditionState): void {
// No targetID means no provider is actively rendering media (e.g. the
// viewer shows "No media to display" instead of showing a player). Don't
// start the timeout as there's nothing to wait for.
if (!state.targetID) {
this._deactivate();
return;
}
if (this._hasError(state)) {
this._activate();
return;
}
const targetID = state.targetID;
// When the target changes to one without a known error, clear the active
// state so the new target gets its own timeout window instead of
// inheriting the previous target's error.
if (this._issueActive && this._timerTargetID !== targetID) {
this._deactivate();
}
// Start (or restart) the timer for this target.
if (!this._timer.isRunning() || this._timerTargetID !== targetID) {
this._timerTargetID = targetID;
this._timer.start(MEDIA_LOADING_TIMEOUT_SECONDS, () => {
// Record the error on timeout so retry() knows which epoch to bump.
// targetID is guaranteed non-null here — the null case bails at the
// top of _handleMediaNotLoaded.
this._erroredTargetIDs.add(targetID);
this._activate();
this._onChange?.();
});
}
}
private _hasError(state: ConditionState): boolean {
return !!state.targetID && this._erroredTargetIDs.has(state.targetID);
}
private _activate(): void {
this._timer.stop();
this._issueActive = true;
}
private _deactivate(): void {
this._timer.stop();
this._timerTargetID = null;
this._issueActive = false;
}
}
@@ -0,0 +1,57 @@
import { createNotificationFromError } from '../../../components-lib/notification/factory.js';
import { Notification } from '../../../config/schema/actions/types.js';
import { localize } from '../../../localize/localize.js';
import { CardIssueManagerAPI } from '../../types.js';
import { createRetryControl } from '../retry-control.js';
import { IssueDescription } from '../types.js';
import { AbstractErrorIssue } from './abstract-error-issue.js';
declare module 'issue' {
interface IssueTriggerContext {
media_query: { error: unknown };
}
}
export class MediaQueryIssue extends AbstractErrorIssue {
public readonly key = 'media_query' as const;
private _api: CardIssueManagerAPI;
constructor(api: CardIssueManagerAPI) {
super();
this._api = api;
}
public needsRetry(): boolean {
return this._error !== null;
}
public retry(): boolean {
if (this._error === null) {
return false;
}
this._error = null;
this._api.getViewManager().setViewByParametersWithNewQuery();
// Exclusive retry. No other issue should attempt to retry until the next
// evaluation cycle, when we'll know if this was successful.
return true;
}
public getNotification(): Notification | null {
return this.getIssue()?.notification ?? null;
}
protected _buildDescription(error: NonNullable<unknown>): IssueDescription {
return {
icon: 'mdi:alert',
severity: 'high',
notification: {
...createNotificationFromError(error, {
heading: { text: localize('issues.media_query.heading') },
}),
controls: [createRetryControl(this.key)],
},
};
}
}
@@ -0,0 +1,48 @@
import { createNotificationFromText } from '../../../components-lib/notification/factory.js';
import { localize } from '../../../localize/localize.js';
import { getContextFromError } from '../../../utils/error-context.js';
import { CardIssueManagerAPI } from '../../types.js';
import { IssueDescription } from '../types.js';
import { AbstractErrorIssue } from './abstract-error-issue.js';
declare module 'issue' {
interface IssueTriggerContext {
view_incompatible: { error: unknown };
}
}
export class ViewIncompatibleIssue extends AbstractErrorIssue {
public readonly key = 'view_incompatible' as const;
private _api: CardIssueManagerAPI;
constructor(api: CardIssueManagerAPI) {
super();
this._api = api;
}
// Full-card when no view is available to anchor a popup to (initial load
// with an unrealizable default view); popup when an existing view remains
// visible underneath (mid-session user action that can't resolve).
public isFullCardIssue(): boolean {
return !this._api.getViewManager().getView();
}
protected _buildDescription(error: NonNullable<unknown>): IssueDescription {
return {
icon: 'mdi:video-off',
severity: 'high',
notification: createNotificationFromText(
localize('issues.view_incompatible.text'),
{
heading: {
text: localize('issues.view_incompatible.heading'),
icon: 'mdi:video-off',
severity: 'high',
},
context: getContextFromError(error) ?? undefined,
},
),
};
}
}
@@ -0,0 +1,17 @@
import { NotificationControl } from '../../config/schema/actions/types.js';
import { localize } from '../../localize/localize.js';
import { createInternalCallbackAction } from '../../utils/action.js';
import { IssueKey } from './types.js';
export function createRetryControl(key: IssueKey): NotificationControl {
return {
icon: 'mdi:refresh',
tooltip: localize('common.retry'),
dismiss: true,
actions: {
tap_action: createInternalCallbackAction(async (api) => {
api.getIssueManager().retry(key, true);
}),
},
};
}
+172
View File
@@ -0,0 +1,172 @@
import type { IssueTriggerContext } from 'issue';
import { summarizeNotification } from '../../components-lib/notification/summarize';
import { ConditionState } from '../../conditions/types';
import { Notification } from '../../config/schema/actions/types';
import { HomeAssistant } from '../../ha/types';
import { isTruthy } from '../../utils/basic';
import {
Issue,
IssueDescription,
IssueKey,
IssuePresence,
IssueReadOnlyState,
IssueTriggerContextKey,
KeyedIssueDescription,
} from './types';
export class IssueStateManager implements IssueReadOnlyState {
private _issues = new Map<IssueKey, Issue>();
private _loggedKeys = new Set<IssueKey>();
// =========================================================================
// Setup.
// =========================================================================
public addIssue(issue: Issue): void {
this._issues.set(issue.key, issue);
}
// =========================================================================
// Detection — static (one-shot on init) and dynamic (on every state change).
// =========================================================================
public async detectStatic(hass: HomeAssistant): Promise<void> {
for (const issue of this._issues.values()) {
await issue.detectStatic?.(hass);
this._logIfNew(issue);
}
}
public trigger<K extends IssueTriggerContextKey>(
key: K,
context: IssueTriggerContext[K],
): void {
const issue = this._issues.get(key);
if (!issue) {
return;
}
issue.trigger?.(context);
this._logIfNew(issue);
}
public detectDynamic(context: ConditionState): void {
for (const issue of this._issues.values()) {
issue.detectDynamic?.(context);
this._logIfNew(issue);
}
}
// =========================================================================
// Queries — read active issue state.
// =========================================================================
public getFullCardIssue(): IssueDescription | null {
for (const issue of this._issues.values()) {
if (issue.hasIssue() && issue.isFullCardIssue?.()) {
return issue.getIssue();
}
}
return null;
}
public hasFullCardIssue(): boolean {
return !!this.getFullCardIssue();
}
public getIssueDescriptions(): KeyedIssueDescription[] {
const descriptions: KeyedIssueDescription[] = [];
for (const issue of this._issues.values()) {
const description = issue.getIssue();
if (description) {
descriptions.push({ key: issue.key, issue: description });
}
}
return descriptions;
}
public getIssuePresence(): IssuePresence {
const presence: IssuePresence = new Map();
for (const issue of this._issues.values()) {
const description = issue.getIssue();
if (description) {
presence.set(issue.key, description);
}
}
return presence;
}
public getNotification(key: IssueKey): Notification | null {
return this._issues.get(key)?.getNotification?.() ?? null;
}
// =========================================================================
// Retry.
// =========================================================================
public needsRetry(): boolean {
return [...this._issues.values()].some((issue) => issue.needsRetry?.());
}
public retry(key?: IssueKey, force?: boolean): void {
const issues = key
? [this._issues.get(key)].filter(isTruthy)
: [...this._issues.values()];
for (const issue of issues) {
if (!force && !issue.needsRetry?.()) {
continue;
}
if (issue.retry?.()) {
return;
}
}
}
// =========================================================================
// Lifecycle.
// =========================================================================
public reset(key?: IssueKey): void {
const issues = key
? [this._issues.get(key)].filter(isTruthy)
: [...this._issues.values()];
for (const issue of issues) {
issue.reset?.();
}
}
public suspend(): void {
for (const issue of this._issues.values()) {
issue.suspend?.();
}
}
public destroy(): void {
this.reset();
this._issues.clear();
this._loggedKeys.clear();
}
// =========================================================================
// Private helpers.
// =========================================================================
private _logIfNew(issue: Issue): void {
const description = issue.getIssue();
if (!description) {
// Issue cleared (explicit reset, or self-clear via detectDynamic).
// Release the dedupe so the next activation is logged as a new episode.
this._loggedKeys.delete(issue.key);
return;
}
if (this._loggedKeys.has(issue.key)) {
return;
}
this._loggedKeys.add(issue.key);
const summary = summarizeNotification(description.notification);
if (summary) {
console.warn(`Advanced Camera Card [issue=${issue.key}]: ${summary}`);
}
}
}
+96
View File
@@ -0,0 +1,96 @@
import type { IssueTriggerContext } from 'issue';
import { ConditionState } from '../../conditions/types';
import { Notification } from '../../config/schema/actions/types';
import { HomeAssistant } from '../../ha/types';
import { Severity } from '../../severity';
export type IssueKey =
| 'config_error'
| 'config_upgrade'
| 'connection'
| 'initialization'
| 'legacy_resource'
| 'media_load'
| 'media_query'
| 'view_incompatible';
export interface IssueDescription {
icon: string;
severity: Severity;
notification: Notification;
}
export interface KeyedIssueDescription {
key: IssueKey;
issue: IssueDescription;
}
// Map of currently active issues keyed by IssueKey, with each entry's value
// being the issue's current rendered description. Stored as a Map (not just
// a Set of keys) so that sub-state changes within an issue — e.g.
// ConnectionIssue swapping between 'lost' and 'starting' — are reflected as
// real value-level diffs to the condition state, triggering re-renders and
// any user-defined conditions that depend on issue state.
export type IssuePresence = Map<IssueKey, IssueDescription>;
export interface IssueReadOnlyState {
hasFullCardIssue(): boolean;
getFullCardIssue(): IssueDescription | null;
getIssueDescriptions(): KeyedIssueDescription[];
getIssuePresence(): IssuePresence;
getNotification(key: IssueKey): Notification | null;
}
export type IssueTriggerContextKey = keyof IssueTriggerContext;
export type IssueTriggerEventData = {
[K in IssueTriggerContextKey]: { key: K } & IssueTriggerContext[K];
}[IssueTriggerContextKey];
export interface Issue {
readonly key: IssueKey;
// One-time async detection (WS calls, config checks).
detectStatic?(hass?: HomeAssistant): Promise<void>;
// Ongoing sync evaluation, called on state changes.
detectDynamic?(context: ConditionState): void;
// Explicitly trigger this issue with key-specific context.
trigger?(context: IssueTriggerContext[IssueTriggerContextKey]): void;
hasIssue(): boolean;
getIssue(): IssueDescription | null;
// Whether this issue renders as a full-card display when active. Defaults
// to false when absent (popup notification). Issues that take over the
// entire card must explicitly return true.
isFullCardIssue?(): boolean;
// Return notification content regardless of active state, for user-initiated
// queries (e.g. clicking a loading icon). May return null when content
// depends on transient state (e.g. no current error to show).
getNotification?(): Notification | null;
// Whether this issue wants the manager to schedule a retry. Gates
// scheduled retries; user-initiated (forced) retries bypass this check.
needsRetry?(): boolean;
// Called by the manager when a retry is due. Returns true to stop the retry
// loop (exclusive), false to allow subsequent issues to also retry.
retry?(): boolean;
// Optional user-initiated fix. Not called by the issue infrastructure —
// callers (e.g. notification control actions) invoke this directly.
fix?(hass: HomeAssistant): Promise<boolean>;
// Reset internal state (clear errors, stop timers, etc.).
reset?(): void;
// Called when the card is detached. Issues with age-based timers (e.g.
// loading-timeout timers) must stop them here so that time spent offscreen
// doesn't count against the user. Must preserve already-active issue state
// — a full-card issue visible at detach should still be visible on
// reattach. No `resume` hook: IssueManager.resume() triggers a normal
// evaluate(), so any timer that should restart is re-armed via
// detectDynamic against the current condition state.
suspend?(): void;
}