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