feat: Add event-based automation triggers (#2537)
This commit is contained in:
committed by
dermotduffy
parent
b701366762
commit
a31816c168
@@ -37,6 +37,7 @@ export class AutomationsManager {
|
||||
const triggers = new TriggersManager(
|
||||
automation.triggers,
|
||||
this._api.getConditionStateManager(),
|
||||
this._api.getHASSManager(),
|
||||
);
|
||||
|
||||
// The ongoing `conditions:` block is pull-evaluated at trigger time, so
|
||||
|
||||
@@ -44,6 +44,10 @@ export class CardElementManager {
|
||||
return this._element;
|
||||
}
|
||||
|
||||
public isConnected(): boolean {
|
||||
return this._element.isConnected;
|
||||
}
|
||||
|
||||
public scrollReset(): void {
|
||||
this._scrollCallback();
|
||||
}
|
||||
@@ -160,7 +164,8 @@ export class CardElementManager {
|
||||
|
||||
this._api.getIssueManager().resume();
|
||||
|
||||
// Make sure reconnections call the initialization code.
|
||||
// A reconnected card (e.g. after HA rebuilt it on restart) won't re-render
|
||||
// on its own; request one so it re-initializes and shows current state.
|
||||
this._element.requestUpdate();
|
||||
}
|
||||
|
||||
|
||||
@@ -122,7 +122,7 @@ export class CardController
|
||||
private _expandManager = new ExpandManager(this);
|
||||
private _foldersManager = new FoldersManager(this);
|
||||
private _fullscreenManager = new FullscreenManager(this);
|
||||
private _hassManager = new HASSManager(this);
|
||||
private _hassManager: HASSManager;
|
||||
private _initializationManager = new InitializationManager(this);
|
||||
private _interactionManager = new InteractionManager(this);
|
||||
private _keyboardStateManager = new KeyboardStateManager(this);
|
||||
@@ -133,7 +133,7 @@ export class CardController
|
||||
private _microphoneManager = new MicrophoneManager(this);
|
||||
private _notificationManager = new NotificationManager(this);
|
||||
private _pipManager = new PIPManager(this);
|
||||
private _issueManager = createIssueManager(this);
|
||||
private _issueManager: IssueManager;
|
||||
private _queryStringManager = new QueryStringManager(this);
|
||||
private _statusBarItemManager = new StatusBarItemManager(this);
|
||||
private _styleManager = new StyleManager(this);
|
||||
@@ -145,8 +145,13 @@ export class CardController
|
||||
host: CardHTMLElement,
|
||||
scrollCallback: ScrollCallback,
|
||||
menuToggleCallback: MenuToggleCallback,
|
||||
hassManager?: HASSManager,
|
||||
) {
|
||||
host.addController(this);
|
||||
this._hassManager = hassManager ?? new HASSManager(this);
|
||||
this._issueManager = createIssueManager(
|
||||
this,
|
||||
this._hassManager.getEventWatcher().getHealth(),
|
||||
);
|
||||
|
||||
this._cardElementManager = new CardElementManager(
|
||||
this,
|
||||
@@ -154,6 +159,16 @@ export class CardController
|
||||
scrollCallback,
|
||||
menuToggleCallback,
|
||||
);
|
||||
|
||||
// ConditionStateManager MUST be wired first so its `hass` is current before
|
||||
// any later listener fires. Otherwise StateWatcher could fire a
|
||||
// camera-trigger handler that writes back to ConditionStateManager, fanning
|
||||
// out to automations that still read a stale `hass`.
|
||||
this._hassManager.addListener((hass) =>
|
||||
this._conditionStateManager.setState({ hass }),
|
||||
);
|
||||
|
||||
host.addController(this);
|
||||
}
|
||||
|
||||
// *************************************************************************
|
||||
|
||||
@@ -1,55 +1,75 @@
|
||||
import { HassEvent } from 'home-assistant-js-websocket';
|
||||
import { HomeAssistant } from '../../ha/types';
|
||||
import { KeyedSubscriptionManager } from '../../utils/concurrency/keyed-subscription-manager';
|
||||
import {
|
||||
SubscriptionHealthInterface,
|
||||
SubscriptionHealthMonitor,
|
||||
} from '../../ha/connection/subscription-health-monitor';
|
||||
import { HASSConnectionSubscriptionManager } from '../../ha/connection/subscription-manager';
|
||||
import { HASSSource } from '../../ha/source';
|
||||
|
||||
export interface EventSubscriptionRequest {
|
||||
event_type: string;
|
||||
callback: (data: unknown) => void;
|
||||
callback: (event: HassEvent) => void;
|
||||
|
||||
// Optional payload filter. Receives the event's `data`; if it returns false
|
||||
// the event is dropped for this request.
|
||||
matcher?: (data: unknown) => boolean;
|
||||
// Optional filter receiving the full event so callers can match on payload
|
||||
// (`event.data`) and/or context (`event.context`). Returning false drops the
|
||||
// event for this request.
|
||||
matcher?: (event: HassEvent) => boolean;
|
||||
}
|
||||
|
||||
export interface EventWatcherSubscriptionInterface {
|
||||
subscribe(hass: HomeAssistant, request: EventSubscriptionRequest): Promise<void>;
|
||||
unsubscribe(request: EventSubscriptionRequest): Promise<void>;
|
||||
subscribe(request: EventSubscriptionRequest): void;
|
||||
unsubscribe(request: EventSubscriptionRequest): void;
|
||||
getHealth(): SubscriptionHealthInterface<string>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribes to HA bus events via the WebSocket connection. Refcounted per
|
||||
* `event_type`: the first subscriber for a type opens the WS subscription, the
|
||||
* last to unsubscribe tears it down. Each fired event is fanned out to every
|
||||
* registered request whose `event_type` matches and whose `matcher` accepts the
|
||||
* payload.
|
||||
* Subscribes to HA bus events via the WebSocket connection. Thin wrapper over
|
||||
* `HASSConnectionSubscriptionManager` (connection-era lifecycle, refcounting,
|
||||
* retry budgets, stale-callback guards): keys by `event_type`, runs each
|
||||
* request's optional matcher before fan-out.
|
||||
*/
|
||||
export class EventWatcher implements EventWatcherSubscriptionInterface {
|
||||
private _subscriptions = new KeyedSubscriptionManager<
|
||||
string,
|
||||
EventSubscriptionRequest
|
||||
>((request) => request.event_type);
|
||||
private _manager: HASSConnectionSubscriptionManager<string, EventSubscriptionRequest>;
|
||||
private _health: SubscriptionHealthMonitor<string, EventSubscriptionRequest>;
|
||||
|
||||
public async subscribe(
|
||||
hass: HomeAssistant,
|
||||
request: EventSubscriptionRequest,
|
||||
): Promise<void> {
|
||||
await this._subscriptions.subscribe(request, () =>
|
||||
hass.connection.subscribeEvents<HassEvent>(
|
||||
(event) => this._receiveEvent(event),
|
||||
request.event_type,
|
||||
),
|
||||
constructor(source: HASSSource) {
|
||||
this._manager = new HASSConnectionSubscriptionManager(
|
||||
(request) => request.event_type,
|
||||
source,
|
||||
);
|
||||
this._health = new SubscriptionHealthMonitor((request) =>
|
||||
this._manager.retry(request),
|
||||
);
|
||||
}
|
||||
|
||||
public async unsubscribe(request: EventSubscriptionRequest): Promise<void> {
|
||||
await this._subscriptions.unsubscribe(request);
|
||||
public subscribe(request: EventSubscriptionRequest): void {
|
||||
this._manager.subscribe(
|
||||
request,
|
||||
(connection, liveness) =>
|
||||
connection.subscribeEvents<HassEvent>((event) => {
|
||||
if (!liveness.isConnected()) {
|
||||
return;
|
||||
}
|
||||
this._dispatch(event);
|
||||
}, request.event_type),
|
||||
(status) => this._health.update(status),
|
||||
);
|
||||
}
|
||||
|
||||
private _receiveEvent(event: HassEvent): void {
|
||||
for (const request of this._subscriptions.getRequestsForKey(event.event_type)) {
|
||||
if (!request.matcher || request.matcher(event.data)) {
|
||||
request.callback(event.data);
|
||||
public unsubscribe(request: EventSubscriptionRequest): void {
|
||||
this._manager.unsubscribe(request);
|
||||
}
|
||||
|
||||
public getHealth(): SubscriptionHealthInterface<string> {
|
||||
return this._health;
|
||||
}
|
||||
|
||||
private _dispatch(event: HassEvent): void {
|
||||
for (const request of this._manager.getRequestsForKey(event.event_type)) {
|
||||
if (request.matcher && !request.matcher(event)) {
|
||||
continue;
|
||||
}
|
||||
request.callback(event);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,19 +1,26 @@
|
||||
import { STATE_RUNNING } from 'home-assistant-js-websocket';
|
||||
import { isHassReady } from '../../ha/is-hass-ready';
|
||||
import { HASSListener, HASSUnlistenCallback } from '../../ha/source';
|
||||
import { HomeAssistant } from '../../ha/types';
|
||||
import { log } from '../../utils/debug';
|
||||
import { InitializationAspect } from '../initialization-manager';
|
||||
import { CardHASSAPI } from '../types';
|
||||
import { EventWatcher, EventWatcherSubscriptionInterface } from './event-watcher';
|
||||
import { StateWatcher, StateWatcherSubscriptionInterface } from './state-watcher';
|
||||
import { HASSManagerReadonlyInterface } from './types';
|
||||
|
||||
export class HASSManager {
|
||||
export class HASSManager implements HASSManagerReadonlyInterface {
|
||||
private _hass: HomeAssistant | null = null;
|
||||
private _api: CardHASSAPI;
|
||||
private _stateWatcher: StateWatcher = new StateWatcher();
|
||||
private _eventWatcher: EventWatcher = new EventWatcher();
|
||||
|
||||
private _hassListeners = new Set<HASSListener>();
|
||||
|
||||
private _stateWatcher: StateWatcherSubscriptionInterface;
|
||||
private _eventWatcher: EventWatcherSubscriptionInterface;
|
||||
|
||||
constructor(api: CardHASSAPI) {
|
||||
this._api = api;
|
||||
this._stateWatcher = new StateWatcher(this);
|
||||
this._eventWatcher = new EventWatcher(this);
|
||||
}
|
||||
|
||||
public getHASS(): HomeAssistant | null {
|
||||
@@ -32,20 +39,22 @@ export class HASSManager {
|
||||
return this._eventWatcher;
|
||||
}
|
||||
|
||||
public addListener(listener: HASSListener): HASSUnlistenCallback {
|
||||
this._hassListeners.add(listener);
|
||||
return () => {
|
||||
this._hassListeners.delete(listener);
|
||||
};
|
||||
}
|
||||
|
||||
public setHASS(hass?: HomeAssistant | null): void {
|
||||
// When HA transitions from "not ready" to "ready" (WebSocket reconnected
|
||||
// AND all integrations finished loading), reinitialize cameras and the
|
||||
// view. This is necessary because event subscriptions (e.g. Frigate
|
||||
// WebSocket subscriptions via hass.connection.subscribeMessage) are tied to
|
||||
// the old connection and are lost when it drops. Without reinitialization,
|
||||
// triggers and thumbnail updates stop working.
|
||||
//
|
||||
// We deliberately wait for hass.config.state === STATE_RUNNING rather than
|
||||
// just hass.connected, because HA exposes the WebSocket before integrations
|
||||
// have finished loading. Triggering re-init too early would race against
|
||||
// integration startup and fail with "Unknown command" on
|
||||
// integration-specific WS calls.
|
||||
if (this._hass && !this._isReady(this._hass) && this._isReady(hass)) {
|
||||
// When HA goes from "not ready" to "ready" (WebSocket reconnected AND all
|
||||
// integrations finished loading), rebuild cameras and the view from
|
||||
// scratch: the available entities may have changed while it was down.
|
||||
const becameReady = !!this._hass && !isHassReady(this._hass) && isHassReady(hass);
|
||||
|
||||
if (becameReady) {
|
||||
// Tear cameras down before the listeners below see the new hass,
|
||||
// otherwise they would briefly rebuild against the old entities.
|
||||
log(
|
||||
this._api.getConfigManager().getCardWideConfig(),
|
||||
'Advanced Camera Card: HA fully ready, reinitializing...',
|
||||
@@ -66,17 +75,15 @@ export class HASSManager {
|
||||
const oldHass = this._hass;
|
||||
this._hass = hass;
|
||||
|
||||
this._api.getConditionStateManager().setState({
|
||||
hass: this._hass,
|
||||
});
|
||||
// Notify each listener of the new hass, in subscription order.
|
||||
for (const listener of this._hassListeners) {
|
||||
listener(hass, oldHass);
|
||||
}
|
||||
|
||||
// Theme may depend on HASS.
|
||||
this._api.getStyleManager().applyTheme();
|
||||
|
||||
this._stateWatcher.setHASS(oldHass, hass);
|
||||
}
|
||||
|
||||
private _isReady(hass?: HomeAssistant | null): boolean {
|
||||
return !!hass?.connected && hass.config?.state === STATE_RUNNING;
|
||||
// Try to (re)initialize whenever hass changes. Initialization normally
|
||||
// happens on the next re-render, but the teardown above can leave a
|
||||
// reconnected card without a re-render, so it could stay stuck
|
||||
// uninitialized. Harmless no-op when already initialized or not yet ready.
|
||||
this._api.getInitializationManager().triggerInitialization();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,21 +1,53 @@
|
||||
import { getHassDifferences } from '../../ha/get-hass-differences';
|
||||
import { HASSSource, HASSUnlistenCallback } from '../../ha/source';
|
||||
import { HassStateDifference, HomeAssistant } from '../../ha/types';
|
||||
|
||||
type StateWatcherCallback = (difference: HassStateDifference) => void;
|
||||
|
||||
export interface StateWatcherSubscriptionInterface {
|
||||
subscribe(callback: StateWatcherCallback, entityIDs: string[]): void;
|
||||
subscribe(callback: StateWatcherCallback, entityIDs: string[]): boolean;
|
||||
unsubscribe(callback: StateWatcherCallback): void;
|
||||
}
|
||||
|
||||
export class StateWatcher implements StateWatcherSubscriptionInterface {
|
||||
private _source: HASSSource;
|
||||
private _watcherCallbacks = new Map<StateWatcherCallback, string[]>();
|
||||
private _unlisten: HASSUnlistenCallback | null = null;
|
||||
|
||||
public setHASS(oldHass: HomeAssistant | null, hass: HomeAssistant): void {
|
||||
constructor(source: HASSSource) {
|
||||
this._source = source;
|
||||
}
|
||||
|
||||
public subscribe(callback: StateWatcherCallback, entityIDs: string[]): boolean {
|
||||
if (!entityIDs.length) {
|
||||
return false;
|
||||
}
|
||||
const wasEmpty = this._watcherCallbacks.size === 0;
|
||||
if (this._watcherCallbacks.has(callback)) {
|
||||
this._watcherCallbacks.get(callback)?.push(...entityIDs);
|
||||
} else {
|
||||
this._watcherCallbacks.set(callback, entityIDs);
|
||||
}
|
||||
if (wasEmpty) {
|
||||
this._unlisten = this._source.addListener((hass, oldHass) =>
|
||||
this._onHASS(hass, oldHass),
|
||||
);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
public unsubscribe(callback: StateWatcherCallback): void {
|
||||
this._watcherCallbacks.delete(callback);
|
||||
if (this._watcherCallbacks.size === 0 && this._unlisten) {
|
||||
this._unlisten();
|
||||
this._unlisten = null;
|
||||
}
|
||||
}
|
||||
|
||||
private _onHASS(hass: HomeAssistant, oldHass: HomeAssistant | null): void {
|
||||
if (!oldHass) {
|
||||
return;
|
||||
}
|
||||
|
||||
for (const [callback, entityIDs] of this._watcherCallbacks.entries()) {
|
||||
const differences = getHassDifferences(hass, oldHass, entityIDs, {
|
||||
stateOnly: true,
|
||||
@@ -26,26 +58,4 @@ export class StateWatcher implements StateWatcherSubscriptionInterface {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Calls callback when the state of any of the entities changes. The callback is
|
||||
* called with the state difference of the first entity that changed.
|
||||
* @param callback The callback.
|
||||
* @param entityIDs An array of entity IDs to watch.
|
||||
*/
|
||||
public subscribe(callback: StateWatcherCallback, entityIDs: string[]): boolean {
|
||||
if (!entityIDs.length) {
|
||||
return false;
|
||||
}
|
||||
if (this._watcherCallbacks.has(callback)) {
|
||||
this._watcherCallbacks.get(callback)?.push(...entityIDs);
|
||||
} else {
|
||||
this._watcherCallbacks.set(callback, entityIDs);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
public unsubscribe(callback: StateWatcherCallback): void {
|
||||
this._watcherCallbacks.delete(callback);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
import { HASSSource } from '../../ha/source';
|
||||
import { EventWatcherSubscriptionInterface } from './event-watcher';
|
||||
import { StateWatcherSubscriptionInterface } from './state-watcher';
|
||||
|
||||
export interface HASSManagerReadonlyInterface extends HASSSource {
|
||||
getStateWatcher(): StateWatcherSubscriptionInterface;
|
||||
getEventWatcher(): EventWatcherSubscriptionInterface;
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { STATE_RUNNING } from 'home-assistant-js-websocket';
|
||||
import PQueue from 'p-queue';
|
||||
import { isHassReady } from '../ha/is-hass-ready';
|
||||
import { sideLoadHomeAssistantElements } from '../ha/side-load-ha-elements';
|
||||
import { loadLanguages } from '../localize/localize';
|
||||
import { errorToConsole } from '../utils/basic';
|
||||
@@ -70,6 +71,32 @@ export class InitializationManager {
|
||||
]);
|
||||
}
|
||||
|
||||
// The one place that decides whether to (re)start mandatory initialization,
|
||||
// so callers don't re-check the conditions themselves. Called on every render
|
||||
// (from the card's shouldUpdate) and whenever hass changes (from
|
||||
// HASSManager); a reconnect or a cleared issue reaches it by causing a
|
||||
// render.
|
||||
public triggerInitialization(): void {
|
||||
if (!this._shouldInitializeMandatory()) {
|
||||
return;
|
||||
}
|
||||
/* async */ this.initializeMandatory();
|
||||
}
|
||||
|
||||
private _shouldInitializeMandatory(): boolean {
|
||||
return (
|
||||
this._api.getConfigManager().hasConfig() &&
|
||||
this._api.getCardElementManager().isConnected() &&
|
||||
isHassReady(this._api.getHASSManager().getHASS()) &&
|
||||
!this.isInitializedMandatory() &&
|
||||
// Don't start while a full-card issue (e.g. the "Home Assistant is
|
||||
// starting" notice) is shown: each initialization step aborts as soon as
|
||||
// it sees one, so an attempt now would be wasted. The card tries again
|
||||
// once the issue clears.
|
||||
!this._api.getIssueManager().getStateManager().hasFullCardIssue()
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize the hard requirements for rendering anything.
|
||||
* @returns `true` if card rendering can continue.
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user