feat: Add event-based automation triggers (#2537)

This commit is contained in:
Dermot Duffy
2026-06-30 17:45:13 -07:00
committed by dermotduffy
parent b701366762
commit a31816c168
109 changed files with 4607 additions and 1623 deletions
+7 -1
View File
@@ -1,16 +1,21 @@
import { SubscriptionHealthInterface } from '../../ha/connection/subscription-health-monitor';
import { CardIssueManagerAPI } from '../types';
import { IssueManager } from './issue-manager';
import { ConfigErrorIssue } from './issues/config-error';
import { ConfigUpgradeIssue } from './issues/config-upgrade';
import { ConfigUpgradeFailureIssue } from './issues/config-upgrade-failure';
import { ConnectionIssue } from './issues/connection';
import { EventSubscriptionIssue } from './issues/event-subscription';
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 => {
export const createIssueManager = (
api: CardIssueManagerAPI,
eventSubscriptionHealth: SubscriptionHealthInterface<string>,
): IssueManager => {
const manager = new IssueManager(api);
const changeCallback = () => manager.evaluate();
@@ -23,6 +28,7 @@ export const createIssueManager = (api: CardIssueManagerAPI): IssueManager => {
manager.addIssue(new ConfigUpgradeFailureIssue(api));
manager.addIssue(new ViewIncompatibleIssue(api));
manager.addIssue(new ConnectionIssue());
manager.addIssue(new EventSubscriptionIssue(eventSubscriptionHealth, changeCallback));
manager.addIssue(new InitializationIssue(api));
manager.addIssue(new LegacyResourceIssue(changeCallback));
manager.addIssue(new MediaQueryIssue(api));
+56 -52
View File
@@ -1,7 +1,7 @@
import type { IssueTriggerContext } from 'issue';
import { ConditionStateChange } from '../../condition-trigger/conditions/types';
import { isActionAllowedBasedOnInteractionState } from '../../utils/interaction-mode';
import { Timer } from '../../utils/timer';
import { RetryTimer } from '../../utils/retry-timer';
import { CardIssueManagerAPI } from '../types';
import { IssueStateManager } from './state-manager';
import { Issue, IssueKey, IssueReadOnlyState, IssueTriggerContextKey } from './types';
@@ -11,21 +11,21 @@ import { Issue, IssueKey, IssueReadOnlyState, IssueTriggerContextKey } from './t
// 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().
// 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 _retryTimer = new RetryTimer({
baseSeconds: RETRY_EXPONENTIAL_BASE_SECONDS,
maxSeconds: RETRY_EXPONENTIAL_MAX_SECONDS,
});
private _suspended = false;
// Reentrancy guard: evaluate() calls setState() on the condition state
@@ -89,6 +89,9 @@ export class IssueManager {
issues: this._stateManager.getIssuePresence(),
})
) {
// Re-render to show the change. The re-render also re-attempts
// initialization, which matters when a blocking notice like "Home
// Assistant is starting" clears and the card can finally initialize.
this._api.getCardElementManager().update();
}
@@ -106,7 +109,7 @@ export class IssueManager {
// user action resets the backoff schedule.
public retry(key: IssueKey, force?: boolean): void {
this._stateManager.retry(key, force);
this._retryTimer.stop();
this._retryTimer.reset();
this.evaluate();
}
@@ -140,7 +143,7 @@ export class IssueManager {
// loading timeout). Evaluation resumes on resume().
public suspend(): void {
this._suspended = true;
this._retryTimer.stop();
this._retryTimer.cancel();
this._stateManager.suspend();
}
@@ -150,7 +153,7 @@ export class IssueManager {
}
public destroy(): void {
this._retryTimer.stop();
this._retryTimer.cancel();
this._stateManager.destroy();
}
@@ -178,8 +181,7 @@ export class IssueManager {
private _scheduleRetryIfNeeded(): void {
if (!this._stateManager.needsRetry()) {
this._retryTimer.stop();
this._retryAttempt = 0;
this._retryTimer.reset();
return;
}
if (this._retryTimer.isRunning()) {
@@ -188,48 +190,50 @@ export class IssueManager {
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;
this._retryTimer.reset();
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;
const retryConfig = config.view.issues.retry_seconds;
if (retryConfig === 0) {
this._retryTimer.reset();
return;
}
// '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,
this._retryTimer.setOptions(
retryConfig === 'auto'
? {
baseSeconds: RETRY_EXPONENTIAL_BASE_SECONDS,
maxSeconds: RETRY_EXPONENTIAL_MAX_SECONDS,
}
: retryConfig,
);
// Schedule without advancing: the backoff only escalates if the retry
// actually runs (via the explicit advance() below), not when it's gated.
this._retryTimer.schedule(
() => {
if (!this._stateManager.needsRetry()) {
this._retryTimer.reset();
return;
}
if (this._isScheduledRetryAllowed()) {
this._stateManager.retry();
// This attempt counts: advance the backoff so the next schedule
// (re-armed by evaluate() via _scheduleRetryIfNeeded) uses a longer
// delay. For static-delay mode (base = max, no jitter) advancing is
// observable in `getAttempts()` but doesn't change the next delay.
this._retryTimer.advance();
this.evaluate();
} else {
// Retry was gated (e.g. user interaction). Not a failed attempt; the
// backoff stays put and we re-arm at the same delay.
this._scheduleRetryIfNeeded();
}
},
{ advance: false },
);
const jitter =
RETRY_EXPONENTIAL_JITTER_MIN +
Math.random() * (RETRY_EXPONENTIAL_JITTER_MAX - RETRY_EXPONENTIAL_JITTER_MIN);
return exp * jitter;
}
private _isScheduledRetryAllowed(): boolean {
@@ -0,0 +1,81 @@
import { Notification } from '../../../config/schema/actions/types';
import { SubscriptionHealthInterface } from '../../../ha/connection/subscription-health-monitor';
import { UnlistenCallback } from '../../../health';
import { localize } from '../../../localize/localize';
import { createRetryControl } from '../retry-control';
import { Issue, IssueDescription } from '../types';
const ISSUE_ICON = 'mdi:lan-disconnect';
/**
* Surfaces persistent HA event-subscription failures (from the EventWatcher's
* health monitor) as a non-full-card notification listing the failing event
* types. Self-detects by observing the health monitor and asking the
* IssueManager to re-evaluate on change.
*
* Detection scope: the transport reports `failing` only when a subscribe
* attempt rejects (initial subscribe, era replay, or retry) -- there is no
* heartbeat on an established subscription, so this catches subscribe-time
* failures, not a subscription that goes silently dead after subscribing.
*
* Recovery is the subscription manager's own forever-retry loop, so this issue
* does NOT implement `needsRetry()` (no IssueManager-scheduled retry that would
* race the transport loop). The notification's Retry button is user-forced
* only: it re-drives the failing subscriptions immediately via the monitor.
*/
export class EventSubscriptionIssue implements Issue {
public readonly key = 'event_subscription' as const;
private _health: SubscriptionHealthInterface<string>;
private _unsubscribe: UnlistenCallback;
constructor(health: SubscriptionHealthInterface<string>, changeCallback: () => void) {
this._health = health;
this._unsubscribe = health.addListener(changeCallback);
}
public hasIssue(): boolean {
return this._health.getFailures().length > 0;
}
public getIssue(): IssueDescription | null {
if (!this.hasIssue()) {
return null;
}
return {
icon: ISSUE_ICON,
severity: 'medium',
notification: this._buildNotification(),
};
}
public getNotification(): Notification | null {
return this.getIssue()?.notification ?? null;
}
public retry(): boolean {
this._health.retry();
return true;
}
public destroy(): void {
this._unsubscribe();
}
private _buildNotification(): Notification {
const eventTypes = this._health
.getFailures()
.map((failure) => failure.key)
.sort();
return {
heading: {
text: localize('issues.event_subscription.heading'),
icon: ISSUE_ICON,
severity: 'medium',
},
body: { text: localize('issues.event_subscription.text') },
metadata: eventTypes.map((eventType) => ({ text: eventType })),
controls: [createRetryControl(this.key)],
};
}
}
@@ -143,6 +143,9 @@ export class IssueStateManager implements IssueReadOnlyState {
}
public destroy(): void {
for (const issue of this._issues.values()) {
issue.destroy?.();
}
this.reset();
this._issues.clear();
this._loggedKeys.clear();
+11 -1
View File
@@ -9,6 +9,7 @@ export type IssueKey =
| 'config_upgrade'
| 'config_upgrade_failure'
| 'connection'
| 'event_subscription'
| 'initialization'
| 'legacy_resource'
| 'media_load'
@@ -83,7 +84,10 @@ export interface Issue {
// callers (e.g. notification control actions) invoke this directly.
fix?(hass: HomeAssistant): Promise<boolean>;
// Reset internal state (clear errors, stop timers, etc.).
// Clear transient state (errors, timers) while the issue stays registered and
// able to re-activate. Runs repeatedly during the card's life (e.g. when the
// underlying problem recovers), so it must NOT release anything the issue
// needs to keep working -- that belongs in `destroy()`.
reset?(): void;
// Called when the card is detached. Issues with age-based timers (e.g.
@@ -94,4 +98,10 @@ export interface Issue {
// evaluate(), so any timer that should restart is re-armed via
// detectDynamic against the current condition state.
suspend?(): void;
// Release external resources (e.g. a listener registered on another manager)
// at end of life. Called once when the IssueManager is destroyed -- unlike
// `reset()`, which runs repeatedly while the issue is still live, this is the
// final teardown.
destroy?(): void;
}