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
@@ -1,5 +1,4 @@
import { EventWatcherSubscriptionInterface } from '../../card-controller/hass/event-watcher';
import { StateWatcherSubscriptionInterface } from '../../card-controller/hass/state-watcher';
import { HASSManagerReadonlyInterface } from '../../card-controller/hass/types';
import { CameraConfig } from '../../config/schema/cameras';
import { BROWSE_MEDIA_CACHE_SECONDS } from '../../ha/browse-media/types';
import { BrowseMediaWalker } from '../../ha/browse-media/walker';
@@ -37,14 +36,13 @@ export class BrowseMediaCameraManagerEngine
public constructor(
entityRegistryManager: EntityRegistryManager,
stateWatcher: StateWatcherSubscriptionInterface,
eventWatcher: EventWatcherSubscriptionInterface,
hassManager: HASSManagerReadonlyInterface,
browseMediaManager: BrowseMediaWalker,
resolvedMediaCache: ResolvedMediaCache,
requestCache: CameraManagerRequestCache,
eventCallback?: CameraEventCallback,
) {
super(stateWatcher, eventWatcher, entityRegistryManager, eventCallback);
super(hassManager, entityRegistryManager, eventCallback);
this._entityRegistryManager = entityRegistryManager;
this._browseMediaWalker = browseMediaManager;
this._resolvedMediaCache = resolvedMediaCache;
+54 -59
View File
@@ -1,20 +1,19 @@
import { uniq } from 'lodash-es';
import { ActionsExecutor } from '../card-controller/actions/types';
import {
EventSubscriptionRequest,
EventWatcherSubscriptionInterface,
} from '../card-controller/hass/event-watcher';
import { StateWatcherSubscriptionInterface } from '../card-controller/hass/state-watcher';
import { EventSubscriptionRequest } from '../card-controller/hass/event-watcher';
import { HASSManagerReadonlyInterface } from '../card-controller/hass/types';
import { PTZAction, PTZActionPhase } from '../config/schema/actions/custom/ptz';
import { CameraConfig, TriggerEvent } from '../config/schema/cameras';
import { CameraConfig } from '../config/schema/cameras';
import { HAEvent } from '../config/schema/common/ha-event';
import { EnabledProxyConfig, resolveProxyConfig } from '../config/schema/common/proxy';
import { computeDomain } from '../ha/compute-domain';
import { matchesEventData } from '../ha/event-data-match';
import { matchesEventContext, matchesEventData } from '../ha/event-match';
import { getTriggerEventType } from '../ha/get-trigger-event-type';
import { Entity, EntityRegistryManager } from '../ha/registry/entity/types';
import { HassStateDifference, HomeAssistant } from '../ha/types';
import { localize } from '../localize/localize';
import { CapabilitiesRaw, CapabilityKey, Endpoint } from '../types';
import { arrayify } from '../utils/basic';
import { liveProviderSupports2WayAudio } from '../utils/live-provider';
import { Capabilities } from './capabilities';
import { CameraManagerEngine } from './engine';
@@ -43,9 +42,7 @@ interface CapabilityOptions {
}
export interface CameraInitializationOptions {
hass: HomeAssistant;
stateWatcher: StateWatcherSubscriptionInterface;
eventWatcher: EventWatcherSubscriptionInterface;
hassManager: HASSManagerReadonlyInterface;
capabilityOptions?: CapabilityOptions;
entityRegistryManager?: EntityRegistryManager;
}
@@ -58,7 +55,6 @@ export class Camera {
protected _capabilities?: Capabilities;
protected _eventCallback?: CameraEventCallback;
protected _destroyCallbacks: DestroyCallback[] = [];
protected _destroyed = false;
protected _entity: Entity | null = null;
constructor(
@@ -80,63 +76,57 @@ export class Camera {
}
async initialize(options: CameraInitializationOptions): Promise<Camera> {
this._entity = await this._resolveEntity(options);
await this._initialize(options);
// Freeze a single HASS snapshot for the whole (async, multi-step)
// initialization so every step observes a consistent entity world; live
// subscriptions below still use the manager's current watchers.
const hass = options.hassManager.getHASS();
if (!hass) {
return this;
}
this._entity = await this._resolveEntity(hass, options);
await this._initialize(hass, options);
this._capabilities =
options.capabilityOptions?.capabilities ??
this._capabilities ??
(await this._buildCapabilities(options));
(await this._buildCapabilities(hass, options));
if (this._capabilities.has('trigger')) {
await this._getTriggerEntities(options);
await this._getTriggerEntities(hass, options);
this._config.triggers.entities = uniq(this._config.triggers.entities);
// Subscribe to state based triggers (sync; no race with destroy).
options.stateWatcher.subscribe(
this._stateChangeHandler,
this._config.triggers.entities,
);
this._onDestroy(() => options.stateWatcher.unsubscribe(this._stateChangeHandler));
const stateWatcher = options.hassManager.getStateWatcher();
stateWatcher.subscribe(this._stateChangeHandler, this._config.triggers.entities);
this._onDestroy(() => stateWatcher.unsubscribe(this._stateChangeHandler));
// Subscribe to event based triggers.
// Subscribe to event based triggers. List-form `event_type` expands into
// one subscription per type sharing the same data/context matcher.
const eventWatcher = options.hassManager.getEventWatcher();
for (const event of this._config.triggers.events) {
const request = this._buildEventSubscriptionRequest(event);
await this._setupSubscription(
() => options.eventWatcher.subscribe(options.hass, request),
() => options.eventWatcher.unsubscribe(request),
);
for (const request of this._buildEventSubscriptionRequests(event)) {
eventWatcher.subscribe(request);
this._onDestroy(() => eventWatcher.unsubscribe(request));
}
}
}
return this;
}
/**
* Wire up an async subscription with its teardown. Registers the unsubscribe
* callback synchronously before awaiting subscribe, so a destroy during the
* await reliably triggers cleanup; short-circuits if destroy has already
* run, so the cleanup callback can't fire (and enqueue an unsubscribe)
* before the subscribe runs.
*/
protected async _setupSubscription(
subscribe: () => Promise<void>,
unsubscribe: () => void | Promise<void>,
): Promise<void> {
if (this._destroyed) {
return;
}
this._onDestroy(unsubscribe);
await subscribe();
}
private _buildEventSubscriptionRequest(event: TriggerEvent): EventSubscriptionRequest {
const filter = event.event_data;
return {
event_type: event.event_type,
...(filter && { matcher: (data) => matchesEventData(filter, data) }),
callback: () => this._momentaryEventHandler(event.event_type),
};
private _buildEventSubscriptionRequests(event: HAEvent): EventSubscriptionRequest[] {
const dataFilter = event.event_data;
const contextFilter = event.context;
return uniq(arrayify(event.event_type)).map((eventType) => ({
event_type: eventType,
...((dataFilter || contextFilter) && {
matcher: (evt) =>
(!dataFilter || matchesEventData(dataFilter, evt.data)) &&
(!contextFilter || matchesEventContext(contextFilter, evt.context)),
}),
callback: () => this._momentaryEventHandler(eventType),
}));
}
private _momentaryEventHandler(eventType: string): void {
@@ -148,13 +138,14 @@ export class Camera {
}
private async _resolveEntity(
hass: HomeAssistant,
options: CameraInitializationOptions,
): Promise<Entity | null> {
const cameraEntityID = getCameraEntityFromConfig(this._config);
if (!cameraEntityID || !options.entityRegistryManager) {
return null;
}
return await options.entityRegistryManager.getEntity(options.hass, cameraEntityID);
return await options.entityRegistryManager.getEntity(hass, cameraEntityID);
}
/**
@@ -162,12 +153,14 @@ export class Camera {
* to add engine-specific discovery; call `super` to keep the base discoveries.
*/
protected async _getTriggerEntities(
hass: HomeAssistant,
options: CameraInitializationOptions,
): Promise<void> {
await this._getDoorbellEntities(options);
await this._getDoorbellEntities(hass, options);
}
private async _getDoorbellEntities(
hass: HomeAssistant,
options: CameraInitializationOptions,
): Promise<void> {
if (
@@ -183,7 +176,7 @@ export class Camera {
// narrow by `device_id` + domain first and filter by device_class against
// `hass.states` second.
const candidates = await options.entityRegistryManager.getMatchingEntities(
options.hass,
hass,
(ent) =>
ent.device_id === deviceID &&
!ent.disabled_by &&
@@ -192,8 +185,7 @@ export class Camera {
const doorbells = candidates
.filter(
(ent) =>
options.hass.states[ent.entity_id]?.attributes?.device_class === 'doorbell',
(ent) => hass.states[ent.entity_id]?.attributes?.device_class === 'doorbell',
)
.map((ent) => ent.entity_id);
@@ -204,16 +196,19 @@ export class Camera {
* Subclass initialization hook. Override for async initialization work.
*/
protected async _initialize(
// eslint-disable-next-line @typescript-eslint/no-unused-vars
_hass: HomeAssistant,
// eslint-disable-next-line @typescript-eslint/no-unused-vars
_options: CameraInitializationOptions,
): Promise<void> {}
protected async _buildCapabilities(
hass: HomeAssistant,
options: CameraInitializationOptions,
): Promise<Capabilities> {
const rawCapabilities = await this._getRawCapabilities(options);
const rawCapabilities = await this._getRawCapabilities(hass, options);
const config = this.getConfig();
const has2WayAudio = await this._has2WayAudioCapability(options.hass);
const has2WayAudio = await this._has2WayAudioCapability(hass);
return new Capabilities(
{
@@ -254,6 +249,7 @@ export class Camera {
* and call super._getRawCapabilities() to extend defaults.
*/
protected async _getRawCapabilities(
_hass: HomeAssistant,
options: CameraInitializationOptions,
): Promise<CapabilitiesRaw> {
return {
@@ -267,7 +263,6 @@ export class Camera {
}
public async destroy(): Promise<void> {
this._destroyed = true;
const callbacks = this._destroyCallbacks;
this._destroyCallbacks = [];
await Promise.all(callbacks.map((callback) => callback()));
+7 -14
View File
@@ -1,5 +1,4 @@
import { EventWatcherSubscriptionInterface } from '../card-controller/hass/event-watcher';
import { StateWatcherSubscriptionInterface } from '../card-controller/hass/state-watcher';
import { HASSManagerReadonlyInterface } from '../card-controller/hass/types';
import { CameraConfig } from '../config/schema/cameras';
import { BrowseMediaWalker } from '../ha/browse-media/walker';
import { DeviceRegistryManager } from '../ha/registry/device';
@@ -13,8 +12,7 @@ import { CameraEventCallback, CameraManagerRequestCache, Engine } from './types'
import { getCameraEntityFromConfig } from './utils/camera-entity-from-config';
interface CameraManagerEngineFactoryOptions {
stateWatcher: StateWatcherSubscriptionInterface;
eventWatcher: EventWatcherSubscriptionInterface;
hassManager: HASSManagerReadonlyInterface;
resolvedMediaCache: ResolvedMediaCache;
eventCallback?: CameraEventCallback;
}
@@ -40,8 +38,7 @@ export class CameraManagerEngineFactory {
case Engine.Generic:
const { GenericCameraManagerEngine } = await import('./generic/engine-generic');
cameraManagerEngine = new GenericCameraManagerEngine(
options.stateWatcher,
options.eventWatcher,
options.hassManager,
this._entityRegistryManager,
options.eventCallback,
);
@@ -50,8 +47,7 @@ export class CameraManagerEngineFactory {
const { FrigateCameraManagerEngine } = await import('./frigate/engine-frigate');
cameraManagerEngine = new FrigateCameraManagerEngine(
this._entityRegistryManager,
options.stateWatcher,
options.eventWatcher,
options.hassManager,
new RecordingSegmentsCache(),
new CameraManagerRequestCache(),
options.eventCallback,
@@ -63,8 +59,7 @@ export class CameraManagerEngineFactory {
);
cameraManagerEngine = new MotionEyeCameraManagerEngine(
this._entityRegistryManager,
options.stateWatcher,
options.eventWatcher,
options.hassManager,
new BrowseMediaWalker(),
options.resolvedMediaCache,
new CameraManagerRequestCache(),
@@ -76,8 +71,7 @@ export class CameraManagerEngineFactory {
cameraManagerEngine = new ReolinkCameraManagerEngine(
this._entityRegistryManager,
this._deviceRegistryManager,
options.stateWatcher,
options.eventWatcher,
options.hassManager,
new BrowseMediaWalker(),
options.resolvedMediaCache,
new CameraManagerRequestCache(),
@@ -88,8 +82,7 @@ export class CameraManagerEngineFactory {
const { TPLinkCameraManagerEngine } = await import('./tplink/engine-tplink');
cameraManagerEngine = new TPLinkCameraManagerEngine(
this._entityRegistryManager,
options.stateWatcher,
options.eventWatcher,
options.hassManager,
options.eventCallback,
);
break;
+1 -1
View File
@@ -34,7 +34,7 @@ export const CAMERA_MANAGER_ENGINE_EVENT_LIMIT_DEFAULT = 10000;
export interface CameraManagerEngine {
getEngineType(): Engine;
createCamera(hass: HomeAssistant, cameraConfig: CameraConfig): Promise<Camera>;
createCamera(cameraConfig: CameraConfig): Promise<Camera>;
/**
* Get default query parameters for a camera based on its configuration.
+3 -1
View File
@@ -1,3 +1,4 @@
import { HomeAssistant } from '../ha/types';
import { Camera, CameraInitializationOptions } from './camera';
import { CameraNoEntityError } from './error';
@@ -9,11 +10,12 @@ import { CameraNoEntityError } from './error';
*/
export class EntityCamera extends Camera {
protected override async _initialize(
hass: HomeAssistant,
options: CameraInitializationOptions,
): Promise<void> {
if (!this._entity) {
throw new CameraNoEntityError(this.getConfig());
}
await super._initialize(options);
await super._initialize(hass, options);
}
}
+62 -41
View File
@@ -43,17 +43,33 @@ export const isBirdseye = (cameraConfig: CameraConfig): boolean => {
};
export class FrigateCamera extends Camera {
// Short-circuits subscription when destroy() was invoked while base
// initialization was still awaiting. Set BEFORE awaiting `super.destroy()` so
// an in-flight initialize() sees the flip immediately.
private _destroyed = false;
public async initialize(options: FrigateCameraInitializationOptions): Promise<Camera> {
await super.initialize(options);
// A destroy() during the await above means the camera is being torn down;
// it must not register live subscriptions afterward.
if (this._destroyed) {
return this;
}
if (this._capabilities?.has('trigger')) {
await this._subscribeToEvents(options.hass, options.frigateEventWatcher);
await this._subscribeToReviews(options.hass, options.frigateReviewWatcher);
this._subscribeToEvents(options.frigateEventWatcher);
this._subscribeToReviews(options.frigateReviewWatcher);
}
return this;
}
public override async destroy(): Promise<void> {
this._destroyed = true;
await super.destroy();
}
public async executePTZAction(
executor: ActionsExecutor,
action: PTZAction,
@@ -104,9 +120,7 @@ export class FrigateCamera extends Camera {
return true;
}
protected override async _initialize(
options: FrigateCameraInitializationOptions,
): Promise<void> {
protected override async _initialize(hass: HomeAssistant): Promise<void> {
const config = this.getConfig();
const hasCameraName = !!config.frigate?.camera_name;
const cameraEntity = getCameraEntityFromConfig(config);
@@ -126,7 +140,7 @@ export class FrigateCamera extends Camera {
}
if (!this._config.frigate.client_id) {
const stateEntity = cameraEntity ? options.hass.states[cameraEntity] : undefined;
const stateEntity = cameraEntity ? hass.states[cameraEntity] : undefined;
const clientID = stateEntity?.attributes?.client_id;
if (typeof clientID === 'string' && clientID) {
this._config.frigate.client_id = clientID;
@@ -137,13 +151,15 @@ export class FrigateCamera extends Camera {
}
protected override async _getTriggerEntities(
hass: HomeAssistant,
options: FrigateCameraInitializationOptions,
): Promise<void> {
await this._getFrigateMotionAndOccupancyEntities(options);
await super._getTriggerEntities(options);
await this._getFrigateMotionAndOccupancyEntities(hass, options);
await super._getTriggerEntities(hass, options);
}
private async _getFrigateMotionAndOccupancyEntities(
hass: HomeAssistant,
options: FrigateCameraInitializationOptions,
): Promise<void> {
const config = this.getConfig();
@@ -162,7 +178,7 @@ export class FrigateCamera extends Camera {
// searching via unique_id ensures this still works if the user renames
// the entity_id.
const binarySensorEntities = await options.entityRegistryManager.getMatchingEntities(
options.hass,
hass,
(ent) =>
ent.config_entry_id === this._entity?.config_entry_id &&
!ent.disabled_by &&
@@ -189,12 +205,13 @@ export class FrigateCamera extends Camera {
}
protected async _getRawCapabilities(
hass: HomeAssistant,
options: FrigateCameraInitializationOptions,
): Promise<CapabilitiesRaw> {
const base = await super._getRawCapabilities(options);
const base = await super._getRawCapabilities(hass, options);
const config = this.getConfig();
const frigatePTZ = await this._getPTZCapabilities(options.hass, config);
const frigatePTZ = await this._getPTZCapabilities(hass, config);
const configPTZ = getPTZCapabilitiesFromCameraConfig(config);
const combinedPTZ: PTZCapabilities | null =
configPTZ || frigatePTZ ? { ...frigatePTZ, ...configPTZ } : null;
@@ -451,10 +468,9 @@ export class FrigateCamera extends Camera {
return null;
}
private async _subscribeToEvents(
hass: HomeAssistant,
private _subscribeToEvents(
frigateEventWatcher: FrigateWatcherSubscriptionInterface<FrigateEventChange>,
): Promise<void> {
): void {
const config = this.getConfig();
if (
!config.triggers.media_events.length ||
@@ -473,10 +489,8 @@ export class FrigateCamera extends Camera {
event.after.camera === config.frigate.camera_name,
};
await this._setupSubscription(
() => frigateEventWatcher.subscribe(hass, request),
() => frigateEventWatcher.unsubscribe(request),
);
frigateEventWatcher.subscribe(request);
this._onDestroy(() => frigateEventWatcher.unsubscribe(request));
}
private _frigateEventHandler = (ev: FrigateEventChange): void => {
@@ -494,23 +508,33 @@ export class FrigateCamera extends Camera {
return;
}
if (
(config.frigate.zones?.length &&
!config.frigate.zones.some((zone) => ev.after.current_zones.includes(zone))) ||
(config.frigate.labels?.length && !config.frigate.labels.includes(ev.after.label))
) {
return;
}
const mediaEventsToTriggerOn = config.triggers.media_events;
if (
!(
mediaEventsToTriggerOn.includes('events') ||
(mediaEventsToTriggerOn.includes('snapshots') && snapshotChange) ||
(mediaEventsToTriggerOn.includes('clips') && clipChange)
)
) {
return;
// The zone/label/media checks decide when to START a trigger, so they only
// apply to 'new'/'update'. An 'end' always passes through: it ends whatever
// trigger an earlier event with the same id started, and by 'end' the
// object may have left the zone or the media flag may differ -- the trigger
// must still clear. (The trigger manager ignores an 'end' for an id that
// never triggered, so a pass-through 'end' is harmless.)
if (ev.type !== 'end') {
if (
(config.frigate.zones?.length &&
!config.frigate.zones.some((zone) => ev.after.current_zones.includes(zone))) ||
(config.frigate.labels?.length &&
!config.frigate.labels.includes(ev.after.label))
) {
return;
}
if (
!(
mediaEventsToTriggerOn.includes('events') ||
(mediaEventsToTriggerOn.includes('snapshots') && snapshotChange) ||
(mediaEventsToTriggerOn.includes('clips') && clipChange)
)
) {
return;
}
}
this._eventCallback?.({
@@ -525,10 +549,9 @@ export class FrigateCamera extends Camera {
});
};
private async _subscribeToReviews(
hass: HomeAssistant,
private _subscribeToReviews(
frigateReviewWatcher: FrigateWatcherSubscriptionInterface<FrigateReviewChange>,
): Promise<void> {
): void {
const config = this.getConfig();
const reviewConfig = config.triggers.reviews;
@@ -550,10 +573,8 @@ export class FrigateCamera extends Camera {
review.after.camera === config.frigate.camera_name,
};
await this._setupSubscription(
() => frigateReviewWatcher.subscribe(hass, request),
() => frigateReviewWatcher.unsubscribe(request),
);
frigateReviewWatcher.subscribe(request);
this._onDestroy(() => frigateReviewWatcher.unsubscribe(request));
}
private _frigateReviewHandler = (review: FrigateReviewChange): void => {
+7 -14
View File
@@ -1,7 +1,6 @@
import { add, endOfHour, fromUnixTime, startOfHour } from 'date-fns';
import { isEqual, orderBy, throttle, uniqWith } from 'lodash-es';
import { EventWatcherSubscriptionInterface } from '../../card-controller/hass/event-watcher';
import { StateWatcherSubscriptionInterface } from '../../card-controller/hass/state-watcher';
import { HASSManagerReadonlyInterface } from '../../card-controller/hass/types';
import { CameraConfig } from '../../config/schema/cameras';
import { getEntityTitle } from '../../ha/get-entity-title';
import { EntityRegistryManager } from '../../ha/registry/entity/types';
@@ -136,16 +135,15 @@ export class FrigateCameraManagerEngine
constructor(
entityRegistryManager: EntityRegistryManager,
stateWatcher: StateWatcherSubscriptionInterface,
eventWatcher: EventWatcherSubscriptionInterface,
hassManager: HASSManagerReadonlyInterface,
recordingSegmentsCache: RecordingSegmentsCache,
requestCache: CameraManagerRequestCache,
eventCallback?: CameraEventCallback,
) {
super(stateWatcher, eventWatcher, entityRegistryManager, eventCallback);
super(hassManager, entityRegistryManager, eventCallback);
this._entityRegistryManager = entityRegistryManager;
this._frigateEventWatcher = new FrigateEventWatcher();
this._frigateReviewWatcher = new FrigateReviewWatcher();
this._frigateEventWatcher = new FrigateEventWatcher(hassManager);
this._frigateReviewWatcher = new FrigateReviewWatcher(hassManager);
this._recordingSegmentsCache = recordingSegmentsCache;
this._requestCache = requestCache;
}
@@ -154,18 +152,13 @@ export class FrigateCameraManagerEngine
return Engine.Frigate;
}
public async createCamera(
hass: HomeAssistant,
cameraConfig: CameraConfig,
): Promise<Camera> {
public async createCamera(cameraConfig: CameraConfig): Promise<Camera> {
const camera = new FrigateCamera(cameraConfig, this, {
eventCallback: this._eventCallback,
});
return await camera.initialize({
hass,
hassManager: this._hassManager,
entityRegistryManager: this._entityRegistryManager,
stateWatcher: this._stateWatcher,
eventWatcher: this._eventWatcher,
frigateEventWatcher: this._frigateEventWatcher,
frigateReviewWatcher: this._frigateReviewWatcher,
});
+29 -24
View File
@@ -1,6 +1,6 @@
import { z } from 'zod';
import { HomeAssistant } from '../../ha/types';
import { KeyedSubscriptionManager } from '../../utils/concurrency/keyed-subscription-manager';
import { HASSConnectionSubscriptionManager } from '../../ha/connection/subscription-manager';
import { HASSSource } from '../../ha/source';
import {
FrigateEventChange,
FrigateReviewChange,
@@ -17,43 +17,48 @@ export interface FrigateWatcherRequest<T> {
// Generic subscription interface
export interface FrigateWatcherSubscriptionInterface<T> {
subscribe(hass: HomeAssistant, request: FrigateWatcherRequest<T>): Promise<void>;
unsubscribe(request: FrigateWatcherRequest<T>): Promise<void>;
subscribe(request: FrigateWatcherRequest<T>): void;
unsubscribe(request: FrigateWatcherRequest<T>): void;
}
/**
* Base class for Frigate WebSocket watchers. Counted per `instanceID`: the
* first subscriber for an instance opens the WS subscription, the last to
* unsubscribe tears it down. Each message is parsed, schema-validated, and
* fanned out to every registered request whose `instanceID` matches and whose
* `matcher` accepts the payload.
* Base class for Frigate WebSocket watchers. Thin wrapper over
* `HASSConnectionSubscriptionManager`: keys by `instanceID`, parses and
* schema-validates each message, then fans out to every registered request
* whose `instanceID` matches and whose optional `matcher` accepts the payload.
*/
abstract class FrigateWatcher<T> implements FrigateWatcherSubscriptionInterface<T> {
protected abstract _type: string;
protected abstract _schema: z.ZodType<T>;
private _subscriptions = new KeyedSubscriptionManager<
string,
FrigateWatcherRequest<T>
>((request) => request.instanceID);
private _manager: HASSConnectionSubscriptionManager<string, FrigateWatcherRequest<T>>;
public async subscribe(
hass: HomeAssistant,
request: FrigateWatcherRequest<T>,
): Promise<void> {
await this._subscriptions.subscribe(request, () =>
hass.connection.subscribeMessage<string>(
(data) => this._receiveHandler(request.instanceID, data),
constructor(source: HASSSource) {
this._manager = new HASSConnectionSubscriptionManager(
(request) => request.instanceID,
source,
);
}
public subscribe(request: FrigateWatcherRequest<T>): void {
this._manager.subscribe(request, (connection, liveness) =>
connection.subscribeMessage<string>(
(data) => {
if (!liveness.isConnected()) {
return;
}
this._receive(request.instanceID, data);
},
{ type: this._type, instance_id: request.instanceID },
),
);
}
public async unsubscribe(request: FrigateWatcherRequest<T>): Promise<void> {
await this._subscriptions.unsubscribe(request);
public unsubscribe(request: FrigateWatcherRequest<T>): void {
this._manager.unsubscribe(request);
}
protected _receiveHandler(instanceID: string, data: string): void {
private _receive(instanceID: string, data: string): void {
let json: unknown;
try {
json = JSON.parse(data);
@@ -69,7 +74,7 @@ abstract class FrigateWatcher<T> implements FrigateWatcherSubscriptionInterface<
return;
}
for (const request of this._subscriptions.getRequestsForKey(instanceID)) {
for (const request of this._manager.getRequestsForKey(instanceID)) {
if (!request.matcher || request.matcher(parseResult.data)) {
request.callback(parseResult.data);
}
+6 -15
View File
@@ -1,7 +1,6 @@
/* eslint-disable @typescript-eslint/no-unused-vars */
import { EventWatcherSubscriptionInterface } from '../../card-controller/hass/event-watcher';
import { StateWatcherSubscriptionInterface } from '../../card-controller/hass/state-watcher';
import { HASSManagerReadonlyInterface } from '../../card-controller/hass/types';
import { CameraConfig } from '../../config/schema/cameras';
import { getEntityTitle } from '../../ha/get-entity-title';
import { EntityRegistryManager } from '../../ha/registry/entity/types';
@@ -41,18 +40,15 @@ import { getPTZCapabilitiesFromCameraConfig } from '../utils/ptz';
export class GenericCameraManagerEngine implements CameraManagerEngine {
protected _eventCallback?: CameraEventCallback;
protected _stateWatcher: StateWatcherSubscriptionInterface;
protected _eventWatcher: EventWatcherSubscriptionInterface;
protected _hassManager: HASSManagerReadonlyInterface;
protected _entityRegistryManager?: EntityRegistryManager;
constructor(
stateWatcher: StateWatcherSubscriptionInterface,
eventWatcher: EventWatcherSubscriptionInterface,
hassManager: HASSManagerReadonlyInterface,
entityRegistryManager?: EntityRegistryManager,
eventCallback?: CameraEventCallback,
) {
this._stateWatcher = stateWatcher;
this._eventWatcher = eventWatcher;
this._hassManager = hassManager;
this._entityRegistryManager = entityRegistryManager;
this._eventCallback = eventCallback;
}
@@ -61,16 +57,11 @@ export class GenericCameraManagerEngine implements CameraManagerEngine {
return Engine.Generic;
}
public async createCamera(
hass: HomeAssistant,
cameraConfig: CameraConfig,
): Promise<Camera> {
public async createCamera(cameraConfig: CameraConfig): Promise<Camera> {
return await new Camera(cameraConfig, this, {
eventCallback: this._eventCallback,
}).initialize({
hass,
stateWatcher: this._stateWatcher,
eventWatcher: this._eventWatcher,
hassManager: this._hassManager,
entityRegistryManager: this._entityRegistryManager,
capabilityOptions: {
raw: {
+2 -3
View File
@@ -205,8 +205,7 @@ export class CameraManager {
(await this._engineFactory.createEngine(engineType, {
eventCallback: (ev) =>
this._api.getCameraTriggersManager().handleCameraEvent(ev),
stateWatcher: this._api.getHASSManager().getStateWatcher(),
eventWatcher: this._api.getHASSManager().getEventWatcher(),
hassManager: this._api.getHASSManager(),
resolvedMediaCache: this._api.getResolvedMediaCache(),
}))
: null;
@@ -253,7 +252,7 @@ export class CameraManager {
// Configuration is initialized in parallel.
const cameras = await allPromises(
engineByConfig.entries(),
async ([cameraConfig, engine]) => await engine.createCamera(hass, cameraConfig),
async ([cameraConfig, engine]) => await engine.createCamera(cameraConfig),
);
const destroyCameras = async () => {
+3 -1
View File
@@ -1,3 +1,4 @@
import { HomeAssistant } from '../../ha/types';
import { CapabilitiesRaw, Endpoint } from '../../types';
import { CameraInitializationOptions } from '../camera';
import { EntityCamera } from '../entity-camera';
@@ -20,12 +21,13 @@ export class MotionEyeCamera extends EntityCamera {
}
protected async _getRawCapabilities(
hass: HomeAssistant,
options: CameraInitializationOptions,
): Promise<CapabilitiesRaw> {
const ptz = getPTZCapabilitiesFromCameraConfig(this.getConfig());
return {
...(await super._getRawCapabilities(options)),
...(await super._getRawCapabilities(hass, options)),
clips: true,
snapshots: true,
...(ptz && { ptz }),
@@ -67,18 +67,13 @@ export class MotionEyeCameraManagerEngine extends BrowseMediaCameraManagerEngine
return Engine.MotionEye;
}
public async createCamera(
hass: HomeAssistant,
cameraConfig: CameraConfig,
): Promise<Camera> {
public async createCamera(cameraConfig: CameraConfig): Promise<Camera> {
const camera = new MotionEyeCamera(cameraConfig, this, {
eventCallback: this._eventCallback,
});
return await camera.initialize({
hassManager: this._hassManager,
entityRegistryManager: this._entityRegistryManager,
hass,
stateWatcher: this._stateWatcher,
eventWatcher: this._eventWatcher,
});
}
+7 -8
View File
@@ -172,14 +172,12 @@ export class ReolinkCamera extends EntityCamera {
}
protected async _initialize(
hass: HomeAssistant,
options: ReolinkCameraInitializationOptions,
): Promise<void> {
await super._initialize(options);
await this._initializeChannel(options.hass, options.deviceRegistryManager);
this._ptzEntities = await this._getPTZEntities(
options.hass,
options.entityRegistryManager,
);
await super._initialize(hass, options);
await this._initializeChannel(hass, options.deviceRegistryManager);
this._ptzEntities = await this._getPTZEntities(hass, options.entityRegistryManager);
}
// eslint-disable-next-line @typescript-eslint/no-unused-vars
@@ -188,18 +186,19 @@ export class ReolinkCamera extends EntityCamera {
}
protected async _getRawCapabilities(
hass: HomeAssistant,
options: ReolinkCameraInitializationOptions,
): Promise<CapabilitiesRaw> {
const configPTZ = getPTZCapabilitiesFromCameraConfig(this.getConfig());
const reolinkPTZ = this._ptzEntities
? this._entitiesToCapabilities(options.hass, this._ptzEntities)
? this._entitiesToCapabilities(hass, this._ptzEntities)
: null;
const combinedPTZ: PTZCapabilities | null =
configPTZ || reolinkPTZ ? { ...reolinkPTZ, ...configPTZ } : null;
return {
...(await super._getRawCapabilities(options)),
...(await super._getRawCapabilities(hass, options)),
clips: true,
...(combinedPTZ && { ptz: combinedPTZ }),
};
+5 -13
View File
@@ -1,7 +1,6 @@
import { add, endOfDay, parse, startOfDay } from 'date-fns';
import { orderBy } from 'lodash-es';
import { EventWatcherSubscriptionInterface } from '../../card-controller/hass/event-watcher';
import { StateWatcherSubscriptionInterface } from '../../card-controller/hass/state-watcher';
import { HASSManagerReadonlyInterface } from '../../card-controller/hass/types';
import { CameraConfig } from '../../config/schema/cameras';
import { getViewMediaFromBrowseMediaArray } from '../../ha/browse-media/browse-media-to-view-media';
import { sortMostRecentFirst } from '../../ha/browse-media/sort';
@@ -61,8 +60,7 @@ export class ReolinkCameraManagerEngine extends BrowseMediaCameraManagerEngine {
public constructor(
entityRegistryManager: EntityRegistryManager,
deviceRegistryManager: DeviceRegistryManager,
stateWatcher: StateWatcherSubscriptionInterface,
eventWatcher: EventWatcherSubscriptionInterface,
hassManager: HASSManagerReadonlyInterface,
browseMediaManager: BrowseMediaWalker,
resolvedMediaCache: ResolvedMediaCache,
requestCache: CameraManagerRequestCache,
@@ -70,8 +68,7 @@ export class ReolinkCameraManagerEngine extends BrowseMediaCameraManagerEngine {
) {
super(
entityRegistryManager,
stateWatcher,
eventWatcher,
hassManager,
browseMediaManager,
resolvedMediaCache,
requestCache,
@@ -167,19 +164,14 @@ export class ReolinkCameraManagerEngine extends BrowseMediaCameraManagerEngine {
: null;
}
public async createCamera(
hass: HomeAssistant,
cameraConfig: CameraConfig,
): Promise<Camera> {
public async createCamera(cameraConfig: CameraConfig): Promise<Camera> {
const camera = new ReolinkCamera(cameraConfig, this, {
eventCallback: this._eventCallback,
});
return await camera.initialize({
hassManager: this._hassManager,
entityRegistryManager: this._entityRegistryManager,
deviceRegistryManager: this._deviceRegistryManager,
hass,
stateWatcher: this._stateWatcher,
eventWatcher: this._eventWatcher,
});
}
+5 -6
View File
@@ -23,16 +23,15 @@ export class TPLinkCamera extends EntityCamera {
private _ptzEntities: PTZEntities | null = null;
protected async _initialize(
hass: HomeAssistant,
options: TPLinkCameraInitializationOptions,
): Promise<void> {
await super._initialize(options);
this._ptzEntities = await this._getPTZEntities(
options.hass,
options.entityRegistryManager,
);
await super._initialize(hass, options);
this._ptzEntities = await this._getPTZEntities(hass, options.entityRegistryManager);
}
protected async _getRawCapabilities(
hass: HomeAssistant,
options: TPLinkCameraInitializationOptions,
): Promise<CapabilitiesRaw> {
const configPTZ = getPTZCapabilitiesFromCameraConfig(this.getConfig());
@@ -44,7 +43,7 @@ export class TPLinkCamera extends EntityCamera {
configPTZ || tplinkPTZ ? { ...tplinkPTZ, ...configPTZ } : null;
return {
...(await super._getRawCapabilities(options)),
...(await super._getRawCapabilities(hass, options)),
...(combinedPTZ && { ptz: combinedPTZ }),
};
}
+5 -12
View File
@@ -1,5 +1,4 @@
import { EventWatcherSubscriptionInterface } from '../../card-controller/hass/event-watcher';
import { StateWatcherSubscriptionInterface } from '../../card-controller/hass/state-watcher';
import { HASSManagerReadonlyInterface } from '../../card-controller/hass/types';
import { CameraConfig } from '../../config/schema/cameras';
import { EntityRegistryManager } from '../../ha/registry/entity/types';
import { HomeAssistant } from '../../ha/types';
@@ -11,11 +10,10 @@ import { TPLinkCamera } from './camera';
export class TPLinkCameraManagerEngine extends GenericCameraManagerEngine {
constructor(
entityRegistryManager: EntityRegistryManager,
stateWatcher: StateWatcherSubscriptionInterface,
eventWatcher: EventWatcherSubscriptionInterface,
hassManager: HASSManagerReadonlyInterface,
eventCallback?: CameraEventCallback,
) {
super(stateWatcher, eventWatcher, entityRegistryManager, eventCallback);
super(hassManager, entityRegistryManager, eventCallback);
this._entityRegistryManager = entityRegistryManager;
}
@@ -23,18 +21,13 @@ export class TPLinkCameraManagerEngine extends GenericCameraManagerEngine {
return Engine.TPLink;
}
public async createCamera(
hass: HomeAssistant,
cameraConfig: CameraConfig,
): Promise<Camera> {
public async createCamera(cameraConfig: CameraConfig): Promise<Camera> {
const camera = new TPLinkCamera(cameraConfig, this, {
eventCallback: this._eventCallback,
});
return await camera.initialize({
hassManager: this._hassManager,
entityRegistryManager: this._entityRegistryManager,
hass,
stateWatcher: this._stateWatcher,
eventWatcher: this._eventWatcher,
});
}
@@ -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
+6 -1
View File
@@ -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();
}
+18 -3
View File
@@ -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);
}
// *************************************************************************
+52 -32
View File
@@ -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);
}
}
}
+35 -28
View File
@@ -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();
}
}
+35 -25
View File
@@ -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);
}
}
+8
View File
@@ -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.
+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;
}
+1 -9
View File
@@ -183,15 +183,7 @@ class AdvancedCameraCard extends LitElement {
return false;
}
// Always allow blocking issues to render, as they may be generated during
// initialization.
if (this._controller.getIssueManager().getStateManager().hasFullCardIssue()) {
return true;
}
if (!this._controller.getInitializationManager().isInitializedMandatory()) {
/* async */ this._controller.getInitializationManager().initializeMandatory();
}
this._controller.getInitializationManager().triggerInitialization();
return true;
}
@@ -3,6 +3,7 @@ import { CallTrigger } from './triggers/call';
import { CameraTrigger } from './triggers/camera';
import { ConfigTrigger } from './triggers/config';
import { DisplayModeTrigger } from './triggers/display-mode';
import { EventTrigger } from './triggers/event';
import { ExpandTrigger } from './triggers/expand';
import { FullscreenTrigger } from './triggers/fullscreen';
import { InitializedTrigger } from './triggers/initialized';
@@ -30,6 +31,8 @@ export const createTriggerEvaluator = (
return new NumericStateTrigger(trigger, context);
case 'template':
return new TemplateTrigger(trigger, context);
case 'event':
return new EventTrigger(trigger, context);
// `screen` watches window.matchMedia.
case 'screen':
+7 -1
View File
@@ -1,3 +1,4 @@
import { HASSManagerReadonlyInterface } from '../../card-controller/hass/types';
import { TemplateRenderer } from '../../card-controller/templates';
import { Trigger } from '../../config/schema/condition-trigger/triggers/types';
import { isEnabled } from '../common/is-enabled';
@@ -30,8 +31,13 @@ export class TriggersManager {
constructor(
triggers: Trigger[],
stateManager: ConditionStateManagerReadonlyInterface,
hassManager: HASSManagerReadonlyInterface,
) {
this._context = { stateManager, templateRenderer: new TemplateRenderer() };
this._context = {
stateManager,
templateRenderer: new TemplateRenderer(),
hassManager,
};
this._triggers = triggers.map((config) => ({
config,
evaluator: createTriggerEvaluator(config, this._context),
@@ -0,0 +1,57 @@
import { uniq } from 'lodash-es';
import {
EventSubscriptionRequest,
EventWatcherSubscriptionInterface,
} from '../../../card-controller/hass/event-watcher';
import { matchesEventContext, matchesEventData } from '../../../ha/event-match';
import { arrayify } from '../../../utils/basic';
import {
TriggerCallback,
TriggerEvaluator,
TriggerEvaluatorContext,
TriggerOfType,
} from './types';
// Subscribes via the shared EventWatcher to one or more HA bus event types and
// fires every time a matching event arrives. List-form `event_type` expands
// into one EventWatcher subscription per (de-duplicated) type sharing the same
// data/context matcher; `event_data` and `context` filters are AND-gated.
//
// https://www.home-assistant.io/docs/automation/trigger/#event-trigger
export class EventTrigger implements TriggerEvaluator {
private _trigger: TriggerOfType<'event'>;
private _eventWatcher: EventWatcherSubscriptionInterface;
private _unsubscribeCallback: (() => void) | null = null;
constructor(trigger: TriggerOfType<'event'>, context: TriggerEvaluatorContext) {
this._trigger = trigger;
this._eventWatcher = context.hassManager.getEventWatcher();
}
public subscribe(callback: TriggerCallback): void {
const dataFilter = this._trigger.event_data;
const contextFilter = this._trigger.context;
const requests = uniq(arrayify(this._trigger.event_type)).map(
(eventType): EventSubscriptionRequest => ({
event_type: eventType,
...((dataFilter || contextFilter) && {
matcher: (evt) =>
(!dataFilter || matchesEventData(dataFilter, evt.data)) &&
(!contextFilter || matchesEventContext(contextFilter, evt.context)),
}),
callback: (event) => callback({ platform: 'event', event }),
}),
);
requests.forEach((request) => this._eventWatcher.subscribe(request));
this._unsubscribeCallback = () =>
requests.forEach((request) => this._eventWatcher.unsubscribe(request));
}
public destroy(): void {
this._unsubscribeCallback?.();
this._unsubscribeCallback = null;
}
}
@@ -1,3 +1,4 @@
import { HASSManagerReadonlyInterface } from '../../../card-controller/hass/types';
import { TemplateRenderer } from '../../../card-controller/templates';
import { Trigger } from '../../../config/schema/condition-trigger/triggers/types';
import { ConditionStateManagerReadonlyInterface } from '../../conditions/types';
@@ -8,6 +9,7 @@ export type TriggerCallback = (data: TriggerData) => void;
export interface TriggerEvaluatorContext {
stateManager: ConditionStateManagerReadonlyInterface;
templateRenderer: TemplateRenderer;
hassManager: HASSManagerReadonlyInterface;
}
export type TriggerOfType<T extends string> = Extract<Trigger, { trigger: T }>;
+5 -1
View File
@@ -1,4 +1,4 @@
import { HassEntity } from 'home-assistant-js-websocket';
import { HassEntity, HassEvent } from 'home-assistant-js-websocket';
import { TemplateAdvancedCameraCardState } from '../../card-controller/templates/types';
// The top-level `trigger` template variable produced each time an evaluator
@@ -15,6 +15,10 @@ export interface TriggerData {
from_state?: HassEntity;
to_state?: HassEntity;
// For `platform: 'event'` (HA event trigger) -- the full HA event, surfaced
// as `trigger.event.*` to mirror HA's event-trigger template variables.
event?: HassEvent;
// Card (`acc` platform) fields -- full before/after card-state trigger data:
from_acc?: TemplateAdvancedCameraCardState;
to_acc?: TemplateAdvancedCameraCardState;
+2 -7
View File
@@ -4,6 +4,7 @@ import { mediaLayoutConfigSchema } from './camera/media-layout';
import { ptzCameraConfigDefaults, ptzCameraConfigSchema } from './camera/ptz';
import { aspectRatioSchema } from './common/aspect-ratio';
import { eventsMediaTypeSchema } from './common/events-media';
import { haEventSchema } from './common/ha-event';
import { imageBaseConfigDefault, imageBaseConfigSchema } from './common/image';
import { proxyBaseConfigDefault, proxyBaseConfigSchema } from './common/proxy';
import { severitySchema } from './common/severity';
@@ -219,12 +220,6 @@ const cameraMediaConfigSchema = z.object({
.default(cameraMediaConfigDefault.reviewed),
});
const triggerEventSchema = z.object({
event_type: z.string().min(1),
event_data: z.record(z.string(), z.unknown()).optional(),
});
export type TriggerEvent = z.infer<typeof triggerEventSchema>;
export const cameraConfigSchema = z
.looseObject({
camera_entity: z.string().optional(),
@@ -259,7 +254,7 @@ export const cameraConfigSchema = z
occupancy: z.boolean().default(cameraConfigDefault.triggers.occupancy),
doorbell: z.boolean().default(cameraConfigDefault.triggers.doorbell),
entities: z.string().array().default(cameraConfigDefault.triggers.entities),
events: triggerEventSchema.array().default(cameraConfigDefault.triggers.events),
events: haEventSchema.array().default(cameraConfigDefault.triggers.events),
media_events: z
.enum(CAMERA_TRIGGER_MEDIA_EVENT_TYPES)
.array()
+30
View File
@@ -0,0 +1,30 @@
import { z } from 'zod';
import { stringOrArray } from './string-or-array';
// Filter on the event's `context` (HA's three fixed fields). Each defined field
// is equality-matched against a scalar or membership-matched against a list.
// `.strict()` rejects unknown keys at parse time so a typo (e.g. `user:` vs
// `user_id:`) surfaces instead of silently collapsing to "match everything".
const eventContextFilterSchema = z
.object({
id: stringOrArray.optional(),
user_id: stringOrArray.optional(),
parent_id: stringOrArray.optional(),
})
.strict();
export type HAEventContextFilter = z.infer<typeof eventContextFilterSchema>;
// A Home Assistant bus event filter: `event_type` (one type, or a list to match
// any of them) plus optional payload (`event_data`) and context (`context`)
// filters. `event_data` mirrors HA's matching exactly: listed keys (top-level
// and nested) must be present and extra keys are ignored -- see `event-match.ts`
// for the precise nested-object/array semantics. `context` is field-level
// equality or list-membership. Field names mirror HA's native event trigger
// exactly so the same YAML works in either place.
// https://www.home-assistant.io/docs/automation/trigger/#event-trigger
export const haEventSchema = z.object({
event_type: stringOrArray,
event_data: z.record(z.string(), z.unknown()).optional(),
context: eventContextFilterSchema.optional(),
});
export type HAEvent = z.infer<typeof haEventSchema>;
@@ -0,0 +1,11 @@
import { z } from 'zod';
import { haEventSchema } from '../../../common/ha-event';
import { triggerBaseSchema } from '../base';
// Subscribes to one or more Home Assistant bus event types and fires every time
// a matching event arrives. `event_type`, `event_data` and `context` filters
// mirror HA's native fields exactly.
// https://www.home-assistant.io/docs/automation/trigger/#event-trigger
export const eventTriggerSchema = triggerBaseSchema.extend(haEventSchema.shape).extend({
trigger: z.literal('event'),
});
@@ -13,12 +13,14 @@ import { microphoneTriggerSchema } from './custom/microphone';
import { screenTriggerSchema } from './custom/screen';
import { triggeredTriggerSchema } from './custom/triggered';
import { viewTriggerSchema } from './custom/view';
import { eventTriggerSchema } from './stock/event';
import { numericStateTriggerSchema } from './stock/numeric-state';
import { stateTriggerSchema } from './stock/state';
import { templateTriggerSchema } from './stock/template';
export const triggerSchema = z.union([
// Stock triggers (HA automation triggers):
eventTriggerSchema,
numericStateTriggerSchema,
stateTriggerSchema,
templateTriggerSchema,
@@ -0,0 +1,101 @@
import { RecoverableHealthInterface, UnlistenCallback } from '../../health';
import { HASSWebSocketSubscriptionStatus } from './subscription-manager';
// One keyed subscription that is currently failing, with the most recent
// rejection reason and attempt count.
export interface SubscriptionFailure<K> {
key: K;
error: unknown;
failureCount?: number;
}
// Recoverable health of a set of keyed subscriptions. A named specialisation
// for readability and the extension point for any subscription-specific health
// surface later.
export type SubscriptionHealthInterface<K> = RecoverableHealthInterface<
SubscriptionFailure<K>
>;
/**
* Aggregates the per-request status stream of a
* `HASSConnectionSubscriptionManager` into per-key health: which keys are
* currently failing, observable for changes, and retriable on demand. Generic
* over the manager's key/request types so any consumer can reuse it; it never
* knows how failures are surfaced (issue, log, nothing).
*/
export class SubscriptionHealthMonitor<K, R> implements SubscriptionHealthInterface<K> {
// The latest significant (`subscribed`/`failing`) status per request.
private _health = new Map<R, HASSWebSocketSubscriptionStatus<K, R>>();
private _listeners = new Set<() => void>();
private _retry: (request: R) => void;
constructor(retry: (request: R) => void) {
this._retry = retry;
}
public update(status: HASSWebSocketSubscriptionStatus<K, R>): void {
// `waiting` is emitted before every retry attempt; treating it as
// significant would flap a failing request back to healthy mid-backoff.
if (status.state === 'waiting') {
return;
}
const wasFailing = this._failingKeys().has(status.key);
switch (status.state) {
case 'subscribed':
case 'failing':
// Retain the status as-is; only these two are significant.
this._health.set(status.request, status);
break;
case 'unsubscribed':
this._health.delete(status.request);
break;
}
// A single status changes at most one key's failing state, so notifying on
// that key's transition keeps observers off the manager's per-retry churn.
if (this._failingKeys().has(status.key) !== wasFailing) {
for (const listener of this._listeners) {
listener();
}
}
}
public getFailures(): SubscriptionFailure<K>[] {
const failing = new Map<K, SubscriptionFailure<K>>();
for (const status of this._health.values()) {
if (status.state === 'failing' && !failing.has(status.key)) {
failing.set(status.key, {
key: status.key,
error: status.error,
failureCount: status.failureCount,
});
}
}
return [...failing.values()];
}
public addListener(listener: () => void): UnlistenCallback {
this._listeners.add(listener);
return () => {
this._listeners.delete(listener);
};
}
// Re-drive one currently-`failing` request per failing key; one suffices
// since the WS subscription is keyed.
public retry(): void {
const retried = new Set<K>();
for (const [request, status] of this._health) {
if (status.state === 'failing' && !retried.has(status.key)) {
retried.add(status.key);
this._retry(request);
}
}
}
private _failingKeys(): Set<K> {
return new Set(this.getFailures().map((failure) => failure.key));
}
}
+378
View File
@@ -0,0 +1,378 @@
import { Connection } from 'home-assistant-js-websocket';
import {
GetKeyCallback,
KeyedSubscriptionManager,
} from '../../utils/concurrency/keyed-subscription-manager';
import { RetryTimer } from '../../utils/retry-timer';
import { isHassReady } from '../is-hass-ready';
import { HASSSource, HASSUnlistenCallback } from '../source';
import { HomeAssistant } from '../types';
import { HASSWebSocketLiveness, HASSWebSocketOpenCallback } from './types';
const RETRY_BASE_SECONDS = 1;
const RETRY_MAX_SECONDS = 300;
/**
* Lifecycle status reported back to subscribers via their optional
* `statusCallback` at `subscribe` time. The manager owns the retry policy;
* consumers are pure observers that translate state changes into whatever they
* want (an Issue/notification, a log, nothing). Consumers never call
* `subscribe` again to retry -- they use `retry(request)` which routes through
* the same state machine.
*/
export interface HASSWebSocketSubscriptionStatus<K, R> {
key: K;
request: R;
// The subscription's lifecycle state:
// - `waiting`: not subscribed (e.g. HA isn't ready yet, submission in flight).
// - `subscribed`: the underlying WS subscription is live.
// - `failing`: the most recent attempt rejected. A retry is armed; the next
// status will be `waiting` then either `subscribed` or `failing` again.
// - `unsubscribed`: the request was removed via `unsubscribe()`.
state: 'subscribed' | 'failing' | 'waiting' | 'unsubscribed';
// Present only on `failing`: the rejection reason, and the total
// failed-attempts count so far.
error?: unknown;
failureCount?: number;
}
export type HASSWebSocketStatusCallback<K, R> = (
status: HASSWebSocketSubscriptionStatus<K, R>,
) => void;
interface RequestRegistration<K, R> {
openCallback: HASSWebSocketOpenCallback;
statusCallback: HASSWebSocketStatusCallback<K, R> | null;
// Era-local, reset on every era boundary (see the class docs for "era").
//
// `token` tags the latest subscribe attempt so a newer attempt can replace
// old/slow attempts; null when none is in flight.
token: symbol | null;
// `retry` schedules the next attempt after a failed subscribe; exponential
// backoff spaces attempts out across HASS pushes.
retry: RetryTimer;
}
/**
* Manages subscriptions whose lifetime is bound to a HASS WebSocket
* `Connection`. Layered on top of `KeyedSubscriptionManager` (KSM: per-key
* refcount + sub/unsub serialization within ONE connection era).
*
* An **era** is a contiguous window during which the manager is bound to a
* single live `Connection`. The KSM instance is replaced and each request's
* era-local state (`token` + `retry`) is reset on every era boundary. The
* durable `_requests` mirror is preserved across eras and drives replay.
* - Era STARTS when: a ready HASS arrives for the first time, OR the
* `Connection` object swaps to a different instance, OR the manager
* transitions from a not-ready dead era back to ready.
* - Era ENDS (becomes a dead era) when: HASS goes not-ready.
*
* Each era is identified by a `Symbol()` minted at era start and stored in
* `_connectionEra`. The `HASSWebSocketLiveness` objects (returned to callers'
* dispatch closures) capture the era symbol; their `isConnected()` method
* compares the captured symbol against the manager's current `_connectionEra`.
* Nulling or replacing `_connectionEra` therefore synchronously flips every
* outstanding state to disconnected.
*
* Symbol identity (not `Connection` pointer identity) is what defines an era,
* because the HA `Connection` library can reuse the same `Connection` object
* across reconnect cycles. From the manager's standpoint a not-ready -> ready
* transition with the same `Connection` is a NEW era (we've torn down era state
* during not-ready), and we need old states to keep reporting disconnected even
* if the pointer matches.
*
* Adds on top of KSM:
* - Deferred submit until HASS is ready.
* - Era boundaries as described above.
* - `HASSWebSocketLiveness` for caller dispatch callbacks: drops events that
* arrived from an era that's no longer current.
* - Time-spaced exponential-backoff retries on subscribe failure. Retries fire
* on a per-request `Timer`, NOT on HASS-push cadence (which is far too
* frequent). After many failures, retries naturally space out to the
* `RETRY_MAX_SECONDS` ceiling.
* - Lazy source attach / detach driven by request count.
*/
export class HASSConnectionSubscriptionManager<K, R> {
private readonly _source: HASSSource;
private readonly _getKeyCallback: GetKeyCallback<R, K>;
private _connection: Connection | null = null;
private _connectionEra: symbol | null = null;
// Per-key refcount + sub/unsub serialization for the CURRENT era only.
// Replaced with a fresh instance on every era boundary; the abandoned
// instance's pending tasks resolve into an unreachable object.
private _ksm: KeyedSubscriptionManager<K, R>;
// Durable: source-of-truth list of currently-registered requests (with their
// era-local token + retry). Survives era transitions and drives replay
// against the new era's `KeyedSubscriptionManager`. The
// `KeyedSubscriptionManager`'s internal request list lags behind by the time
// of its async task; this mirror is updated synchronously on
// subscribe/unsubscribe.
private _requests = new Map<R, RequestRegistration<K, R>>();
private _unlistenCallback: HASSUnlistenCallback | null = null;
constructor(getKeyCallback: GetKeyCallback<R, K>, source: HASSSource) {
this._getKeyCallback = getKeyCallback;
this._source = source;
this._ksm = this._createEmptyKSM();
}
public subscribe(
request: R,
openCallback: HASSWebSocketOpenCallback,
statusCallback?: HASSWebSocketStatusCallback<K, R>,
): void {
const wasEmpty = this._requests.size === 0;
const registration: RequestRegistration<K, R> = {
openCallback,
statusCallback: statusCallback ?? null,
token: null,
retry: new RetryTimer({
baseSeconds: RETRY_BASE_SECONDS,
maxSeconds: RETRY_MAX_SECONDS,
}),
};
this._requests.set(request, registration);
if (wasEmpty) {
this._listenToHASS();
}
if (this._connection && !registration.token) {
this._submit(this._connection, request, registration);
} else if (!this._connection) {
// Dead era. Caller observes the request as waiting until the era starts
// and `_submit` fires, at which point status flips to `subscribed` or
// `failing`.
this._emitStatus(request, 'waiting');
}
}
public retry(request: R): void {
const registration = this._requests.get(request);
if (!registration) {
return;
}
registration.retry.reset();
if (this._connection) {
this._submit(this._connection, request, registration);
}
}
public unsubscribe(request: R): void {
const registration = this._requests.get(request);
if (!registration) {
return;
}
this._emitStatus(request, 'unsubscribed');
registration.retry.cancel();
this._requests.delete(request);
// `KeyedSubscriptionManager` unsubscribe failures are internal (HA returned
// an error on the close message). Caller can't act; swallow.
this._ksm.unsubscribe(request).catch(() => {});
if (this._requests.size === 0) {
this._unlistenFromHASS();
}
}
public destroy(): void {
this._unlistenFromHASS();
this._endEra();
this._requests.clear();
}
public getRequestsForKey(key: K): R[] {
const result: R[] = [];
for (const request of this._requests.keys()) {
if (this._getKeyCallback(request) === key) {
result.push(request);
}
}
return result;
}
private _listenToHASS(): void {
/* istanbul ignore if: only called when transitioning from zero to one
request, so `_unlistenCallback` is always null here -- @preserve */
if (this._unlistenCallback) {
return;
}
this._unlistenCallback = this._source.addListener((hass) =>
this._handleHASSChange(hass),
);
// Handle initial state.
this._handleHASSChange(this._source.getHASS());
}
private _unlistenFromHASS(): void {
if (!this._unlistenCallback) {
return;
}
this._unlistenCallback();
this._unlistenCallback = null;
}
private _handleHASSChange(hass: HomeAssistant | null): void {
if (!hass || !isHassReady(hass)) {
if (this._connectionEra !== null) {
this._endEra();
// Surface the era end to consumers so they can update any UI that was
// reflecting `subscribed` or `failing` for the now-dead era.
for (const request of this._requests.keys()) {
this._emitStatus(request, 'waiting');
}
}
return;
}
if (hass.connection === this._connection && this._connectionEra !== null) {
// Nothing to do.
return;
}
// Era transition (connection swap or reanimation from a dead era).
this._endEra();
this._connection = hass.connection;
this._connectionEra = Symbol();
for (const [request, registration] of this._requests) {
this._submit(this._connection, request, registration);
}
}
private _submit(
connection: Connection,
request: R,
registration: RequestRegistration<K, R>,
): void {
const token = Symbol();
registration.token = token;
registration.retry.cancel();
const { openCallback } = registration;
const liveness = this._createWebSocketLiveness();
this._emitStatus(request, 'waiting');
this._ksm
.subscribe(request, () => openCallback(connection, liveness))
.then(() => {
const eraState = this._getCurrentEraState(request, token);
if (!eraState) {
return;
}
// Reset the backoff so the next failure (e.g. after an era swap)
// starts at the base delay again instead of jumping to wherever we
// had escalated to.
eraState.retry.reset();
this._emitStatus(request, 'subscribed');
})
.catch((e) => {
const eraState = this._getCurrentEraState(request, token);
if (!eraState) {
return;
}
eraState.token = null;
eraState.retry.schedule(() => this._runScheduledRetry(request));
this._emitStatus(request, 'failing', e, eraState.retry.getAttempts());
});
}
private _runScheduledRetry(request: R): void {
const registration = this._requests.get(request);
/* istanbul ignore if: unsubscribe() and `_endEra()` both stop the timer
before tearing down state, so by the time we get here the request is
still alive and the era is still ready -- @preserve */
if (!registration || !this._connection) {
return;
}
/* istanbul ignore if: the timer can only fire while its token is null (set
null by the catch that scheduled this timer) -- @preserve */
if (registration.token != null) {
return;
}
this._submit(this._connection, request, registration);
}
private _emitStatus(
request: R,
state: HASSWebSocketSubscriptionStatus<K, R>['state'],
error?: unknown,
failureCount?: number,
): void {
const registration = this._requests.get(request);
try {
registration?.statusCallback?.({
key: this._getKeyCallback(request),
request,
state,
...(error != null && { error }),
...(failureCount && { failureCount }),
});
} catch {
// Swallowed: a buggy observer must not corrupt the state machine.
}
}
// Returns the request's registration only while `token` is still its current
// submission. A mismatch (or a removed request) means the era moved on or a
// newer submission superseded this one, so the caller must leave all state
// untouched.
private _getCurrentEraState(
request: R,
token: symbol,
): RequestRegistration<K, R> | null {
const registration = this._requests.get(request);
return registration?.token === token ? registration : null;
}
// End the current era: drop the connection and clear the era symbol (so every
// outstanding `HASSWebSocketLiveness.isConnected()` flips to disconnected),
// reset each request's era-local state, and close + replace the KSM.
// Subscriptions are closed via the durable `_requests` mirror because KSM's
// own list lags pending subscribe tasks; per-request close failures are
// swallowed (an abandoned/dead connection has no live socket to ack the
// close).
private _endEra(): void {
this._connection = null;
this._connectionEra = null;
const ksm = this._ksm;
this._ksm = this._createEmptyKSM();
for (const [request, registration] of this._requests) {
registration.retry.reset();
registration.token = null;
ksm.unsubscribe(request).catch(() => {});
}
}
private _createWebSocketLiveness(): HASSWebSocketLiveness {
// Capture the era at submit time. `isConnected` compares it against the
// manager's current era; if they differ, the manager has moved on and the
// liveness reports disconnected. Arrow form so `this` is the class
// instance.
const era = this._connectionEra;
return {
isConnected: (): boolean => era !== null && this._connectionEra === era,
};
}
private _createEmptyKSM(): KeyedSubscriptionManager<K, R> {
return new KeyedSubscriptionManager<K, R>(this._getKeyCallback);
}
}
+26
View File
@@ -0,0 +1,26 @@
import { Connection } from 'home-assistant-js-websocket';
/**
* Types describing the caller-supplied callbacks for opening and closing a
* single WebSocket subscription on the HA bus.
* `HASSConnectionSubscriptionManager` invokes `HASSWebSocketOpenCallback` every
* time it needs to open a fresh WebSocket subscription on a given `Connection`
* (initial open, replay after the manager moved to a new connection, retry
* after a previous failure), and later invokes the returned
* `HASSWebSocketCloseCallback` once to close it.
*
* `HASSWebSocketLiveness.isConnected()` is provided to the caller's open-callback
* so its WS dispatch callback can drop events that arrived after the manager
* has moved on to a different connection or is torn down. See
* `subscription-manager.ts` for the "era" model behind this.
*/
export interface HASSWebSocketLiveness {
isConnected(): boolean;
}
type HASSWebSocketCloseCallback = () => Promise<void>;
export type HASSWebSocketOpenCallback = (
connection: Connection,
liveness: HASSWebSocketLiveness,
) => Promise<HASSWebSocketCloseCallback>;
-12
View File
@@ -1,12 +0,0 @@
import { isMatch } from 'lodash-es';
import { isRecord } from '../utils/basic';
// Deep subset match between an HA bus event's payload `data` and a user-
// configured filter. Mirrors HA automation `event_data` semantics: every key in
// `filter` must exist in `data` and recursively match; extra keys in `data` are
// ignored. The `unknown` guard lives here (not at the caller) because HA event
// payloads arrive untyped from the WebSocket bus.
export const matchesEventData = (
filter: Record<string, unknown>,
data: unknown,
): boolean => isRecord(data) && isMatch(data, filter);
+111
View File
@@ -0,0 +1,111 @@
import { HassEventBase } from 'home-assistant-js-websocket';
import { isEqualWith } from 'lodash-es';
import { HAEventContextFilter } from '../config/schema/common/ha-event';
import { isRecord } from '../utils/basic';
// A plain object (HA/Python `dict`), excluding arrays -- the distinction HA's
// event trigger keys off when deciding how to match a value.
const isDict = (value: unknown): value is Record<string, unknown> =>
isRecord(value) && !Array.isArray(value);
// Deep equality matching Python's `==`: identical to a normal deep-equal except
// that Python's `bool` is a subtype of `int`, so `true`/`false` equal `1`/`0`
// (and that equivalence propagates through nested lists/dicts). HA relies on
// it, so we must too for byte-for-byte parity.
const haEqual = (a: unknown, b: unknown): boolean =>
isEqualWith(a, b, (x, y) => {
if (typeof x === 'boolean' && typeof y === 'number') {
return Number(x) === y;
}
if (typeof x === 'number' && typeof y === 'boolean') {
return x === Number(y);
}
return undefined;
});
// Matches HA's event-trigger `event_data` filtering precisely, including its
// fast-path/slow-path split (homeassistant/components/homeassistant/triggers/
// event.py):
//
// - If NO top-level filter value is a dict, HA does a plain items-subset
// compare (`event.items() >= filter.items()`): every filter key must be
// present with an equal value (`haEqual`; lists by order + length).
// - If ANY top-level filter value is a dict, HA validates the event against
// `vol.Schema(filter, extra=ALLOW_EXTRA, required=True)` instead: every
// filter key is required (recursively into nested dicts), extra keys are
// allowed at every level, and a list filter is matched by voluptuous
// membership -- each event-array element must equal one of the filter
// array's entries (order/length-free). Scalars match by equality.
//
// The asymmetry is real: the same top-level list filter is strict in the fast
// path but membership-matched in the slow path (when a sibling key is a dict).
// The `unknown` guard lives here because event payloads arrive untyped from the
// WebSocket bus.
// https://github.com/home-assistant/core/blob/dev/homeassistant/components/homeassistant/triggers/event.py
export const matchesEventData = (
filter: Record<string, unknown>,
data: unknown,
): boolean => {
if (!isRecord(data)) {
return false;
}
if (!Object.values(filter).some(isDict)) {
// Fast path: strict items subset.
return Object.entries(filter).every(
([key, expected]) => key in data && haEqual(data[key], expected),
);
}
// Slow path: `vol.Schema(filter, extra=ALLOW_EXTRA, required=True)`.
return matchesSchemaDict(filter, data);
};
// A voluptuous dict schema with `required=True, extra=ALLOW_EXTRA`: every schema
// key must be present and recursively match; extra event keys are allowed.
const matchesSchemaDict = (schema: Record<string, unknown>, value: unknown): boolean =>
isDict(value) &&
Object.entries(schema).every(
([key, expected]) => key in value && matchesSchemaValue(expected, value[key]),
);
const matchesSchemaValue = (expected: unknown, actual: unknown): boolean => {
if (isDict(expected)) {
return matchesSchemaDict(expected, actual);
}
if (Array.isArray(expected)) {
// voluptuous list schema: `actual` must be a list whose every element
// matches one of the filter's element schemas.
return (
Array.isArray(actual) &&
actual.every((item) => expected.some((schema) => matchesSchemaValue(schema, item)))
);
}
return haEqual(actual, expected);
};
// HA-faithful match for the event `context` object: every defined filter field
// must match the event's corresponding field by equality (scalar filter) or
// list-membership (array filter). A null event-side field never satisfies an
// explicit filter, mirroring HA's behaviour.
// https://www.home-assistant.io/docs/automation/trigger/#event-trigger
export const matchesEventContext = (
filter: HAEventContextFilter,
context: HassEventBase['context'],
): boolean =>
matchesContextField(filter.id, context.id) &&
matchesContextField(filter.user_id, context.user_id) &&
matchesContextField(filter.parent_id, context.parent_id);
const matchesContextField = (
expected: string | string[] | undefined,
actual: string | null,
): boolean => {
if (expected === undefined) {
return true;
}
if (actual === null) {
return false;
}
return Array.isArray(expected) ? expected.includes(actual) : expected === actual;
};
+14
View File
@@ -0,0 +1,14 @@
import { STATE_RUNNING } from 'home-assistant-js-websocket';
import { HomeAssistant } from './types';
// HA is "ready" when the WebSocket is connected AND integrations have finished
// loading (`config.state === STATE_RUNNING`). HA exposes the WebSocket before
// integrations load, so `connected` alone is insufficient for
// integration-specific calls (e.g. Frigate WS subscriptions, which fail with
// "Unknown command" against a half-loaded HA).
//
// Typed as a predicate so callers can use the readiness check to narrow a
// nullable `hass?` reference to a non-null `HomeAssistant` for follow-up
// `hass.connection`-style access.
export const isHassReady = (hass?: HomeAssistant | null): hass is HomeAssistant =>
!!hass?.connected && hass.config?.state === STATE_RUNNING;
+16
View File
@@ -0,0 +1,16 @@
import { HomeAssistant } from './types';
/**
* `HASSSource` is the observer-pattern API that `HASSManager` exposes for any
* long-lived code that needs to react to HASS changes. The listener fires on
* every non-null HASS push; `oldHass` is the previous value (null on first
* fire).
*/
export type HASSListener = (hass: HomeAssistant, oldHass: HomeAssistant | null) => void;
export type HASSUnlistenCallback = () => void;
export interface HASSSource {
getHASS(): HomeAssistant | null;
addListener(listener: HASSListener): HASSUnlistenCallback;
}
+16
View File
@@ -0,0 +1,16 @@
// Returned by `addListener`; invoke it to stop listening.
export type UnlistenCallback = () => void;
// A source of health information: the current failures (of some domain-specific
// shape `F`) and a way to observe changes to them.
interface HealthInterface<F> {
getFailures(): F[];
addListener(listener: () => void): UnlistenCallback;
}
// Health that also supports a user-driven retry of whatever is currently
// failing. Separate from HealthInterface because observation and recovery are
// distinct capabilities: a read-only health source has nothing to retry.
export interface RecoverableHealthInterface<F> extends HealthInterface<F> {
retry(): void;
}
+4
View File
@@ -844,6 +844,10 @@
"text": "Waiting for Home Assistant startup to complete"
}
},
"event_subscription": {
"heading": "Home Assistant event subscriptions",
"text": "The card could not subscribe to one or more Home Assistant event types and will keep retrying"
},
"initialization": {
"heading": "Initialization failed"
},
@@ -1,7 +1,14 @@
import PQueue from 'p-queue';
type UnsubscribeFn = () => Promise<void>;
type SubscribeFn = () => Promise<UnsubscribeFn>;
type UnsubscribeCallback = () => Promise<void>;
type SubscribeCallback = () => Promise<UnsubscribeCallback>;
/**
* Extracts the key from a request. Used by `KeyedSubscriptionManager` and any
* higher-level wrapper that shares its request-to-key mapping (e.g. the HASS
* connection subscription manager).
*/
export type GetKeyCallback<R, K> = (request: R) => K;
/**
* Manages subscriptions keyed by `K`: the first subscriber for a key invokes
@@ -15,27 +22,36 @@ type SubscribeFn = () => Promise<UnsubscribeFn>;
*/
export class KeyedSubscriptionManager<K, R> {
private _requests: R[] = [];
private _unsubscribers = new Map<K, UnsubscribeFn>();
private _unsubscribers = new Map<K, UnsubscribeCallback>();
private _queues = new Map<K, PQueue>();
private _getKeyFn: (request: R) => K;
private _getKeyCallback: GetKeyCallback<R, K>;
constructor(getKeyFn: (request: R) => K) {
this._getKeyFn = getKeyFn;
constructor(getKeyCallback: GetKeyCallback<R, K>) {
this._getKeyCallback = getKeyCallback;
}
public async subscribe(request: R, subscribeFn: SubscribeFn): Promise<void> {
const key = this._getKeyFn(request);
public async subscribe(
request: R,
subscribeCallback: SubscribeCallback,
): Promise<void> {
const key = this._getKeyCallback(request);
await this._queueFor(key).add(async () => {
this._requests.push(request);
if (!this._unsubscribers.has(key)) {
const unsubscribe = await subscribeFn();
this._unsubscribers.set(key, unsubscribe);
try {
this._unsubscribers.set(key, await subscribeCallback());
} catch (e) {
// Roll back the orphan request so it doesn't sit in `_requests`
// dispatching against a connection that was never established.
this._requests = this._requests.filter((r) => r !== request);
throw e;
}
}
});
}
public async unsubscribe(request: R): Promise<void> {
const key = this._getKeyFn(request);
const key = this._getKeyCallback(request);
await this._queueFor(key).add(async () => {
this._requests = this._requests.filter((r) => r !== request);
if (!this._hasSubscribers(key)) {
@@ -47,7 +63,7 @@ export class KeyedSubscriptionManager<K, R> {
}
public getRequestsForKey(key: K): readonly R[] {
return this._requests.filter((r) => this._getKeyFn(r) === key);
return this._requests.filter((r) => this._getKeyCallback(r) === key);
}
private _queueFor(key: K): PQueue {
@@ -60,6 +76,6 @@ export class KeyedSubscriptionManager<K, R> {
}
private _hasSubscribers(key: K): boolean {
return this._requests.some((r) => this._getKeyFn(r) === key);
return this._requests.some((r) => this._getKeyCallback(r) === key);
}
}
+85
View File
@@ -0,0 +1,85 @@
// Default jitter range applied to each computed delay: a random multiplier in
// [50%, 100%] of the pre-jitter value, avoiding thundering-herd retries when
// multiple instances back off in lockstep.
const DEFAULT_JITTER_MIN = 0.5;
const DEFAULT_JITTER_MAX = 1.0;
export interface ExponentialBackoffOptions {
// Delay for the first retry (attempt 1). Subsequent attempts double the delay
// until `maxSeconds` is reached.
baseSeconds: number;
// Upper bound on the delay after exponential growth. The delay never exceeds
// this regardless of attempt count.
maxSeconds: number;
// Random multiplier applied to each computed delay. Defaults to
// [DEFAULT_JITTER_MIN, DEFAULT_JITTER_MAX].
jitterMin?: number;
jitterMax?: number;
}
/**
* Stateful exponential-backoff delay calculator. Holds an attempt counter,
* returns the next delay on each `next()` call, and can be `reset()` after a
* successful operation.
*
* Example:
* ```ts
* const backoff = new ExponentialBackoff({ baseSeconds: 1, maxSeconds: 300 });
* // 1st failure -> backoff.next() returns ~1s (jittered).
* // 2nd failure -> ~2s.
* // 3rd failure -> ~4s. ... -> 300s ceiling.
* // After success: backoff.reset().
* ```
*/
export class ExponentialBackoff {
private _baseSeconds = 0;
private _maxSeconds = 0;
private _jitterMin = DEFAULT_JITTER_MIN;
private _jitterMax = DEFAULT_JITTER_MAX;
private _attempts = 0;
constructor(options: ExponentialBackoffOptions) {
this.setOptions(options);
}
public setOptions(options: ExponentialBackoffOptions): void {
this._baseSeconds = options.baseSeconds;
this._maxSeconds = options.maxSeconds;
this._jitterMin = options.jitterMin ?? DEFAULT_JITTER_MIN;
this._jitterMax = options.jitterMax ?? DEFAULT_JITTER_MAX;
}
/**
* Returns the next delay in seconds and increments the attempt counter. The
* pre-jitter delay is `baseSeconds * 2^(attempts before increment)`, capped
* at `maxSeconds`. Jitter is a random multiplier in [jitterMin, jitterMax].
*/
public next(): number {
const delay = this.peek();
this._attempts += 1;
return delay;
}
/**
* Returns what the next `next()` call would return WITHOUT incrementing the
* counter. Useful for "re-arm at the same backoff level" cases (a scheduled
* retry deferred for an unrelated reason; don't compound the backoff). Note
* jitter is re-rolled each call, so two consecutive `peek()`s may return
* slightly different values for the same attempt count.
*/
public peek(): number {
const exp = Math.min(this._maxSeconds, this._baseSeconds * 2 ** this._attempts);
const jitter = this._jitterMin + Math.random() * (this._jitterMax - this._jitterMin);
return exp * jitter;
}
public reset(): void {
this._attempts = 0;
}
public getAttempts(): number {
return this._attempts;
}
}
+20 -3
View File
@@ -3,13 +3,22 @@ import { allPromises } from '../basic';
type InitializationCallback = () => Promise<void>;
/**
* Manages initialization state & calling initializers. There is no guarantee
* something will not be initialized twice unless there are concurrency controls
* applied to the usage of this class.
* Manages initialization state and runs initializers.
*
* Safe when `uninitialize()` is called while an (async) initializer is still
* running: that initializer's result is discarded instead of marking the aspect
* initialized again. (Two initializers running for the same aspect at once is
* still the caller's job to avoid.)
*/
export class Initializer {
private _initialized: Set<string> = new Set();
// Bumped on every `uninitialize()`. An `initializeIfNecessary()` captures the
// generation before awaiting its initializer and, on completion, only records
// success if the generation is unchanged -- i.e. no `uninitialize()` for that
// aspect landed while it was running.
private _generation: Map<string, number> = new Map();
public async initializeMultipleIfNecessary(
aspects: Record<string, InitializationCallback>,
): Promise<void> {
@@ -26,14 +35,22 @@ export class Initializer {
if (this._initialized.has(aspect)) {
return;
}
const generation = this._generation.get(aspect) ?? 0;
if (initializer) {
await initializer();
}
// If `uninitialize()` ran while we were awaiting, a newer attempt has taken
// over -- throw this result away (don't mark it initialized) so a stale
// result can't leave the card stuck, and a fresh attempt runs next time.
if ((this._generation.get(aspect) ?? 0) !== generation) {
return;
}
this._initialized.add(aspect);
}
public uninitialize(aspect: string): void {
this._initialized.delete(aspect);
this._generation.set(aspect, (this._generation.get(aspect) ?? 0) + 1);
}
public isInitialized(aspect: string): boolean {
+98
View File
@@ -0,0 +1,98 @@
import { ExponentialBackoff, ExponentialBackoffOptions } from './exponential-backoff';
import { Timer } from './timer';
// Expand a plain `number` (fixed delay in seconds) into the equivalent
// `ExponentialBackoffOptions`: base = max so growth flattens, jitter pinned to
// 1.0 so the delay is exactly N every time.
const convertToBackoffOptions = (
options: ExponentialBackoffOptions | number,
): ExponentialBackoffOptions => {
if (typeof options === 'number') {
return {
baseSeconds: options,
maxSeconds: options,
jitterMin: 1,
jitterMax: 1,
};
}
return options;
};
/**
* Pairs an `ExponentialBackoff` with a `Timer` for retry scheduling. Each
* `schedule(...)` call fires after the current backoff delay; `advance()`
* bumps the counter for next time.
*
* Constructor and `setOptions` accept either `ExponentialBackoffOptions`
* (growth + jitter) or a plain `number` (fixed delay in seconds, no growth,
* no jitter). Internally a number expands to `{ baseSeconds: N, maxSeconds:
* N, jitterMin: 1, jitterMax: 1 }` so all methods behave uniformly -- callers
* never branch.
*
* Typical patterns:
* - "Failure happened, retry later, count this failure": `schedule(cb)`.
* - "Retry deferred for an unrelated reason; don't compound":
* `schedule(cb, { advance: false })` (re-arms at the current delay).
* - "Attempt happened, count it separately from scheduling": `advance()`.
* - "Operation succeeded": `reset()`.
* - "Caller going away": `cancel()`.
*/
export class RetryTimer {
private readonly _backoff: ExponentialBackoff;
private readonly _timer = new Timer();
constructor(options: ExponentialBackoffOptions | number) {
this._backoff = new ExponentialBackoff(convertToBackoffOptions(options));
}
/**
* Replace the backoff configuration. Cheap and idempotent: doesn't cancel
* pending callbacks or reset the attempt counter, so it's safe to call on
* every scheduling pass regardless of whether the options actually changed.
* Call `reset()` separately if zeroing the counter is desired (e.g. on a
* semantically distinct mode switch).
*/
public setOptions(options: ExponentialBackoffOptions | number): void {
this._backoff.setOptions(convertToBackoffOptions(options));
}
/**
* Schedule `callback` to fire after the current delay, then advance the
* attempt counter so the next schedule uses a longer delay. Pass
* `{ advance: false }` to re-arm at the current delay without counting it
* (e.g. a retry that may be gated and re-scheduled). Any pending callback
* is canceled before scheduling.
*/
public schedule(callback: () => void, options?: { advance?: boolean }): void {
this._timer.start(this._backoff.peek(), callback);
if (options?.advance !== false) {
this._backoff.next();
}
}
/**
* Bump the attempt counter without scheduling. For flows where the schedule
* call and the "attempt happened, count it" event are separate (e.g. the
* scheduled callback may or may not actually retry, depending on a gate).
*/
public advance(): void {
this._backoff.next();
}
public cancel(): void {
this._timer.stop();
}
public reset(): void {
this._timer.stop();
this._backoff.reset();
}
public isRunning(): boolean {
return this._timer.isRunning();
}
public getAttempts(): number {
return this._backoff.getAttempts();
}
}