feat: Add hardened error handling and retries (#2451)
- Closes #1830 - Closes #2099
This commit is contained in:
committed by
dermotduffy
parent
4bc787e2b7
commit
47bcce93d3
@@ -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;
|
||||
};
|
||||
@@ -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);
|
||||
}),
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user