import type { IssueResolveContext, IssueTriggerContext } from 'issue'; import { summarizeNotification } from '../../components-lib/notification/summarize'; import type { ConditionState } from '../../condition-trigger/conditions/types'; import type { Notification } from '../../config/schema/actions/types'; import type { HomeAssistant } from '../../ha/types'; import { errorToConsole, isTruthy } from '../../utils/basic'; import type { Issue, IssueDescription, IssueKey, IssuePresence, IssueReadOnlyState, IssueResolveContextKey, IssueTriggerContextKey, KeyedIssueDescription, } from './types'; export class IssueStateManager implements IssueReadOnlyState { private _issues = new Map(); private _loggedKeys = new Set(); // ========================================================================= // Setup. // ========================================================================= public addIssue(issue: Issue): void { this._issues.set(issue.key, issue); } // ========================================================================= // Detection -- static (one-shot on init), dynamic (on every state change) and // explicit (triggered or resolved by a component). // ========================================================================= public async detectStatic(hass: HomeAssistant): Promise { for (const issue of this._issues.values()) { try { await issue.detectStatic?.(hass); } catch (e) { // Isolate one issue's detection failure so it cannot abort detection // for the rest; log so the cause is visible. errorToConsole(e); } this._logIfNew(issue); } } public trigger( key: K, context: IssueTriggerContext[K], ): void { const issue = this._issues.get(key); if (!issue) { return; } issue.trigger?.(context); this._logIfNew(issue); } public resolve( key: K, context: IssueResolveContext[K], ): void { const issue = this._issues.get(key); if (!issue) { return; } issue.resolve?.(context); this._logIfNew(issue); } public detectDynamic(context: ConditionState): void { for (const issue of this._issues.values()) { issue.detectDynamic?.(context); 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 canRetryNow(): boolean { return [...this._issues.values()].some((issue) => this._canRetryNow(issue)); } 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 && !this._canRetryNow(issue)) { continue; } if (issue.retry?.(force)) { 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 { for (const issue of this._issues.values()) { issue.destroy?.(); } this.reset(); this._issues.clear(); this._loggedKeys.clear(); } // ========================================================================= // Private helpers. // ========================================================================= // An issue that does not implement canRetryNow falls back to needsRetry, so // issues with no in-flight concept can be retried whenever they need one. private _canRetryNow(issue: Issue): boolean { return issue.canRetryNow?.() ?? issue.needsRetry?.() ?? false; } 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}`); } } }