feat: Add event-based automation triggers (#2537)
This commit is contained in:
committed by
dermotduffy
parent
b701366762
commit
a31816c168
@@ -416,10 +416,11 @@ cameras:
|
||||
- event_type: home_doorbell_pressed
|
||||
```
|
||||
|
||||
| Option | Default | Description |
|
||||
| ------------ | ------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `event_type` | | The Home Assistant event type to subscribe to (e.g. `zha_event`, `deconz_event`, or a custom event name fired by one of your automations). Same field name and meaning as in HA automation YAML. |
|
||||
| `event_data` | | Optional dictionary of key/value pairs the event's payload must contain for this entry to trigger. Matching is a deep subset (every key listed must be present in the event payload and match; extra keys in the event are ignored). Same field name and semantics as in HA automation YAML. Omit entirely to trigger on every fire of this `event_type`. |
|
||||
| Option | Default | Description |
|
||||
| ------------ | ------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `event_type` | | The Home Assistant event type to subscribe to (e.g. `zha_event`, `deconz_event`, or a custom event name fired by one of your automations). Same field name and meaning as in HA automation YAML. May be a single string or a list of strings to match any of them. |
|
||||
| `event_data` | | Optional dictionary of key/value pairs the event's payload must contain for this entry to trigger. Mirrors Home Assistant's `event_data` matching exactly: the top-level keys you list must be present in the payload (extra payload keys are ignored), and nested objects are matched the same way -- list only the keys you care about and extra nested keys are ignored. Omit entirely to trigger on every fire of this `event_type`. |
|
||||
| `context` | | Optional filter on the event's `context` object. Recognised fields: `id`, `user_id`, `parent_id`. Each field may be a single value (equality) or a list (membership). All listed fields must match. |
|
||||
|
||||
> [!TIP] Shared `event_type` values like `zha_event` and `deconz_event` fire for **every** device on that integration. Without an `event_data` filter the camera would trigger on any Zigbee/deCONZ device press in your home. Use `event_data` to narrow down to the specific device you care about; you can copy values straight out of **Developer tools → Events** in Home Assistant.
|
||||
|
||||
|
||||
@@ -176,6 +176,33 @@ triggers:
|
||||
| `condition` / `trigger` | Must be `display_mode`. |
|
||||
| `display_mode` | Must be `single` or `grid`. |
|
||||
|
||||
## `event`
|
||||
|
||||
_Trigger only._
|
||||
|
||||
Fires when a Home Assistant bus event matching `event_type` is dispatched, with optional payload (`event_data`) and context (`context`) filtering. Field names and semantics mirror HA's [event trigger](https://www.home-assistant.io/docs/automation/trigger/#event-trigger), so YAML copied from HA works without modification.
|
||||
|
||||
```yaml
|
||||
triggers:
|
||||
- trigger: event
|
||||
event_type: zha_event
|
||||
event_data:
|
||||
device_ieee: '00:11:22:33:44:55:66:77'
|
||||
command: press
|
||||
```
|
||||
|
||||
| Parameter | Description |
|
||||
| ------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `trigger` | Must be `event`. |
|
||||
| `event_type` | The Home Assistant event type to subscribe to (e.g. `zha_event`, `deconz_event`, or a custom event fired by one of your automations). May be a single string or a list of strings to match any of them. |
|
||||
| `event_data` | Optional dictionary of key/value pairs the event's payload must contain for this entry to match. Mirrors Home Assistant's `event_data` matching exactly: the top-level keys you list must be present in the payload (extra payload keys are ignored), and nested objects are matched the same way -- list only the keys you care about and extra nested keys are ignored. Omit entirely to match every fire of `event_type`. |
|
||||
| `context` | Optional filter on the event's `context` object. Recognised fields: `id`, `user_id`, `parent_id`. Each field may be a single value (equality) or a list (membership). All listed fields must match. |
|
||||
|
||||
The fired event is exposed to action templates as `trigger.event.*`, matching HA's event trigger template surface (`trigger.event.event_type`, `trigger.event.data`, `trigger.event.context`, `trigger.event.origin`, `trigger.event.time_fired`).
|
||||
|
||||
> [!TIP]
|
||||
> Shared event types like `zha_event` and `deconz_event` fire for **every** device on that integration. Without an `event_data` filter the trigger would fire on every Zigbee/deCONZ device press in your home. Use `event_data` to narrow to the specific device you care about; you can copy values straight out of **Developer tools → Events** in Home Assistant.
|
||||
|
||||
## `expand`
|
||||
|
||||
Matches whether the card is in "expanded" mode (in a dialog/popup). As a
|
||||
@@ -654,9 +681,9 @@ Several Home Assistant condition types are **not** currently supported: `time`,
|
||||
`zone`, `sun`, `location`, `device`, and `condition: trigger` (matching on the
|
||||
`id` of the trigger that fired).
|
||||
|
||||
On the trigger side, only the stock `state`, `numeric_state` and `template`
|
||||
platforms are supported, alongside the card-specific triggers listed above.
|
||||
Other Home Assistant trigger platforms -- including `event`, `time`,
|
||||
On the trigger side, only the stock `event`, `state`, `numeric_state` and
|
||||
`template` platforms are supported, alongside the card-specific triggers listed
|
||||
above. Other Home Assistant trigger platforms -- including `time`,
|
||||
`time_pattern`, `sun`, `zone`, `calendar`, `webhook`, `tag`, `device` and
|
||||
`mqtt` -- are **not** supported.
|
||||
|
||||
@@ -751,6 +778,14 @@ triggers:
|
||||
- 'menu.style'
|
||||
- trigger: display_mode
|
||||
display_mode: single
|
||||
- trigger: event
|
||||
event_type:
|
||||
- zha_event
|
||||
- deconz_event
|
||||
event_data:
|
||||
command: press
|
||||
context:
|
||||
user_id: 581fca7fdc014b8b894519cc531f9a04
|
||||
- trigger: expand
|
||||
expand: true
|
||||
- trigger: fullscreen
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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()));
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 => {
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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 }),
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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 }),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -44,6 +44,10 @@ export class CardElementManager {
|
||||
return this._element;
|
||||
}
|
||||
|
||||
public isConnected(): boolean {
|
||||
return this._element.isConnected;
|
||||
}
|
||||
|
||||
public scrollReset(): void {
|
||||
this._scrollCallback();
|
||||
}
|
||||
@@ -160,7 +164,8 @@ export class CardElementManager {
|
||||
|
||||
this._api.getIssueManager().resume();
|
||||
|
||||
// Make sure reconnections call the initialization code.
|
||||
// A reconnected card (e.g. after HA rebuilt it on restart) won't re-render
|
||||
// on its own; request one so it re-initializes and shows current state.
|
||||
this._element.requestUpdate();
|
||||
}
|
||||
|
||||
|
||||
@@ -122,7 +122,7 @@ export class CardController
|
||||
private _expandManager = new ExpandManager(this);
|
||||
private _foldersManager = new FoldersManager(this);
|
||||
private _fullscreenManager = new FullscreenManager(this);
|
||||
private _hassManager = new HASSManager(this);
|
||||
private _hassManager: HASSManager;
|
||||
private _initializationManager = new InitializationManager(this);
|
||||
private _interactionManager = new InteractionManager(this);
|
||||
private _keyboardStateManager = new KeyboardStateManager(this);
|
||||
@@ -133,7 +133,7 @@ export class CardController
|
||||
private _microphoneManager = new MicrophoneManager(this);
|
||||
private _notificationManager = new NotificationManager(this);
|
||||
private _pipManager = new PIPManager(this);
|
||||
private _issueManager = createIssueManager(this);
|
||||
private _issueManager: IssueManager;
|
||||
private _queryStringManager = new QueryStringManager(this);
|
||||
private _statusBarItemManager = new StatusBarItemManager(this);
|
||||
private _styleManager = new StyleManager(this);
|
||||
@@ -145,8 +145,13 @@ export class CardController
|
||||
host: CardHTMLElement,
|
||||
scrollCallback: ScrollCallback,
|
||||
menuToggleCallback: MenuToggleCallback,
|
||||
hassManager?: HASSManager,
|
||||
) {
|
||||
host.addController(this);
|
||||
this._hassManager = hassManager ?? new HASSManager(this);
|
||||
this._issueManager = createIssueManager(
|
||||
this,
|
||||
this._hassManager.getEventWatcher().getHealth(),
|
||||
);
|
||||
|
||||
this._cardElementManager = new CardElementManager(
|
||||
this,
|
||||
@@ -154,6 +159,16 @@ export class CardController
|
||||
scrollCallback,
|
||||
menuToggleCallback,
|
||||
);
|
||||
|
||||
// ConditionStateManager MUST be wired first so its `hass` is current before
|
||||
// any later listener fires. Otherwise StateWatcher could fire a
|
||||
// camera-trigger handler that writes back to ConditionStateManager, fanning
|
||||
// out to automations that still read a stale `hass`.
|
||||
this._hassManager.addListener((hass) =>
|
||||
this._conditionStateManager.setState({ hass }),
|
||||
);
|
||||
|
||||
host.addController(this);
|
||||
}
|
||||
|
||||
// *************************************************************************
|
||||
|
||||
@@ -1,55 +1,75 @@
|
||||
import { HassEvent } from 'home-assistant-js-websocket';
|
||||
import { HomeAssistant } from '../../ha/types';
|
||||
import { KeyedSubscriptionManager } from '../../utils/concurrency/keyed-subscription-manager';
|
||||
import {
|
||||
SubscriptionHealthInterface,
|
||||
SubscriptionHealthMonitor,
|
||||
} from '../../ha/connection/subscription-health-monitor';
|
||||
import { HASSConnectionSubscriptionManager } from '../../ha/connection/subscription-manager';
|
||||
import { HASSSource } from '../../ha/source';
|
||||
|
||||
export interface EventSubscriptionRequest {
|
||||
event_type: string;
|
||||
callback: (data: unknown) => void;
|
||||
callback: (event: HassEvent) => void;
|
||||
|
||||
// Optional payload filter. Receives the event's `data`; if it returns false
|
||||
// the event is dropped for this request.
|
||||
matcher?: (data: unknown) => boolean;
|
||||
// Optional filter receiving the full event so callers can match on payload
|
||||
// (`event.data`) and/or context (`event.context`). Returning false drops the
|
||||
// event for this request.
|
||||
matcher?: (event: HassEvent) => boolean;
|
||||
}
|
||||
|
||||
export interface EventWatcherSubscriptionInterface {
|
||||
subscribe(hass: HomeAssistant, request: EventSubscriptionRequest): Promise<void>;
|
||||
unsubscribe(request: EventSubscriptionRequest): Promise<void>;
|
||||
subscribe(request: EventSubscriptionRequest): void;
|
||||
unsubscribe(request: EventSubscriptionRequest): void;
|
||||
getHealth(): SubscriptionHealthInterface<string>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribes to HA bus events via the WebSocket connection. Refcounted per
|
||||
* `event_type`: the first subscriber for a type opens the WS subscription, the
|
||||
* last to unsubscribe tears it down. Each fired event is fanned out to every
|
||||
* registered request whose `event_type` matches and whose `matcher` accepts the
|
||||
* payload.
|
||||
* Subscribes to HA bus events via the WebSocket connection. Thin wrapper over
|
||||
* `HASSConnectionSubscriptionManager` (connection-era lifecycle, refcounting,
|
||||
* retry budgets, stale-callback guards): keys by `event_type`, runs each
|
||||
* request's optional matcher before fan-out.
|
||||
*/
|
||||
export class EventWatcher implements EventWatcherSubscriptionInterface {
|
||||
private _subscriptions = new KeyedSubscriptionManager<
|
||||
string,
|
||||
EventSubscriptionRequest
|
||||
>((request) => request.event_type);
|
||||
private _manager: HASSConnectionSubscriptionManager<string, EventSubscriptionRequest>;
|
||||
private _health: SubscriptionHealthMonitor<string, EventSubscriptionRequest>;
|
||||
|
||||
public async subscribe(
|
||||
hass: HomeAssistant,
|
||||
request: EventSubscriptionRequest,
|
||||
): Promise<void> {
|
||||
await this._subscriptions.subscribe(request, () =>
|
||||
hass.connection.subscribeEvents<HassEvent>(
|
||||
(event) => this._receiveEvent(event),
|
||||
request.event_type,
|
||||
),
|
||||
constructor(source: HASSSource) {
|
||||
this._manager = new HASSConnectionSubscriptionManager(
|
||||
(request) => request.event_type,
|
||||
source,
|
||||
);
|
||||
this._health = new SubscriptionHealthMonitor((request) =>
|
||||
this._manager.retry(request),
|
||||
);
|
||||
}
|
||||
|
||||
public async unsubscribe(request: EventSubscriptionRequest): Promise<void> {
|
||||
await this._subscriptions.unsubscribe(request);
|
||||
public subscribe(request: EventSubscriptionRequest): void {
|
||||
this._manager.subscribe(
|
||||
request,
|
||||
(connection, liveness) =>
|
||||
connection.subscribeEvents<HassEvent>((event) => {
|
||||
if (!liveness.isConnected()) {
|
||||
return;
|
||||
}
|
||||
this._dispatch(event);
|
||||
}, request.event_type),
|
||||
(status) => this._health.update(status),
|
||||
);
|
||||
}
|
||||
|
||||
private _receiveEvent(event: HassEvent): void {
|
||||
for (const request of this._subscriptions.getRequestsForKey(event.event_type)) {
|
||||
if (!request.matcher || request.matcher(event.data)) {
|
||||
request.callback(event.data);
|
||||
public unsubscribe(request: EventSubscriptionRequest): void {
|
||||
this._manager.unsubscribe(request);
|
||||
}
|
||||
|
||||
public getHealth(): SubscriptionHealthInterface<string> {
|
||||
return this._health;
|
||||
}
|
||||
|
||||
private _dispatch(event: HassEvent): void {
|
||||
for (const request of this._manager.getRequestsForKey(event.event_type)) {
|
||||
if (request.matcher && !request.matcher(event)) {
|
||||
continue;
|
||||
}
|
||||
request.callback(event);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,19 +1,26 @@
|
||||
import { STATE_RUNNING } from 'home-assistant-js-websocket';
|
||||
import { isHassReady } from '../../ha/is-hass-ready';
|
||||
import { HASSListener, HASSUnlistenCallback } from '../../ha/source';
|
||||
import { HomeAssistant } from '../../ha/types';
|
||||
import { log } from '../../utils/debug';
|
||||
import { InitializationAspect } from '../initialization-manager';
|
||||
import { CardHASSAPI } from '../types';
|
||||
import { EventWatcher, EventWatcherSubscriptionInterface } from './event-watcher';
|
||||
import { StateWatcher, StateWatcherSubscriptionInterface } from './state-watcher';
|
||||
import { HASSManagerReadonlyInterface } from './types';
|
||||
|
||||
export class HASSManager {
|
||||
export class HASSManager implements HASSManagerReadonlyInterface {
|
||||
private _hass: HomeAssistant | null = null;
|
||||
private _api: CardHASSAPI;
|
||||
private _stateWatcher: StateWatcher = new StateWatcher();
|
||||
private _eventWatcher: EventWatcher = new EventWatcher();
|
||||
|
||||
private _hassListeners = new Set<HASSListener>();
|
||||
|
||||
private _stateWatcher: StateWatcherSubscriptionInterface;
|
||||
private _eventWatcher: EventWatcherSubscriptionInterface;
|
||||
|
||||
constructor(api: CardHASSAPI) {
|
||||
this._api = api;
|
||||
this._stateWatcher = new StateWatcher(this);
|
||||
this._eventWatcher = new EventWatcher(this);
|
||||
}
|
||||
|
||||
public getHASS(): HomeAssistant | null {
|
||||
@@ -32,20 +39,22 @@ export class HASSManager {
|
||||
return this._eventWatcher;
|
||||
}
|
||||
|
||||
public addListener(listener: HASSListener): HASSUnlistenCallback {
|
||||
this._hassListeners.add(listener);
|
||||
return () => {
|
||||
this._hassListeners.delete(listener);
|
||||
};
|
||||
}
|
||||
|
||||
public setHASS(hass?: HomeAssistant | null): void {
|
||||
// When HA transitions from "not ready" to "ready" (WebSocket reconnected
|
||||
// AND all integrations finished loading), reinitialize cameras and the
|
||||
// view. This is necessary because event subscriptions (e.g. Frigate
|
||||
// WebSocket subscriptions via hass.connection.subscribeMessage) are tied to
|
||||
// the old connection and are lost when it drops. Without reinitialization,
|
||||
// triggers and thumbnail updates stop working.
|
||||
//
|
||||
// We deliberately wait for hass.config.state === STATE_RUNNING rather than
|
||||
// just hass.connected, because HA exposes the WebSocket before integrations
|
||||
// have finished loading. Triggering re-init too early would race against
|
||||
// integration startup and fail with "Unknown command" on
|
||||
// integration-specific WS calls.
|
||||
if (this._hass && !this._isReady(this._hass) && this._isReady(hass)) {
|
||||
// When HA goes from "not ready" to "ready" (WebSocket reconnected AND all
|
||||
// integrations finished loading), rebuild cameras and the view from
|
||||
// scratch: the available entities may have changed while it was down.
|
||||
const becameReady = !!this._hass && !isHassReady(this._hass) && isHassReady(hass);
|
||||
|
||||
if (becameReady) {
|
||||
// Tear cameras down before the listeners below see the new hass,
|
||||
// otherwise they would briefly rebuild against the old entities.
|
||||
log(
|
||||
this._api.getConfigManager().getCardWideConfig(),
|
||||
'Advanced Camera Card: HA fully ready, reinitializing...',
|
||||
@@ -66,17 +75,15 @@ export class HASSManager {
|
||||
const oldHass = this._hass;
|
||||
this._hass = hass;
|
||||
|
||||
this._api.getConditionStateManager().setState({
|
||||
hass: this._hass,
|
||||
});
|
||||
// Notify each listener of the new hass, in subscription order.
|
||||
for (const listener of this._hassListeners) {
|
||||
listener(hass, oldHass);
|
||||
}
|
||||
|
||||
// Theme may depend on HASS.
|
||||
this._api.getStyleManager().applyTheme();
|
||||
|
||||
this._stateWatcher.setHASS(oldHass, hass);
|
||||
}
|
||||
|
||||
private _isReady(hass?: HomeAssistant | null): boolean {
|
||||
return !!hass?.connected && hass.config?.state === STATE_RUNNING;
|
||||
// Try to (re)initialize whenever hass changes. Initialization normally
|
||||
// happens on the next re-render, but the teardown above can leave a
|
||||
// reconnected card without a re-render, so it could stay stuck
|
||||
// uninitialized. Harmless no-op when already initialized or not yet ready.
|
||||
this._api.getInitializationManager().triggerInitialization();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,21 +1,53 @@
|
||||
import { getHassDifferences } from '../../ha/get-hass-differences';
|
||||
import { HASSSource, HASSUnlistenCallback } from '../../ha/source';
|
||||
import { HassStateDifference, HomeAssistant } from '../../ha/types';
|
||||
|
||||
type StateWatcherCallback = (difference: HassStateDifference) => void;
|
||||
|
||||
export interface StateWatcherSubscriptionInterface {
|
||||
subscribe(callback: StateWatcherCallback, entityIDs: string[]): void;
|
||||
subscribe(callback: StateWatcherCallback, entityIDs: string[]): boolean;
|
||||
unsubscribe(callback: StateWatcherCallback): void;
|
||||
}
|
||||
|
||||
export class StateWatcher implements StateWatcherSubscriptionInterface {
|
||||
private _source: HASSSource;
|
||||
private _watcherCallbacks = new Map<StateWatcherCallback, string[]>();
|
||||
private _unlisten: HASSUnlistenCallback | null = null;
|
||||
|
||||
public setHASS(oldHass: HomeAssistant | null, hass: HomeAssistant): void {
|
||||
constructor(source: HASSSource) {
|
||||
this._source = source;
|
||||
}
|
||||
|
||||
public subscribe(callback: StateWatcherCallback, entityIDs: string[]): boolean {
|
||||
if (!entityIDs.length) {
|
||||
return false;
|
||||
}
|
||||
const wasEmpty = this._watcherCallbacks.size === 0;
|
||||
if (this._watcherCallbacks.has(callback)) {
|
||||
this._watcherCallbacks.get(callback)?.push(...entityIDs);
|
||||
} else {
|
||||
this._watcherCallbacks.set(callback, entityIDs);
|
||||
}
|
||||
if (wasEmpty) {
|
||||
this._unlisten = this._source.addListener((hass, oldHass) =>
|
||||
this._onHASS(hass, oldHass),
|
||||
);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
public unsubscribe(callback: StateWatcherCallback): void {
|
||||
this._watcherCallbacks.delete(callback);
|
||||
if (this._watcherCallbacks.size === 0 && this._unlisten) {
|
||||
this._unlisten();
|
||||
this._unlisten = null;
|
||||
}
|
||||
}
|
||||
|
||||
private _onHASS(hass: HomeAssistant, oldHass: HomeAssistant | null): void {
|
||||
if (!oldHass) {
|
||||
return;
|
||||
}
|
||||
|
||||
for (const [callback, entityIDs] of this._watcherCallbacks.entries()) {
|
||||
const differences = getHassDifferences(hass, oldHass, entityIDs, {
|
||||
stateOnly: true,
|
||||
@@ -26,26 +58,4 @@ export class StateWatcher implements StateWatcherSubscriptionInterface {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Calls callback when the state of any of the entities changes. The callback is
|
||||
* called with the state difference of the first entity that changed.
|
||||
* @param callback The callback.
|
||||
* @param entityIDs An array of entity IDs to watch.
|
||||
*/
|
||||
public subscribe(callback: StateWatcherCallback, entityIDs: string[]): boolean {
|
||||
if (!entityIDs.length) {
|
||||
return false;
|
||||
}
|
||||
if (this._watcherCallbacks.has(callback)) {
|
||||
this._watcherCallbacks.get(callback)?.push(...entityIDs);
|
||||
} else {
|
||||
this._watcherCallbacks.set(callback, entityIDs);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
public unsubscribe(callback: StateWatcherCallback): void {
|
||||
this._watcherCallbacks.delete(callback);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
import { HASSSource } from '../../ha/source';
|
||||
import { EventWatcherSubscriptionInterface } from './event-watcher';
|
||||
import { StateWatcherSubscriptionInterface } from './state-watcher';
|
||||
|
||||
export interface HASSManagerReadonlyInterface extends HASSSource {
|
||||
getStateWatcher(): StateWatcherSubscriptionInterface;
|
||||
getEventWatcher(): EventWatcherSubscriptionInterface;
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { STATE_RUNNING } from 'home-assistant-js-websocket';
|
||||
import PQueue from 'p-queue';
|
||||
import { isHassReady } from '../ha/is-hass-ready';
|
||||
import { sideLoadHomeAssistantElements } from '../ha/side-load-ha-elements';
|
||||
import { loadLanguages } from '../localize/localize';
|
||||
import { errorToConsole } from '../utils/basic';
|
||||
@@ -70,6 +71,32 @@ export class InitializationManager {
|
||||
]);
|
||||
}
|
||||
|
||||
// The one place that decides whether to (re)start mandatory initialization,
|
||||
// so callers don't re-check the conditions themselves. Called on every render
|
||||
// (from the card's shouldUpdate) and whenever hass changes (from
|
||||
// HASSManager); a reconnect or a cleared issue reaches it by causing a
|
||||
// render.
|
||||
public triggerInitialization(): void {
|
||||
if (!this._shouldInitializeMandatory()) {
|
||||
return;
|
||||
}
|
||||
/* async */ this.initializeMandatory();
|
||||
}
|
||||
|
||||
private _shouldInitializeMandatory(): boolean {
|
||||
return (
|
||||
this._api.getConfigManager().hasConfig() &&
|
||||
this._api.getCardElementManager().isConnected() &&
|
||||
isHassReady(this._api.getHASSManager().getHASS()) &&
|
||||
!this.isInitializedMandatory() &&
|
||||
// Don't start while a full-card issue (e.g. the "Home Assistant is
|
||||
// starting" notice) is shown: each initialization step aborts as soon as
|
||||
// it sees one, so an attempt now would be wasted. The card tries again
|
||||
// once the issue clears.
|
||||
!this._api.getIssueManager().getStateManager().hasFullCardIssue()
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize the hard requirements for rendering anything.
|
||||
* @returns `true` if card rendering can continue.
|
||||
|
||||
@@ -1,16 +1,21 @@
|
||||
import { SubscriptionHealthInterface } from '../../ha/connection/subscription-health-monitor';
|
||||
import { CardIssueManagerAPI } from '../types';
|
||||
import { IssueManager } from './issue-manager';
|
||||
import { ConfigErrorIssue } from './issues/config-error';
|
||||
import { ConfigUpgradeIssue } from './issues/config-upgrade';
|
||||
import { ConfigUpgradeFailureIssue } from './issues/config-upgrade-failure';
|
||||
import { ConnectionIssue } from './issues/connection';
|
||||
import { EventSubscriptionIssue } from './issues/event-subscription';
|
||||
import { InitializationIssue } from './issues/initialization';
|
||||
import { LegacyResourceIssue } from './issues/legacy-resource';
|
||||
import { MediaLoadIssue } from './issues/media-load';
|
||||
import { MediaQueryIssue } from './issues/media-query';
|
||||
import { ViewIncompatibleIssue } from './issues/view-incompatible';
|
||||
|
||||
export const createIssueManager = (api: CardIssueManagerAPI): IssueManager => {
|
||||
export const createIssueManager = (
|
||||
api: CardIssueManagerAPI,
|
||||
eventSubscriptionHealth: SubscriptionHealthInterface<string>,
|
||||
): IssueManager => {
|
||||
const manager = new IssueManager(api);
|
||||
const changeCallback = () => manager.evaluate();
|
||||
|
||||
@@ -23,6 +28,7 @@ export const createIssueManager = (api: CardIssueManagerAPI): IssueManager => {
|
||||
manager.addIssue(new ConfigUpgradeFailureIssue(api));
|
||||
manager.addIssue(new ViewIncompatibleIssue(api));
|
||||
manager.addIssue(new ConnectionIssue());
|
||||
manager.addIssue(new EventSubscriptionIssue(eventSubscriptionHealth, changeCallback));
|
||||
manager.addIssue(new InitializationIssue(api));
|
||||
manager.addIssue(new LegacyResourceIssue(changeCallback));
|
||||
manager.addIssue(new MediaQueryIssue(api));
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { IssueTriggerContext } from 'issue';
|
||||
import { ConditionStateChange } from '../../condition-trigger/conditions/types';
|
||||
import { isActionAllowedBasedOnInteractionState } from '../../utils/interaction-mode';
|
||||
import { Timer } from '../../utils/timer';
|
||||
import { RetryTimer } from '../../utils/retry-timer';
|
||||
import { CardIssueManagerAPI } from '../types';
|
||||
import { IssueStateManager } from './state-manager';
|
||||
import { Issue, IssueKey, IssueReadOnlyState, IssueTriggerContextKey } from './types';
|
||||
@@ -11,21 +11,21 @@ import { Issue, IssueKey, IssueReadOnlyState, IssueTriggerContextKey } from './t
|
||||
// lower-level recovery has had a chance to work, not in parallel with it.
|
||||
export const RETRY_EXPONENTIAL_BASE_SECONDS = 30;
|
||||
export const RETRY_EXPONENTIAL_MAX_SECONDS = 600;
|
||||
const RETRY_EXPONENTIAL_JITTER_MIN = 0.5;
|
||||
const RETRY_EXPONENTIAL_JITTER_MAX = 1.0;
|
||||
|
||||
// Wraps the passive IssueStateManager with reaction logic. A single
|
||||
// condition-state listener drives everything: it runs one-shot static
|
||||
// detection when mandatory-init completes (`initialized` transitions to
|
||||
// true), then evaluates dynamic issues on every subsequent state change,
|
||||
// schedules retries, and updates the card. Full-card issues are rendered by
|
||||
// card.ts via getStateManager().getFullCardIssue(). Non-full-card issue
|
||||
// notifications are shown on demand via showNotification().
|
||||
// condition-state listener drives everything: it runs one-shot static detection
|
||||
// when mandatory-init completes (`initialized` transitions to true), then
|
||||
// evaluates dynamic issues on every subsequent state change, schedules retries,
|
||||
// and updates the card. Full-card issues are rendered by card.ts via
|
||||
// getStateManager().getFullCardIssue(). Non-full-card issue notifications are
|
||||
// shown on demand via showNotification().
|
||||
export class IssueManager {
|
||||
private _api: CardIssueManagerAPI;
|
||||
private _stateManager = new IssueStateManager();
|
||||
private _retryTimer = new Timer();
|
||||
private _retryAttempt = 0;
|
||||
private _retryTimer = new RetryTimer({
|
||||
baseSeconds: RETRY_EXPONENTIAL_BASE_SECONDS,
|
||||
maxSeconds: RETRY_EXPONENTIAL_MAX_SECONDS,
|
||||
});
|
||||
private _suspended = false;
|
||||
|
||||
// Reentrancy guard: evaluate() calls setState() on the condition state
|
||||
@@ -89,6 +89,9 @@ export class IssueManager {
|
||||
issues: this._stateManager.getIssuePresence(),
|
||||
})
|
||||
) {
|
||||
// Re-render to show the change. The re-render also re-attempts
|
||||
// initialization, which matters when a blocking notice like "Home
|
||||
// Assistant is starting" clears and the card can finally initialize.
|
||||
this._api.getCardElementManager().update();
|
||||
}
|
||||
|
||||
@@ -106,7 +109,7 @@ export class IssueManager {
|
||||
// user action resets the backoff schedule.
|
||||
public retry(key: IssueKey, force?: boolean): void {
|
||||
this._stateManager.retry(key, force);
|
||||
this._retryTimer.stop();
|
||||
this._retryTimer.reset();
|
||||
this.evaluate();
|
||||
}
|
||||
|
||||
@@ -140,7 +143,7 @@ export class IssueManager {
|
||||
// loading timeout). Evaluation resumes on resume().
|
||||
public suspend(): void {
|
||||
this._suspended = true;
|
||||
this._retryTimer.stop();
|
||||
this._retryTimer.cancel();
|
||||
this._stateManager.suspend();
|
||||
}
|
||||
|
||||
@@ -150,7 +153,7 @@ export class IssueManager {
|
||||
}
|
||||
|
||||
public destroy(): void {
|
||||
this._retryTimer.stop();
|
||||
this._retryTimer.cancel();
|
||||
this._stateManager.destroy();
|
||||
}
|
||||
|
||||
@@ -178,8 +181,7 @@ export class IssueManager {
|
||||
|
||||
private _scheduleRetryIfNeeded(): void {
|
||||
if (!this._stateManager.needsRetry()) {
|
||||
this._retryTimer.stop();
|
||||
this._retryAttempt = 0;
|
||||
this._retryTimer.reset();
|
||||
return;
|
||||
}
|
||||
if (this._retryTimer.isRunning()) {
|
||||
@@ -188,48 +190,50 @@ export class IssueManager {
|
||||
|
||||
const config = this._api.getConfigManager().getConfig();
|
||||
if (!config) {
|
||||
this._retryAttempt = 0;
|
||||
return;
|
||||
}
|
||||
const delaySeconds = this._nextRetryDelaySeconds(config.view.issues.retry_seconds);
|
||||
if (delaySeconds === null) {
|
||||
this._retryAttempt = 0;
|
||||
this._retryTimer.reset();
|
||||
return;
|
||||
}
|
||||
|
||||
this._retryTimer.start(delaySeconds, () => {
|
||||
if (!this._stateManager.needsRetry()) {
|
||||
this._retryAttempt = 0;
|
||||
return;
|
||||
}
|
||||
if (this._isScheduledRetryAllowed()) {
|
||||
this._stateManager.retry();
|
||||
this._retryAttempt++;
|
||||
// evaluate() re-arms the timer via _scheduleRetryIfNeeded.
|
||||
this.evaluate();
|
||||
} else {
|
||||
// Retry was gated (e.g. user interaction). This isn't a failed attempt
|
||||
// so don't increment — re-arm at the same delay.
|
||||
this._scheduleRetryIfNeeded();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private _nextRetryDelaySeconds(retryConfig: 'auto' | number): number | null {
|
||||
if (typeof retryConfig === 'number') {
|
||||
return retryConfig === 0 ? null : retryConfig;
|
||||
const retryConfig = config.view.issues.retry_seconds;
|
||||
if (retryConfig === 0) {
|
||||
this._retryTimer.reset();
|
||||
return;
|
||||
}
|
||||
|
||||
// 'auto': exponential backoff, capped, with jitter to avoid thundering-herd
|
||||
// when multiple cards retry the same backend in lockstep.
|
||||
const exp = Math.min(
|
||||
RETRY_EXPONENTIAL_MAX_SECONDS,
|
||||
RETRY_EXPONENTIAL_BASE_SECONDS * 2 ** this._retryAttempt,
|
||||
this._retryTimer.setOptions(
|
||||
retryConfig === 'auto'
|
||||
? {
|
||||
baseSeconds: RETRY_EXPONENTIAL_BASE_SECONDS,
|
||||
maxSeconds: RETRY_EXPONENTIAL_MAX_SECONDS,
|
||||
}
|
||||
: retryConfig,
|
||||
);
|
||||
|
||||
// Schedule without advancing: the backoff only escalates if the retry
|
||||
// actually runs (via the explicit advance() below), not when it's gated.
|
||||
this._retryTimer.schedule(
|
||||
() => {
|
||||
if (!this._stateManager.needsRetry()) {
|
||||
this._retryTimer.reset();
|
||||
return;
|
||||
}
|
||||
if (this._isScheduledRetryAllowed()) {
|
||||
this._stateManager.retry();
|
||||
|
||||
// This attempt counts: advance the backoff so the next schedule
|
||||
// (re-armed by evaluate() via _scheduleRetryIfNeeded) uses a longer
|
||||
// delay. For static-delay mode (base = max, no jitter) advancing is
|
||||
// observable in `getAttempts()` but doesn't change the next delay.
|
||||
this._retryTimer.advance();
|
||||
this.evaluate();
|
||||
} else {
|
||||
// Retry was gated (e.g. user interaction). Not a failed attempt; the
|
||||
// backoff stays put and we re-arm at the same delay.
|
||||
this._scheduleRetryIfNeeded();
|
||||
}
|
||||
},
|
||||
{ advance: false },
|
||||
);
|
||||
const jitter =
|
||||
RETRY_EXPONENTIAL_JITTER_MIN +
|
||||
Math.random() * (RETRY_EXPONENTIAL_JITTER_MAX - RETRY_EXPONENTIAL_JITTER_MIN);
|
||||
return exp * jitter;
|
||||
}
|
||||
|
||||
private _isScheduledRetryAllowed(): boolean {
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
import { Notification } from '../../../config/schema/actions/types';
|
||||
import { SubscriptionHealthInterface } from '../../../ha/connection/subscription-health-monitor';
|
||||
import { UnlistenCallback } from '../../../health';
|
||||
import { localize } from '../../../localize/localize';
|
||||
import { createRetryControl } from '../retry-control';
|
||||
import { Issue, IssueDescription } from '../types';
|
||||
|
||||
const ISSUE_ICON = 'mdi:lan-disconnect';
|
||||
|
||||
/**
|
||||
* Surfaces persistent HA event-subscription failures (from the EventWatcher's
|
||||
* health monitor) as a non-full-card notification listing the failing event
|
||||
* types. Self-detects by observing the health monitor and asking the
|
||||
* IssueManager to re-evaluate on change.
|
||||
*
|
||||
* Detection scope: the transport reports `failing` only when a subscribe
|
||||
* attempt rejects (initial subscribe, era replay, or retry) -- there is no
|
||||
* heartbeat on an established subscription, so this catches subscribe-time
|
||||
* failures, not a subscription that goes silently dead after subscribing.
|
||||
*
|
||||
* Recovery is the subscription manager's own forever-retry loop, so this issue
|
||||
* does NOT implement `needsRetry()` (no IssueManager-scheduled retry that would
|
||||
* race the transport loop). The notification's Retry button is user-forced
|
||||
* only: it re-drives the failing subscriptions immediately via the monitor.
|
||||
*/
|
||||
export class EventSubscriptionIssue implements Issue {
|
||||
public readonly key = 'event_subscription' as const;
|
||||
|
||||
private _health: SubscriptionHealthInterface<string>;
|
||||
private _unsubscribe: UnlistenCallback;
|
||||
|
||||
constructor(health: SubscriptionHealthInterface<string>, changeCallback: () => void) {
|
||||
this._health = health;
|
||||
this._unsubscribe = health.addListener(changeCallback);
|
||||
}
|
||||
|
||||
public hasIssue(): boolean {
|
||||
return this._health.getFailures().length > 0;
|
||||
}
|
||||
|
||||
public getIssue(): IssueDescription | null {
|
||||
if (!this.hasIssue()) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
icon: ISSUE_ICON,
|
||||
severity: 'medium',
|
||||
notification: this._buildNotification(),
|
||||
};
|
||||
}
|
||||
|
||||
public getNotification(): Notification | null {
|
||||
return this.getIssue()?.notification ?? null;
|
||||
}
|
||||
|
||||
public retry(): boolean {
|
||||
this._health.retry();
|
||||
return true;
|
||||
}
|
||||
|
||||
public destroy(): void {
|
||||
this._unsubscribe();
|
||||
}
|
||||
|
||||
private _buildNotification(): Notification {
|
||||
const eventTypes = this._health
|
||||
.getFailures()
|
||||
.map((failure) => failure.key)
|
||||
.sort();
|
||||
return {
|
||||
heading: {
|
||||
text: localize('issues.event_subscription.heading'),
|
||||
icon: ISSUE_ICON,
|
||||
severity: 'medium',
|
||||
},
|
||||
body: { text: localize('issues.event_subscription.text') },
|
||||
metadata: eventTypes.map((eventType) => ({ text: eventType })),
|
||||
controls: [createRetryControl(this.key)],
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -143,6 +143,9 @@ export class IssueStateManager implements IssueReadOnlyState {
|
||||
}
|
||||
|
||||
public destroy(): void {
|
||||
for (const issue of this._issues.values()) {
|
||||
issue.destroy?.();
|
||||
}
|
||||
this.reset();
|
||||
this._issues.clear();
|
||||
this._loggedKeys.clear();
|
||||
|
||||
@@ -9,6 +9,7 @@ export type IssueKey =
|
||||
| 'config_upgrade'
|
||||
| 'config_upgrade_failure'
|
||||
| 'connection'
|
||||
| 'event_subscription'
|
||||
| 'initialization'
|
||||
| 'legacy_resource'
|
||||
| 'media_load'
|
||||
@@ -83,7 +84,10 @@ export interface Issue {
|
||||
// callers (e.g. notification control actions) invoke this directly.
|
||||
fix?(hass: HomeAssistant): Promise<boolean>;
|
||||
|
||||
// Reset internal state (clear errors, stop timers, etc.).
|
||||
// Clear transient state (errors, timers) while the issue stays registered and
|
||||
// able to re-activate. Runs repeatedly during the card's life (e.g. when the
|
||||
// underlying problem recovers), so it must NOT release anything the issue
|
||||
// needs to keep working -- that belongs in `destroy()`.
|
||||
reset?(): void;
|
||||
|
||||
// Called when the card is detached. Issues with age-based timers (e.g.
|
||||
@@ -94,4 +98,10 @@ export interface Issue {
|
||||
// evaluate(), so any timer that should restart is re-armed via
|
||||
// detectDynamic against the current condition state.
|
||||
suspend?(): void;
|
||||
|
||||
// Release external resources (e.g. a listener registered on another manager)
|
||||
// at end of life. Called once when the IssueManager is destroyed -- unlike
|
||||
// `reset()`, which runs repeatedly while the issue is still live, this is the
|
||||
// final teardown.
|
||||
destroy?(): void;
|
||||
}
|
||||
|
||||
+1
-9
@@ -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':
|
||||
|
||||
@@ -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 }>;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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>;
|
||||
@@ -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);
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -7,21 +7,18 @@ import {
|
||||
CameraQuery,
|
||||
QueryType,
|
||||
} from '../../../src/camera-manager/types';
|
||||
import { EventWatcherSubscriptionInterface } from '../../../src/card-controller/hass/event-watcher';
|
||||
import { StateWatcherSubscriptionInterface } from '../../../src/card-controller/hass/state-watcher';
|
||||
import { BROWSE_MEDIA_CACHE_SECONDS } from '../../../src/ha/browse-media/types';
|
||||
import { BrowseMediaWalker } from '../../../src/ha/browse-media/walker';
|
||||
import { ResolvedMediaCache } from '../../../src/ha/resolved-media';
|
||||
import { QuerySource } from '../../../src/query-source';
|
||||
import { ViewMedia } from '../../../src/view/item';
|
||||
import { EntityRegistryManagerMock } from '../../ha/registry/entity/mock';
|
||||
import { createCameraConfig, createHASS } from '../../test-utils';
|
||||
import { createCameraConfig, createHASS, createHASSManager } from '../../test-utils';
|
||||
|
||||
const createEngine = (): BrowseMediaCameraManagerEngine => {
|
||||
return new BrowseMediaCameraManagerEngine(
|
||||
new EntityRegistryManagerMock(),
|
||||
mock<StateWatcherSubscriptionInterface>(),
|
||||
mock<EventWatcherSubscriptionInterface>(),
|
||||
createHASSManager(),
|
||||
new BrowseMediaWalker(),
|
||||
new ResolvedMediaCache(),
|
||||
new CameraManagerRequestCache(),
|
||||
|
||||
+150
-218
@@ -13,6 +13,8 @@ import {
|
||||
createCameraConfig,
|
||||
createCapabilities,
|
||||
createHASS,
|
||||
createHASSManager,
|
||||
createHASSEvent,
|
||||
createInitializedCamera,
|
||||
createRegistryEntity,
|
||||
createStateEntity,
|
||||
@@ -25,10 +27,7 @@ describe('Camera', () => {
|
||||
const config = createCameraConfig();
|
||||
const camera = new Camera(
|
||||
config,
|
||||
new GenericCameraManagerEngine(
|
||||
mock<StateWatcherSubscriptionInterface>(),
|
||||
mock<EventWatcherSubscriptionInterface>(),
|
||||
),
|
||||
new GenericCameraManagerEngine(createHASSManager()),
|
||||
);
|
||||
expect(camera.getConfig()).toBe(config);
|
||||
});
|
||||
@@ -38,10 +37,7 @@ describe('Camera', () => {
|
||||
const capabilities = createCapabilities();
|
||||
const camera = await createInitializedCamera(
|
||||
createCameraConfig(),
|
||||
new GenericCameraManagerEngine(
|
||||
mock<StateWatcherSubscriptionInterface>(),
|
||||
mock<EventWatcherSubscriptionInterface>(),
|
||||
),
|
||||
new GenericCameraManagerEngine(createHASSManager()),
|
||||
capabilities,
|
||||
);
|
||||
expect(camera.getCapabilities()).toBe(capabilities);
|
||||
@@ -50,20 +46,14 @@ describe('Camera', () => {
|
||||
it('when unpopulated', async () => {
|
||||
const camera = new Camera(
|
||||
createCameraConfig(),
|
||||
new GenericCameraManagerEngine(
|
||||
mock<StateWatcherSubscriptionInterface>(),
|
||||
mock<EventWatcherSubscriptionInterface>(),
|
||||
),
|
||||
new GenericCameraManagerEngine(createHASSManager()),
|
||||
);
|
||||
expect(camera.getCapabilities()).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
it('should get engine', async () => {
|
||||
const engine = new GenericCameraManagerEngine(
|
||||
mock<StateWatcherSubscriptionInterface>(),
|
||||
mock<EventWatcherSubscriptionInterface>(),
|
||||
);
|
||||
const engine = new GenericCameraManagerEngine(createHASSManager());
|
||||
const camera = new Camera(createCameraConfig(), engine);
|
||||
expect(camera.getEngine()).toBe(engine);
|
||||
});
|
||||
@@ -71,10 +61,7 @@ describe('Camera', () => {
|
||||
it('should set and get id', async () => {
|
||||
const camera = new Camera(
|
||||
createCameraConfig(),
|
||||
new GenericCameraManagerEngine(
|
||||
mock<StateWatcherSubscriptionInterface>(),
|
||||
mock<EventWatcherSubscriptionInterface>(),
|
||||
),
|
||||
new GenericCameraManagerEngine(createHASSManager()),
|
||||
);
|
||||
camera.setID('foo');
|
||||
expect(camera.getID()).toBe('foo');
|
||||
@@ -84,10 +71,7 @@ describe('Camera', () => {
|
||||
it('should throw without id', async () => {
|
||||
const camera = new Camera(
|
||||
createCameraConfig(),
|
||||
new GenericCameraManagerEngine(
|
||||
mock<StateWatcherSubscriptionInterface>(),
|
||||
mock<EventWatcherSubscriptionInterface>(),
|
||||
),
|
||||
new GenericCameraManagerEngine(createHASSManager()),
|
||||
);
|
||||
expect(() => camera.getID()).toThrowError(
|
||||
'Could not determine camera id for the following ' +
|
||||
@@ -107,17 +91,12 @@ describe('Camera', () => {
|
||||
entities: ['camera.foo'],
|
||||
},
|
||||
}),
|
||||
new GenericCameraManagerEngine(
|
||||
mock<StateWatcherSubscriptionInterface>(),
|
||||
mock<EventWatcherSubscriptionInterface>(),
|
||||
),
|
||||
new GenericCameraManagerEngine(createHASSManager()),
|
||||
);
|
||||
|
||||
const stateWatcher = mock<StateWatcherSubscriptionInterface>();
|
||||
await camera.initialize({
|
||||
hass: createHASS(),
|
||||
stateWatcher: stateWatcher,
|
||||
eventWatcher: mock<EventWatcherSubscriptionInterface>(),
|
||||
hassManager: createHASSManager({ stateWatcher }),
|
||||
capabilityOptions: { capabilities: createCapabilities({ trigger: true }) },
|
||||
});
|
||||
|
||||
@@ -130,6 +109,26 @@ describe('Camera', () => {
|
||||
expect(stateWatcher.unsubscribe).toBeCalled();
|
||||
});
|
||||
|
||||
it('should skip initialization when hass is unavailable', async () => {
|
||||
const camera = new Camera(
|
||||
createCameraConfig({
|
||||
triggers: {
|
||||
entities: ['camera.foo'],
|
||||
},
|
||||
}),
|
||||
new GenericCameraManagerEngine(createHASSManager()),
|
||||
);
|
||||
|
||||
const stateWatcher = mock<StateWatcherSubscriptionInterface>();
|
||||
await camera.initialize({
|
||||
hassManager: createHASSManager({ hass: null, stateWatcher }),
|
||||
capabilityOptions: { capabilities: createCapabilities({ trigger: true }) },
|
||||
});
|
||||
|
||||
expect(stateWatcher.subscribe).not.toBeCalled();
|
||||
expect(camera.getCapabilities()).toBeNull();
|
||||
});
|
||||
|
||||
it('should set capabilities and use go2rtc metadata endpoint', async () => {
|
||||
const camera = new Camera(
|
||||
createCameraConfig({
|
||||
@@ -138,18 +137,13 @@ describe('Camera', () => {
|
||||
stream: 'stream',
|
||||
},
|
||||
}),
|
||||
new GenericCameraManagerEngine(
|
||||
mock<StateWatcherSubscriptionInterface>(),
|
||||
mock<EventWatcherSubscriptionInterface>(),
|
||||
),
|
||||
new GenericCameraManagerEngine(createHASSManager()),
|
||||
);
|
||||
|
||||
vi.mocked(liveProviderSupports2WayAudio).mockResolvedValue(true);
|
||||
|
||||
await camera.initialize({
|
||||
hass: createHASS(),
|
||||
stateWatcher: mock<StateWatcherSubscriptionInterface>(),
|
||||
eventWatcher: mock<EventWatcherSubscriptionInterface>(),
|
||||
hassManager: createHASSManager(),
|
||||
});
|
||||
|
||||
expect(liveProviderSupports2WayAudio).toHaveBeenCalledWith(
|
||||
@@ -180,18 +174,13 @@ describe('Camera', () => {
|
||||
stream: 'stream',
|
||||
},
|
||||
}),
|
||||
new GenericCameraManagerEngine(
|
||||
mock<StateWatcherSubscriptionInterface>(),
|
||||
mock<EventWatcherSubscriptionInterface>(),
|
||||
),
|
||||
new GenericCameraManagerEngine(createHASSManager()),
|
||||
);
|
||||
|
||||
vi.mocked(liveProviderSupports2WayAudio).mockResolvedValue(false);
|
||||
|
||||
await camera.initialize({
|
||||
hass: createHASS(),
|
||||
stateWatcher: mock<StateWatcherSubscriptionInterface>(),
|
||||
eventWatcher: mock<EventWatcherSubscriptionInterface>(),
|
||||
hassManager: createHASSManager(),
|
||||
});
|
||||
|
||||
expect(camera.getCapabilities()?.has('2-way-audio')).toBe(false);
|
||||
@@ -206,18 +195,13 @@ describe('Camera', () => {
|
||||
metadata_fetch_timeout_seconds: 20,
|
||||
},
|
||||
}),
|
||||
new GenericCameraManagerEngine(
|
||||
mock<StateWatcherSubscriptionInterface>(),
|
||||
mock<EventWatcherSubscriptionInterface>(),
|
||||
),
|
||||
new GenericCameraManagerEngine(createHASSManager()),
|
||||
);
|
||||
|
||||
vi.mocked(liveProviderSupports2WayAudio).mockResolvedValue(true);
|
||||
|
||||
await camera.initialize({
|
||||
hass: createHASS(),
|
||||
stateWatcher: mock<StateWatcherSubscriptionInterface>(),
|
||||
eventWatcher: mock<EventWatcherSubscriptionInterface>(),
|
||||
hassManager: createHASSManager(),
|
||||
});
|
||||
|
||||
expect(liveProviderSupports2WayAudio).toHaveBeenCalledWith(
|
||||
@@ -240,10 +224,7 @@ describe('Camera', () => {
|
||||
live: true,
|
||||
},
|
||||
}),
|
||||
new GenericCameraManagerEngine(
|
||||
mock<StateWatcherSubscriptionInterface>(),
|
||||
mock<EventWatcherSubscriptionInterface>(),
|
||||
),
|
||||
new GenericCameraManagerEngine(createHASSManager()),
|
||||
);
|
||||
|
||||
vi.mocked(liveProviderSupports2WayAudio).mockResolvedValue(true);
|
||||
@@ -252,9 +233,7 @@ describe('Camera', () => {
|
||||
hass.config.components = ['hass_web_proxy'];
|
||||
|
||||
await camera.initialize({
|
||||
hass,
|
||||
stateWatcher: mock<StateWatcherSubscriptionInterface>(),
|
||||
eventWatcher: mock<EventWatcherSubscriptionInterface>(),
|
||||
hassManager: createHASSManager({ hass }),
|
||||
});
|
||||
|
||||
expect(liveProviderSupports2WayAudio).toHaveBeenCalledWith(
|
||||
@@ -281,10 +260,7 @@ describe('Camera', () => {
|
||||
createCameraConfig({
|
||||
proxy: { live: true },
|
||||
}),
|
||||
new GenericCameraManagerEngine(
|
||||
mock<StateWatcherSubscriptionInterface>(),
|
||||
mock<EventWatcherSubscriptionInterface>(),
|
||||
),
|
||||
new GenericCameraManagerEngine(createHASSManager()),
|
||||
);
|
||||
expect(camera.getLiveProxyConfig()).toEqual(
|
||||
expect.objectContaining({ enabled: true, enforce: true }),
|
||||
@@ -296,10 +272,7 @@ describe('Camera', () => {
|
||||
createCameraConfig({
|
||||
proxy: { media: true },
|
||||
}),
|
||||
new GenericCameraManagerEngine(
|
||||
mock<StateWatcherSubscriptionInterface>(),
|
||||
mock<EventWatcherSubscriptionInterface>(),
|
||||
),
|
||||
new GenericCameraManagerEngine(createHASSManager()),
|
||||
);
|
||||
expect(camera.getMediaProxyConfig()).toEqual(
|
||||
expect.objectContaining({ enabled: true, enforce: true }),
|
||||
@@ -312,10 +285,7 @@ describe('Camera', () => {
|
||||
live_provider: 'go2rtc',
|
||||
go2rtc: { url: 'http://go2rtc' },
|
||||
}),
|
||||
new GenericCameraManagerEngine(
|
||||
mock<StateWatcherSubscriptionInterface>(),
|
||||
mock<EventWatcherSubscriptionInterface>(),
|
||||
),
|
||||
new GenericCameraManagerEngine(createHASSManager()),
|
||||
);
|
||||
expect(camera.getLiveProxyConfig()).toEqual(
|
||||
expect.objectContaining({ enabled: true, enforce: false }),
|
||||
@@ -327,10 +297,7 @@ describe('Camera', () => {
|
||||
createCameraConfig({
|
||||
proxy: { media: 'auto' },
|
||||
}),
|
||||
new GenericCameraManagerEngine(
|
||||
mock<StateWatcherSubscriptionInterface>(),
|
||||
mock<EventWatcherSubscriptionInterface>(),
|
||||
),
|
||||
new GenericCameraManagerEngine(createHASSManager()),
|
||||
);
|
||||
expect(camera.getMediaProxyConfig()).toEqual(
|
||||
expect.objectContaining({ enabled: false, enforce: false }),
|
||||
@@ -344,16 +311,11 @@ describe('Camera', () => {
|
||||
force: ['2-way-audio'],
|
||||
},
|
||||
}),
|
||||
new GenericCameraManagerEngine(
|
||||
mock<StateWatcherSubscriptionInterface>(),
|
||||
mock<EventWatcherSubscriptionInterface>(),
|
||||
),
|
||||
new GenericCameraManagerEngine(createHASSManager()),
|
||||
);
|
||||
|
||||
await camera.initialize({
|
||||
hass: createHASS(),
|
||||
stateWatcher: mock<StateWatcherSubscriptionInterface>(),
|
||||
eventWatcher: mock<EventWatcherSubscriptionInterface>(),
|
||||
hassManager: createHASSManager(),
|
||||
});
|
||||
|
||||
expect(liveProviderSupports2WayAudio).not.toHaveBeenCalled();
|
||||
@@ -368,16 +330,11 @@ describe('Camera', () => {
|
||||
force: ['2-way-audio'],
|
||||
},
|
||||
}),
|
||||
new GenericCameraManagerEngine(
|
||||
mock<StateWatcherSubscriptionInterface>(),
|
||||
mock<EventWatcherSubscriptionInterface>(),
|
||||
),
|
||||
new GenericCameraManagerEngine(createHASSManager()),
|
||||
);
|
||||
|
||||
await camera.initialize({
|
||||
hass: createHASS(),
|
||||
stateWatcher: mock<StateWatcherSubscriptionInterface>(),
|
||||
eventWatcher: mock<EventWatcherSubscriptionInterface>(),
|
||||
hassManager: createHASSManager(),
|
||||
});
|
||||
|
||||
expect(liveProviderSupports2WayAudio).not.toHaveBeenCalled();
|
||||
@@ -392,16 +349,11 @@ describe('Camera', () => {
|
||||
force: ['2-way-audio'],
|
||||
},
|
||||
}),
|
||||
new GenericCameraManagerEngine(
|
||||
mock<StateWatcherSubscriptionInterface>(),
|
||||
mock<EventWatcherSubscriptionInterface>(),
|
||||
),
|
||||
new GenericCameraManagerEngine(createHASSManager()),
|
||||
);
|
||||
|
||||
await camera.initialize({
|
||||
hass: createHASS(),
|
||||
stateWatcher: mock<StateWatcherSubscriptionInterface>(),
|
||||
eventWatcher: mock<EventWatcherSubscriptionInterface>(),
|
||||
hassManager: createHASSManager(),
|
||||
});
|
||||
|
||||
expect(liveProviderSupports2WayAudio).not.toHaveBeenCalled();
|
||||
@@ -415,16 +367,11 @@ describe('Camera', () => {
|
||||
disable: ['2-way-audio'],
|
||||
},
|
||||
}),
|
||||
new GenericCameraManagerEngine(
|
||||
mock<StateWatcherSubscriptionInterface>(),
|
||||
mock<EventWatcherSubscriptionInterface>(),
|
||||
),
|
||||
new GenericCameraManagerEngine(createHASSManager()),
|
||||
);
|
||||
|
||||
await camera.initialize({
|
||||
hass: createHASS(),
|
||||
stateWatcher: mock<StateWatcherSubscriptionInterface>(),
|
||||
eventWatcher: mock<EventWatcherSubscriptionInterface>(),
|
||||
hassManager: createHASSManager(),
|
||||
});
|
||||
|
||||
expect(liveProviderSupports2WayAudio).not.toHaveBeenCalled();
|
||||
@@ -438,16 +385,11 @@ describe('Camera', () => {
|
||||
disable_except: ['substream'],
|
||||
},
|
||||
}),
|
||||
new GenericCameraManagerEngine(
|
||||
mock<StateWatcherSubscriptionInterface>(),
|
||||
mock<EventWatcherSubscriptionInterface>(),
|
||||
),
|
||||
new GenericCameraManagerEngine(createHASSManager()),
|
||||
);
|
||||
|
||||
await camera.initialize({
|
||||
hass: createHASS(),
|
||||
stateWatcher: mock<StateWatcherSubscriptionInterface>(),
|
||||
eventWatcher: mock<EventWatcherSubscriptionInterface>(),
|
||||
hassManager: createHASSManager(),
|
||||
});
|
||||
|
||||
expect(liveProviderSupports2WayAudio).not.toHaveBeenCalled();
|
||||
@@ -461,17 +403,12 @@ describe('Camera', () => {
|
||||
disable_except: ['substream', '2-way-audio'],
|
||||
},
|
||||
}),
|
||||
new GenericCameraManagerEngine(
|
||||
mock<StateWatcherSubscriptionInterface>(),
|
||||
mock<EventWatcherSubscriptionInterface>(),
|
||||
),
|
||||
new GenericCameraManagerEngine(createHASSManager()),
|
||||
);
|
||||
vi.mocked(liveProviderSupports2WayAudio).mockResolvedValue(true);
|
||||
|
||||
await camera.initialize({
|
||||
hass: createHASS(),
|
||||
stateWatcher: mock<StateWatcherSubscriptionInterface>(),
|
||||
eventWatcher: mock<EventWatcherSubscriptionInterface>(),
|
||||
hassManager: createHASSManager(),
|
||||
});
|
||||
|
||||
expect(liveProviderSupports2WayAudio).toHaveBeenCalled();
|
||||
@@ -485,17 +422,12 @@ describe('Camera', () => {
|
||||
disable_except: [],
|
||||
},
|
||||
}),
|
||||
new GenericCameraManagerEngine(
|
||||
mock<StateWatcherSubscriptionInterface>(),
|
||||
mock<EventWatcherSubscriptionInterface>(),
|
||||
),
|
||||
new GenericCameraManagerEngine(createHASSManager()),
|
||||
);
|
||||
vi.mocked(liveProviderSupports2WayAudio).mockResolvedValue(true);
|
||||
|
||||
await camera.initialize({
|
||||
hass: createHASS(),
|
||||
stateWatcher: mock<StateWatcherSubscriptionInterface>(),
|
||||
eventWatcher: mock<EventWatcherSubscriptionInterface>(),
|
||||
hassManager: createHASSManager(),
|
||||
});
|
||||
|
||||
expect(liveProviderSupports2WayAudio).toHaveBeenCalled();
|
||||
@@ -510,16 +442,11 @@ describe('Camera', () => {
|
||||
});
|
||||
const camera = new Camera(
|
||||
createCameraConfig({ camera_entity: 'camera.front_door' }),
|
||||
new GenericCameraManagerEngine(
|
||||
mock<StateWatcherSubscriptionInterface>(),
|
||||
mock<EventWatcherSubscriptionInterface>(),
|
||||
),
|
||||
new GenericCameraManagerEngine(createHASSManager()),
|
||||
);
|
||||
|
||||
await camera.initialize({
|
||||
hass: createHASS(),
|
||||
stateWatcher: mock<StateWatcherSubscriptionInterface>(),
|
||||
eventWatcher: mock<EventWatcherSubscriptionInterface>(),
|
||||
hassManager: createHASSManager(),
|
||||
entityRegistryManager: new EntityRegistryManagerMock([cameraEntity]),
|
||||
});
|
||||
|
||||
@@ -529,16 +456,11 @@ describe('Camera', () => {
|
||||
it('should leave entity null when camera_entity is unset', async () => {
|
||||
const camera = new Camera(
|
||||
createCameraConfig(),
|
||||
new GenericCameraManagerEngine(
|
||||
mock<StateWatcherSubscriptionInterface>(),
|
||||
mock<EventWatcherSubscriptionInterface>(),
|
||||
),
|
||||
new GenericCameraManagerEngine(createHASSManager()),
|
||||
);
|
||||
|
||||
await camera.initialize({
|
||||
hass: createHASS(),
|
||||
stateWatcher: mock<StateWatcherSubscriptionInterface>(),
|
||||
eventWatcher: mock<EventWatcherSubscriptionInterface>(),
|
||||
hassManager: createHASSManager(),
|
||||
entityRegistryManager: new EntityRegistryManagerMock(),
|
||||
});
|
||||
|
||||
@@ -548,16 +470,11 @@ describe('Camera', () => {
|
||||
it('should leave entity null when entityRegistryManager is not provided', async () => {
|
||||
const camera = new Camera(
|
||||
createCameraConfig({ camera_entity: 'camera.front_door' }),
|
||||
new GenericCameraManagerEngine(
|
||||
mock<StateWatcherSubscriptionInterface>(),
|
||||
mock<EventWatcherSubscriptionInterface>(),
|
||||
),
|
||||
new GenericCameraManagerEngine(createHASSManager()),
|
||||
);
|
||||
|
||||
await camera.initialize({
|
||||
hass: createHASS(),
|
||||
stateWatcher: mock<StateWatcherSubscriptionInterface>(),
|
||||
eventWatcher: mock<EventWatcherSubscriptionInterface>(),
|
||||
hassManager: createHASSManager(),
|
||||
});
|
||||
|
||||
expect(camera.getEntity()).toBeNull();
|
||||
@@ -594,10 +511,7 @@ describe('Camera', () => {
|
||||
...(options?.userEntities && { entities: options.userEntities }),
|
||||
},
|
||||
}),
|
||||
new GenericCameraManagerEngine(
|
||||
mock<StateWatcherSubscriptionInterface>(),
|
||||
mock<EventWatcherSubscriptionInterface>(),
|
||||
),
|
||||
new GenericCameraManagerEngine(createHASSManager()),
|
||||
);
|
||||
const stateWatcher = mock<StateWatcherSubscriptionInterface>();
|
||||
const hass = createHASS(
|
||||
@@ -609,9 +523,7 @@ describe('Camera', () => {
|
||||
},
|
||||
);
|
||||
await camera.initialize({
|
||||
hass,
|
||||
stateWatcher,
|
||||
eventWatcher: mock<EventWatcherSubscriptionInterface>(),
|
||||
hassManager: createHASSManager({ hass, stateWatcher }),
|
||||
...(!options?.omitRegistryManager && {
|
||||
entityRegistryManager: new EntityRegistryManagerMock(
|
||||
options?.registryEntities ?? [cameraEntity, doorbellEntity],
|
||||
@@ -737,10 +649,7 @@ describe('Camera', () => {
|
||||
entities: ['binary_sensor.foo'],
|
||||
},
|
||||
}),
|
||||
new GenericCameraManagerEngine(
|
||||
mock<StateWatcherSubscriptionInterface>(),
|
||||
mock<EventWatcherSubscriptionInterface>(),
|
||||
),
|
||||
new GenericCameraManagerEngine(createHASSManager()),
|
||||
{
|
||||
eventCallback: eventCallback,
|
||||
},
|
||||
@@ -748,9 +657,7 @@ describe('Camera', () => {
|
||||
|
||||
const stateWatcher = mock<StateWatcherSubscriptionInterface>();
|
||||
await camera.initialize({
|
||||
hass: createHASS(),
|
||||
stateWatcher: stateWatcher,
|
||||
eventWatcher: mock<EventWatcherSubscriptionInterface>(),
|
||||
hassManager: createHASSManager({ stateWatcher }),
|
||||
capabilityOptions: { capabilities: createCapabilities({ trigger: true }) },
|
||||
});
|
||||
|
||||
@@ -780,27 +687,25 @@ describe('Camera', () => {
|
||||
events: [{ event_type: 'zha_event' }],
|
||||
},
|
||||
}),
|
||||
new GenericCameraManagerEngine(
|
||||
mock<StateWatcherSubscriptionInterface>(),
|
||||
mock<EventWatcherSubscriptionInterface>(),
|
||||
),
|
||||
new GenericCameraManagerEngine(createHASSManager()),
|
||||
{ eventCallback },
|
||||
);
|
||||
|
||||
const eventWatcher = mock<EventWatcherSubscriptionInterface>();
|
||||
await camera.initialize({
|
||||
hass: createHASS(),
|
||||
stateWatcher: mock<StateWatcherSubscriptionInterface>(),
|
||||
eventWatcher,
|
||||
hassManager: createHASSManager({ eventWatcher }),
|
||||
capabilityOptions: { capabilities: createCapabilities({ trigger: true }) },
|
||||
});
|
||||
|
||||
expect(eventWatcher.subscribe).toBeCalledTimes(1);
|
||||
const request = vi.mocked(eventWatcher.subscribe).mock.calls[0][1];
|
||||
const request = vi.mocked(eventWatcher.subscribe).mock.calls[0][0];
|
||||
expect(request.event_type).toBe('zha_event');
|
||||
expect(request.matcher).toBeUndefined();
|
||||
|
||||
callEventWatcherCallback(eventWatcher, { command: 'press' });
|
||||
callEventWatcherCallback(
|
||||
eventWatcher,
|
||||
createHASSEvent('zha_event', { command: 'press' }),
|
||||
);
|
||||
|
||||
expect(eventCallback).toBeCalledWith({
|
||||
cameraID: 'camera_1',
|
||||
@@ -812,6 +717,63 @@ describe('Camera', () => {
|
||||
expect(eventWatcher.unsubscribe).toBeCalled();
|
||||
});
|
||||
|
||||
it('should attach a context-only matcher when only a context filter is set', async () => {
|
||||
const camera = new Camera(
|
||||
createCameraConfig({
|
||||
id: 'camera_1',
|
||||
triggers: {
|
||||
events: [{ event_type: 'zha_event', context: { user_id: 'u-1' } }],
|
||||
},
|
||||
}),
|
||||
new GenericCameraManagerEngine(createHASSManager()),
|
||||
);
|
||||
|
||||
const eventWatcher = mock<EventWatcherSubscriptionInterface>();
|
||||
await camera.initialize({
|
||||
hassManager: createHASSManager({ eventWatcher }),
|
||||
capabilityOptions: { capabilities: createCapabilities({ trigger: true }) },
|
||||
});
|
||||
|
||||
const matcher = vi.mocked(eventWatcher.subscribe).mock.calls[0][0].matcher;
|
||||
expect(matcher).toBeDefined();
|
||||
expect(
|
||||
matcher?.(
|
||||
createHASSEvent('zha_event', {}, { id: 'i', user_id: 'u-1', parent_id: null }),
|
||||
),
|
||||
).toBe(true);
|
||||
expect(
|
||||
matcher?.(
|
||||
createHASSEvent('zha_event', {}, { id: 'i', user_id: 'u-2', parent_id: null }),
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('should expand list-form event_type into one subscription per type', async () => {
|
||||
const camera = new Camera(
|
||||
createCameraConfig({
|
||||
id: 'camera_1',
|
||||
triggers: {
|
||||
events: [{ event_type: ['zha_event', 'deconz_event'] }],
|
||||
},
|
||||
}),
|
||||
new GenericCameraManagerEngine(createHASSManager()),
|
||||
);
|
||||
|
||||
const eventWatcher = mock<EventWatcherSubscriptionInterface>();
|
||||
await camera.initialize({
|
||||
hassManager: createHASSManager({ eventWatcher }),
|
||||
capabilityOptions: { capabilities: createCapabilities({ trigger: true }) },
|
||||
});
|
||||
|
||||
expect(eventWatcher.subscribe).toBeCalledTimes(2);
|
||||
expect(vi.mocked(eventWatcher.subscribe).mock.calls[0][0].event_type).toBe(
|
||||
'zha_event',
|
||||
);
|
||||
expect(vi.mocked(eventWatcher.subscribe).mock.calls[1][0].event_type).toBe(
|
||||
'deconz_event',
|
||||
);
|
||||
});
|
||||
|
||||
it('should attach a matcher when triggers.events entry has data filter', async () => {
|
||||
const camera = new Camera(
|
||||
createCameraConfig({
|
||||
@@ -820,24 +782,23 @@ describe('Camera', () => {
|
||||
events: [{ event_type: 'zha_event', event_data: { command: 'press' } }],
|
||||
},
|
||||
}),
|
||||
new GenericCameraManagerEngine(
|
||||
mock<StateWatcherSubscriptionInterface>(),
|
||||
mock<EventWatcherSubscriptionInterface>(),
|
||||
),
|
||||
new GenericCameraManagerEngine(createHASSManager()),
|
||||
);
|
||||
|
||||
const eventWatcher = mock<EventWatcherSubscriptionInterface>();
|
||||
await camera.initialize({
|
||||
hass: createHASS(),
|
||||
stateWatcher: mock<StateWatcherSubscriptionInterface>(),
|
||||
eventWatcher,
|
||||
hassManager: createHASSManager({ eventWatcher }),
|
||||
capabilityOptions: { capabilities: createCapabilities({ trigger: true }) },
|
||||
});
|
||||
|
||||
const matcher = vi.mocked(eventWatcher.subscribe).mock.calls[0][1].matcher;
|
||||
const matcher = vi.mocked(eventWatcher.subscribe).mock.calls[0][0].matcher;
|
||||
expect(matcher).toBeDefined();
|
||||
expect(matcher?.({ command: 'press', extra: 1 })).toBe(true);
|
||||
expect(matcher?.({ command: 'release' })).toBe(false);
|
||||
expect(
|
||||
matcher?.(createHASSEvent('zha_event', { command: 'press', extra: 1 })),
|
||||
).toBe(true);
|
||||
expect(matcher?.(createHASSEvent('zha_event', { command: 'release' }))).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
it('should not subscribe to events when trigger capability is disabled', async () => {
|
||||
@@ -848,17 +809,12 @@ describe('Camera', () => {
|
||||
events: [{ event_type: 'zha_event' }],
|
||||
},
|
||||
}),
|
||||
new GenericCameraManagerEngine(
|
||||
mock<StateWatcherSubscriptionInterface>(),
|
||||
mock<EventWatcherSubscriptionInterface>(),
|
||||
),
|
||||
new GenericCameraManagerEngine(createHASSManager()),
|
||||
);
|
||||
|
||||
const eventWatcher = mock<EventWatcherSubscriptionInterface>();
|
||||
await camera.initialize({
|
||||
hass: createHASS(),
|
||||
stateWatcher: mock<StateWatcherSubscriptionInterface>(),
|
||||
eventWatcher,
|
||||
hassManager: createHASSManager({ eventWatcher }),
|
||||
capabilityOptions: { capabilities: createCapabilities({ trigger: false }) },
|
||||
});
|
||||
|
||||
@@ -876,10 +832,7 @@ describe('Camera', () => {
|
||||
entities: ['event.front_door_doorbell'],
|
||||
},
|
||||
}),
|
||||
new GenericCameraManagerEngine(
|
||||
mock<StateWatcherSubscriptionInterface>(),
|
||||
mock<EventWatcherSubscriptionInterface>(),
|
||||
),
|
||||
new GenericCameraManagerEngine(createHASSManager()),
|
||||
{
|
||||
eventCallback: eventCallback,
|
||||
},
|
||||
@@ -887,9 +840,7 @@ describe('Camera', () => {
|
||||
|
||||
const stateWatcher = mock<StateWatcherSubscriptionInterface>();
|
||||
await camera.initialize({
|
||||
hass: createHASS(),
|
||||
stateWatcher: stateWatcher,
|
||||
eventWatcher: mock<EventWatcherSubscriptionInterface>(),
|
||||
hassManager: createHASSManager({ stateWatcher }),
|
||||
capabilityOptions: { capabilities: createCapabilities({ trigger: true }) },
|
||||
});
|
||||
|
||||
@@ -913,10 +864,7 @@ describe('Camera', () => {
|
||||
entities: ['event.front_door_doorbell'],
|
||||
},
|
||||
}),
|
||||
new GenericCameraManagerEngine(
|
||||
mock<StateWatcherSubscriptionInterface>(),
|
||||
mock<EventWatcherSubscriptionInterface>(),
|
||||
),
|
||||
new GenericCameraManagerEngine(createHASSManager()),
|
||||
{
|
||||
eventCallback: eventCallback,
|
||||
},
|
||||
@@ -924,9 +872,7 @@ describe('Camera', () => {
|
||||
|
||||
const stateWatcher = mock<StateWatcherSubscriptionInterface>();
|
||||
await camera.initialize({
|
||||
hass: createHASS(),
|
||||
stateWatcher: stateWatcher,
|
||||
eventWatcher: mock<EventWatcherSubscriptionInterface>(),
|
||||
hassManager: createHASSManager({ stateWatcher }),
|
||||
capabilityOptions: { capabilities: createCapabilities({ trigger: true }) },
|
||||
});
|
||||
|
||||
@@ -953,10 +899,7 @@ describe('Camera', () => {
|
||||
entities: ['binary_sensor.foo'],
|
||||
},
|
||||
}),
|
||||
new GenericCameraManagerEngine(
|
||||
mock<StateWatcherSubscriptionInterface>(),
|
||||
mock<EventWatcherSubscriptionInterface>(),
|
||||
),
|
||||
new GenericCameraManagerEngine(createHASSManager()),
|
||||
{
|
||||
eventCallback: eventCallback,
|
||||
},
|
||||
@@ -964,9 +907,7 @@ describe('Camera', () => {
|
||||
|
||||
const stateWatcher = mock<StateWatcherSubscriptionInterface>();
|
||||
await camera.initialize({
|
||||
hass: createHASS(),
|
||||
stateWatcher: stateWatcher,
|
||||
eventWatcher: mock<EventWatcherSubscriptionInterface>(),
|
||||
hassManager: createHASSManager({ stateWatcher }),
|
||||
capabilityOptions: { capabilities: createCapabilities({ trigger: false }) },
|
||||
});
|
||||
|
||||
@@ -1128,10 +1069,7 @@ describe('Camera', () => {
|
||||
(_name: string, cameraConfig: unknown, expectedResult: CameraProxyConfig) => {
|
||||
const camera = new Camera(
|
||||
createCameraConfig(cameraConfig),
|
||||
new GenericCameraManagerEngine(
|
||||
mock<StateWatcherSubscriptionInterface>(),
|
||||
mock<EventWatcherSubscriptionInterface>(),
|
||||
),
|
||||
new GenericCameraManagerEngine(createHASSManager()),
|
||||
);
|
||||
expect(camera.getProxyConfig()).toEqual(expectedResult);
|
||||
},
|
||||
@@ -1145,10 +1083,7 @@ describe('Camera', () => {
|
||||
go2rtc: { stream: '' },
|
||||
camera_entity: '',
|
||||
}),
|
||||
new GenericCameraManagerEngine(
|
||||
mock<StateWatcherSubscriptionInterface>(),
|
||||
mock<EventWatcherSubscriptionInterface>(),
|
||||
),
|
||||
new GenericCameraManagerEngine(createHASSManager()),
|
||||
);
|
||||
expect(camera.getEndpoints()).toBeNull();
|
||||
});
|
||||
@@ -1162,10 +1097,7 @@ describe('Camera', () => {
|
||||
},
|
||||
camera_entity: 'camera.foo',
|
||||
}),
|
||||
new GenericCameraManagerEngine(
|
||||
mock<StateWatcherSubscriptionInterface>(),
|
||||
mock<EventWatcherSubscriptionInterface>(),
|
||||
),
|
||||
new GenericCameraManagerEngine(createHASSManager()),
|
||||
);
|
||||
|
||||
expect(camera.getEndpoints()).toEqual({
|
||||
|
||||
@@ -7,8 +7,6 @@ import { MotionEyeCameraManagerEngine } from '../../src/camera-manager/motioneye
|
||||
import { ReolinkCameraManagerEngine } from '../../src/camera-manager/reolink/engine-reolink.js';
|
||||
import { TPLinkCameraManagerEngine } from '../../src/camera-manager/tplink/engine-tplink.js';
|
||||
import { Engine } from '../../src/camera-manager/types.js';
|
||||
import { EventWatcherSubscriptionInterface } from '../../src/card-controller/hass/event-watcher.js';
|
||||
import { StateWatcherSubscriptionInterface } from '../../src/card-controller/hass/state-watcher.js';
|
||||
import { CardWideConfig } from '../../src/config/schema/types.js';
|
||||
import { DeviceRegistryManager } from '../../src/ha/registry/device';
|
||||
import { EntityRegistryManager } from '../../src/ha/registry/entity/types.js';
|
||||
@@ -17,6 +15,7 @@ import { EntityRegistryManagerMock } from '../ha/registry/entity/mock.js';
|
||||
import {
|
||||
createCameraConfig,
|
||||
createHASS,
|
||||
createHASSManager,
|
||||
createRegistryEntity,
|
||||
createStateEntity,
|
||||
} from '../test-utils';
|
||||
@@ -232,8 +231,7 @@ describe('createEngine()', () => {
|
||||
it('should create generic engine', async () => {
|
||||
expect(
|
||||
await createFactory().createEngine(Engine.Generic, {
|
||||
stateWatcher: mock<StateWatcherSubscriptionInterface>(),
|
||||
eventWatcher: mock<EventWatcherSubscriptionInterface>(),
|
||||
hassManager: createHASSManager(),
|
||||
resolvedMediaCache: mock<ResolvedMediaCache>(),
|
||||
}),
|
||||
).toBeInstanceOf(GenericCameraManagerEngine);
|
||||
@@ -241,8 +239,7 @@ describe('createEngine()', () => {
|
||||
it('should create frigate engine', async () => {
|
||||
expect(
|
||||
await createFactory().createEngine(Engine.Frigate, {
|
||||
stateWatcher: mock<StateWatcherSubscriptionInterface>(),
|
||||
eventWatcher: mock<EventWatcherSubscriptionInterface>(),
|
||||
hassManager: createHASSManager(),
|
||||
resolvedMediaCache: mock<ResolvedMediaCache>(),
|
||||
}),
|
||||
).toBeInstanceOf(FrigateCameraManagerEngine);
|
||||
@@ -250,8 +247,7 @@ describe('createEngine()', () => {
|
||||
it('should create motioneye engine', async () => {
|
||||
expect(
|
||||
await createFactory().createEngine(Engine.MotionEye, {
|
||||
stateWatcher: mock<StateWatcherSubscriptionInterface>(),
|
||||
eventWatcher: mock<EventWatcherSubscriptionInterface>(),
|
||||
hassManager: createHASSManager(),
|
||||
resolvedMediaCache: mock<ResolvedMediaCache>(),
|
||||
}),
|
||||
).toBeInstanceOf(MotionEyeCameraManagerEngine);
|
||||
@@ -259,8 +255,7 @@ describe('createEngine()', () => {
|
||||
it('should create reolink engine', async () => {
|
||||
expect(
|
||||
await createFactory().createEngine(Engine.Reolink, {
|
||||
stateWatcher: mock<StateWatcherSubscriptionInterface>(),
|
||||
eventWatcher: mock<EventWatcherSubscriptionInterface>(),
|
||||
hassManager: createHASSManager(),
|
||||
resolvedMediaCache: mock<ResolvedMediaCache>(),
|
||||
}),
|
||||
).toBeInstanceOf(ReolinkCameraManagerEngine);
|
||||
@@ -268,8 +263,7 @@ describe('createEngine()', () => {
|
||||
it('should create tplink engine', async () => {
|
||||
expect(
|
||||
await createFactory().createEngine(Engine.TPLink, {
|
||||
stateWatcher: mock<StateWatcherSubscriptionInterface>(),
|
||||
eventWatcher: mock<EventWatcherSubscriptionInterface>(),
|
||||
hassManager: createHASSManager(),
|
||||
resolvedMediaCache: mock<ResolvedMediaCache>(),
|
||||
}),
|
||||
).toBeInstanceOf(TPLinkCameraManagerEngine);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { format } from 'date-fns';
|
||||
import { assert, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { mock } from 'vitest-mock-extended';
|
||||
import { CameraManagerEngine } from '../../../src/camera-manager/engine';
|
||||
import { FrigateCamera } from '../../../src/camera-manager/frigate/camera';
|
||||
@@ -18,8 +18,6 @@ import {
|
||||
FrigateReviewWatcher,
|
||||
} from '../../../src/camera-manager/frigate/watcher';
|
||||
import { ActionsExecutor } from '../../../src/card-controller/actions/types';
|
||||
import { EventWatcherSubscriptionInterface } from '../../../src/card-controller/hass/event-watcher';
|
||||
import { StateWatcherSubscriptionInterface } from '../../../src/card-controller/hass/state-watcher';
|
||||
import { PTZAction } from '../../../src/config/schema/actions/custom/ptz';
|
||||
import { CameraTriggerMediaEventType } from '../../../src/config/schema/cameras';
|
||||
import { Entity, EntityRegistryManager } from '../../../src/ha/registry/entity/types';
|
||||
@@ -29,6 +27,7 @@ import {
|
||||
createCameraConfig,
|
||||
createCapabilities,
|
||||
createHASS,
|
||||
createHASSManager,
|
||||
createRegistryEntity,
|
||||
createStateEntity,
|
||||
} from '../../test-utils';
|
||||
@@ -42,7 +41,7 @@ const callEventWatcherCallback = (
|
||||
): void => {
|
||||
const mock = vi.mocked(eventWatcher.subscribe).mock;
|
||||
expect(mock.calls.length).greaterThan(n);
|
||||
mock.calls[n][1].callback(event);
|
||||
mock.calls[n][0].callback(event);
|
||||
};
|
||||
|
||||
const callReviewWatcherCallback = (
|
||||
@@ -52,7 +51,7 @@ const callReviewWatcherCallback = (
|
||||
): void => {
|
||||
const mock = vi.mocked(reviewWatcher.subscribe).mock;
|
||||
expect(mock.calls.length).greaterThan(n);
|
||||
mock.calls[n][1].callback(review);
|
||||
mock.calls[n][0].callback(review);
|
||||
};
|
||||
|
||||
describe('FrigateCamera', () => {
|
||||
@@ -69,10 +68,8 @@ describe('FrigateCamera', () => {
|
||||
const beforeConfig = { ...config };
|
||||
|
||||
await camera.initialize({
|
||||
hass: createHASS(),
|
||||
hassManager: createHASSManager(),
|
||||
entityRegistryManager: mock<EntityRegistryManager>(),
|
||||
stateWatcher: mock<StateWatcherSubscriptionInterface>(),
|
||||
eventWatcher: mock<EventWatcherSubscriptionInterface>(),
|
||||
frigateEventWatcher: mock<FrigateEventWatcher>(),
|
||||
frigateReviewWatcher: mock<FrigateReviewWatcher>(),
|
||||
});
|
||||
@@ -92,10 +89,8 @@ describe('FrigateCamera', () => {
|
||||
expect(
|
||||
async () =>
|
||||
await camera.initialize({
|
||||
hass: createHASS(),
|
||||
hassManager: createHASSManager(),
|
||||
entityRegistryManager: entityRegistryManager,
|
||||
stateWatcher: mock<StateWatcherSubscriptionInterface>(),
|
||||
eventWatcher: mock<EventWatcherSubscriptionInterface>(),
|
||||
frigateEventWatcher: mock<FrigateEventWatcher>(),
|
||||
frigateReviewWatcher: mock<FrigateReviewWatcher>(),
|
||||
}),
|
||||
@@ -118,10 +113,8 @@ describe('FrigateCamera', () => {
|
||||
]);
|
||||
|
||||
await camera.initialize({
|
||||
hass: createHASS(),
|
||||
hassManager: createHASSManager(),
|
||||
entityRegistryManager: entityRegistryManager,
|
||||
stateWatcher: mock<StateWatcherSubscriptionInterface>(),
|
||||
eventWatcher: mock<EventWatcherSubscriptionInterface>(),
|
||||
frigateEventWatcher: mock<FrigateEventWatcher>(),
|
||||
frigateReviewWatcher: mock<FrigateReviewWatcher>(),
|
||||
});
|
||||
@@ -144,10 +137,8 @@ describe('FrigateCamera', () => {
|
||||
]);
|
||||
|
||||
await camera.initialize({
|
||||
hass: createHASS(),
|
||||
hassManager: createHASSManager(),
|
||||
entityRegistryManager: entityRegistryManager,
|
||||
stateWatcher: mock<StateWatcherSubscriptionInterface>(),
|
||||
eventWatcher: mock<EventWatcherSubscriptionInterface>(),
|
||||
frigateEventWatcher: mock<FrigateEventWatcher>(),
|
||||
frigateReviewWatcher: mock<FrigateReviewWatcher>(),
|
||||
});
|
||||
@@ -170,10 +161,8 @@ describe('FrigateCamera', () => {
|
||||
]);
|
||||
|
||||
await camera.initialize({
|
||||
hass: createHASS(),
|
||||
hassManager: createHASSManager(),
|
||||
entityRegistryManager: entityRegistryManager,
|
||||
stateWatcher: mock<StateWatcherSubscriptionInterface>(),
|
||||
eventWatcher: mock<EventWatcherSubscriptionInterface>(),
|
||||
frigateEventWatcher: mock<FrigateEventWatcher>(),
|
||||
frigateReviewWatcher: mock<FrigateReviewWatcher>(),
|
||||
});
|
||||
@@ -191,15 +180,15 @@ describe('FrigateCamera', () => {
|
||||
mock<CameraManagerEngine>(),
|
||||
);
|
||||
await camera.initialize({
|
||||
hass: createHASS({
|
||||
'camera.front_door': createStateEntity({
|
||||
entity_id: 'camera.front_door',
|
||||
attributes: { client_id: 'remote_frigate' },
|
||||
hassManager: createHASSManager({
|
||||
hass: createHASS({
|
||||
'camera.front_door': createStateEntity({
|
||||
entity_id: 'camera.front_door',
|
||||
attributes: { client_id: 'remote_frigate' },
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
entityRegistryManager: mock<EntityRegistryManager>(),
|
||||
stateWatcher: mock<StateWatcherSubscriptionInterface>(),
|
||||
eventWatcher: mock<EventWatcherSubscriptionInterface>(),
|
||||
frigateEventWatcher: mock<FrigateEventWatcher>(),
|
||||
frigateReviewWatcher: mock<FrigateReviewWatcher>(),
|
||||
});
|
||||
@@ -215,15 +204,15 @@ describe('FrigateCamera', () => {
|
||||
mock<CameraManagerEngine>(),
|
||||
);
|
||||
await camera.initialize({
|
||||
hass: createHASS({
|
||||
'camera.front_door': createStateEntity({
|
||||
entity_id: 'camera.front_door',
|
||||
attributes: {},
|
||||
hassManager: createHASSManager({
|
||||
hass: createHASS({
|
||||
'camera.front_door': createStateEntity({
|
||||
entity_id: 'camera.front_door',
|
||||
attributes: {},
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
entityRegistryManager: mock<EntityRegistryManager>(),
|
||||
stateWatcher: mock<StateWatcherSubscriptionInterface>(),
|
||||
eventWatcher: mock<EventWatcherSubscriptionInterface>(),
|
||||
frigateEventWatcher: mock<FrigateEventWatcher>(),
|
||||
frigateReviewWatcher: mock<FrigateReviewWatcher>(),
|
||||
});
|
||||
@@ -238,10 +227,8 @@ describe('FrigateCamera', () => {
|
||||
mock<CameraManagerEngine>(),
|
||||
);
|
||||
await camera.initialize({
|
||||
hass: createHASS(),
|
||||
hassManager: createHASSManager(),
|
||||
entityRegistryManager: mock<EntityRegistryManager>(),
|
||||
stateWatcher: mock<StateWatcherSubscriptionInterface>(),
|
||||
eventWatcher: mock<EventWatcherSubscriptionInterface>(),
|
||||
frigateEventWatcher: mock<FrigateEventWatcher>(),
|
||||
frigateReviewWatcher: mock<FrigateReviewWatcher>(),
|
||||
});
|
||||
@@ -257,15 +244,15 @@ describe('FrigateCamera', () => {
|
||||
mock<CameraManagerEngine>(),
|
||||
);
|
||||
await camera.initialize({
|
||||
hass: createHASS({
|
||||
'camera.front_door': createStateEntity({
|
||||
entity_id: 'camera.front_door',
|
||||
attributes: { client_id: 'something_else' },
|
||||
hassManager: createHASSManager({
|
||||
hass: createHASS({
|
||||
'camera.front_door': createStateEntity({
|
||||
entity_id: 'camera.front_door',
|
||||
attributes: { client_id: 'something_else' },
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
entityRegistryManager: mock<EntityRegistryManager>(),
|
||||
stateWatcher: mock<StateWatcherSubscriptionInterface>(),
|
||||
eventWatcher: mock<EventWatcherSubscriptionInterface>(),
|
||||
frigateEventWatcher: mock<FrigateEventWatcher>(),
|
||||
frigateReviewWatcher: mock<FrigateReviewWatcher>(),
|
||||
});
|
||||
@@ -281,16 +268,16 @@ describe('FrigateCamera', () => {
|
||||
mock<CameraManagerEngine>(),
|
||||
);
|
||||
await camera.initialize({
|
||||
hass: createHASS({
|
||||
'camera.front_door': createStateEntity({
|
||||
entity_id: 'camera.front_door',
|
||||
state: 'unavailable',
|
||||
attributes: {},
|
||||
hassManager: createHASSManager({
|
||||
hass: createHASS({
|
||||
'camera.front_door': createStateEntity({
|
||||
entity_id: 'camera.front_door',
|
||||
state: 'unavailable',
|
||||
attributes: {},
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
entityRegistryManager: mock<EntityRegistryManager>(),
|
||||
stateWatcher: mock<StateWatcherSubscriptionInterface>(),
|
||||
eventWatcher: mock<EventWatcherSubscriptionInterface>(),
|
||||
frigateEventWatcher: mock<FrigateEventWatcher>(),
|
||||
frigateReviewWatcher: mock<FrigateReviewWatcher>(),
|
||||
});
|
||||
@@ -311,10 +298,8 @@ describe('FrigateCamera', () => {
|
||||
);
|
||||
|
||||
await camera.initialize({
|
||||
hass: createHASS(),
|
||||
hassManager: createHASSManager(),
|
||||
entityRegistryManager: mock<EntityRegistryManager>(),
|
||||
stateWatcher: mock<StateWatcherSubscriptionInterface>(),
|
||||
eventWatcher: mock<EventWatcherSubscriptionInterface>(),
|
||||
frigateEventWatcher: mock<FrigateEventWatcher>(),
|
||||
frigateReviewWatcher: mock<FrigateReviewWatcher>(),
|
||||
});
|
||||
@@ -340,10 +325,8 @@ describe('FrigateCamera', () => {
|
||||
);
|
||||
|
||||
await camera.initialize({
|
||||
hass: createHASS(),
|
||||
hassManager: createHASSManager(),
|
||||
entityRegistryManager: mock<EntityRegistryManager>(),
|
||||
stateWatcher: mock<StateWatcherSubscriptionInterface>(),
|
||||
eventWatcher: mock<EventWatcherSubscriptionInterface>(),
|
||||
frigateEventWatcher: mock<FrigateEventWatcher>(),
|
||||
frigateReviewWatcher: mock<FrigateReviewWatcher>(),
|
||||
});
|
||||
@@ -373,10 +356,8 @@ describe('FrigateCamera', () => {
|
||||
vi.mocked(getPTZInfo).mockRejectedValue(new Error());
|
||||
|
||||
await camera.initialize({
|
||||
hass: createHASS(),
|
||||
hassManager: createHASSManager(),
|
||||
entityRegistryManager: mock<EntityRegistryManager>(),
|
||||
stateWatcher: mock<StateWatcherSubscriptionInterface>(),
|
||||
eventWatcher: mock<EventWatcherSubscriptionInterface>(),
|
||||
frigateEventWatcher: mock<FrigateEventWatcher>(),
|
||||
frigateReviewWatcher: mock<FrigateReviewWatcher>(),
|
||||
});
|
||||
@@ -404,10 +385,8 @@ describe('FrigateCamera', () => {
|
||||
});
|
||||
|
||||
await camera.initialize({
|
||||
hass: createHASS(),
|
||||
hassManager: createHASSManager(),
|
||||
entityRegistryManager: mock<EntityRegistryManager>(),
|
||||
stateWatcher: mock<StateWatcherSubscriptionInterface>(),
|
||||
eventWatcher: mock<EventWatcherSubscriptionInterface>(),
|
||||
frigateEventWatcher: mock<FrigateEventWatcher>(),
|
||||
frigateReviewWatcher: mock<FrigateReviewWatcher>(),
|
||||
});
|
||||
@@ -442,10 +421,8 @@ describe('FrigateCamera', () => {
|
||||
});
|
||||
|
||||
await camera.initialize({
|
||||
hass: createHASS(),
|
||||
hassManager: createHASSManager(),
|
||||
entityRegistryManager: mock<EntityRegistryManager>(),
|
||||
stateWatcher: mock<StateWatcherSubscriptionInterface>(),
|
||||
eventWatcher: mock<EventWatcherSubscriptionInterface>(),
|
||||
frigateEventWatcher: mock<FrigateEventWatcher>(),
|
||||
frigateReviewWatcher: mock<FrigateReviewWatcher>(),
|
||||
});
|
||||
@@ -475,10 +452,8 @@ describe('FrigateCamera', () => {
|
||||
});
|
||||
|
||||
await camera.initialize({
|
||||
hass: createHASS(),
|
||||
hassManager: createHASSManager(),
|
||||
entityRegistryManager: mock<EntityRegistryManager>(),
|
||||
stateWatcher: mock<StateWatcherSubscriptionInterface>(),
|
||||
eventWatcher: mock<EventWatcherSubscriptionInterface>(),
|
||||
frigateEventWatcher: mock<FrigateEventWatcher>(),
|
||||
frigateReviewWatcher: mock<FrigateReviewWatcher>(),
|
||||
});
|
||||
@@ -512,10 +487,8 @@ describe('FrigateCamera', () => {
|
||||
});
|
||||
|
||||
await camera.initialize({
|
||||
hass: createHASS(),
|
||||
hassManager: createHASSManager(),
|
||||
entityRegistryManager: mock<EntityRegistryManager>(),
|
||||
stateWatcher: mock<StateWatcherSubscriptionInterface>(),
|
||||
eventWatcher: mock<EventWatcherSubscriptionInterface>(),
|
||||
frigateEventWatcher: mock<FrigateEventWatcher>(),
|
||||
frigateReviewWatcher: mock<FrigateReviewWatcher>(),
|
||||
});
|
||||
@@ -842,15 +815,12 @@ describe('FrigateCamera', () => {
|
||||
|
||||
const eventWatcher = mock<FrigateEventWatcher>();
|
||||
await camera.initialize({
|
||||
hass: hass,
|
||||
hassManager: createHASSManager({ hass }),
|
||||
entityRegistryManager: mock<EntityRegistryManager>(),
|
||||
stateWatcher: mock<StateWatcherSubscriptionInterface>(),
|
||||
eventWatcher: mock<EventWatcherSubscriptionInterface>(),
|
||||
frigateEventWatcher: eventWatcher,
|
||||
frigateReviewWatcher: mock<FrigateReviewWatcher>(),
|
||||
});
|
||||
expect(eventWatcher.subscribe).toBeCalledWith(
|
||||
hass,
|
||||
expect.objectContaining({
|
||||
instanceID: 'CLIENT_ID',
|
||||
}),
|
||||
@@ -874,10 +844,8 @@ describe('FrigateCamera', () => {
|
||||
|
||||
const eventWatcher = mock<FrigateEventWatcher>();
|
||||
await camera.initialize({
|
||||
hass: hass,
|
||||
hassManager: createHASSManager({ hass }),
|
||||
entityRegistryManager: mock<EntityRegistryManager>(),
|
||||
stateWatcher: mock<StateWatcherSubscriptionInterface>(),
|
||||
eventWatcher: mock<EventWatcherSubscriptionInterface>(),
|
||||
frigateEventWatcher: eventWatcher,
|
||||
frigateReviewWatcher: mock<FrigateReviewWatcher>(),
|
||||
});
|
||||
@@ -901,10 +869,8 @@ describe('FrigateCamera', () => {
|
||||
|
||||
const eventWatcher = mock<FrigateEventWatcher>();
|
||||
await camera.initialize({
|
||||
hass: hass,
|
||||
hassManager: createHASSManager({ hass }),
|
||||
entityRegistryManager: mock<EntityRegistryManager>(),
|
||||
stateWatcher: mock<StateWatcherSubscriptionInterface>(),
|
||||
eventWatcher: mock<EventWatcherSubscriptionInterface>(),
|
||||
frigateEventWatcher: eventWatcher,
|
||||
frigateReviewWatcher: mock<FrigateReviewWatcher>(),
|
||||
});
|
||||
@@ -927,10 +893,8 @@ describe('FrigateCamera', () => {
|
||||
|
||||
const eventWatcher = mock<FrigateEventWatcher>();
|
||||
await camera.initialize({
|
||||
hass: hass,
|
||||
hassManager: createHASSManager({ hass }),
|
||||
entityRegistryManager: mock<EntityRegistryManager>(),
|
||||
stateWatcher: mock<StateWatcherSubscriptionInterface>(),
|
||||
eventWatcher: mock<EventWatcherSubscriptionInterface>(),
|
||||
frigateEventWatcher: eventWatcher,
|
||||
frigateReviewWatcher: mock<FrigateReviewWatcher>(),
|
||||
});
|
||||
@@ -953,10 +917,8 @@ describe('FrigateCamera', () => {
|
||||
|
||||
const eventWatcher = mock<FrigateEventWatcher>();
|
||||
await camera.initialize({
|
||||
hass: hass,
|
||||
hassManager: createHASSManager({ hass }),
|
||||
entityRegistryManager: mock<EntityRegistryManager>(),
|
||||
stateWatcher: mock<StateWatcherSubscriptionInterface>(),
|
||||
eventWatcher: mock<EventWatcherSubscriptionInterface>(),
|
||||
frigateEventWatcher: eventWatcher,
|
||||
frigateReviewWatcher: mock<FrigateReviewWatcher>(),
|
||||
});
|
||||
@@ -966,9 +928,10 @@ describe('FrigateCamera', () => {
|
||||
expect(eventWatcher.unsubscribe).toBeCalled();
|
||||
});
|
||||
|
||||
it('should unsubscribe on destroy while event subscription is pending', async () => {
|
||||
it('should not subscribe when destroyed while base initialization is pending', async () => {
|
||||
const camera = new FrigateCamera(
|
||||
createCameraConfig({
|
||||
camera_entity: 'camera.front_door',
|
||||
frigate: { client_id: 'CLIENT_ID', camera_name: 'front_door' },
|
||||
triggers: {
|
||||
media_events: ['events'],
|
||||
@@ -978,45 +941,40 @@ describe('FrigateCamera', () => {
|
||||
mock<CameraManagerEngine>(),
|
||||
);
|
||||
const hass = createHASS();
|
||||
let resolveSubscribe: () => void = () => {};
|
||||
const eventWatcher = mock<FrigateEventWatcher>();
|
||||
const reviewWatcher = mock<FrigateReviewWatcher>();
|
||||
vi.mocked(eventWatcher.subscribe).mockReturnValue(
|
||||
new Promise<void>((resolve) => {
|
||||
resolveSubscribe = resolve;
|
||||
|
||||
// Pend base initialization on entity resolution so destroy() can flip
|
||||
// `_destroyed` before initialize() reaches the subscribe calls.
|
||||
let resolveEntity: () => void = () => {};
|
||||
const entityRegistryManager = mock<EntityRegistryManager>();
|
||||
vi.mocked(entityRegistryManager.getEntity).mockReturnValue(
|
||||
new Promise((resolve) => {
|
||||
resolveEntity = () => resolve(createRegistryEntity());
|
||||
}),
|
||||
);
|
||||
|
||||
const initializePromise = camera.initialize({
|
||||
hass: hass,
|
||||
entityRegistryManager: mock<EntityRegistryManager>(),
|
||||
stateWatcher: mock<StateWatcherSubscriptionInterface>(),
|
||||
eventWatcher: mock<EventWatcherSubscriptionInterface>(),
|
||||
hassManager: createHASSManager({ hass }),
|
||||
entityRegistryManager: entityRegistryManager,
|
||||
frigateEventWatcher: eventWatcher,
|
||||
frigateReviewWatcher: reviewWatcher,
|
||||
|
||||
// Pre-built so `_buildCapabilities` (which calls the un-mocked
|
||||
// `liveProviderSupports2WayAudio`) is skipped and init reaches the
|
||||
// pending Frigate event subscribe.
|
||||
capabilityOptions: { capabilities: createCapabilities({ trigger: true }) },
|
||||
});
|
||||
await vi.waitFor(() => expect(eventWatcher.subscribe).toBeCalled());
|
||||
await vi.waitFor(() => expect(entityRegistryManager.getEntity).toBeCalled());
|
||||
|
||||
await camera.destroy();
|
||||
|
||||
// Destroy iterated `_destroyCallbacks` and called the unsubscribe that
|
||||
// was registered before the (still pending) event subscribe.
|
||||
const subscribeCall = vi.mocked(eventWatcher.subscribe).mock.calls[0];
|
||||
assert(subscribeCall);
|
||||
expect(eventWatcher.unsubscribe).toBeCalledWith(subscribeCall[1]);
|
||||
// `_destroyed` short-circuits initialize() after the pending await, so
|
||||
// neither watcher is ever subscribed.
|
||||
expect(eventWatcher.subscribe).not.toBeCalled();
|
||||
expect(reviewWatcher.subscribe).not.toBeCalled();
|
||||
|
||||
resolveSubscribe();
|
||||
resolveEntity();
|
||||
await initializePromise;
|
||||
|
||||
// The subsequent `_subscribeToReviews` short-circuited on `_destroyed`,
|
||||
// so the review watcher was never subscribed (and so never needs an
|
||||
// unsubscribe -- which would otherwise be ordered before the subscribe
|
||||
// in the per-key PQueue and leak the resulting subscription).
|
||||
expect(eventWatcher.subscribe).not.toBeCalled();
|
||||
expect(eventWatcher.unsubscribe).not.toBeCalled();
|
||||
expect(reviewWatcher.subscribe).not.toBeCalled();
|
||||
expect(reviewWatcher.unsubscribe).not.toBeCalled();
|
||||
});
|
||||
@@ -1106,10 +1064,8 @@ describe('FrigateCamera', () => {
|
||||
const hass = createHASS();
|
||||
const eventWatcher = mock<FrigateEventWatcher>();
|
||||
await camera.initialize({
|
||||
hass: hass,
|
||||
hassManager: createHASSManager({ hass }),
|
||||
entityRegistryManager: mock<EntityRegistryManager>(),
|
||||
stateWatcher: mock<StateWatcherSubscriptionInterface>(),
|
||||
eventWatcher: mock<EventWatcherSubscriptionInterface>(),
|
||||
frigateEventWatcher: eventWatcher,
|
||||
frigateReviewWatcher: mock<FrigateReviewWatcher>(),
|
||||
});
|
||||
@@ -1152,6 +1108,75 @@ describe('FrigateCamera', () => {
|
||||
);
|
||||
});
|
||||
|
||||
describe('should always forward end events to clear the trigger', () => {
|
||||
it.each([
|
||||
['with media still present', true, ['front_steps']],
|
||||
['with no media present at end', false, ['front_steps']],
|
||||
['even after the object left the configured zone', true, []],
|
||||
])('%s', async (_name: string, hasClip: boolean, currentZones: string[]) => {
|
||||
const eventCallback = vi.fn();
|
||||
const camera = new FrigateCamera(
|
||||
createCameraConfig({
|
||||
id: 'CAMERA_1',
|
||||
frigate: {
|
||||
camera_name: 'camera.front_door',
|
||||
zones: ['front_steps'],
|
||||
},
|
||||
triggers: {
|
||||
media_events: ['clips'],
|
||||
},
|
||||
}),
|
||||
mock<CameraManagerEngine>(),
|
||||
{
|
||||
eventCallback: eventCallback,
|
||||
},
|
||||
);
|
||||
|
||||
const hass = createHASS();
|
||||
const eventWatcher = mock<FrigateEventWatcher>();
|
||||
await camera.initialize({
|
||||
hassManager: createHASSManager({ hass }),
|
||||
entityRegistryManager: mock<EntityRegistryManager>(),
|
||||
frigateEventWatcher: eventWatcher,
|
||||
frigateReviewWatcher: mock<FrigateReviewWatcher>(),
|
||||
});
|
||||
|
||||
// An 'end' clears the trigger regardless of the start criteria: the
|
||||
// media may be unchanged or absent, and the object may have left the
|
||||
// zone by now.
|
||||
callEventWatcherCallback(eventWatcher, {
|
||||
type: 'end',
|
||||
before: {
|
||||
id: 'event-1',
|
||||
camera: 'camera.front_door',
|
||||
snapshot: null,
|
||||
has_clip: hasClip,
|
||||
has_snapshot: false,
|
||||
label: 'person',
|
||||
current_zones: currentZones,
|
||||
},
|
||||
after: {
|
||||
id: 'event-1',
|
||||
camera: 'camera.front_door',
|
||||
snapshot: null,
|
||||
has_clip: hasClip,
|
||||
has_snapshot: false,
|
||||
label: 'person',
|
||||
current_zones: currentZones,
|
||||
},
|
||||
});
|
||||
|
||||
expect(eventCallback).toBeCalledWith({
|
||||
type: 'end',
|
||||
cameraID: 'CAMERA_1',
|
||||
id: 'event-1',
|
||||
clip: false,
|
||||
snapshot: false,
|
||||
fidelity: 'high',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('should handle zones correctly', () => {
|
||||
it.each([
|
||||
['has no zone', [], false],
|
||||
@@ -1179,10 +1204,8 @@ describe('FrigateCamera', () => {
|
||||
const hass = createHASS();
|
||||
const eventWatcher = mock<FrigateEventWatcher>();
|
||||
await camera.initialize({
|
||||
hass: hass,
|
||||
hassManager: createHASSManager({ hass }),
|
||||
entityRegistryManager: mock<EntityRegistryManager>(),
|
||||
stateWatcher: mock<StateWatcherSubscriptionInterface>(),
|
||||
eventWatcher: mock<EventWatcherSubscriptionInterface>(),
|
||||
frigateEventWatcher: eventWatcher,
|
||||
frigateReviewWatcher: mock<FrigateReviewWatcher>(),
|
||||
});
|
||||
@@ -1239,10 +1262,8 @@ describe('FrigateCamera', () => {
|
||||
const hass = createHASS();
|
||||
const eventWatcher = mock<FrigateEventWatcher>();
|
||||
await camera.initialize({
|
||||
hass: hass,
|
||||
hassManager: createHASSManager({ hass }),
|
||||
entityRegistryManager: mock<EntityRegistryManager>(),
|
||||
stateWatcher: mock<StateWatcherSubscriptionInterface>(),
|
||||
eventWatcher: mock<EventWatcherSubscriptionInterface>(),
|
||||
frigateEventWatcher: eventWatcher,
|
||||
frigateReviewWatcher: mock<FrigateReviewWatcher>(),
|
||||
});
|
||||
@@ -1296,10 +1317,8 @@ describe('FrigateCamera', () => {
|
||||
const hass = createHASS();
|
||||
const eventWatcher = mock<FrigateEventWatcher>();
|
||||
await camera.initialize({
|
||||
hass: hass,
|
||||
hassManager: createHASSManager({ hass }),
|
||||
entityRegistryManager: mock<EntityRegistryManager>(),
|
||||
stateWatcher: mock<StateWatcherSubscriptionInterface>(),
|
||||
eventWatcher: mock<EventWatcherSubscriptionInterface>(),
|
||||
frigateEventWatcher: eventWatcher,
|
||||
frigateReviewWatcher: mock<FrigateReviewWatcher>(),
|
||||
});
|
||||
@@ -1350,15 +1369,12 @@ describe('FrigateCamera', () => {
|
||||
|
||||
const reviewWatcher = mock<FrigateReviewWatcher>();
|
||||
await camera.initialize({
|
||||
hass: hass,
|
||||
hassManager: createHASSManager({ hass }),
|
||||
entityRegistryManager: mock<EntityRegistryManager>(),
|
||||
stateWatcher: mock<StateWatcherSubscriptionInterface>(),
|
||||
eventWatcher: mock<EventWatcherSubscriptionInterface>(),
|
||||
frigateEventWatcher: mock<FrigateEventWatcher>(),
|
||||
frigateReviewWatcher: reviewWatcher,
|
||||
});
|
||||
expect(reviewWatcher.subscribe).toBeCalledWith(
|
||||
hass,
|
||||
expect.objectContaining({
|
||||
instanceID: 'CLIENT_ID',
|
||||
}),
|
||||
@@ -1384,10 +1400,8 @@ describe('FrigateCamera', () => {
|
||||
|
||||
const reviewWatcher = mock<FrigateReviewWatcher>();
|
||||
await camera.initialize({
|
||||
hass: hass,
|
||||
hassManager: createHASSManager({ hass }),
|
||||
entityRegistryManager: mock<EntityRegistryManager>(),
|
||||
stateWatcher: mock<StateWatcherSubscriptionInterface>(),
|
||||
eventWatcher: mock<EventWatcherSubscriptionInterface>(),
|
||||
frigateEventWatcher: mock<FrigateEventWatcher>(),
|
||||
frigateReviewWatcher: reviewWatcher,
|
||||
});
|
||||
@@ -1414,10 +1428,8 @@ describe('FrigateCamera', () => {
|
||||
|
||||
const reviewWatcher = mock<FrigateReviewWatcher>();
|
||||
await camera.initialize({
|
||||
hass: hass,
|
||||
hassManager: createHASSManager({ hass }),
|
||||
entityRegistryManager: mock<EntityRegistryManager>(),
|
||||
stateWatcher: mock<StateWatcherSubscriptionInterface>(),
|
||||
eventWatcher: mock<EventWatcherSubscriptionInterface>(),
|
||||
frigateEventWatcher: mock<FrigateEventWatcher>(),
|
||||
frigateReviewWatcher: reviewWatcher,
|
||||
});
|
||||
@@ -1449,10 +1461,8 @@ describe('FrigateCamera', () => {
|
||||
const hass = createHASS();
|
||||
const reviewWatcher = mock<FrigateReviewWatcher>();
|
||||
await camera.initialize({
|
||||
hass: hass,
|
||||
hassManager: createHASSManager({ hass }),
|
||||
entityRegistryManager: mock<EntityRegistryManager>(),
|
||||
stateWatcher: mock<StateWatcherSubscriptionInterface>(),
|
||||
eventWatcher: mock<EventWatcherSubscriptionInterface>(),
|
||||
frigateEventWatcher: mock<FrigateEventWatcher>(),
|
||||
frigateReviewWatcher: reviewWatcher,
|
||||
});
|
||||
@@ -1514,10 +1524,8 @@ describe('FrigateCamera', () => {
|
||||
const hass = createHASS();
|
||||
const reviewWatcher = mock<FrigateReviewWatcher>();
|
||||
await camera.initialize({
|
||||
hass: hass,
|
||||
hassManager: createHASSManager({ hass }),
|
||||
entityRegistryManager: mock<EntityRegistryManager>(),
|
||||
stateWatcher: mock<StateWatcherSubscriptionInterface>(),
|
||||
eventWatcher: mock<EventWatcherSubscriptionInterface>(),
|
||||
frigateEventWatcher: mock<FrigateEventWatcher>(),
|
||||
frigateReviewWatcher: reviewWatcher,
|
||||
});
|
||||
@@ -1587,10 +1595,8 @@ describe('FrigateCamera', () => {
|
||||
const hass = createHASS();
|
||||
const reviewWatcher = mock<FrigateReviewWatcher>();
|
||||
await camera.initialize({
|
||||
hass: hass,
|
||||
hassManager: createHASSManager({ hass }),
|
||||
entityRegistryManager: mock<EntityRegistryManager>(),
|
||||
stateWatcher: mock<StateWatcherSubscriptionInterface>(),
|
||||
eventWatcher: mock<EventWatcherSubscriptionInterface>(),
|
||||
frigateEventWatcher: mock<FrigateEventWatcher>(),
|
||||
frigateReviewWatcher: reviewWatcher,
|
||||
});
|
||||
@@ -1660,10 +1666,8 @@ describe('FrigateCamera', () => {
|
||||
const hass = createHASS();
|
||||
const reviewWatcher = mock<FrigateReviewWatcher>();
|
||||
await camera.initialize({
|
||||
hass: hass,
|
||||
hassManager: createHASSManager({ hass }),
|
||||
entityRegistryManager: mock<EntityRegistryManager>(),
|
||||
stateWatcher: mock<StateWatcherSubscriptionInterface>(),
|
||||
eventWatcher: mock<EventWatcherSubscriptionInterface>(),
|
||||
frigateEventWatcher: mock<FrigateEventWatcher>(),
|
||||
frigateReviewWatcher: reviewWatcher,
|
||||
});
|
||||
@@ -1719,10 +1723,8 @@ describe('FrigateCamera', () => {
|
||||
const hass = createHASS();
|
||||
const reviewWatcher = mock<FrigateReviewWatcher>();
|
||||
await camera.initialize({
|
||||
hass: hass,
|
||||
hassManager: createHASSManager({ hass }),
|
||||
entityRegistryManager: mock<EntityRegistryManager>(),
|
||||
stateWatcher: mock<StateWatcherSubscriptionInterface>(),
|
||||
eventWatcher: mock<EventWatcherSubscriptionInterface>(),
|
||||
frigateEventWatcher: mock<FrigateEventWatcher>(),
|
||||
frigateReviewWatcher: reviewWatcher,
|
||||
});
|
||||
@@ -1777,10 +1779,8 @@ describe('FrigateCamera', () => {
|
||||
const hass = createHASS();
|
||||
const reviewWatcher = mock<FrigateReviewWatcher>();
|
||||
await camera.initialize({
|
||||
hass: hass,
|
||||
hassManager: createHASSManager({ hass }),
|
||||
entityRegistryManager: mock<EntityRegistryManager>(),
|
||||
stateWatcher: mock<StateWatcherSubscriptionInterface>(),
|
||||
eventWatcher: mock<EventWatcherSubscriptionInterface>(),
|
||||
frigateEventWatcher: mock<FrigateEventWatcher>(),
|
||||
frigateReviewWatcher: reviewWatcher,
|
||||
});
|
||||
@@ -1839,10 +1839,8 @@ describe('FrigateCamera', () => {
|
||||
const hass = createHASS();
|
||||
const reviewWatcher = mock<FrigateReviewWatcher>();
|
||||
await camera.initialize({
|
||||
hass: hass,
|
||||
hassManager: createHASSManager({ hass }),
|
||||
entityRegistryManager: mock<EntityRegistryManager>(),
|
||||
stateWatcher: mock<StateWatcherSubscriptionInterface>(),
|
||||
eventWatcher: mock<EventWatcherSubscriptionInterface>(),
|
||||
frigateEventWatcher: mock<FrigateEventWatcher>(),
|
||||
frigateReviewWatcher: reviewWatcher,
|
||||
});
|
||||
@@ -1898,10 +1896,8 @@ describe('FrigateCamera', () => {
|
||||
const hass = createHASS();
|
||||
const reviewWatcher = mock<FrigateReviewWatcher>();
|
||||
await camera.initialize({
|
||||
hass: hass,
|
||||
hassManager: createHASSManager({ hass }),
|
||||
entityRegistryManager: mock<EntityRegistryManager>(),
|
||||
stateWatcher: mock<StateWatcherSubscriptionInterface>(),
|
||||
eventWatcher: mock<EventWatcherSubscriptionInterface>(),
|
||||
frigateEventWatcher: mock<FrigateEventWatcher>(),
|
||||
frigateReviewWatcher: reviewWatcher,
|
||||
});
|
||||
@@ -1965,10 +1961,8 @@ describe('FrigateCamera', () => {
|
||||
const hass = createHASS();
|
||||
const reviewWatcher = mock<FrigateReviewWatcher>();
|
||||
await camera.initialize({
|
||||
hass: hass,
|
||||
hassManager: createHASSManager({ hass }),
|
||||
entityRegistryManager: mock<EntityRegistryManager>(),
|
||||
stateWatcher: mock<StateWatcherSubscriptionInterface>(),
|
||||
eventWatcher: mock<EventWatcherSubscriptionInterface>(),
|
||||
frigateEventWatcher: mock<FrigateEventWatcher>(),
|
||||
frigateReviewWatcher: reviewWatcher,
|
||||
});
|
||||
@@ -2044,10 +2038,8 @@ describe('FrigateCamera', () => {
|
||||
|
||||
const hass = createHASS();
|
||||
await camera.initialize({
|
||||
hass: hass,
|
||||
hassManager: createHASSManager({ hass }),
|
||||
entityRegistryManager: entityRegistryManager,
|
||||
stateWatcher: mock<StateWatcherSubscriptionInterface>(),
|
||||
eventWatcher: mock<EventWatcherSubscriptionInterface>(),
|
||||
frigateEventWatcher: mock<FrigateEventWatcher>(),
|
||||
frigateReviewWatcher: mock<FrigateReviewWatcher>(),
|
||||
});
|
||||
@@ -2075,10 +2067,8 @@ describe('FrigateCamera', () => {
|
||||
|
||||
const hass = createHASS();
|
||||
await camera.initialize({
|
||||
hass: hass,
|
||||
hassManager: createHASSManager({ hass }),
|
||||
entityRegistryManager: entityRegistryManager,
|
||||
stateWatcher: mock<StateWatcherSubscriptionInterface>(),
|
||||
eventWatcher: mock<EventWatcherSubscriptionInterface>(),
|
||||
frigateEventWatcher: mock<FrigateEventWatcher>(),
|
||||
frigateReviewWatcher: mock<FrigateReviewWatcher>(),
|
||||
});
|
||||
@@ -2100,10 +2090,8 @@ describe('FrigateCamera', () => {
|
||||
);
|
||||
const hass = createHASS();
|
||||
await camera.initialize({
|
||||
hass: hass,
|
||||
hassManager: createHASSManager({ hass }),
|
||||
entityRegistryManager: new EntityRegistryManagerMock(),
|
||||
stateWatcher: mock<StateWatcherSubscriptionInterface>(),
|
||||
eventWatcher: mock<EventWatcherSubscriptionInterface>(),
|
||||
frigateEventWatcher: mock<FrigateEventWatcher>(),
|
||||
frigateReviewWatcher: mock<FrigateReviewWatcher>(),
|
||||
});
|
||||
@@ -2126,10 +2114,8 @@ describe('FrigateCamera', () => {
|
||||
);
|
||||
await expect(
|
||||
camera.initialize({
|
||||
hass: createHASS(),
|
||||
hassManager: createHASSManager(),
|
||||
entityRegistryManager: new EntityRegistryManagerMock(),
|
||||
stateWatcher: mock<StateWatcherSubscriptionInterface>(),
|
||||
eventWatcher: mock<EventWatcherSubscriptionInterface>(),
|
||||
frigateEventWatcher: mock<FrigateEventWatcher>(),
|
||||
frigateReviewWatcher: mock<FrigateReviewWatcher>(),
|
||||
}),
|
||||
@@ -2153,10 +2139,8 @@ describe('FrigateCamera', () => {
|
||||
);
|
||||
const hass = createHASS();
|
||||
await camera.initialize({
|
||||
hass: hass,
|
||||
hassManager: createHASSManager({ hass }),
|
||||
entityRegistryManager: entityRegistryManager,
|
||||
stateWatcher: mock<StateWatcherSubscriptionInterface>(),
|
||||
eventWatcher: mock<EventWatcherSubscriptionInterface>(),
|
||||
frigateEventWatcher: mock<FrigateEventWatcher>(),
|
||||
frigateReviewWatcher: mock<FrigateReviewWatcher>(),
|
||||
});
|
||||
@@ -2179,10 +2163,8 @@ describe('FrigateCamera', () => {
|
||||
);
|
||||
const hass = createHASS();
|
||||
await camera.initialize({
|
||||
hass: hass,
|
||||
hassManager: createHASSManager({ hass }),
|
||||
entityRegistryManager: entityRegistryManager,
|
||||
stateWatcher: mock<StateWatcherSubscriptionInterface>(),
|
||||
eventWatcher: mock<EventWatcherSubscriptionInterface>(),
|
||||
frigateEventWatcher: mock<FrigateEventWatcher>(),
|
||||
frigateReviewWatcher: mock<FrigateReviewWatcher>(),
|
||||
});
|
||||
@@ -2204,10 +2186,8 @@ describe('FrigateCamera', () => {
|
||||
);
|
||||
const hass = createHASS();
|
||||
await camera.initialize({
|
||||
hass: hass,
|
||||
hassManager: createHASSManager({ hass }),
|
||||
entityRegistryManager: new EntityRegistryManagerMock(),
|
||||
stateWatcher: mock<StateWatcherSubscriptionInterface>(),
|
||||
eventWatcher: mock<EventWatcherSubscriptionInterface>(),
|
||||
frigateEventWatcher: mock<FrigateEventWatcher>(),
|
||||
frigateReviewWatcher: mock<FrigateReviewWatcher>(),
|
||||
});
|
||||
@@ -2238,10 +2218,8 @@ describe('FrigateCamera', () => {
|
||||
);
|
||||
const hass = createHASS();
|
||||
await camera.initialize({
|
||||
hass: hass,
|
||||
hassManager: createHASSManager({ hass }),
|
||||
entityRegistryManager: entityRegistryManager,
|
||||
stateWatcher: mock<StateWatcherSubscriptionInterface>(),
|
||||
eventWatcher: mock<EventWatcherSubscriptionInterface>(),
|
||||
frigateEventWatcher: mock<FrigateEventWatcher>(),
|
||||
frigateReviewWatcher: mock<FrigateReviewWatcher>(),
|
||||
});
|
||||
@@ -2274,10 +2252,8 @@ describe('FrigateCamera', () => {
|
||||
);
|
||||
const hass = createHASS();
|
||||
await camera.initialize({
|
||||
hass: hass,
|
||||
hassManager: createHASSManager({ hass }),
|
||||
entityRegistryManager: entityRegistryManager,
|
||||
stateWatcher: mock<StateWatcherSubscriptionInterface>(),
|
||||
eventWatcher: mock<EventWatcherSubscriptionInterface>(),
|
||||
frigateEventWatcher: mock<FrigateEventWatcher>(),
|
||||
frigateReviewWatcher: mock<FrigateReviewWatcher>(),
|
||||
});
|
||||
@@ -2295,10 +2271,8 @@ describe('FrigateCamera', () => {
|
||||
|
||||
const hass = createHASS();
|
||||
await camera.initialize({
|
||||
hass: hass,
|
||||
hassManager: createHASSManager({ hass }),
|
||||
entityRegistryManager: mock<EntityRegistryManager>(),
|
||||
stateWatcher: mock<StateWatcherSubscriptionInterface>(),
|
||||
eventWatcher: mock<EventWatcherSubscriptionInterface>(),
|
||||
frigateEventWatcher: mock<FrigateEventWatcher>(),
|
||||
frigateReviewWatcher: mock<FrigateReviewWatcher>(),
|
||||
});
|
||||
@@ -2327,12 +2301,10 @@ describe('FrigateCamera', () => {
|
||||
);
|
||||
|
||||
await camera.initialize({
|
||||
hass: createHASS(),
|
||||
hassManager: createHASSManager(),
|
||||
entityRegistryManager: new EntityRegistryManagerMock([
|
||||
createRegistryEntity({ entity_id: 'camera.office_frigate' }),
|
||||
]),
|
||||
stateWatcher: mock<StateWatcherSubscriptionInterface>(),
|
||||
eventWatcher: mock<EventWatcherSubscriptionInterface>(),
|
||||
frigateEventWatcher: mock<FrigateEventWatcher>(),
|
||||
frigateReviewWatcher: mock<FrigateReviewWatcher>(),
|
||||
});
|
||||
@@ -2366,12 +2338,10 @@ describe('FrigateCamera', () => {
|
||||
);
|
||||
|
||||
await camera.initialize({
|
||||
hass: createHASS(),
|
||||
hassManager: createHASSManager(),
|
||||
entityRegistryManager: new EntityRegistryManagerMock([
|
||||
createRegistryEntity({ entity_id: 'camera.office_frigate' }),
|
||||
]),
|
||||
stateWatcher: mock<StateWatcherSubscriptionInterface>(),
|
||||
eventWatcher: mock<EventWatcherSubscriptionInterface>(),
|
||||
frigateEventWatcher: mock<FrigateEventWatcher>(),
|
||||
frigateReviewWatcher: mock<FrigateReviewWatcher>(),
|
||||
});
|
||||
@@ -2420,12 +2390,10 @@ describe('FrigateCamera', () => {
|
||||
);
|
||||
|
||||
await camera.initialize({
|
||||
hass: createHASS(),
|
||||
hassManager: createHASSManager(),
|
||||
entityRegistryManager: new EntityRegistryManagerMock([
|
||||
createRegistryEntity({ entity_id: 'camera.office_frigate' }),
|
||||
]),
|
||||
stateWatcher: mock<StateWatcherSubscriptionInterface>(),
|
||||
eventWatcher: mock<EventWatcherSubscriptionInterface>(),
|
||||
frigateEventWatcher: mock<FrigateEventWatcher>(),
|
||||
frigateReviewWatcher: mock<FrigateReviewWatcher>(),
|
||||
});
|
||||
@@ -2457,12 +2425,10 @@ describe('FrigateCamera', () => {
|
||||
);
|
||||
|
||||
await camera.initialize({
|
||||
hass: createHASS(),
|
||||
hassManager: createHASSManager(),
|
||||
entityRegistryManager: new EntityRegistryManagerMock([
|
||||
createRegistryEntity({ entity_id: 'camera.office_frigate' }),
|
||||
]),
|
||||
stateWatcher: mock<StateWatcherSubscriptionInterface>(),
|
||||
eventWatcher: mock<EventWatcherSubscriptionInterface>(),
|
||||
frigateEventWatcher: mock<FrigateEventWatcher>(),
|
||||
frigateReviewWatcher: mock<FrigateReviewWatcher>(),
|
||||
});
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { afterEach, assert, describe, expect, it, vi } from 'vitest';
|
||||
import { mock } from 'vitest-mock-extended';
|
||||
import { RecordingSegmentsCache } from '../../../src/camera-manager/cache';
|
||||
import { Camera } from '../../../src/camera-manager/camera';
|
||||
import {
|
||||
@@ -33,8 +32,6 @@ import {
|
||||
QueryResultsType,
|
||||
QueryType,
|
||||
} from '../../../src/camera-manager/types';
|
||||
import { EventWatcherSubscriptionInterface } from '../../../src/card-controller/hass/event-watcher';
|
||||
import { StateWatcher } from '../../../src/card-controller/hass/state-watcher';
|
||||
import { CameraConfig } from '../../../src/config/schema/cameras';
|
||||
import { RawAdvancedCameraCardConfig } from '../../../src/config/types';
|
||||
import { QuerySource } from '../../../src/query-source';
|
||||
@@ -47,6 +44,7 @@ import {
|
||||
createFrigateRecording,
|
||||
createFrigateReview,
|
||||
createHASS,
|
||||
createHASSManager,
|
||||
createStore,
|
||||
TestViewMedia,
|
||||
} from '../../test-utils';
|
||||
@@ -59,8 +57,7 @@ const createEngine = (options?: {
|
||||
}): FrigateCameraManagerEngine => {
|
||||
return new FrigateCameraManagerEngine(
|
||||
new EntityRegistryManagerMock(),
|
||||
new StateWatcher(),
|
||||
mock<EventWatcherSubscriptionInterface>(),
|
||||
createHASSManager(),
|
||||
options?.cache ?? new RecordingSegmentsCache(),
|
||||
options?.requestCache ?? new CameraManagerRequestCache(),
|
||||
);
|
||||
@@ -223,10 +220,7 @@ describe('FrigateCameraManagerEngine', () => {
|
||||
const engine = createEngine();
|
||||
vi.mocked(getPTZInfo).mockResolvedValue({ features: [], presets: [] });
|
||||
|
||||
const camera = await engine.createCamera(
|
||||
createHASS(),
|
||||
createFrigateCameraConfig(),
|
||||
);
|
||||
const camera = await engine.createCamera(createFrigateCameraConfig());
|
||||
|
||||
expect(camera).toBeInstanceOf(Camera);
|
||||
});
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { afterEach, assert, describe, expect, it, vi } from 'vitest';
|
||||
import { Connection } from 'home-assistant-js-websocket';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { mock } from 'vitest-mock-extended';
|
||||
import {
|
||||
FrigateEventChange,
|
||||
FrigateReviewChange,
|
||||
@@ -8,7 +10,7 @@ import {
|
||||
FrigateReviewWatcher,
|
||||
} from '../../../src/camera-manager/frigate/watcher.js';
|
||||
import { HomeAssistant } from '../../../src/ha/types.js';
|
||||
import { createHASS } from '../../test-utils.js';
|
||||
import { createHASS, createHASSSource, flushPromises } from '../../test-utils.js';
|
||||
|
||||
const createEventChange = (): FrigateEventChange => {
|
||||
return {
|
||||
@@ -68,110 +70,91 @@ const createReviewChange = (): FrigateReviewChange => {
|
||||
},
|
||||
};
|
||||
};
|
||||
const callHASubscribeMessageCallback = (
|
||||
hass: HomeAssistant,
|
||||
data: unknown,
|
||||
n = 0,
|
||||
): void => {
|
||||
|
||||
// Drive the dispatcher registered with `hass.connection.subscribeMessage` to
|
||||
// simulate a Frigate WS message arriving over the bus.
|
||||
const fireMessage = (hass: HomeAssistant, data: unknown, n = 0): void => {
|
||||
const mock = vi.mocked(hass.connection.subscribeMessage).mock;
|
||||
expect(mock.calls.length).greaterThan(n);
|
||||
mock.calls[n][0](data);
|
||||
};
|
||||
|
||||
// @vitest-environment jsdom
|
||||
describe('FrigateEventWatcher', () => {
|
||||
it('should subscribe to a given topic once', async () => {
|
||||
const stateWatcher = new FrigateEventWatcher();
|
||||
const hass = createHASS();
|
||||
|
||||
await stateWatcher.subscribe(hass, {
|
||||
instanceID: 'frigate',
|
||||
callback: vi.fn(),
|
||||
});
|
||||
|
||||
await stateWatcher.subscribe(hass, {
|
||||
instanceID: 'frigate',
|
||||
callback: vi.fn(),
|
||||
});
|
||||
|
||||
expect(hass.connection.subscribeMessage).toBeCalledTimes(1);
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('should only subscribe from a given topic once', async () => {
|
||||
const stateWatcher = new FrigateEventWatcher();
|
||||
it('should open a WS subscription with the frigate event type and instance id', async () => {
|
||||
const hass = createHASS();
|
||||
const { source } = createHASSSource(hass);
|
||||
const watcher = new FrigateEventWatcher(source);
|
||||
|
||||
const unsubscribeCallback = vi.fn();
|
||||
vi.mocked(hass.connection.subscribeMessage).mockResolvedValue(unsubscribeCallback);
|
||||
watcher.subscribe({ instanceID: 'frigate', callback: vi.fn() });
|
||||
await flushPromises();
|
||||
|
||||
const request_1 = {
|
||||
instanceID: 'frigate',
|
||||
callback: vi.fn(),
|
||||
};
|
||||
const request_2 = { ...request_1 };
|
||||
|
||||
await stateWatcher.subscribe(hass, request_1);
|
||||
await stateWatcher.subscribe(hass, request_2);
|
||||
|
||||
await stateWatcher.unsubscribe(request_1);
|
||||
expect(unsubscribeCallback).not.toBeCalled();
|
||||
|
||||
await stateWatcher.unsubscribe(request_2);
|
||||
expect(unsubscribeCallback).toBeCalledTimes(1);
|
||||
expect(hass.connection.subscribeMessage).toBeCalledWith(
|
||||
expect.any(Function),
|
||||
expect.objectContaining({
|
||||
type: 'frigate/events/subscribe',
|
||||
instance_id: 'frigate',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle unsubscribe during pending subscription', async () => {
|
||||
const stateWatcher = new FrigateEventWatcher();
|
||||
it('should unsubscribe from the WS subscription', async () => {
|
||||
const hass = createHASS();
|
||||
const unsub = vi.fn();
|
||||
vi.mocked(hass.connection.subscribeMessage).mockResolvedValue(unsub);
|
||||
const { source } = createHASSSource(hass);
|
||||
const watcher = new FrigateEventWatcher(source);
|
||||
const request = { instanceID: 'frigate', callback: vi.fn() };
|
||||
|
||||
let resolveSubscription: ((callback: () => Promise<void>) => void) | undefined;
|
||||
const subscriptionPromise = new Promise<() => Promise<void>>((resolve) => {
|
||||
resolveSubscription = resolve;
|
||||
});
|
||||
vi.mocked(hass.connection.subscribeMessage).mockReturnValue(subscriptionPromise);
|
||||
watcher.subscribe(request);
|
||||
await flushPromises();
|
||||
|
||||
const request = {
|
||||
instanceID: 'frigate',
|
||||
callback: vi.fn(),
|
||||
};
|
||||
watcher.unsubscribe(request);
|
||||
await flushPromises();
|
||||
|
||||
// Start subscription (doesn't complete yet as not awaited).
|
||||
const subscribePromise = stateWatcher.subscribe(hass, request);
|
||||
expect(unsub).toBeCalledTimes(1);
|
||||
});
|
||||
|
||||
// Unsubscribe while subscription is still pending.
|
||||
const unsubscribePromise = stateWatcher.unsubscribe(request);
|
||||
it('should drop messages from an old-connection subscription after a swap', async () => {
|
||||
const oldHass = createHASS();
|
||||
const { source, push } = createHASSSource(oldHass);
|
||||
const watcher = new FrigateEventWatcher(source);
|
||||
const callback = vi.fn();
|
||||
|
||||
// Complete the subscription: both subscribe and unsubscribe await the same
|
||||
// pending promise, and unsubscribe then invokes the resolved unsub.
|
||||
const unsubscribeCallback = vi.fn();
|
||||
assert(resolveSubscription);
|
||||
resolveSubscription(unsubscribeCallback);
|
||||
await subscribePromise;
|
||||
await unsubscribePromise;
|
||||
watcher.subscribe({ instanceID: 'frigate', callback });
|
||||
await flushPromises();
|
||||
|
||||
expect(unsubscribeCallback).toBeCalledTimes(1);
|
||||
callHASubscribeMessageCallback(hass, JSON.stringify(createEventChange()));
|
||||
expect(request.callback).not.toBeCalled();
|
||||
// Capture the dispatcher registered against the OLD connection before the
|
||||
// swap, so it still points at the now-stale era guard.
|
||||
const oldDispatcher = vi.mocked(oldHass.connection.subscribeMessage).mock
|
||||
.calls[0][0];
|
||||
|
||||
const newHass = createHASS();
|
||||
newHass.connection = mock<Connection>();
|
||||
vi.mocked(newHass.connection.subscribeMessage).mockResolvedValue(vi.fn());
|
||||
push(newHass);
|
||||
await flushPromises();
|
||||
|
||||
oldDispatcher(JSON.stringify(createEventChange()));
|
||||
expect(callback).not.toBeCalled();
|
||||
});
|
||||
|
||||
describe('should call handler', () => {
|
||||
afterEach(() => {
|
||||
vi.resetAllMocks();
|
||||
});
|
||||
|
||||
it('with invalid JSON', async () => {
|
||||
const spy = vi.spyOn(global.console, 'warn').mockImplementation(() => true);
|
||||
|
||||
const stateWatcher = new FrigateEventWatcher();
|
||||
const hass = createHASS();
|
||||
const { source } = createHASSSource(hass);
|
||||
const watcher = new FrigateEventWatcher(source);
|
||||
|
||||
const callback = vi.fn();
|
||||
const request = {
|
||||
instanceID: 'frigate',
|
||||
callback: callback,
|
||||
};
|
||||
|
||||
await stateWatcher.subscribe(hass, request);
|
||||
callHASubscribeMessageCallback(hass, 'NOT_JSON');
|
||||
watcher.subscribe({ instanceID: 'frigate', callback });
|
||||
await flushPromises();
|
||||
fireMessage(hass, 'NOT_JSON');
|
||||
|
||||
expect(callback).not.toBeCalled();
|
||||
expect(spy).toBeCalledWith(
|
||||
@@ -183,18 +166,15 @@ describe('FrigateEventWatcher', () => {
|
||||
it('with malformed event', async () => {
|
||||
const spy = vi.spyOn(global.console, 'warn').mockImplementation(() => true);
|
||||
|
||||
const stateWatcher = new FrigateEventWatcher();
|
||||
const hass = createHASS();
|
||||
const { source } = createHASSSource(hass);
|
||||
const watcher = new FrigateEventWatcher(source);
|
||||
|
||||
const callback = vi.fn();
|
||||
const request = {
|
||||
instanceID: 'frigate',
|
||||
callback: callback,
|
||||
};
|
||||
|
||||
await stateWatcher.subscribe(hass, request);
|
||||
watcher.subscribe({ instanceID: 'frigate', callback });
|
||||
await flushPromises();
|
||||
const data = JSON.stringify({});
|
||||
callHASubscribeMessageCallback(hass, data);
|
||||
fireMessage(hass, data);
|
||||
|
||||
expect(callback).not.toBeCalled();
|
||||
expect(spy).toBeCalledWith(
|
||||
@@ -204,71 +184,40 @@ describe('FrigateEventWatcher', () => {
|
||||
});
|
||||
|
||||
it('without a matcher', async () => {
|
||||
const stateWatcher = new FrigateEventWatcher();
|
||||
const hass = createHASS();
|
||||
const { source } = createHASSSource(hass);
|
||||
const watcher = new FrigateEventWatcher(source);
|
||||
|
||||
const callback = vi.fn();
|
||||
const request = {
|
||||
instanceID: 'frigate',
|
||||
callback: callback,
|
||||
};
|
||||
|
||||
await stateWatcher.subscribe(hass, request);
|
||||
watcher.subscribe({ instanceID: 'frigate', callback });
|
||||
await flushPromises();
|
||||
const eventChange = createEventChange();
|
||||
callHASubscribeMessageCallback(hass, JSON.stringify(eventChange));
|
||||
fireMessage(hass, JSON.stringify(eventChange));
|
||||
|
||||
expect(callback).toBeCalledWith(eventChange);
|
||||
});
|
||||
|
||||
it('with a non-matching instance_id', async () => {
|
||||
const stateWatcher = new FrigateEventWatcher();
|
||||
const hass = createHASS();
|
||||
|
||||
const callback_1 = vi.fn();
|
||||
const request_1 = {
|
||||
instanceID: 'frigate_1',
|
||||
callback: callback_1,
|
||||
};
|
||||
|
||||
const callback_2 = vi.fn();
|
||||
const request_2 = {
|
||||
instanceID: 'frigate_2',
|
||||
callback: callback_2,
|
||||
};
|
||||
|
||||
await stateWatcher.subscribe(hass, request_1);
|
||||
await stateWatcher.subscribe(hass, request_2);
|
||||
|
||||
const eventChange = createEventChange();
|
||||
callHASubscribeMessageCallback(hass, JSON.stringify(eventChange), 1);
|
||||
|
||||
expect(callback_1).not.toBeCalledWith(eventChange);
|
||||
expect(callback_2).toBeCalledWith(eventChange);
|
||||
});
|
||||
|
||||
it('with a matcher', async () => {
|
||||
const stateWatcher = new FrigateEventWatcher();
|
||||
const hass = createHASS();
|
||||
const { source } = createHASSSource(hass);
|
||||
const watcher = new FrigateEventWatcher(source);
|
||||
|
||||
const matching_callback = vi.fn();
|
||||
const matching_request = {
|
||||
const non_matching_callback = vi.fn();
|
||||
watcher.subscribe({
|
||||
instanceID: 'frigate',
|
||||
callback: matching_callback,
|
||||
matcher: (event: FrigateEventChange) => event.after.camera === 'front_door',
|
||||
};
|
||||
|
||||
const non_matching_callback = vi.fn();
|
||||
const non_matching_request = {
|
||||
});
|
||||
watcher.subscribe({
|
||||
instanceID: 'frigate',
|
||||
callback: non_matching_callback,
|
||||
matcher: (event: FrigateEventChange) => event.after.camera === 'back_door',
|
||||
};
|
||||
|
||||
await stateWatcher.subscribe(hass, matching_request);
|
||||
await stateWatcher.subscribe(hass, non_matching_request);
|
||||
});
|
||||
await flushPromises();
|
||||
|
||||
const eventChange = createEventChange();
|
||||
callHASubscribeMessageCallback(hass, JSON.stringify(eventChange));
|
||||
fireMessage(hass, JSON.stringify(eventChange));
|
||||
|
||||
expect(non_matching_callback).not.toBeCalledWith(eventChange);
|
||||
expect(matching_callback).toBeCalledWith(eventChange);
|
||||
@@ -276,20 +225,20 @@ describe('FrigateEventWatcher', () => {
|
||||
});
|
||||
});
|
||||
|
||||
// @vitest-environment jsdom
|
||||
describe('FrigateReviewWatcher', () => {
|
||||
it('should subscribe to a given topic once', async () => {
|
||||
const stateWatcher = new FrigateReviewWatcher();
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('should subscribe to the frigate reviews channel and dispatch review changes', async () => {
|
||||
const hass = createHASS();
|
||||
const { source } = createHASSSource(hass);
|
||||
const watcher = new FrigateReviewWatcher(source);
|
||||
|
||||
await stateWatcher.subscribe(hass, {
|
||||
instanceID: 'frigate',
|
||||
callback: vi.fn(),
|
||||
});
|
||||
|
||||
await stateWatcher.subscribe(hass, {
|
||||
instanceID: 'frigate',
|
||||
callback: vi.fn(),
|
||||
});
|
||||
const callback = vi.fn();
|
||||
watcher.subscribe({ instanceID: 'frigate', callback });
|
||||
await flushPromises();
|
||||
|
||||
expect(hass.connection.subscribeMessage).toBeCalledWith(
|
||||
expect.any(Function),
|
||||
@@ -297,73 +246,10 @@ describe('FrigateReviewWatcher', () => {
|
||||
type: 'frigate/reviews/subscribe',
|
||||
}),
|
||||
);
|
||||
expect(hass.connection.subscribeMessage).toBeCalledTimes(1);
|
||||
});
|
||||
|
||||
describe('should call handler', () => {
|
||||
afterEach(() => {
|
||||
vi.resetAllMocks();
|
||||
});
|
||||
const reviewChange = createReviewChange();
|
||||
fireMessage(hass, JSON.stringify(reviewChange));
|
||||
|
||||
it('with a review change', async () => {
|
||||
const stateWatcher = new FrigateReviewWatcher();
|
||||
const hass = createHASS();
|
||||
|
||||
const callback = vi.fn();
|
||||
const request = {
|
||||
instanceID: 'frigate',
|
||||
callback: callback,
|
||||
};
|
||||
|
||||
await stateWatcher.subscribe(hass, request);
|
||||
|
||||
const reviewChange = createReviewChange();
|
||||
|
||||
callHASubscribeMessageCallback(hass, JSON.stringify(reviewChange));
|
||||
|
||||
expect(callback).toBeCalledWith(reviewChange);
|
||||
});
|
||||
|
||||
it('with a genai review change', async () => {
|
||||
const stateWatcher = new FrigateReviewWatcher();
|
||||
const hass = createHASS();
|
||||
|
||||
const callback = vi.fn();
|
||||
const request = {
|
||||
instanceID: 'frigate',
|
||||
callback: callback,
|
||||
};
|
||||
|
||||
await stateWatcher.subscribe(hass, request);
|
||||
|
||||
const reviewChange = createReviewChange();
|
||||
reviewChange.type = 'genai';
|
||||
|
||||
callHASubscribeMessageCallback(hass, JSON.stringify(reviewChange));
|
||||
|
||||
expect(callback).toBeCalledWith(reviewChange);
|
||||
});
|
||||
|
||||
it('with invalid JSON', async () => {
|
||||
const spy = vi.spyOn(global.console, 'warn').mockImplementation(() => true);
|
||||
|
||||
const stateWatcher = new FrigateReviewWatcher();
|
||||
const hass = createHASS();
|
||||
|
||||
const callback = vi.fn();
|
||||
const request = {
|
||||
instanceID: 'frigate',
|
||||
callback: callback,
|
||||
};
|
||||
|
||||
await stateWatcher.subscribe(hass, request);
|
||||
callHASubscribeMessageCallback(hass, 'NOT_JSON');
|
||||
|
||||
expect(callback).not.toBeCalled();
|
||||
expect(spy).toBeCalledWith(
|
||||
'Received non-JSON payload from subscription: frigate/reviews/subscribe',
|
||||
'NOT_JSON',
|
||||
);
|
||||
});
|
||||
expect(callback).toBeCalledWith(reviewChange);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,9 +1,6 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { mock } from 'vitest-mock-extended';
|
||||
import { GenericCameraManagerEngine } from '../../../src/camera-manager/generic/engine-generic';
|
||||
import { Engine, QueryResultsType, QueryType } from '../../../src/camera-manager/types';
|
||||
import { EventWatcherSubscriptionInterface } from '../../../src/card-controller/hass/event-watcher';
|
||||
import { StateWatcherSubscriptionInterface } from '../../../src/card-controller/hass/state-watcher';
|
||||
import { CameraConfig } from '../../../src/config/schema/cameras';
|
||||
import { RawAdvancedCameraCardConfig } from '../../../src/config/types';
|
||||
import { QuerySource } from '../../../src/query-source';
|
||||
@@ -11,15 +8,13 @@ import {
|
||||
TestViewMedia,
|
||||
createCameraConfig,
|
||||
createHASS,
|
||||
createHASSManager,
|
||||
createStateEntity,
|
||||
createStore,
|
||||
} from '../../test-utils';
|
||||
|
||||
const createEngine = (): GenericCameraManagerEngine => {
|
||||
return new GenericCameraManagerEngine(
|
||||
mock<StateWatcherSubscriptionInterface>(),
|
||||
mock<EventWatcherSubscriptionInterface>(),
|
||||
);
|
||||
return new GenericCameraManagerEngine(createHASSManager());
|
||||
};
|
||||
|
||||
const createGenericCameraConfig = (
|
||||
@@ -35,7 +30,7 @@ describe('GenericCameraManagerEngine', () => {
|
||||
|
||||
it('should initialize camera', async () => {
|
||||
const config = createGenericCameraConfig();
|
||||
const camera = await createEngine().createCamera(createHASS(), config);
|
||||
const camera = await createEngine().createCamera(config);
|
||||
|
||||
expect(camera.getConfig()).toEqual(config);
|
||||
expect(camera.getCapabilities()).toBeTruthy();
|
||||
@@ -50,7 +45,7 @@ describe('GenericCameraManagerEngine', () => {
|
||||
|
||||
it('should get default query parameters', async () => {
|
||||
const config = createGenericCameraConfig();
|
||||
const camera = await createEngine().createCamera(createHASS(), config);
|
||||
const camera = await createEngine().createCamera(config);
|
||||
expect(createEngine().getDefaultQueryParameters(camera, QueryType.Event)).toEqual(
|
||||
{},
|
||||
);
|
||||
@@ -375,16 +370,12 @@ describe('GenericCameraManagerEngine', () => {
|
||||
|
||||
describe('should get camera endpoints', () => {
|
||||
it('default', async () => {
|
||||
const camera = await createEngine().createCamera(
|
||||
createHASS(),
|
||||
createGenericCameraConfig(),
|
||||
);
|
||||
const camera = await createEngine().createCamera(createGenericCameraConfig());
|
||||
expect(camera.getEndpoints()).toBeNull();
|
||||
});
|
||||
|
||||
it('for go2rtc', async () => {
|
||||
const camera = await createEngine().createCamera(
|
||||
createHASS(),
|
||||
createGenericCameraConfig({
|
||||
go2rtc: {
|
||||
stream: 'stream',
|
||||
@@ -403,7 +394,6 @@ describe('GenericCameraManagerEngine', () => {
|
||||
|
||||
it('for webrtc-card', async () => {
|
||||
const camera = await createEngine().createCamera(
|
||||
createHASS(),
|
||||
createGenericCameraConfig({
|
||||
camera_entity: 'camera.office',
|
||||
}),
|
||||
|
||||
@@ -31,7 +31,6 @@ import { StateWatcherSubscriptionInterface } from '../../src/card-controller/has
|
||||
import { sortItems } from '../../src/card-controller/view/sort.js';
|
||||
import { CameraConfig } from '../../src/config/schema/cameras.js';
|
||||
import { advancedCameraCardConfigSchema } from '../../src/config/schema/types.js';
|
||||
import { HomeAssistant } from '../../src/ha/types.js';
|
||||
import { QuerySource } from '../../src/query-source.js';
|
||||
import { Endpoint, PTZMovementType } from '../../src/types.js';
|
||||
import { ViewFolder, ViewItem, ViewMedia } from '../../src/view/item.js';
|
||||
@@ -297,7 +296,7 @@ describe('CameraManager', () => {
|
||||
camera.engineType === undefined ? Engine.Generic : camera.engineType;
|
||||
if (engineType) {
|
||||
vi.mocked(mockEngine.createCamera).mockImplementationOnce(
|
||||
async (_hass: HomeAssistant, cameraConfig: CameraConfig): Promise<Camera> =>
|
||||
async (cameraConfig: CameraConfig): Promise<Camera> =>
|
||||
await createInitializedCamera(
|
||||
cameraConfig,
|
||||
mockEngine,
|
||||
|
||||
@@ -2,10 +2,12 @@ import { describe, expect, it } from 'vitest';
|
||||
import { mock } from 'vitest-mock-extended';
|
||||
import { CameraManagerEngine } from '../../../src/camera-manager/engine';
|
||||
import { MotionEyeCamera } from '../../../src/camera-manager/motioneye/camera';
|
||||
import { EventWatcherSubscriptionInterface } from '../../../src/card-controller/hass/event-watcher';
|
||||
import { StateWatcherSubscriptionInterface } from '../../../src/card-controller/hass/state-watcher';
|
||||
import { EntityRegistryManagerMock } from '../../ha/registry/entity/mock';
|
||||
import { createCameraConfig, createHASS, createRegistryEntity } from '../../test-utils';
|
||||
import {
|
||||
createCameraConfig,
|
||||
createHASSManager,
|
||||
createRegistryEntity,
|
||||
} from '../../test-utils';
|
||||
|
||||
const cameraEntity = createRegistryEntity({
|
||||
entity_id: 'camera.motioneye',
|
||||
@@ -47,10 +49,8 @@ describe('MotionEyeCamera', () => {
|
||||
});
|
||||
const camera = new MotionEyeCamera(config, mock<CameraManagerEngine>());
|
||||
await camera.initialize({
|
||||
hass: createHASS(),
|
||||
hassManager: createHASSManager(),
|
||||
entityRegistryManager: new EntityRegistryManagerMock([cameraEntity]),
|
||||
stateWatcher: mock<StateWatcherSubscriptionInterface>(),
|
||||
eventWatcher: mock<EventWatcherSubscriptionInterface>(),
|
||||
});
|
||||
|
||||
const endpoints = camera.getEndpoints();
|
||||
@@ -65,10 +65,8 @@ describe('MotionEyeCamera', () => {
|
||||
});
|
||||
const camera = new MotionEyeCamera(config, mock<CameraManagerEngine>());
|
||||
await camera.initialize({
|
||||
hass: createHASS(),
|
||||
hassManager: createHASSManager(),
|
||||
entityRegistryManager: new EntityRegistryManagerMock([cameraEntity]),
|
||||
stateWatcher: mock<StateWatcherSubscriptionInterface>(),
|
||||
eventWatcher: mock<EventWatcherSubscriptionInterface>(),
|
||||
});
|
||||
|
||||
const endpoints = camera.getEndpoints();
|
||||
@@ -83,10 +81,8 @@ describe('MotionEyeCamera', () => {
|
||||
});
|
||||
const camera = new MotionEyeCamera(config, mock<CameraManagerEngine>());
|
||||
await camera.initialize({
|
||||
hass: createHASS(),
|
||||
hassManager: createHASSManager(),
|
||||
entityRegistryManager: new EntityRegistryManagerMock([cameraEntity]),
|
||||
stateWatcher: mock<StateWatcherSubscriptionInterface>(),
|
||||
eventWatcher: mock<EventWatcherSubscriptionInterface>(),
|
||||
});
|
||||
|
||||
const capabilities = camera.getCapabilities();
|
||||
@@ -109,10 +105,8 @@ describe('MotionEyeCamera', () => {
|
||||
});
|
||||
const camera = new MotionEyeCamera(config, mock<CameraManagerEngine>());
|
||||
await camera.initialize({
|
||||
hass: createHASS(),
|
||||
hassManager: createHASSManager(),
|
||||
entityRegistryManager: new EntityRegistryManagerMock([cameraEntity]),
|
||||
stateWatcher: mock<StateWatcherSubscriptionInterface>(),
|
||||
eventWatcher: mock<EventWatcherSubscriptionInterface>(),
|
||||
});
|
||||
|
||||
const capabilities = camera.getCapabilities();
|
||||
|
||||
@@ -14,8 +14,6 @@ import {
|
||||
QueryResultsType,
|
||||
QueryType,
|
||||
} from '../../../src/camera-manager/types';
|
||||
import { EventWatcherSubscriptionInterface } from '../../../src/card-controller/hass/event-watcher';
|
||||
import { StateWatcherSubscriptionInterface } from '../../../src/card-controller/hass/state-watcher';
|
||||
import { BrowseMediaMetadata } from '../../../src/ha/browse-media/types';
|
||||
import { BrowseMediaStep, BrowseMediaWalker } from '../../../src/ha/browse-media/walker';
|
||||
import { Entity } from '../../../src/ha/registry/entity/types';
|
||||
@@ -25,6 +23,7 @@ import { EntityRegistryManagerMock } from '../../ha/registry/entity/mock';
|
||||
import {
|
||||
createCameraConfig,
|
||||
createHASS,
|
||||
createHASSManager,
|
||||
createRegistryEntity,
|
||||
createRichBrowseMedia,
|
||||
} from '../../test-utils';
|
||||
@@ -51,8 +50,7 @@ const createEngine = (options?: {
|
||||
}): MotionEyeCameraManagerEngine => {
|
||||
return new MotionEyeCameraManagerEngine(
|
||||
new EntityRegistryManagerMock(options?.entities ?? [createEntity()]),
|
||||
mock<StateWatcherSubscriptionInterface>(),
|
||||
mock<EventWatcherSubscriptionInterface>(),
|
||||
createHASSManager(),
|
||||
options?.walker ?? new BrowseMediaWalker(),
|
||||
new ResolvedMediaCache(),
|
||||
options?.requestCache ?? new CameraManagerRequestCache(),
|
||||
@@ -73,10 +71,8 @@ const createMotionEyeStore = async (
|
||||
});
|
||||
const camera = new MotionEyeCamera(config, engine);
|
||||
await camera.initialize({
|
||||
hass: createHASS(),
|
||||
hassManager: createHASSManager(),
|
||||
entityRegistryManager: new EntityRegistryManagerMock([entity]),
|
||||
stateWatcher: mock<StateWatcherSubscriptionInterface>(),
|
||||
eventWatcher: mock<EventWatcherSubscriptionInterface>(),
|
||||
});
|
||||
camera.setID(options?.cameraID ?? 'camera-1');
|
||||
const store = new CameraManagerStore();
|
||||
@@ -136,7 +132,7 @@ describe('MotionEyeCameraManagerEngine', () => {
|
||||
camera_entity: CAMERA_ENTITY_ID,
|
||||
});
|
||||
|
||||
const camera = await engine.createCamera(createHASS(), config);
|
||||
const camera = await engine.createCamera(config);
|
||||
|
||||
expect(camera).toBeInstanceOf(Camera);
|
||||
expect(camera).toBeInstanceOf(MotionEyeCamera);
|
||||
@@ -498,10 +494,8 @@ describe('MotionEyeCameraManagerEngine', () => {
|
||||
const engine = createEngine({ walker });
|
||||
const camera = new MotionEyeCamera(config, engine);
|
||||
await camera.initialize({
|
||||
hass: createHASS(),
|
||||
hassManager: createHASSManager(),
|
||||
entityRegistryManager: new EntityRegistryManagerMock([createEntity()]),
|
||||
stateWatcher: mock<StateWatcherSubscriptionInterface>(),
|
||||
eventWatcher: mock<EventWatcherSubscriptionInterface>(),
|
||||
});
|
||||
camera.setID('camera-1');
|
||||
const store = new CameraManagerStore();
|
||||
@@ -547,10 +541,8 @@ describe('MotionEyeCameraManagerEngine', () => {
|
||||
const engine = createEngine({ walker });
|
||||
const camera = new MotionEyeCamera(config, engine);
|
||||
await camera.initialize({
|
||||
hass: createHASS(),
|
||||
hassManager: createHASSManager(),
|
||||
entityRegistryManager: new EntityRegistryManagerMock([createEntity()]),
|
||||
stateWatcher: mock<StateWatcherSubscriptionInterface>(),
|
||||
eventWatcher: mock<EventWatcherSubscriptionInterface>(),
|
||||
});
|
||||
camera.setID('camera-1');
|
||||
const store = new CameraManagerStore();
|
||||
@@ -602,10 +594,8 @@ describe('MotionEyeCameraManagerEngine', () => {
|
||||
const engine = createEngine({ walker });
|
||||
const camera = new MotionEyeCamera(config, engine);
|
||||
await camera.initialize({
|
||||
hass: createHASS(),
|
||||
hassManager: createHASSManager(),
|
||||
entityRegistryManager: new EntityRegistryManagerMock([createEntity()]),
|
||||
stateWatcher: mock<StateWatcherSubscriptionInterface>(),
|
||||
eventWatcher: mock<EventWatcherSubscriptionInterface>(),
|
||||
});
|
||||
camera.setID('camera-1');
|
||||
const store = new CameraManagerStore();
|
||||
|
||||
@@ -4,14 +4,13 @@ import { CameraManagerEngine } from '../../../src/camera-manager/engine';
|
||||
import { ReolinkCamera } from '../../../src/camera-manager/reolink/camera';
|
||||
import { CameraProxyConfig } from '../../../src/camera-manager/types';
|
||||
import { ActionsExecutor } from '../../../src/card-controller/actions/types';
|
||||
import { EventWatcherSubscriptionInterface } from '../../../src/card-controller/hass/event-watcher';
|
||||
import { StateWatcherSubscriptionInterface } from '../../../src/card-controller/hass/state-watcher';
|
||||
import { DeviceRegistryManager } from '../../../src/ha/registry/device';
|
||||
import { EntityRegistryManagerLive } from '../../../src/ha/registry/entity';
|
||||
import { EntityRegistryManagerMock } from '../../ha/registry/entity/mock';
|
||||
import {
|
||||
createCameraConfig,
|
||||
createHASS,
|
||||
createHASSManager,
|
||||
createRegistryEntity,
|
||||
createStateEntity,
|
||||
} from '../../test-utils';
|
||||
@@ -103,11 +102,9 @@ describe('ReolinkCamera', () => {
|
||||
expect(
|
||||
async () =>
|
||||
await camera.initialize({
|
||||
hass: createHASS(),
|
||||
hassManager: createHASSManager(),
|
||||
entityRegistryManager: mock<EntityRegistryManagerLive>(),
|
||||
deviceRegistryManager: mock<DeviceRegistryManager>(),
|
||||
stateWatcher: mock<StateWatcherSubscriptionInterface>(),
|
||||
eventWatcher: mock<EventWatcherSubscriptionInterface>(),
|
||||
}),
|
||||
).rejects.toThrowError('Could not find camera entity');
|
||||
});
|
||||
@@ -128,11 +125,9 @@ describe('ReolinkCamera', () => {
|
||||
expect(
|
||||
async () =>
|
||||
await camera.initialize({
|
||||
hass: createHASS(),
|
||||
hassManager: createHASSManager(),
|
||||
entityRegistryManager,
|
||||
deviceRegistryManager: mock<DeviceRegistryManager>(),
|
||||
stateWatcher: mock<StateWatcherSubscriptionInterface>(),
|
||||
eventWatcher: mock<EventWatcherSubscriptionInterface>(),
|
||||
}),
|
||||
).rejects.toThrowError('Could not initialize Reolink camera');
|
||||
});
|
||||
@@ -153,11 +148,9 @@ describe('ReolinkCamera', () => {
|
||||
expect(
|
||||
async () =>
|
||||
await camera.initialize({
|
||||
hass: createHASS(),
|
||||
hassManager: createHASSManager(),
|
||||
entityRegistryManager,
|
||||
deviceRegistryManager: mock<DeviceRegistryManager>(),
|
||||
stateWatcher: mock<StateWatcherSubscriptionInterface>(),
|
||||
eventWatcher: mock<EventWatcherSubscriptionInterface>(),
|
||||
}),
|
||||
).rejects.toThrowError('Could not initialize Reolink camera');
|
||||
});
|
||||
@@ -170,11 +163,9 @@ describe('ReolinkCamera', () => {
|
||||
const entityRegistryManager = new EntityRegistryManagerMock([cameraEntity]);
|
||||
|
||||
await camera.initialize({
|
||||
hass: createHASS(),
|
||||
hassManager: createHASSManager(),
|
||||
entityRegistryManager,
|
||||
deviceRegistryManager: mock<DeviceRegistryManager>(),
|
||||
stateWatcher: mock<StateWatcherSubscriptionInterface>(),
|
||||
eventWatcher: mock<EventWatcherSubscriptionInterface>(),
|
||||
});
|
||||
|
||||
expect(camera.getChannel()).toBe(0);
|
||||
@@ -203,11 +194,9 @@ describe('ReolinkCamera', () => {
|
||||
});
|
||||
|
||||
await camera.initialize({
|
||||
hass: createHASS(),
|
||||
hassManager: createHASSManager(),
|
||||
entityRegistryManager,
|
||||
deviceRegistryManager,
|
||||
stateWatcher: mock<StateWatcherSubscriptionInterface>(),
|
||||
eventWatcher: mock<EventWatcherSubscriptionInterface>(),
|
||||
});
|
||||
|
||||
expect(camera.getChannel()).toBe(3);
|
||||
@@ -227,11 +216,9 @@ describe('ReolinkCamera', () => {
|
||||
]);
|
||||
|
||||
await camera.initialize({
|
||||
hass: createHASS(),
|
||||
hassManager: createHASSManager(),
|
||||
entityRegistryManager,
|
||||
deviceRegistryManager: mock<DeviceRegistryManager>(),
|
||||
stateWatcher: mock<StateWatcherSubscriptionInterface>(),
|
||||
eventWatcher: mock<EventWatcherSubscriptionInterface>(),
|
||||
});
|
||||
|
||||
expect(camera.getChannel()).toBe(7);
|
||||
@@ -251,11 +238,9 @@ describe('ReolinkCamera', () => {
|
||||
]);
|
||||
|
||||
await camera.initialize({
|
||||
hass: createHASS(),
|
||||
hassManager: createHASSManager(),
|
||||
entityRegistryManager,
|
||||
deviceRegistryManager: mock<DeviceRegistryManager>(),
|
||||
stateWatcher: mock<StateWatcherSubscriptionInterface>(),
|
||||
eventWatcher: mock<EventWatcherSubscriptionInterface>(),
|
||||
});
|
||||
|
||||
expect(camera.getChannel()).toBe(0);
|
||||
@@ -275,11 +260,9 @@ describe('ReolinkCamera', () => {
|
||||
]);
|
||||
|
||||
await camera.initialize({
|
||||
hass: createHASS(),
|
||||
hassManager: createHASSManager(),
|
||||
entityRegistryManager,
|
||||
deviceRegistryManager: mock<DeviceRegistryManager>(),
|
||||
stateWatcher: mock<StateWatcherSubscriptionInterface>(),
|
||||
eventWatcher: mock<EventWatcherSubscriptionInterface>(),
|
||||
});
|
||||
|
||||
expect(camera.getChannel()).toBe(7);
|
||||
@@ -301,11 +284,9 @@ describe('ReolinkCamera', () => {
|
||||
]);
|
||||
|
||||
await camera.initialize({
|
||||
hass: createHASS(),
|
||||
hassManager: createHASSManager(),
|
||||
entityRegistryManager,
|
||||
deviceRegistryManager: mock<DeviceRegistryManager>(),
|
||||
stateWatcher: mock<StateWatcherSubscriptionInterface>(),
|
||||
eventWatcher: mock<EventWatcherSubscriptionInterface>(),
|
||||
});
|
||||
|
||||
expect(camera.getChannel()).toBe(0);
|
||||
@@ -334,11 +315,9 @@ describe('ReolinkCamera', () => {
|
||||
});
|
||||
|
||||
await camera.initialize({
|
||||
hass: createHASS(),
|
||||
hassManager: createHASSManager(),
|
||||
entityRegistryManager,
|
||||
deviceRegistryManager,
|
||||
stateWatcher: mock<StateWatcherSubscriptionInterface>(),
|
||||
eventWatcher: mock<EventWatcherSubscriptionInterface>(),
|
||||
});
|
||||
|
||||
expect(camera.getChannel()).toBe(0);
|
||||
@@ -367,11 +346,9 @@ describe('ReolinkCamera', () => {
|
||||
});
|
||||
|
||||
await camera.initialize({
|
||||
hass: createHASS(),
|
||||
hassManager: createHASSManager(),
|
||||
entityRegistryManager,
|
||||
deviceRegistryManager,
|
||||
stateWatcher: mock<StateWatcherSubscriptionInterface>(),
|
||||
eventWatcher: mock<EventWatcherSubscriptionInterface>(),
|
||||
});
|
||||
|
||||
expect(camera.getChannel()).toBe(0);
|
||||
@@ -400,11 +377,9 @@ describe('ReolinkCamera', () => {
|
||||
});
|
||||
|
||||
await camera.initialize({
|
||||
hass: createHASS(),
|
||||
hassManager: createHASSManager(),
|
||||
entityRegistryManager,
|
||||
deviceRegistryManager,
|
||||
stateWatcher: mock<StateWatcherSubscriptionInterface>(),
|
||||
eventWatcher: mock<EventWatcherSubscriptionInterface>(),
|
||||
});
|
||||
|
||||
expect(camera.getChannel()).toBe(0);
|
||||
@@ -420,11 +395,9 @@ describe('ReolinkCamera', () => {
|
||||
const camera = new ReolinkCamera(config, mock<CameraManagerEngine>());
|
||||
|
||||
await camera.initialize({
|
||||
hass: createHASS(),
|
||||
hassManager: createHASSManager(),
|
||||
entityRegistryManager: ptzPopulatedEntityRegistryManager,
|
||||
deviceRegistryManager: mock<DeviceRegistryManager>(),
|
||||
stateWatcher: mock<StateWatcherSubscriptionInterface>(),
|
||||
eventWatcher: mock<EventWatcherSubscriptionInterface>(),
|
||||
});
|
||||
|
||||
expect(camera.getCapabilities()?.getPTZCapabilities()).toEqual({
|
||||
@@ -444,7 +417,7 @@ describe('ReolinkCamera', () => {
|
||||
const camera = new ReolinkCamera(config, mock<CameraManagerEngine>());
|
||||
|
||||
await camera.initialize({
|
||||
hass: createHASS(),
|
||||
hassManager: createHASSManager(),
|
||||
entityRegistryManager: new EntityRegistryManagerMock([
|
||||
createRegistryEntity({
|
||||
entity_id: 'camera.office_reolink',
|
||||
@@ -463,8 +436,6 @@ describe('ReolinkCamera', () => {
|
||||
}),
|
||||
]),
|
||||
deviceRegistryManager: mock<DeviceRegistryManager>(),
|
||||
stateWatcher: mock<StateWatcherSubscriptionInterface>(),
|
||||
eventWatcher: mock<EventWatcherSubscriptionInterface>(),
|
||||
});
|
||||
|
||||
expect(camera.getCapabilities()?.getPTZCapabilities()).toEqual({
|
||||
@@ -480,18 +451,18 @@ describe('ReolinkCamera', () => {
|
||||
const camera = new ReolinkCamera(config, mock<CameraManagerEngine>());
|
||||
|
||||
await camera.initialize({
|
||||
hass: createHASS({
|
||||
'select.office_reolink_ptz_preset': createStateEntity({
|
||||
state: 'foo',
|
||||
attributes: {
|
||||
options: ['preset-one', 'preset-two'],
|
||||
},
|
||||
hassManager: createHASSManager({
|
||||
hass: createHASS({
|
||||
'select.office_reolink_ptz_preset': createStateEntity({
|
||||
state: 'foo',
|
||||
attributes: {
|
||||
options: ['preset-one', 'preset-two'],
|
||||
},
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
entityRegistryManager: ptzPopulatedEntityRegistryManager,
|
||||
deviceRegistryManager: mock<DeviceRegistryManager>(),
|
||||
stateWatcher: mock<StateWatcherSubscriptionInterface>(),
|
||||
eventWatcher: mock<EventWatcherSubscriptionInterface>(),
|
||||
});
|
||||
|
||||
expect(camera.getCapabilities()?.getPTZCapabilities()).toEqual({
|
||||
@@ -521,11 +492,9 @@ describe('ReolinkCamera', () => {
|
||||
const camera = new ReolinkCamera(config, mock<CameraManagerEngine>());
|
||||
|
||||
await camera.initialize({
|
||||
hass: createHASS(),
|
||||
hassManager: createHASSManager(),
|
||||
entityRegistryManager: ptzPopulatedEntityRegistryManager,
|
||||
deviceRegistryManager: mock<DeviceRegistryManager>(),
|
||||
stateWatcher: mock<StateWatcherSubscriptionInterface>(),
|
||||
eventWatcher: mock<EventWatcherSubscriptionInterface>(),
|
||||
});
|
||||
|
||||
expect(camera.getCapabilities()?.getPTZCapabilities()).toEqual({
|
||||
@@ -709,11 +678,9 @@ describe('ReolinkCamera', () => {
|
||||
const camera = new ReolinkCamera(config, mock<CameraManagerEngine>());
|
||||
|
||||
await camera.initialize({
|
||||
hass: createHASS(),
|
||||
hassManager: createHASSManager(),
|
||||
entityRegistryManager: new EntityRegistryManagerMock([cameraEntity]),
|
||||
deviceRegistryManager: mock<DeviceRegistryManager>(),
|
||||
stateWatcher: mock<StateWatcherSubscriptionInterface>(),
|
||||
eventWatcher: mock<EventWatcherSubscriptionInterface>(),
|
||||
});
|
||||
const executor = mock<ActionsExecutor>();
|
||||
|
||||
@@ -739,14 +706,12 @@ describe('ReolinkCamera', () => {
|
||||
const camera = new ReolinkCamera(config, mock<CameraManagerEngine>());
|
||||
|
||||
await camera.initialize({
|
||||
hass: createHASS(),
|
||||
hassManager: createHASSManager(),
|
||||
entityRegistryManager: new EntityRegistryManagerMock([
|
||||
cameraEntity,
|
||||
buttonEntityPTZLeft,
|
||||
]),
|
||||
deviceRegistryManager: mock<DeviceRegistryManager>(),
|
||||
stateWatcher: mock<StateWatcherSubscriptionInterface>(),
|
||||
eventWatcher: mock<EventWatcherSubscriptionInterface>(),
|
||||
});
|
||||
const executor = mock<ActionsExecutor>();
|
||||
await camera.executePTZAction(executor, 'left', { phase: 'start' });
|
||||
@@ -770,11 +735,9 @@ describe('ReolinkCamera', () => {
|
||||
const camera = new ReolinkCamera(config, mock<CameraManagerEngine>());
|
||||
|
||||
await camera.initialize({
|
||||
hass: createHASS(),
|
||||
hassManager: createHASSManager(),
|
||||
entityRegistryManager: ptzPopulatedEntityRegistryManager,
|
||||
deviceRegistryManager: mock<DeviceRegistryManager>(),
|
||||
stateWatcher: mock<StateWatcherSubscriptionInterface>(),
|
||||
eventWatcher: mock<EventWatcherSubscriptionInterface>(),
|
||||
});
|
||||
const executor = mock<ActionsExecutor>();
|
||||
|
||||
@@ -812,11 +775,9 @@ describe('ReolinkCamera', () => {
|
||||
const camera = new ReolinkCamera(config, mock<CameraManagerEngine>());
|
||||
|
||||
await camera.initialize({
|
||||
hass: createHASS(),
|
||||
hassManager: createHASSManager(),
|
||||
entityRegistryManager: ptzPopulatedEntityRegistryManager,
|
||||
deviceRegistryManager: mock<DeviceRegistryManager>(),
|
||||
stateWatcher: mock<StateWatcherSubscriptionInterface>(),
|
||||
eventWatcher: mock<EventWatcherSubscriptionInterface>(),
|
||||
});
|
||||
const executor = mock<ActionsExecutor>();
|
||||
|
||||
@@ -832,18 +793,18 @@ describe('ReolinkCamera', () => {
|
||||
const camera = new ReolinkCamera(config, mock<CameraManagerEngine>());
|
||||
|
||||
await camera.initialize({
|
||||
hass: createHASS({
|
||||
'select.office_reolink_ptz_preset': createStateEntity({
|
||||
state: 'foo',
|
||||
attributes: {
|
||||
options: ['preset-one', 'preset-two'],
|
||||
},
|
||||
hassManager: createHASSManager({
|
||||
hass: createHASS({
|
||||
'select.office_reolink_ptz_preset': createStateEntity({
|
||||
state: 'foo',
|
||||
attributes: {
|
||||
options: ['preset-one', 'preset-two'],
|
||||
},
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
entityRegistryManager: ptzPopulatedEntityRegistryManager,
|
||||
deviceRegistryManager: mock<DeviceRegistryManager>(),
|
||||
stateWatcher: mock<StateWatcherSubscriptionInterface>(),
|
||||
eventWatcher: mock<EventWatcherSubscriptionInterface>(),
|
||||
});
|
||||
const executor = mock<ActionsExecutor>();
|
||||
|
||||
@@ -871,11 +832,9 @@ describe('ReolinkCamera', () => {
|
||||
const camera = new ReolinkCamera(config, mock<CameraManagerEngine>());
|
||||
|
||||
await camera.initialize({
|
||||
hass: createHASS(),
|
||||
hassManager: createHASSManager(),
|
||||
entityRegistryManager: ptzPopulatedEntityRegistryManager,
|
||||
deviceRegistryManager: mock<DeviceRegistryManager>(),
|
||||
stateWatcher: mock<StateWatcherSubscriptionInterface>(),
|
||||
eventWatcher: mock<EventWatcherSubscriptionInterface>(),
|
||||
});
|
||||
const executor = mock<ActionsExecutor>();
|
||||
|
||||
@@ -892,14 +851,12 @@ describe('ReolinkCamera', () => {
|
||||
const camera = new ReolinkCamera(config, mock<CameraManagerEngine>());
|
||||
|
||||
await camera.initialize({
|
||||
hass: createHASS(),
|
||||
hassManager: createHASSManager(),
|
||||
entityRegistryManager: new EntityRegistryManagerMock([
|
||||
cameraEntity,
|
||||
numberEntityZoom,
|
||||
]),
|
||||
deviceRegistryManager: mock<DeviceRegistryManager>(),
|
||||
stateWatcher: mock<StateWatcherSubscriptionInterface>(),
|
||||
eventWatcher: mock<EventWatcherSubscriptionInterface>(),
|
||||
});
|
||||
|
||||
expect(camera.getCapabilities()?.getPTZCapabilities()).toEqual({
|
||||
@@ -915,7 +872,7 @@ describe('ReolinkCamera', () => {
|
||||
const camera = new ReolinkCamera(config, mock<CameraManagerEngine>());
|
||||
|
||||
await camera.initialize({
|
||||
hass: createHASS(),
|
||||
hassManager: createHASSManager(),
|
||||
entityRegistryManager: new EntityRegistryManagerMock([
|
||||
cameraEntity,
|
||||
createRegistryEntity({
|
||||
@@ -926,8 +883,6 @@ describe('ReolinkCamera', () => {
|
||||
}),
|
||||
]),
|
||||
deviceRegistryManager: mock<DeviceRegistryManager>(),
|
||||
stateWatcher: mock<StateWatcherSubscriptionInterface>(),
|
||||
eventWatcher: mock<EventWatcherSubscriptionInterface>(),
|
||||
});
|
||||
|
||||
expect(camera.getCapabilities()?.getPTZCapabilities()).toBeNull();
|
||||
@@ -940,7 +895,7 @@ describe('ReolinkCamera', () => {
|
||||
const camera = new ReolinkCamera(config, mock<CameraManagerEngine>());
|
||||
|
||||
await camera.initialize({
|
||||
hass: createHASS(),
|
||||
hassManager: createHASSManager(),
|
||||
entityRegistryManager: new EntityRegistryManagerMock([
|
||||
cameraEntity,
|
||||
buttonEntityPTZZoomIn,
|
||||
@@ -949,8 +904,6 @@ describe('ReolinkCamera', () => {
|
||||
numberEntityZoom,
|
||||
]),
|
||||
deviceRegistryManager: mock<DeviceRegistryManager>(),
|
||||
stateWatcher: mock<StateWatcherSubscriptionInterface>(),
|
||||
eventWatcher: mock<EventWatcherSubscriptionInterface>(),
|
||||
});
|
||||
|
||||
expect(camera.getCapabilities()?.getPTZCapabilities()).toEqual({
|
||||
@@ -966,14 +919,12 @@ describe('ReolinkCamera', () => {
|
||||
const camera = new ReolinkCamera(config, mock<CameraManagerEngine>());
|
||||
|
||||
await camera.initialize({
|
||||
hass: createHASS(),
|
||||
hassManager: createHASSManager(),
|
||||
entityRegistryManager: new EntityRegistryManagerMock([
|
||||
cameraEntity,
|
||||
numberEntityZoom,
|
||||
]),
|
||||
deviceRegistryManager: mock<DeviceRegistryManager>(),
|
||||
stateWatcher: mock<StateWatcherSubscriptionInterface>(),
|
||||
eventWatcher: mock<EventWatcherSubscriptionInterface>(),
|
||||
});
|
||||
const executor = mock<ActionsExecutor>();
|
||||
|
||||
@@ -1005,14 +956,12 @@ describe('ReolinkCamera', () => {
|
||||
const camera = new ReolinkCamera(config, mock<CameraManagerEngine>());
|
||||
|
||||
await camera.initialize({
|
||||
hass: createHASS(),
|
||||
hassManager: createHASSManager(),
|
||||
entityRegistryManager: new EntityRegistryManagerMock([
|
||||
cameraEntity,
|
||||
numberEntityZoom,
|
||||
]),
|
||||
deviceRegistryManager: mock<DeviceRegistryManager>(),
|
||||
stateWatcher: mock<StateWatcherSubscriptionInterface>(),
|
||||
eventWatcher: mock<EventWatcherSubscriptionInterface>(),
|
||||
});
|
||||
const executor = mock<ActionsExecutor>();
|
||||
|
||||
@@ -1044,14 +993,12 @@ describe('ReolinkCamera', () => {
|
||||
const camera = new ReolinkCamera(config, mock<CameraManagerEngine>());
|
||||
|
||||
await camera.initialize({
|
||||
hass: createHASS(),
|
||||
hassManager: createHASSManager(),
|
||||
entityRegistryManager: new EntityRegistryManagerMock([
|
||||
cameraEntity,
|
||||
numberEntityZoom,
|
||||
]),
|
||||
deviceRegistryManager: mock<DeviceRegistryManager>(),
|
||||
stateWatcher: mock<StateWatcherSubscriptionInterface>(),
|
||||
eventWatcher: mock<EventWatcherSubscriptionInterface>(),
|
||||
});
|
||||
const executor = mock<ActionsExecutor>();
|
||||
|
||||
@@ -1083,14 +1030,12 @@ describe('ReolinkCamera', () => {
|
||||
const camera = new ReolinkCamera(config, mock<CameraManagerEngine>());
|
||||
|
||||
await camera.initialize({
|
||||
hass: createHASS(),
|
||||
hassManager: createHASSManager(),
|
||||
entityRegistryManager: new EntityRegistryManagerMock([
|
||||
cameraEntity,
|
||||
numberEntityZoom,
|
||||
]),
|
||||
deviceRegistryManager: mock<DeviceRegistryManager>(),
|
||||
stateWatcher: mock<StateWatcherSubscriptionInterface>(),
|
||||
eventWatcher: mock<EventWatcherSubscriptionInterface>(),
|
||||
});
|
||||
const executor = mock<ActionsExecutor>();
|
||||
|
||||
@@ -1122,14 +1067,12 @@ describe('ReolinkCamera', () => {
|
||||
const camera = new ReolinkCamera(config, mock<CameraManagerEngine>());
|
||||
|
||||
await camera.initialize({
|
||||
hass: createHASS(),
|
||||
hassManager: createHASSManager(),
|
||||
entityRegistryManager: new EntityRegistryManagerMock([
|
||||
cameraEntity,
|
||||
numberEntityZoom,
|
||||
]),
|
||||
deviceRegistryManager: mock<DeviceRegistryManager>(),
|
||||
stateWatcher: mock<StateWatcherSubscriptionInterface>(),
|
||||
eventWatcher: mock<EventWatcherSubscriptionInterface>(),
|
||||
});
|
||||
const executor = mock<ActionsExecutor>();
|
||||
|
||||
@@ -1152,14 +1095,12 @@ describe('ReolinkCamera', () => {
|
||||
const camera = new ReolinkCamera(config, mock<CameraManagerEngine>());
|
||||
|
||||
await camera.initialize({
|
||||
hass: createHASS(),
|
||||
hassManager: createHASSManager(),
|
||||
entityRegistryManager: new EntityRegistryManagerMock([
|
||||
cameraEntity,
|
||||
numberEntityZoom,
|
||||
]),
|
||||
deviceRegistryManager: mock<DeviceRegistryManager>(),
|
||||
stateWatcher: mock<StateWatcherSubscriptionInterface>(),
|
||||
eventWatcher: mock<EventWatcherSubscriptionInterface>(),
|
||||
});
|
||||
const executor = mock<ActionsExecutor>();
|
||||
|
||||
@@ -1181,14 +1122,12 @@ describe('ReolinkCamera', () => {
|
||||
const camera = new ReolinkCamera(config, mock<CameraManagerEngine>());
|
||||
|
||||
await camera.initialize({
|
||||
hass: createHASS(),
|
||||
hassManager: createHASSManager(),
|
||||
entityRegistryManager: new EntityRegistryManagerMock([
|
||||
cameraEntity,
|
||||
numberEntityZoom,
|
||||
]),
|
||||
deviceRegistryManager: mock<DeviceRegistryManager>(),
|
||||
stateWatcher: mock<StateWatcherSubscriptionInterface>(),
|
||||
eventWatcher: mock<EventWatcherSubscriptionInterface>(),
|
||||
});
|
||||
const executor = mock<ActionsExecutor>();
|
||||
|
||||
@@ -1204,7 +1143,7 @@ describe('ReolinkCamera', () => {
|
||||
const camera = new ReolinkCamera(config, mock<CameraManagerEngine>());
|
||||
|
||||
await camera.initialize({
|
||||
hass: createHASS(),
|
||||
hassManager: createHASSManager(),
|
||||
entityRegistryManager: new EntityRegistryManagerMock([
|
||||
cameraEntity,
|
||||
buttonEntityPTZZoomIn,
|
||||
@@ -1213,8 +1152,6 @@ describe('ReolinkCamera', () => {
|
||||
numberEntityZoom,
|
||||
]),
|
||||
deviceRegistryManager: mock<DeviceRegistryManager>(),
|
||||
stateWatcher: mock<StateWatcherSubscriptionInterface>(),
|
||||
eventWatcher: mock<EventWatcherSubscriptionInterface>(),
|
||||
});
|
||||
const executor = mock<ActionsExecutor>();
|
||||
|
||||
@@ -1238,15 +1175,13 @@ describe('ReolinkCamera', () => {
|
||||
const camera = new ReolinkCamera(config, mock<CameraManagerEngine>());
|
||||
|
||||
await camera.initialize({
|
||||
hass: createHASS(),
|
||||
hassManager: createHASSManager(),
|
||||
entityRegistryManager: new EntityRegistryManagerMock([
|
||||
cameraEntity,
|
||||
buttonEntityPTZLeft,
|
||||
buttonEntityPTZStop,
|
||||
]),
|
||||
deviceRegistryManager: mock<DeviceRegistryManager>(),
|
||||
stateWatcher: mock<StateWatcherSubscriptionInterface>(),
|
||||
eventWatcher: mock<EventWatcherSubscriptionInterface>(),
|
||||
});
|
||||
const executor = mock<ActionsExecutor>();
|
||||
|
||||
@@ -1262,14 +1197,12 @@ describe('ReolinkCamera', () => {
|
||||
const camera = new ReolinkCamera(config, mock<CameraManagerEngine>());
|
||||
|
||||
await camera.initialize({
|
||||
hass: createHASS(),
|
||||
hassManager: createHASSManager(),
|
||||
entityRegistryManager: new EntityRegistryManagerMock([
|
||||
cameraEntity,
|
||||
numberEntityZoom,
|
||||
]),
|
||||
deviceRegistryManager: mock<DeviceRegistryManager>(),
|
||||
stateWatcher: mock<StateWatcherSubscriptionInterface>(),
|
||||
eventWatcher: mock<EventWatcherSubscriptionInterface>(),
|
||||
});
|
||||
const executor = mock<ActionsExecutor>();
|
||||
|
||||
|
||||
@@ -23,8 +23,6 @@ import {
|
||||
QueryReturnType,
|
||||
QueryType,
|
||||
} from '../../../src/camera-manager/types';
|
||||
import { EventWatcherSubscriptionInterface } from '../../../src/card-controller/hass/event-watcher';
|
||||
import { StateWatcherSubscriptionInterface } from '../../../src/card-controller/hass/state-watcher';
|
||||
import { BrowseMedia, browseMediaSchema } from '../../../src/ha/browse-media/types';
|
||||
import { BrowseMediaWalker } from '../../../src/ha/browse-media/walker';
|
||||
import { DeviceRegistryManager } from '../../../src/ha/registry/device';
|
||||
@@ -36,6 +34,7 @@ import { EntityRegistryManagerMock } from '../../ha/registry/entity/mock';
|
||||
import {
|
||||
createCameraConfig,
|
||||
createHASS,
|
||||
createHASSManager,
|
||||
createInitializedCamera,
|
||||
createRegistryEntity,
|
||||
createStore,
|
||||
@@ -187,8 +186,7 @@ const createEngine = (options?: {
|
||||
return new ReolinkCameraManagerEngine(
|
||||
options?.entityRegistryManager ?? new EntityRegistryManagerMock(),
|
||||
mock<DeviceRegistryManager>(),
|
||||
mock<StateWatcherSubscriptionInterface>(),
|
||||
mock<EventWatcherSubscriptionInterface>(),
|
||||
createHASSManager(),
|
||||
options?.browseMediaManager ?? new BrowseMediaWalker(),
|
||||
new ResolvedMediaCache(),
|
||||
new CameraManagerRequestCache(),
|
||||
@@ -212,7 +210,6 @@ const createStoreWithReolinkCamera = async (
|
||||
): Promise<CameraManagerStore> => {
|
||||
const store = new CameraManagerStore();
|
||||
const camera = await engine.createCamera(
|
||||
createHASS(),
|
||||
createCameraConfig({ camera_entity: 'camera.office', id: 'office' }),
|
||||
);
|
||||
store.addCamera(camera);
|
||||
@@ -252,7 +249,7 @@ describe('ReolinkCameraManagerEngine', () => {
|
||||
unique_id: 'office',
|
||||
});
|
||||
|
||||
const camera = await engine.createCamera(createHASS(), config);
|
||||
const camera = await engine.createCamera(config);
|
||||
|
||||
expect(camera.getConfig()).toBe(config);
|
||||
expect(camera.getEngine()).toBe(engine);
|
||||
@@ -289,7 +286,6 @@ describe('ReolinkCameraManagerEngine', () => {
|
||||
it('should return ui endpoint', async () => {
|
||||
const engine = createPopulatedEngine();
|
||||
const camera = await engine.createCamera(
|
||||
createHASS(),
|
||||
createCameraConfig({
|
||||
camera_entity: 'camera.office',
|
||||
reolink: {
|
||||
@@ -308,7 +304,6 @@ describe('ReolinkCameraManagerEngine', () => {
|
||||
it('should return go2rtc endpoint', async () => {
|
||||
const engine = createPopulatedEngine();
|
||||
const camera = await engine.createCamera(
|
||||
createHASS(),
|
||||
createCameraConfig({
|
||||
camera_entity: 'camera.office',
|
||||
go2rtc: {
|
||||
@@ -497,7 +492,6 @@ describe('ReolinkCameraManagerEngine', () => {
|
||||
it('should request high resolution if configured', async () => {
|
||||
const engine = createPopulatedEngine();
|
||||
const camera = await engine.createCamera(
|
||||
createHASS(),
|
||||
createCameraConfig({
|
||||
camera_entity: 'camera.office',
|
||||
id: 'office',
|
||||
|
||||
@@ -5,14 +5,13 @@ import { Capabilities } from '../../src/camera-manager/capabilities.js';
|
||||
import { CameraManagerEngineFactory } from '../../src/camera-manager/engine-factory.js';
|
||||
import { CameraManagerStore } from '../../src/camera-manager/store.js';
|
||||
import { Engine } from '../../src/camera-manager/types.js';
|
||||
import { EventWatcherSubscriptionInterface } from '../../src/card-controller/hass/event-watcher.js';
|
||||
import { StateWatcherSubscriptionInterface } from '../../src/card-controller/hass/state-watcher.js';
|
||||
import { DeviceRegistryManager } from '../../src/ha/registry/device/index.js';
|
||||
import { EntityRegistryManager } from '../../src/ha/registry/entity/types.js';
|
||||
import { ResolvedMediaCache } from '../../src/ha/resolved-media.js';
|
||||
import {
|
||||
TestViewMedia,
|
||||
createCameraConfig,
|
||||
createHASSManager,
|
||||
createInitializedCamera,
|
||||
} from '../test-utils.js';
|
||||
|
||||
@@ -31,13 +30,11 @@ describe('CameraManagerStore', async () => {
|
||||
);
|
||||
|
||||
const engineGeneric = await engineFactory.createEngine(Engine.Generic, {
|
||||
stateWatcher: mock<StateWatcherSubscriptionInterface>(),
|
||||
eventWatcher: mock<EventWatcherSubscriptionInterface>(),
|
||||
hassManager: createHASSManager(),
|
||||
resolvedMediaCache: mock<ResolvedMediaCache>(),
|
||||
});
|
||||
const engineFrigate = await engineFactory.createEngine(Engine.Frigate, {
|
||||
stateWatcher: mock<StateWatcherSubscriptionInterface>(),
|
||||
eventWatcher: mock<EventWatcherSubscriptionInterface>(),
|
||||
hassManager: createHASSManager(),
|
||||
resolvedMediaCache: mock<ResolvedMediaCache>(),
|
||||
});
|
||||
|
||||
|
||||
@@ -3,10 +3,12 @@ import { mock } from 'vitest-mock-extended';
|
||||
import { CameraManagerEngine } from '../../../src/camera-manager/engine';
|
||||
import { TPLinkCamera } from '../../../src/camera-manager/tplink/camera';
|
||||
import { ActionsExecutor } from '../../../src/card-controller/actions/types';
|
||||
import { EventWatcherSubscriptionInterface } from '../../../src/card-controller/hass/event-watcher';
|
||||
import { StateWatcherSubscriptionInterface } from '../../../src/card-controller/hass/state-watcher';
|
||||
import { EntityRegistryManagerMock } from '../../ha/registry/entity/mock';
|
||||
import { createCameraConfig, createHASS, createRegistryEntity } from '../../test-utils';
|
||||
import {
|
||||
createCameraConfig,
|
||||
createHASSManager,
|
||||
createRegistryEntity,
|
||||
} from '../../test-utils';
|
||||
|
||||
describe('TPLinkCamera', () => {
|
||||
// Entity patterns from: https://github.com/dermotduffy/advanced-camera-card/issues/2183
|
||||
@@ -77,10 +79,8 @@ describe('TPLinkCamera', () => {
|
||||
expect(
|
||||
async () =>
|
||||
await camera.initialize({
|
||||
hass: createHASS(),
|
||||
hassManager: createHASSManager(),
|
||||
entityRegistryManager: new EntityRegistryManagerMock(),
|
||||
stateWatcher: mock<StateWatcherSubscriptionInterface>(),
|
||||
eventWatcher: mock<EventWatcherSubscriptionInterface>(),
|
||||
}),
|
||||
).rejects.toThrowError('Could not find camera entity');
|
||||
});
|
||||
@@ -94,10 +94,8 @@ describe('TPLinkCamera', () => {
|
||||
expect(
|
||||
async () =>
|
||||
await camera.initialize({
|
||||
hass: createHASS(),
|
||||
hassManager: createHASSManager(),
|
||||
entityRegistryManager: new EntityRegistryManagerMock(),
|
||||
stateWatcher: mock<StateWatcherSubscriptionInterface>(),
|
||||
eventWatcher: mock<EventWatcherSubscriptionInterface>(),
|
||||
}),
|
||||
).rejects.toThrowError('Could not find camera entity');
|
||||
});
|
||||
@@ -113,10 +111,8 @@ describe('TPLinkCamera', () => {
|
||||
const entityRegistryManager = new EntityRegistryManagerMock([cameraEntity]);
|
||||
|
||||
await camera.initialize({
|
||||
hass: createHASS(),
|
||||
hassManager: createHASSManager(),
|
||||
entityRegistryManager,
|
||||
stateWatcher: mock<StateWatcherSubscriptionInterface>(),
|
||||
eventWatcher: mock<EventWatcherSubscriptionInterface>(),
|
||||
});
|
||||
|
||||
expect(camera.getEntity()).toBe(cameraEntity);
|
||||
@@ -131,10 +127,8 @@ describe('TPLinkCamera', () => {
|
||||
const entityRegistryManager = new EntityRegistryManagerMock([cameraEntity]);
|
||||
|
||||
await camera.initialize({
|
||||
hass: createHASS(),
|
||||
hassManager: createHASSManager(),
|
||||
entityRegistryManager,
|
||||
stateWatcher: mock<StateWatcherSubscriptionInterface>(),
|
||||
eventWatcher: mock<EventWatcherSubscriptionInterface>(),
|
||||
});
|
||||
|
||||
expect(camera.getEntity()).toBe(cameraEntity);
|
||||
@@ -149,10 +143,8 @@ describe('TPLinkCamera', () => {
|
||||
const camera = new TPLinkCamera(config, mock<CameraManagerEngine>());
|
||||
|
||||
await camera.initialize({
|
||||
hass: createHASS(),
|
||||
hassManager: createHASSManager(),
|
||||
entityRegistryManager: ptzPopulatedEntityRegistryManager,
|
||||
stateWatcher: mock<StateWatcherSubscriptionInterface>(),
|
||||
eventWatcher: mock<EventWatcherSubscriptionInterface>(),
|
||||
});
|
||||
|
||||
expect(camera.getCapabilities()?.getPTZCapabilities()).toEqual({
|
||||
@@ -179,10 +171,8 @@ describe('TPLinkCamera', () => {
|
||||
const camera = new TPLinkCamera(config, mock<CameraManagerEngine>());
|
||||
|
||||
await camera.initialize({
|
||||
hass: createHASS(),
|
||||
hassManager: createHASSManager(),
|
||||
entityRegistryManager: ptzPopulatedEntityRegistryManager,
|
||||
stateWatcher: mock<StateWatcherSubscriptionInterface>(),
|
||||
eventWatcher: mock<EventWatcherSubscriptionInterface>(),
|
||||
});
|
||||
|
||||
expect(camera.getCapabilities()?.getPTZCapabilities()).toEqual({
|
||||
@@ -203,10 +193,8 @@ describe('TPLinkCamera', () => {
|
||||
const camera = new TPLinkCamera(config, mock<CameraManagerEngine>());
|
||||
|
||||
await camera.initialize({
|
||||
hass: createHASS(),
|
||||
hassManager: createHASSManager(),
|
||||
entityRegistryManager: new EntityRegistryManagerMock([cameraEntity]),
|
||||
stateWatcher: mock<StateWatcherSubscriptionInterface>(),
|
||||
eventWatcher: mock<EventWatcherSubscriptionInterface>(),
|
||||
});
|
||||
const executor = mock<ActionsExecutor>();
|
||||
|
||||
@@ -225,10 +213,8 @@ describe('TPLinkCamera', () => {
|
||||
const camera = new TPLinkCamera(config, mock<CameraManagerEngine>());
|
||||
|
||||
await camera.initialize({
|
||||
hass: createHASS(),
|
||||
hassManager: createHASSManager(),
|
||||
entityRegistryManager: ptzPopulatedEntityRegistryManager,
|
||||
stateWatcher: mock<StateWatcherSubscriptionInterface>(),
|
||||
eventWatcher: mock<EventWatcherSubscriptionInterface>(),
|
||||
});
|
||||
const executor = mock<ActionsExecutor>();
|
||||
|
||||
@@ -249,10 +235,8 @@ describe('TPLinkCamera', () => {
|
||||
const camera = new TPLinkCamera(config, mock<CameraManagerEngine>());
|
||||
|
||||
await camera.initialize({
|
||||
hass: createHASS(),
|
||||
hassManager: createHASSManager(),
|
||||
entityRegistryManager: ptzPopulatedEntityRegistryManager,
|
||||
stateWatcher: mock<StateWatcherSubscriptionInterface>(),
|
||||
eventWatcher: mock<EventWatcherSubscriptionInterface>(),
|
||||
});
|
||||
const executor = mock<ActionsExecutor>();
|
||||
|
||||
@@ -270,10 +254,8 @@ describe('TPLinkCamera', () => {
|
||||
const camera = new TPLinkCamera(config, mock<CameraManagerEngine>());
|
||||
|
||||
await camera.initialize({
|
||||
hass: createHASS(),
|
||||
hassManager: createHASSManager(),
|
||||
entityRegistryManager: ptzPopulatedEntityRegistryManager,
|
||||
stateWatcher: mock<StateWatcherSubscriptionInterface>(),
|
||||
eventWatcher: mock<EventWatcherSubscriptionInterface>(),
|
||||
});
|
||||
const executor = mock<ActionsExecutor>();
|
||||
|
||||
@@ -299,10 +281,8 @@ describe('TPLinkCamera', () => {
|
||||
const camera = new TPLinkCamera(config, mock<CameraManagerEngine>());
|
||||
|
||||
await camera.initialize({
|
||||
hass: createHASS(),
|
||||
hassManager: createHASSManager(),
|
||||
entityRegistryManager: ptzPopulatedEntityRegistryManager,
|
||||
stateWatcher: mock<StateWatcherSubscriptionInterface>(),
|
||||
eventWatcher: mock<EventWatcherSubscriptionInterface>(),
|
||||
});
|
||||
const executor = mock<ActionsExecutor>();
|
||||
|
||||
@@ -328,10 +308,8 @@ describe('TPLinkCamera', () => {
|
||||
const camera = new TPLinkCamera(config, mock<CameraManagerEngine>());
|
||||
|
||||
await camera.initialize({
|
||||
hass: createHASS(),
|
||||
hassManager: createHASSManager(),
|
||||
entityRegistryManager: ptzPopulatedEntityRegistryManager,
|
||||
stateWatcher: mock<StateWatcherSubscriptionInterface>(),
|
||||
eventWatcher: mock<EventWatcherSubscriptionInterface>(),
|
||||
});
|
||||
const executor = mock<ActionsExecutor>();
|
||||
|
||||
@@ -365,13 +343,11 @@ describe('TPLinkCamera', () => {
|
||||
const camera = new TPLinkCamera(config, mock<CameraManagerEngine>());
|
||||
|
||||
await camera.initialize({
|
||||
hass: createHASS(),
|
||||
hassManager: createHASSManager(),
|
||||
entityRegistryManager: new EntityRegistryManagerMock([
|
||||
cameraEntity,
|
||||
buttonEntityPanLeft,
|
||||
]),
|
||||
stateWatcher: mock<StateWatcherSubscriptionInterface>(),
|
||||
eventWatcher: mock<EventWatcherSubscriptionInterface>(),
|
||||
});
|
||||
const executor = mock<ActionsExecutor>();
|
||||
await camera.executePTZAction(executor, 'left', { phase: 'start' });
|
||||
@@ -404,13 +380,11 @@ describe('TPLinkCamera', () => {
|
||||
const camera = new TPLinkCamera(config, mock<CameraManagerEngine>());
|
||||
|
||||
await camera.initialize({
|
||||
hass: createHASS(),
|
||||
hassManager: createHASSManager(),
|
||||
entityRegistryManager: new EntityRegistryManagerMock([
|
||||
cameraWithDifferentUniqueId,
|
||||
buttonEntityPanLeft,
|
||||
]),
|
||||
stateWatcher: mock<StateWatcherSubscriptionInterface>(),
|
||||
eventWatcher: mock<EventWatcherSubscriptionInterface>(),
|
||||
});
|
||||
|
||||
// Should not find PTZ entities since unique_id doesn't end with _live_view
|
||||
@@ -430,13 +404,11 @@ describe('TPLinkCamera', () => {
|
||||
const camera = new TPLinkCamera(config, mock<CameraManagerEngine>());
|
||||
|
||||
await camera.initialize({
|
||||
hass: createHASS(),
|
||||
hassManager: createHASSManager(),
|
||||
entityRegistryManager: new EntityRegistryManagerMock([
|
||||
cameraWithoutUniqueId,
|
||||
buttonEntityPanLeft,
|
||||
]),
|
||||
stateWatcher: mock<StateWatcherSubscriptionInterface>(),
|
||||
eventWatcher: mock<EventWatcherSubscriptionInterface>(),
|
||||
});
|
||||
|
||||
// Should not find PTZ entities since camera has no unique_id
|
||||
@@ -453,13 +425,11 @@ describe('TPLinkCamera', () => {
|
||||
const camera = new TPLinkCamera(config, mock<CameraManagerEngine>());
|
||||
|
||||
await camera.initialize({
|
||||
hass: createHASS(),
|
||||
hassManager: createHASSManager(),
|
||||
entityRegistryManager: new EntityRegistryManagerMock([
|
||||
cameraEntity,
|
||||
buttonEntityPanLeft, // Only left button available
|
||||
]),
|
||||
stateWatcher: mock<StateWatcherSubscriptionInterface>(),
|
||||
eventWatcher: mock<EventWatcherSubscriptionInterface>(),
|
||||
});
|
||||
const executor = mock<ActionsExecutor>();
|
||||
|
||||
|
||||
@@ -1,19 +1,20 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { mock } from 'vitest-mock-extended';
|
||||
import { TPLinkCameraManagerEngine } from '../../../src/camera-manager/tplink/engine-tplink';
|
||||
import { Engine } from '../../../src/camera-manager/types';
|
||||
import { EventWatcherSubscriptionInterface } from '../../../src/card-controller/hass/event-watcher';
|
||||
import { StateWatcherSubscriptionInterface } from '../../../src/card-controller/hass/state-watcher';
|
||||
import { EntityRegistryManagerMock } from '../../ha/registry/entity/mock';
|
||||
import { createCameraConfig, createHASS, createRegistryEntity } from '../../test-utils';
|
||||
import {
|
||||
createCameraConfig,
|
||||
createHASS,
|
||||
createHASSManager,
|
||||
createRegistryEntity,
|
||||
} from '../../test-utils';
|
||||
|
||||
const createEngine = (options?: {
|
||||
entityRegistryManager?: EntityRegistryManagerMock;
|
||||
}): TPLinkCameraManagerEngine => {
|
||||
return new TPLinkCameraManagerEngine(
|
||||
options?.entityRegistryManager ?? new EntityRegistryManagerMock(),
|
||||
mock<StateWatcherSubscriptionInterface>(),
|
||||
mock<EventWatcherSubscriptionInterface>(),
|
||||
createHASSManager(),
|
||||
);
|
||||
};
|
||||
|
||||
@@ -42,7 +43,7 @@ describe('TPLinkCameraManagerEngine', () => {
|
||||
id: 'tapo_office',
|
||||
});
|
||||
|
||||
const camera = await engine.createCamera(createHASS(), config);
|
||||
const camera = await engine.createCamera(config);
|
||||
|
||||
expect(camera.getConfig()).toBe(config);
|
||||
expect(camera.getEngine()).toBe(engine);
|
||||
|
||||
@@ -1,8 +1,15 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { mock } from 'vitest-mock-extended';
|
||||
import { ActionsExecutionRequest } from '../../src/card-controller/actions/types.js';
|
||||
import { AutomationsManager } from '../../src/card-controller/automations-manager.js';
|
||||
import { EventWatcherSubscriptionInterface } from '../../src/card-controller/hass/event-watcher.js';
|
||||
import { ConditionStateManager } from '../../src/condition-trigger/conditions/state-manager.js';
|
||||
import { createCardAPI, flushPromises } from '../test-utils.js';
|
||||
import {
|
||||
createCardAPI,
|
||||
createHASS,
|
||||
createHASSEvent,
|
||||
flushPromises,
|
||||
} from '../test-utils.js';
|
||||
|
||||
describe('AutomationsManager', () => {
|
||||
const actions = [
|
||||
@@ -264,6 +271,40 @@ describe('AutomationsManager', () => {
|
||||
expect(api.getActionsManager().executeActions).toBeCalledTimes(10);
|
||||
});
|
||||
|
||||
it('should execute actions on a matching HA bus event trigger', () => {
|
||||
const api = createCardAPI();
|
||||
const eventWatcher = mock<EventWatcherSubscriptionInterface>();
|
||||
vi.mocked(eventWatcher.subscribe).mockResolvedValue();
|
||||
vi.mocked(eventWatcher.unsubscribe).mockResolvedValue();
|
||||
vi.mocked(api.getHASSManager().hasHASS).mockReturnValue(true);
|
||||
vi.mocked(api.getHASSManager().getHASS).mockReturnValue(createHASS());
|
||||
vi.mocked(api.getHASSManager().getEventWatcher).mockReturnValue(eventWatcher);
|
||||
vi.mocked(api.getInitializationManager().isInitializedMandatory).mockReturnValue(
|
||||
true,
|
||||
);
|
||||
const stateManager = new ConditionStateManager();
|
||||
vi.mocked(api.getConditionStateManager).mockReturnValue(stateManager);
|
||||
|
||||
const automationsManager = new AutomationsManager(api);
|
||||
automationsManager.addAutomations([
|
||||
{
|
||||
triggers: [{ trigger: 'event' as const, event_type: 'zha_event' }],
|
||||
actions: actions,
|
||||
},
|
||||
]);
|
||||
|
||||
expect(eventWatcher.subscribe).toBeCalledTimes(1);
|
||||
|
||||
// Simulate an event arrival.
|
||||
const event = createHASSEvent('zha_event', { command: 'press' });
|
||||
vi.mocked(eventWatcher.subscribe).mock.calls[0][0].callback(event);
|
||||
|
||||
expect(api.getActionsManager().executeActions).toBeCalledTimes(1);
|
||||
expect(
|
||||
vi.mocked(api.getActionsManager().executeActions).mock.calls[0][0].triggerData,
|
||||
).toEqual({ platform: 'event', event });
|
||||
});
|
||||
|
||||
it('should delete automations', () => {
|
||||
const api = createCardAPI();
|
||||
vi.mocked(api.getHASSManager().hasHASS).mockReturnValue(true);
|
||||
|
||||
@@ -32,6 +32,24 @@ describe('CardElementManager', () => {
|
||||
expect(manager.getElement()).toBe(element);
|
||||
});
|
||||
|
||||
it('should report whether the element is connected', () => {
|
||||
const element = createCardHTMLElement();
|
||||
const manager = new CardElementManager(
|
||||
createCardAPI(),
|
||||
element,
|
||||
() => undefined,
|
||||
() => undefined,
|
||||
);
|
||||
|
||||
expect(manager.isConnected()).toBe(false);
|
||||
|
||||
document.body.append(element);
|
||||
expect(manager.isConnected()).toBe(true);
|
||||
|
||||
element.remove();
|
||||
expect(manager.isConnected()).toBe(false);
|
||||
});
|
||||
|
||||
it('should reset scroll', () => {
|
||||
const callback = vi.fn();
|
||||
const manager = new CardElementManager(
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { mock, MockProxy } from 'vitest-mock-extended';
|
||||
import { CameraManager } from '../../src/camera-manager/manager';
|
||||
import { ActionsManager } from '../../src/card-controller/actions/actions-manager';
|
||||
import { AutomationsManager } from '../../src/card-controller/automations-manager';
|
||||
@@ -15,6 +16,7 @@ import { DefaultManager } from '../../src/card-controller/default-manager';
|
||||
import { ExpandManager } from '../../src/card-controller/expand-manager';
|
||||
import { FoldersManager } from '../../src/card-controller/folders/manager';
|
||||
import { FullscreenManager } from '../../src/card-controller/fullscreen/fullscreen-manager';
|
||||
import { EventWatcherSubscriptionInterface } from '../../src/card-controller/hass/event-watcher';
|
||||
import { HASSManager } from '../../src/card-controller/hass/hass-manager';
|
||||
import { InitializationManager } from '../../src/card-controller/initialization-manager';
|
||||
import { InteractionManager } from '../../src/card-controller/interaction-manager';
|
||||
@@ -36,6 +38,7 @@ import { AdvancedCameraCardEditor } from '../../src/editor';
|
||||
import { DeviceRegistryManager } from '../../src/ha/registry/device';
|
||||
import { EntityRegistryManagerLive } from '../../src/ha/registry/entity';
|
||||
import { ResolvedMediaCache } from '../../src/ha/resolved-media';
|
||||
import { createSubscriptionHealth } from './test-utils';
|
||||
|
||||
vi.mock('../../src/camera-manager/manager');
|
||||
vi.mock('../../src/card-controller/actions/actions-manager');
|
||||
@@ -49,7 +52,6 @@ vi.mock('../../src/card-controller/download-manager');
|
||||
vi.mock('../../src/card-controller/expand-manager');
|
||||
vi.mock('../../src/card-controller/folders/manager');
|
||||
vi.mock('../../src/card-controller/fullscreen/fullscreen-manager');
|
||||
vi.mock('../../src/card-controller/hass/hass-manager');
|
||||
vi.mock('../../src/card-controller/initialization-manager');
|
||||
vi.mock('../../src/card-controller/interaction-manager');
|
||||
vi.mock('../../src/card-controller/keyboard-state-manager');
|
||||
@@ -78,8 +80,21 @@ const createCardElement = (): CardHTMLElement => {
|
||||
return element;
|
||||
};
|
||||
|
||||
const createController = (): CardController => {
|
||||
return new CardController(createCardElement(), vi.fn(), vi.fn());
|
||||
// Full HASSManager mock for CardController ctor injection (wires
|
||||
// getEventWatcher().getHealth() so construction resolves). Distinct from the
|
||||
// readonly-interface `createHASSManager` helper in tests/test-utils.ts.
|
||||
const createMockHASSManager = (): MockProxy<HASSManager> => {
|
||||
const hassManager = mock<HASSManager>();
|
||||
hassManager.getEventWatcher.mockReturnValue(
|
||||
mock<EventWatcherSubscriptionInterface>({
|
||||
getHealth: () => createSubscriptionHealth(),
|
||||
}),
|
||||
);
|
||||
return hassManager;
|
||||
};
|
||||
|
||||
const createController = (hassManager = createMockHASSManager()): CardController => {
|
||||
return new CardController(createCardElement(), vi.fn(), vi.fn(), hassManager);
|
||||
};
|
||||
|
||||
// @vitest-environment jsdom
|
||||
@@ -104,6 +119,26 @@ describe('CardController', () => {
|
||||
expect(controller.getEffectsManager()).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should wire ConditionStateManager as the first hass listener so semantic state is fresh before any other listener runs', () => {
|
||||
const hassManager = createMockHASSManager();
|
||||
createController(hassManager);
|
||||
|
||||
const calls = vi.mocked(hassManager.addListener).mock.calls;
|
||||
|
||||
// ConditionStateManager (CSM) first ordering is load-bearing: StateWatcher
|
||||
// lazy-attaches later; its diff handlers can synchronously write to CSM, so
|
||||
// CSM.hass must be fresh before any listener whose dispatch path reads
|
||||
// condition state.
|
||||
expect(calls).toHaveLength(1);
|
||||
|
||||
const csmListener = calls[0][0];
|
||||
const hass = {} as Parameters<typeof csmListener>[0];
|
||||
csmListener(hass, null);
|
||||
expect(vi.mocked(ConditionStateManager).mock.instances[0].setState).toBeCalledWith({
|
||||
hass,
|
||||
});
|
||||
});
|
||||
|
||||
describe('accessors', () => {
|
||||
it('should return getActionsManager', () => {
|
||||
expect(createController().getActionsManager()).toBe(
|
||||
@@ -196,9 +231,8 @@ describe('CardController', () => {
|
||||
});
|
||||
|
||||
it('should return getHASSManager', () => {
|
||||
expect(createController().getHASSManager()).toBe(
|
||||
vi.mocked(HASSManager).mock.instances[0],
|
||||
);
|
||||
const hassManager = createMockHASSManager();
|
||||
expect(createController(hassManager).getHASSManager()).toBe(hassManager);
|
||||
});
|
||||
|
||||
it('should return getInitializationManager', () => {
|
||||
|
||||
@@ -1,32 +1,38 @@
|
||||
import { HassEvent } from 'home-assistant-js-websocket';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { Connection, HassEvent } from 'home-assistant-js-websocket';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { mock } from 'vitest-mock-extended';
|
||||
import { EventWatcher } from '../../../src/card-controller/hass/event-watcher';
|
||||
import { HomeAssistant } from '../../../src/ha/types';
|
||||
import { createHASS } from '../../test-utils';
|
||||
import {
|
||||
createHASS,
|
||||
createHASSEvent,
|
||||
createHASSSource,
|
||||
flushPromises,
|
||||
useDeterministicTimers,
|
||||
} from '../../test-utils';
|
||||
|
||||
// Drive the dispatcher registered with `hass.connection.subscribeEvents` to
|
||||
// simulate an event arriving over the WS bus.
|
||||
const fireEvent = (hass: HomeAssistant, event: HassEvent, n = 0): void => {
|
||||
const mock = vi.mocked(hass.connection.subscribeEvents).mock;
|
||||
expect(mock.calls.length).greaterThan(n);
|
||||
|
||||
// subscribeEvents(callback, event_type) -- callback is the first argument.
|
||||
mock.calls[n][0]?.(event);
|
||||
};
|
||||
|
||||
const createHassEvent = (event_type: string, data: object = {}): HassEvent => ({
|
||||
event_type,
|
||||
data: data as { [key: string]: string },
|
||||
origin: 'LOCAL',
|
||||
time_fired: '2026-05-25T00:00:00Z',
|
||||
context: { id: 'ctx', user_id: null, parent_id: null },
|
||||
});
|
||||
|
||||
// @vitest-environment jsdom
|
||||
describe('EventWatcher', () => {
|
||||
it('opens a single WS subscription per event_type regardless of subscribers', async () => {
|
||||
const watcher = new EventWatcher();
|
||||
const hass = createHASS();
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
await watcher.subscribe(hass, { event_type: 'zha_event', callback: vi.fn() });
|
||||
await watcher.subscribe(hass, { event_type: 'zha_event', callback: vi.fn() });
|
||||
it('should open a WS subscription keyed by event_type', async () => {
|
||||
const hass = createHASS();
|
||||
const { source } = createHASSSource(hass);
|
||||
const watcher = new EventWatcher(source);
|
||||
|
||||
watcher.subscribe({ event_type: 'zha_event', callback: vi.fn() });
|
||||
await flushPromises();
|
||||
|
||||
expect(hass.connection.subscribeEvents).toBeCalledTimes(1);
|
||||
expect(vi.mocked(hass.connection.subscribeEvents).mock.calls[0][1]).toBe(
|
||||
@@ -34,100 +40,160 @@ describe('EventWatcher', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('opens separate WS subscriptions for distinct event_types', async () => {
|
||||
const watcher = new EventWatcher();
|
||||
it('should share one WS subscription across subscribers with the same event_type', async () => {
|
||||
const hass = createHASS();
|
||||
const { source } = createHASSSource(hass);
|
||||
const watcher = new EventWatcher(source);
|
||||
|
||||
await watcher.subscribe(hass, { event_type: 'zha_event', callback: vi.fn() });
|
||||
await watcher.subscribe(hass, { event_type: 'deconz_event', callback: vi.fn() });
|
||||
watcher.subscribe({ event_type: 'zha_event', callback: vi.fn() });
|
||||
watcher.subscribe({ event_type: 'zha_event', callback: vi.fn() });
|
||||
await flushPromises();
|
||||
|
||||
expect(hass.connection.subscribeEvents).toBeCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should open separate WS subscriptions for distinct event_types', async () => {
|
||||
const hass = createHASS();
|
||||
const { source } = createHASSSource(hass);
|
||||
const watcher = new EventWatcher(source);
|
||||
|
||||
watcher.subscribe({ event_type: 'zha_event', callback: vi.fn() });
|
||||
watcher.subscribe({ event_type: 'deconz_event', callback: vi.fn() });
|
||||
await flushPromises();
|
||||
|
||||
expect(hass.connection.subscribeEvents).toBeCalledTimes(2);
|
||||
});
|
||||
|
||||
it('only tears down the WS subscription when the last subscriber unsubscribes', async () => {
|
||||
const watcher = new EventWatcher();
|
||||
const hass = createHASS();
|
||||
const unsub = vi.fn();
|
||||
vi.mocked(hass.connection.subscribeEvents).mockResolvedValue(unsub);
|
||||
|
||||
const req1 = { event_type: 'zha_event', callback: vi.fn() };
|
||||
const req2 = { event_type: 'zha_event', callback: vi.fn() };
|
||||
await watcher.subscribe(hass, req1);
|
||||
await watcher.subscribe(hass, req2);
|
||||
|
||||
await watcher.unsubscribe(req1);
|
||||
expect(unsub).not.toBeCalled();
|
||||
|
||||
await watcher.unsubscribe(req2);
|
||||
expect(unsub).toBeCalledTimes(1);
|
||||
});
|
||||
|
||||
it('dispatches to all subscribers whose event_type matches', async () => {
|
||||
const watcher = new EventWatcher();
|
||||
it('should dispatch to every subscriber whose event_type matches', async () => {
|
||||
const hass = createHASS();
|
||||
const { source } = createHASSSource(hass);
|
||||
const watcher = new EventWatcher(source);
|
||||
const cb1 = vi.fn();
|
||||
const cb2 = vi.fn();
|
||||
const event = createHASSEvent('zha_event', { command: 'press' });
|
||||
|
||||
await watcher.subscribe(hass, { event_type: 'zha_event', callback: cb1 });
|
||||
await watcher.subscribe(hass, { event_type: 'zha_event', callback: cb2 });
|
||||
watcher.subscribe({ event_type: 'zha_event', callback: cb1 });
|
||||
watcher.subscribe({ event_type: 'zha_event', callback: cb2 });
|
||||
await flushPromises();
|
||||
|
||||
fireEvent(hass, createHassEvent('zha_event', { command: 'press' }));
|
||||
fireEvent(hass, event);
|
||||
|
||||
expect(cb1).toBeCalledWith({ command: 'press' });
|
||||
expect(cb2).toBeCalledWith({ command: 'press' });
|
||||
expect(cb1).toBeCalledWith(event);
|
||||
expect(cb2).toBeCalledWith(event);
|
||||
});
|
||||
|
||||
it('drops events whose event_type does not match the request', async () => {
|
||||
const watcher = new EventWatcher();
|
||||
it('should gate dispatch on the request matcher when provided', async () => {
|
||||
const hass = createHASS();
|
||||
const { source } = createHASSSource(hass);
|
||||
const watcher = new EventWatcher(source);
|
||||
const cb = vi.fn();
|
||||
const matcher = vi.fn((event: HassEvent) => (event.data as { x?: number }).x === 1);
|
||||
|
||||
await watcher.subscribe(hass, { event_type: 'zha_event', callback: cb });
|
||||
// Inject an unrelated event into the shared dispatcher.
|
||||
fireEvent(hass, createHassEvent('other_event', { x: 1 }));
|
||||
const matching = createHASSEvent('zha_event', { x: 1 });
|
||||
const nonMatching = createHASSEvent('zha_event', { x: 2 });
|
||||
watcher.subscribe({ event_type: 'zha_event', matcher, callback: cb });
|
||||
await flushPromises();
|
||||
|
||||
expect(cb).not.toBeCalled();
|
||||
});
|
||||
|
||||
it('gates dispatch on the request matcher when provided', async () => {
|
||||
const watcher = new EventWatcher();
|
||||
const hass = createHASS();
|
||||
const cb = vi.fn();
|
||||
const matcher = vi.fn((data: unknown) => (data as { x?: number }).x === 1);
|
||||
|
||||
await watcher.subscribe(hass, { event_type: 'zha_event', matcher, callback: cb });
|
||||
|
||||
fireEvent(hass, createHassEvent('zha_event', { x: 1 }));
|
||||
fireEvent(hass, createHassEvent('zha_event', { x: 2 }));
|
||||
fireEvent(hass, matching);
|
||||
fireEvent(hass, nonMatching);
|
||||
|
||||
expect(matcher).toBeCalledTimes(2);
|
||||
expect(cb).toBeCalledTimes(1);
|
||||
expect(cb).toBeCalledWith({ x: 1 });
|
||||
expect(cb).toBeCalledWith(matching);
|
||||
});
|
||||
|
||||
it('handles unsubscribe during a still-pending subscribe without leaking', async () => {
|
||||
const watcher = new EventWatcher();
|
||||
it('should tear down the WS subscription only when the last subscriber unsubscribes', async () => {
|
||||
const hass = createHASS();
|
||||
const unsub = vi.fn();
|
||||
vi.mocked(hass.connection.subscribeEvents).mockResolvedValue(unsub);
|
||||
const { source } = createHASSSource(hass);
|
||||
const watcher = new EventWatcher(source);
|
||||
const req1 = { event_type: 'zha_event', callback: vi.fn() };
|
||||
const req2 = { event_type: 'zha_event', callback: vi.fn() };
|
||||
|
||||
let resolveSubscription: ((cb: () => Promise<void>) => void) | undefined;
|
||||
const subscriptionPromise = new Promise<() => Promise<void>>((resolve) => {
|
||||
resolveSubscription = resolve;
|
||||
});
|
||||
vi.mocked(hass.connection.subscribeEvents).mockReturnValue(subscriptionPromise);
|
||||
watcher.subscribe(req1);
|
||||
watcher.subscribe(req2);
|
||||
await flushPromises();
|
||||
|
||||
const req = { event_type: 'zha_event', callback: vi.fn() };
|
||||
const subscribePromise = watcher.subscribe(hass, req);
|
||||
|
||||
// Unsubscribe before the underlying connection has resolved.
|
||||
const unsubscribePromise = watcher.unsubscribe(req);
|
||||
|
||||
// Resolve the connection -- the watcher should now have the unsub fn and
|
||||
// call it as part of completing the unsubscribe.
|
||||
resolveSubscription?.(unsub);
|
||||
await subscribePromise;
|
||||
await unsubscribePromise;
|
||||
watcher.unsubscribe(req1);
|
||||
await flushPromises();
|
||||
expect(unsub).not.toBeCalled();
|
||||
|
||||
watcher.unsubscribe(req2);
|
||||
await flushPromises();
|
||||
expect(unsub).toBeCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should drop events from an old-connection subscription after a swap', async () => {
|
||||
const oldHass = createHASS();
|
||||
const { source, push } = createHASSSource(oldHass);
|
||||
const watcher = new EventWatcher(source);
|
||||
const cb = vi.fn();
|
||||
|
||||
watcher.subscribe({ event_type: 'zha_event', callback: cb });
|
||||
await flushPromises();
|
||||
|
||||
// Capture the dispatcher registered against the OLD connection BEFORE
|
||||
// the swap, so it still points at the source-bound guard.
|
||||
const oldDispatcher = vi.mocked(oldHass.connection.subscribeEvents).mock.calls[0][0];
|
||||
|
||||
const newHass = createHASS();
|
||||
newHass.connection = mock<Connection>();
|
||||
vi.mocked(newHass.connection.subscribeEvents).mockResolvedValue(vi.fn());
|
||||
push(newHass);
|
||||
await flushPromises();
|
||||
|
||||
// Old dispatcher fires: guard.isConnected() is now false, callback must NOT
|
||||
// receive the event.
|
||||
oldDispatcher?.(createHASSEvent('zha_event', { command: 'press' }));
|
||||
expect(cb).not.toBeCalled();
|
||||
});
|
||||
|
||||
it('should not dispatch to a subscriber that registers mid-dispatch', async () => {
|
||||
const hass = createHASS();
|
||||
const { source } = createHASSSource(hass);
|
||||
const watcher = new EventWatcher(source);
|
||||
const lateCallback = vi.fn();
|
||||
const reentrantCallback = vi.fn(() => {
|
||||
watcher.subscribe({ event_type: 'zha_event', callback: lateCallback });
|
||||
});
|
||||
|
||||
watcher.subscribe({ event_type: 'zha_event', callback: reentrantCallback });
|
||||
await flushPromises();
|
||||
|
||||
fireEvent(hass, createHASSEvent('zha_event', { command: 'press' }));
|
||||
|
||||
expect(reentrantCallback).toBeCalledTimes(1);
|
||||
expect(lateCallback).not.toBeCalled();
|
||||
});
|
||||
|
||||
describe('subscription health monitoring', () => {
|
||||
it('should surface and retry failing subscriptions through getHealth', async () => {
|
||||
useDeterministicTimers();
|
||||
|
||||
const hass = createHASS();
|
||||
vi.mocked(hass.connection.subscribeEvents).mockRejectedValue(new Error('boom'));
|
||||
const { source } = createHASSSource(hass);
|
||||
const watcher = new EventWatcher(source);
|
||||
|
||||
watcher.subscribe({ event_type: 'zha_event', callback: vi.fn() });
|
||||
await flushPromises();
|
||||
|
||||
expect(
|
||||
watcher
|
||||
.getHealth()
|
||||
.getFailures()
|
||||
.map((failure) => failure.key),
|
||||
).toEqual(['zha_event']);
|
||||
|
||||
const before = vi.mocked(hass.connection.subscribeEvents).mock.calls.length;
|
||||
|
||||
watcher.getHealth().retry();
|
||||
|
||||
await flushPromises();
|
||||
expect(vi.mocked(hass.connection.subscribeEvents).mock.calls.length).toBe(
|
||||
before + 1,
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -44,27 +44,62 @@ describe('HASSManager', () => {
|
||||
expect(manager.hasHASS()).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should update theme upon setting hass', () => {
|
||||
const api = createCardAPI();
|
||||
const manager = new HASSManager(api);
|
||||
describe('as a HASS source', () => {
|
||||
it('should fan out to registered listeners with (hass, oldHass)', () => {
|
||||
const manager = new HASSManager(createCardAPI());
|
||||
const listener = vi.fn();
|
||||
manager.addListener(listener);
|
||||
|
||||
manager.setHASS(createHASS());
|
||||
const hass1 = createHASS();
|
||||
manager.setHASS(hass1);
|
||||
expect(listener).toBeCalledWith(hass1, null);
|
||||
|
||||
expect(api.getStyleManager().applyTheme).toBeCalled();
|
||||
});
|
||||
const hass2 = createHASS();
|
||||
manager.setHASS(hass2);
|
||||
expect(listener).toBeCalledWith(hass2, hass1);
|
||||
});
|
||||
|
||||
it('should set condition manager state', () => {
|
||||
const api = createCardAPI();
|
||||
const manager = new HASSManager(api);
|
||||
const hass = createHASS();
|
||||
it('should call listeners in insertion order on every fan-out', () => {
|
||||
const manager = new HASSManager(createCardAPI());
|
||||
const order: string[] = [];
|
||||
manager.addListener(() => order.push('first'));
|
||||
manager.addListener(() => order.push('second'));
|
||||
|
||||
manager.setHASS(hass);
|
||||
manager.setHASS(createHASS());
|
||||
|
||||
expect(api.getConditionStateManager().setState).toBeCalledWith(
|
||||
expect.objectContaining({
|
||||
hass: hass,
|
||||
}),
|
||||
);
|
||||
expect(order).toEqual(['first', 'second']);
|
||||
});
|
||||
|
||||
it('should detach a listener via the returned unlisten callback', () => {
|
||||
const manager = new HASSManager(createCardAPI());
|
||||
const listener = vi.fn();
|
||||
const unlisten = manager.addListener(listener);
|
||||
|
||||
unlisten();
|
||||
manager.setHASS(createHASS());
|
||||
|
||||
expect(listener).not.toBeCalled();
|
||||
});
|
||||
|
||||
it('should not fan out on null/undefined hass', () => {
|
||||
const manager = new HASSManager(createCardAPI());
|
||||
const listener = vi.fn();
|
||||
manager.addListener(listener);
|
||||
|
||||
manager.setHASS(null);
|
||||
manager.setHASS();
|
||||
|
||||
expect(listener).not.toBeCalled();
|
||||
});
|
||||
|
||||
it('should expose current hass via getHASS for source consumers', () => {
|
||||
const manager = new HASSManager(createCardAPI());
|
||||
expect(manager.getHASS()).toBeNull();
|
||||
|
||||
const hass = createHASS();
|
||||
manager.setHASS(hass);
|
||||
expect(manager.getHASS()).toBe(hass);
|
||||
});
|
||||
});
|
||||
|
||||
describe('should handle connection state change when', () => {
|
||||
|
||||
@@ -1,35 +1,61 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { StateWatcher } from '../../../src/card-controller/hass/state-watcher';
|
||||
import { createHASS, createStateEntity } from '../../test-utils';
|
||||
import { createHASS, createHASSSource, createStateEntity } from '../../test-utils';
|
||||
|
||||
describe('StateWatcher', () => {
|
||||
it('should not subscribe with no entities', () => {
|
||||
const stateWatcher = new StateWatcher();
|
||||
const { source } = createHASSSource();
|
||||
const stateWatcher = new StateWatcher(source);
|
||||
expect(stateWatcher.subscribe(vi.fn(), [])).toBeFalsy();
|
||||
});
|
||||
|
||||
it('should attach to the source lazily on first subscriber', () => {
|
||||
const { source, getListenerCount } = createHASSSource(createHASS());
|
||||
const stateWatcher = new StateWatcher(source);
|
||||
expect(getListenerCount()).toBe(0);
|
||||
stateWatcher.subscribe(vi.fn(), ['binary_sensor.foo']);
|
||||
expect(getListenerCount()).toBe(1);
|
||||
});
|
||||
|
||||
it('should stay attached while other subscribers remain', () => {
|
||||
const { source, getListenerCount } = createHASSSource(createHASS());
|
||||
const stateWatcher = new StateWatcher(source);
|
||||
const cb1 = vi.fn();
|
||||
const cb2 = vi.fn();
|
||||
|
||||
stateWatcher.subscribe(cb1, ['binary_sensor.foo']);
|
||||
stateWatcher.subscribe(cb2, ['binary_sensor.bar']);
|
||||
expect(getListenerCount()).toBe(1);
|
||||
|
||||
stateWatcher.unsubscribe(cb1);
|
||||
expect(getListenerCount()).toBe(1);
|
||||
});
|
||||
|
||||
it('should detach from the source when the last subscriber leaves', () => {
|
||||
const { source, getListenerCount } = createHASSSource(createHASS());
|
||||
const stateWatcher = new StateWatcher(source);
|
||||
const callback = vi.fn();
|
||||
expect(stateWatcher.subscribe(callback, [])).toBeFalsy();
|
||||
|
||||
stateWatcher.subscribe(callback, ['binary_sensor.foo']);
|
||||
expect(getListenerCount()).toBe(1);
|
||||
|
||||
stateWatcher.unsubscribe(callback);
|
||||
expect(getListenerCount()).toBe(0);
|
||||
});
|
||||
|
||||
it('should call back with state change', () => {
|
||||
const stateWatcher = new StateWatcher();
|
||||
const initial = createHASS({
|
||||
'binary_sensor.foo': createStateEntity({ state: 'on' }),
|
||||
'binary_sensor.bar': createStateEntity({ state: 'off' }),
|
||||
});
|
||||
const { source, push } = createHASSSource(initial);
|
||||
const stateWatcher = new StateWatcher(source);
|
||||
const callback = vi.fn();
|
||||
|
||||
expect(stateWatcher.subscribe(callback, ['binary_sensor.foo'])).toBeTruthy();
|
||||
expect(stateWatcher.subscribe(callback, ['binary_sensor.bar'])).toBeTruthy();
|
||||
|
||||
stateWatcher.setHASS(
|
||||
null,
|
||||
createHASS({
|
||||
'binary_sensor.foo': createStateEntity({ state: 'on' }),
|
||||
'binary_sensor.bar': createStateEntity({ state: 'off' }),
|
||||
}),
|
||||
);
|
||||
|
||||
expect(callback).not.toBeCalled();
|
||||
|
||||
stateWatcher.setHASS(
|
||||
createHASS({
|
||||
'binary_sensor.foo': createStateEntity({ state: 'on' }),
|
||||
'binary_sensor.bar': createStateEntity({ state: 'off' }),
|
||||
}),
|
||||
push(
|
||||
createHASS({
|
||||
'binary_sensor.foo': createStateEntity({ state: 'on' }),
|
||||
'binary_sensor.bar': createStateEntity({ state: 'on' }),
|
||||
@@ -46,24 +72,33 @@ describe('StateWatcher', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('should not call back without state change', () => {
|
||||
const stateWatcher = new StateWatcher();
|
||||
it('should not call back when oldHass is null on first observed push', () => {
|
||||
const { source, push } = createHASSSource(null);
|
||||
const stateWatcher = new StateWatcher(source);
|
||||
const callback = vi.fn();
|
||||
expect(stateWatcher.subscribe(callback, ['binary_sensor.foo'])).toBeTruthy();
|
||||
|
||||
stateWatcher.setHASS(
|
||||
null,
|
||||
stateWatcher.subscribe(callback, ['binary_sensor.foo']);
|
||||
|
||||
push(
|
||||
createHASS({
|
||||
'binary_sensor.foo': createStateEntity({ state: 'on' }),
|
||||
}),
|
||||
);
|
||||
|
||||
expect(callback).not.toBeCalled();
|
||||
});
|
||||
|
||||
stateWatcher.setHASS(
|
||||
createHASS({
|
||||
'binary_sensor.foo': createStateEntity({ state: 'on' }),
|
||||
}),
|
||||
it('should not call back without state change', () => {
|
||||
const initial = createHASS({
|
||||
'binary_sensor.foo': createStateEntity({ state: 'on' }),
|
||||
});
|
||||
const { source, push } = createHASSSource(initial);
|
||||
const stateWatcher = new StateWatcher(source);
|
||||
const callback = vi.fn();
|
||||
|
||||
expect(stateWatcher.subscribe(callback, ['binary_sensor.foo'])).toBeTruthy();
|
||||
|
||||
push(
|
||||
createHASS({
|
||||
'binary_sensor.foo': createStateEntity({ state: 'on' }),
|
||||
}),
|
||||
@@ -73,24 +108,17 @@ describe('StateWatcher', () => {
|
||||
});
|
||||
|
||||
it('should not call back when unsubscribed', () => {
|
||||
const stateWatcher = new StateWatcher();
|
||||
const initial = createHASS({
|
||||
'binary_sensor.foo': createStateEntity({ state: 'on' }),
|
||||
});
|
||||
const { source, push } = createHASSSource(initial);
|
||||
const stateWatcher = new StateWatcher(source);
|
||||
const callback = vi.fn();
|
||||
|
||||
expect(stateWatcher.subscribe(callback, ['binary_sensor.foo'])).toBeTruthy();
|
||||
expect(stateWatcher.unsubscribe(callback));
|
||||
stateWatcher.unsubscribe(callback);
|
||||
|
||||
stateWatcher.setHASS(
|
||||
null,
|
||||
createHASS({
|
||||
'binary_sensor.foo': createStateEntity({ state: 'on' }),
|
||||
}),
|
||||
);
|
||||
|
||||
expect(callback).not.toBeCalled();
|
||||
|
||||
stateWatcher.setHASS(
|
||||
createHASS({
|
||||
'binary_sensor.foo': createStateEntity({ state: 'on' }),
|
||||
}),
|
||||
push(
|
||||
createHASS({
|
||||
'binary_sensor.foo': createStateEntity({ state: 'off' }),
|
||||
}),
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { STATE_STARTING } from 'home-assistant-js-websocket';
|
||||
import { STATE_RUNNING, STATE_STARTING } from 'home-assistant-js-websocket';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { mock } from 'vitest-mock-extended';
|
||||
import {
|
||||
@@ -293,4 +293,89 @@ describe('InitializationManager', () => {
|
||||
|
||||
expect(initializer.uninitialize).toBeCalledWith(InitializationAspect.CAMERAS);
|
||||
});
|
||||
|
||||
describe('should decide whether to trigger initialization', () => {
|
||||
const createReadyAPI = () => {
|
||||
const api = createCardAPI();
|
||||
vi.mocked(api.getConfigManager().hasConfig).mockReturnValue(true);
|
||||
vi.mocked(api.getConfigManager().getConfig).mockReturnValue(createConfig());
|
||||
vi.mocked(api.getCardElementManager().isConnected).mockReturnValue(true);
|
||||
const hass = createHASS();
|
||||
hass.connected = true;
|
||||
hass.config.state = STATE_RUNNING;
|
||||
vi.mocked(api.getHASSManager().getHASS).mockReturnValue(hass);
|
||||
vi.mocked(
|
||||
api.getIssueManager().getStateManager().hasFullCardIssue,
|
||||
).mockReturnValue(false);
|
||||
return api;
|
||||
};
|
||||
|
||||
it('should initialize when all conditions are met', () => {
|
||||
const initializer = mock<Initializer>();
|
||||
const manager = new InitializationManager(createReadyAPI(), initializer);
|
||||
|
||||
manager.triggerInitialization();
|
||||
|
||||
expect(initializer.initializeMultipleIfNecessary).toBeCalled();
|
||||
});
|
||||
|
||||
it('should not initialize without config', () => {
|
||||
const api = createReadyAPI();
|
||||
vi.mocked(api.getConfigManager().hasConfig).mockReturnValue(false);
|
||||
const initializer = mock<Initializer>();
|
||||
const manager = new InitializationManager(api, initializer);
|
||||
|
||||
manager.triggerInitialization();
|
||||
|
||||
expect(initializer.initializeMultipleIfNecessary).not.toBeCalled();
|
||||
});
|
||||
|
||||
it('should not initialize when the element is disconnected', () => {
|
||||
const api = createReadyAPI();
|
||||
vi.mocked(api.getCardElementManager().isConnected).mockReturnValue(false);
|
||||
const initializer = mock<Initializer>();
|
||||
const manager = new InitializationManager(api, initializer);
|
||||
|
||||
manager.triggerInitialization();
|
||||
|
||||
expect(initializer.initializeMultipleIfNecessary).not.toBeCalled();
|
||||
});
|
||||
|
||||
it('should not initialize when hass is not ready', () => {
|
||||
const api = createReadyAPI();
|
||||
const hass = createHASS();
|
||||
hass.connected = true;
|
||||
hass.config.state = STATE_STARTING;
|
||||
vi.mocked(api.getHASSManager().getHASS).mockReturnValue(hass);
|
||||
const initializer = mock<Initializer>();
|
||||
const manager = new InitializationManager(api, initializer);
|
||||
|
||||
manager.triggerInitialization();
|
||||
|
||||
expect(initializer.initializeMultipleIfNecessary).not.toBeCalled();
|
||||
});
|
||||
|
||||
it('should not initialize when already initialized', () => {
|
||||
const initializer = mock<Initializer>();
|
||||
initializer.isInitializedMultiple.mockReturnValue(true);
|
||||
const manager = new InitializationManager(createReadyAPI(), initializer);
|
||||
|
||||
manager.triggerInitialization();
|
||||
|
||||
expect(initializer.initializeMultipleIfNecessary).not.toBeCalled();
|
||||
});
|
||||
|
||||
it('should not initialize while a full-card issue is shown', () => {
|
||||
const api = createReadyAPI();
|
||||
vi.mocked(
|
||||
api.getIssueManager().getStateManager().hasFullCardIssue,
|
||||
).mockReturnValue(true);
|
||||
const initializer = mock<Initializer>();
|
||||
const manager = new InitializationManager(api, initializer);
|
||||
|
||||
manager.triggerInitialization();
|
||||
|
||||
expect(initializer.initializeMultipleIfNecessary).not.toBeCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -4,6 +4,7 @@ import { createIssueManager } from '../../../src/card-controller/issues/factory'
|
||||
import { IssueManager } from '../../../src/card-controller/issues/issue-manager';
|
||||
import { ConditionStateManager } from '../../../src/condition-trigger/conditions/state-manager';
|
||||
import { createCardAPI } from '../../test-utils';
|
||||
import { createSubscriptionHealth } from '../test-utils';
|
||||
|
||||
describe('createIssueManager', () => {
|
||||
beforeEach(() => {
|
||||
@@ -15,12 +16,15 @@ describe('createIssueManager', () => {
|
||||
});
|
||||
|
||||
it('should return a IssueManager instance', () => {
|
||||
const manager = createIssueManager(createCardAPI());
|
||||
const manager = createIssueManager(createCardAPI(), createSubscriptionHealth());
|
||||
expect(manager).toBeInstanceOf(IssueManager);
|
||||
});
|
||||
|
||||
it('should register all expected issues', () => {
|
||||
const manager = createIssueManager(createCardAPI()).getStateManager();
|
||||
const manager = createIssueManager(
|
||||
createCardAPI(),
|
||||
createSubscriptionHealth(),
|
||||
).getStateManager();
|
||||
|
||||
expect(manager.getIssueDescriptions()).toHaveLength(0);
|
||||
|
||||
@@ -29,6 +33,7 @@ describe('createIssueManager', () => {
|
||||
'config_upgrade',
|
||||
'config_upgrade_failure',
|
||||
'connection',
|
||||
'event_subscription',
|
||||
'initialization',
|
||||
'legacy_resource',
|
||||
'media_query',
|
||||
@@ -51,7 +56,7 @@ describe('createIssueManager', () => {
|
||||
const api = createCardAPI();
|
||||
const stateManager = new ConditionStateManager();
|
||||
vi.mocked(api.getConditionStateManager).mockReturnValue(stateManager);
|
||||
const manager = createIssueManager(api);
|
||||
const manager = createIssueManager(api, createSubscriptionHealth());
|
||||
|
||||
manager.trigger('media_query', { error: new Error('x') });
|
||||
manager.trigger('initialization', { error: new Error('x') });
|
||||
@@ -76,7 +81,7 @@ describe('createIssueManager', () => {
|
||||
const stateManager = new ConditionStateManager();
|
||||
vi.mocked(api.getConditionStateManager).mockReturnValue(stateManager);
|
||||
|
||||
const manager = createIssueManager(api);
|
||||
const manager = createIssueManager(api, createSubscriptionHealth());
|
||||
|
||||
// Setting view starts the media_load timer (via the condition state
|
||||
// listener → evaluate → detectDynamic).
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { EventSubscriptionIssue } from '../../../../src/card-controller/issues/issues/event-subscription';
|
||||
import { Issue } from '../../../../src/card-controller/issues/types';
|
||||
import { SubscriptionFailure } from '../../../../src/ha/connection/subscription-health-monitor';
|
||||
import { localize } from '../../../../src/localize/localize';
|
||||
import { createSubscriptionHealth } from '../../test-utils';
|
||||
|
||||
const createSubscriptionFailure = (key: string): SubscriptionFailure<string> => ({
|
||||
key,
|
||||
error: new Error(key),
|
||||
failureCount: 1,
|
||||
});
|
||||
|
||||
describe('EventSubscriptionIssue', () => {
|
||||
it('should register the change callback as a health listener', () => {
|
||||
const health = createSubscriptionHealth();
|
||||
const changeCallback = vi.fn();
|
||||
|
||||
new EventSubscriptionIssue(health, changeCallback);
|
||||
|
||||
expect(health.addListener).toBeCalledWith(changeCallback);
|
||||
});
|
||||
|
||||
it('should have no issue when there are no failures', () => {
|
||||
const health = createSubscriptionHealth();
|
||||
const issue = new EventSubscriptionIssue(health, vi.fn());
|
||||
|
||||
expect(issue.hasIssue()).toBe(false);
|
||||
expect(issue.getIssue()).toBeNull();
|
||||
expect(issue.getNotification()).toBeNull();
|
||||
});
|
||||
|
||||
it('should describe the failing event types sorted, at medium severity', () => {
|
||||
const health = createSubscriptionHealth();
|
||||
health.getFailures.mockReturnValue([
|
||||
createSubscriptionFailure('zebra_event'),
|
||||
createSubscriptionFailure('alpha_event'),
|
||||
]);
|
||||
const issue = new EventSubscriptionIssue(health, vi.fn());
|
||||
|
||||
expect(issue.hasIssue()).toBe(true);
|
||||
|
||||
const description = issue.getIssue();
|
||||
expect(description?.severity).toBe('medium');
|
||||
expect(description?.notification.heading?.text).toBe(
|
||||
localize('issues.event_subscription.heading'),
|
||||
);
|
||||
expect(description?.notification.metadata?.map((detail) => detail.text)).toEqual([
|
||||
'alpha_event',
|
||||
'zebra_event',
|
||||
]);
|
||||
});
|
||||
|
||||
it('should offer a retry control on the notification', () => {
|
||||
const health = createSubscriptionHealth();
|
||||
health.getFailures.mockReturnValue([createSubscriptionFailure('zha_event')]);
|
||||
const issue = new EventSubscriptionIssue(health, vi.fn());
|
||||
|
||||
expect(issue.getNotification()?.controls?.[0].icon).toBe('mdi:refresh');
|
||||
});
|
||||
|
||||
it('should re-drive the failing subscriptions on retry', () => {
|
||||
const health = createSubscriptionHealth();
|
||||
const issue = new EventSubscriptionIssue(health, vi.fn());
|
||||
|
||||
expect(issue.retry()).toBe(true);
|
||||
expect(health.retry).toBeCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should not opt into IssueManager-scheduled retries', () => {
|
||||
// No `needsRetry()` means the subscription manager stays the sole auto-retry
|
||||
// loop; the IssueManager never schedules this issue.
|
||||
const issue: Issue = new EventSubscriptionIssue(createSubscriptionHealth(), vi.fn());
|
||||
|
||||
expect(issue.needsRetry).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should remove its health listener on destroy', () => {
|
||||
const health = createSubscriptionHealth();
|
||||
const unsubscribe = vi.fn();
|
||||
health.addListener.mockReturnValue(unsubscribe);
|
||||
const issue = new EventSubscriptionIssue(health, vi.fn());
|
||||
|
||||
issue.destroy();
|
||||
|
||||
expect(unsubscribe).toBeCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -539,7 +539,7 @@ describe('IssueStateManager', () => {
|
||||
});
|
||||
|
||||
describe('destroy', () => {
|
||||
it('should destroy all issues and clear', () => {
|
||||
it('should clear, reset and destroy all issues', () => {
|
||||
const manager = createManager();
|
||||
manager.destroy();
|
||||
|
||||
@@ -549,6 +549,14 @@ describe('IssueStateManager', () => {
|
||||
expect(mockConfigUpgrade.reset).toBeCalled();
|
||||
expect(mockLegacyResource.reset).toBeCalled();
|
||||
expect(mockMediaLoad.reset).toBeCalled();
|
||||
|
||||
assert(mockConfigUpgrade.destroy);
|
||||
assert(mockLegacyResource.destroy);
|
||||
assert(mockMediaLoad.destroy);
|
||||
expect(mockConfigUpgrade.destroy).toBeCalled();
|
||||
expect(mockLegacyResource.destroy).toBeCalled();
|
||||
expect(mockMediaLoad.destroy).toBeCalled();
|
||||
|
||||
expect(manager.getIssuePresence().size).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
import { vi } from 'vitest';
|
||||
import { mock, MockProxy } from 'vitest-mock-extended';
|
||||
import { SubscriptionHealthInterface } from '../../src/ha/connection/subscription-health-monitor';
|
||||
|
||||
// A benign mocked event-subscription health surface: no failures, listeners
|
||||
// return a no-op unsubscribe. Tests configure `getFailures`/`retry` as needed.
|
||||
export const createSubscriptionHealth = (): MockProxy<
|
||||
SubscriptionHealthInterface<string>
|
||||
> => {
|
||||
const health = mock<SubscriptionHealthInterface<string>>();
|
||||
health.getFailures.mockReturnValue([]);
|
||||
health.addListener.mockReturnValue(vi.fn());
|
||||
return health;
|
||||
};
|
||||
@@ -6,6 +6,7 @@ import { CallTrigger } from '../../../src/condition-trigger/triggers/triggers/ca
|
||||
import { CameraTrigger } from '../../../src/condition-trigger/triggers/triggers/camera';
|
||||
import { ConfigTrigger } from '../../../src/condition-trigger/triggers/triggers/config';
|
||||
import { DisplayModeTrigger } from '../../../src/condition-trigger/triggers/triggers/display-mode';
|
||||
import { EventTrigger } from '../../../src/condition-trigger/triggers/triggers/event';
|
||||
import { ExpandTrigger } from '../../../src/condition-trigger/triggers/triggers/expand';
|
||||
import { FullscreenTrigger } from '../../../src/condition-trigger/triggers/triggers/fullscreen';
|
||||
import { InitializedTrigger } from '../../../src/condition-trigger/triggers/triggers/initialized';
|
||||
@@ -24,6 +25,7 @@ import {
|
||||
} from '../../../src/condition-trigger/triggers/triggers/types';
|
||||
import { ViewTrigger } from '../../../src/condition-trigger/triggers/triggers/view';
|
||||
import { Trigger } from '../../../src/config/schema/condition-trigger/triggers/types';
|
||||
import { createHASSManager } from '../../test-utils';
|
||||
|
||||
type TriggerEvaluatorConstructor = new (...args: never[]) => TriggerEvaluator;
|
||||
|
||||
@@ -32,9 +34,11 @@ describe('createTriggerEvaluator', () => {
|
||||
const context = (): TriggerEvaluatorContext => ({
|
||||
stateManager: new ConditionStateManager(),
|
||||
templateRenderer: new TemplateRenderer(),
|
||||
hassManager: createHASSManager(),
|
||||
});
|
||||
|
||||
it.each<[Trigger, TriggerEvaluatorConstructor]>([
|
||||
[{ trigger: 'event', event_type: 'zha_event' }, EventTrigger],
|
||||
[{ trigger: 'state', entity_id: 'binary_sensor.x' }, StateTrigger],
|
||||
[{ trigger: 'numeric_state', entity_id: 'sensor.x', above: 5 }, NumericStateTrigger],
|
||||
[{ trigger: 'template', value_template: '{{ true }}' }, TemplateTrigger],
|
||||
|
||||
@@ -2,7 +2,7 @@ import { describe, expect, it, Mock, vi } from 'vitest';
|
||||
import { ConditionStateManager } from '../../../src/condition-trigger/conditions/state-manager';
|
||||
import { TriggersManager } from '../../../src/condition-trigger/triggers/manager';
|
||||
import { Trigger } from '../../../src/config/schema/condition-trigger/triggers/types';
|
||||
import { createHASS, createStateEntity } from '../../test-utils';
|
||||
import { createHASS, createHASSManager, createStateEntity } from '../../test-utils';
|
||||
|
||||
// @vitest-environment jsdom
|
||||
describe('TriggersManager', () => {
|
||||
@@ -14,7 +14,7 @@ describe('TriggersManager', () => {
|
||||
listener: Mock;
|
||||
} => {
|
||||
const stateManager = new ConditionStateManager();
|
||||
const manager = new TriggersManager(triggers, stateManager);
|
||||
const manager = new TriggersManager(triggers, stateManager, createHASSManager());
|
||||
const listener = vi.fn();
|
||||
return { manager, stateManager, listener };
|
||||
};
|
||||
@@ -107,6 +107,7 @@ describe('TriggersManager', () => {
|
||||
const manager = new TriggersManager(
|
||||
[{ trigger: 'camera', cameras: ['front'], enabled }],
|
||||
stateManager,
|
||||
createHASSManager(),
|
||||
);
|
||||
const listener = vi.fn();
|
||||
manager.addListener(listener);
|
||||
@@ -169,6 +170,7 @@ describe('TriggersManager', () => {
|
||||
const manager = new TriggersManager(
|
||||
[{ trigger: 'camera', cameras: ['front', 'back'], enabled: ENABLED_TEMPLATE }],
|
||||
stateManager,
|
||||
createHASSManager(),
|
||||
);
|
||||
const listener = vi.fn();
|
||||
manager.addListener(listener);
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { describe, expect, it, Mock, vi } from 'vitest';
|
||||
import { TemplateRenderer } from '../../../../src/card-controller/templates';
|
||||
import { ConditionStateManager } from '../../../../src/condition-trigger/conditions/state-manager';
|
||||
import { CallTrigger } from '../../../../src/condition-trigger/triggers/triggers/call';
|
||||
import { TriggerOfType } from '../../../../src/condition-trigger/triggers/triggers/types';
|
||||
import { createTriggerEvaluatorContext } from './test-utils';
|
||||
|
||||
// @vitest-environment jsdom
|
||||
describe('CallTrigger', () => {
|
||||
@@ -11,10 +11,9 @@ describe('CallTrigger', () => {
|
||||
): { stateManager: ConditionStateManager; callback: Mock } => {
|
||||
const stateManager = new ConditionStateManager();
|
||||
const callback = vi.fn();
|
||||
new CallTrigger(trigger, {
|
||||
stateManager,
|
||||
templateRenderer: new TemplateRenderer(),
|
||||
}).subscribe(callback);
|
||||
new CallTrigger(trigger, createTriggerEvaluatorContext({ stateManager })).subscribe(
|
||||
callback,
|
||||
);
|
||||
return { stateManager, callback };
|
||||
};
|
||||
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { describe, expect, it, Mock, vi } from 'vitest';
|
||||
import { TemplateRenderer } from '../../../../src/card-controller/templates';
|
||||
import { ConditionStateManager } from '../../../../src/condition-trigger/conditions/state-manager';
|
||||
import { CameraTrigger } from '../../../../src/condition-trigger/triggers/triggers/camera';
|
||||
import { TriggerOfType } from '../../../../src/condition-trigger/triggers/triggers/types';
|
||||
import { createConfig } from '../../../test-utils';
|
||||
import { createTriggerEvaluatorContext } from './test-utils';
|
||||
|
||||
// @vitest-environment jsdom
|
||||
describe('CameraTrigger', () => {
|
||||
@@ -16,10 +16,10 @@ describe('CameraTrigger', () => {
|
||||
} => {
|
||||
const stateManager = new ConditionStateManager();
|
||||
const callback = vi.fn();
|
||||
const cameraTrigger = new CameraTrigger(trigger, {
|
||||
stateManager,
|
||||
templateRenderer: new TemplateRenderer(),
|
||||
});
|
||||
const cameraTrigger = new CameraTrigger(
|
||||
trigger,
|
||||
createTriggerEvaluatorContext({ stateManager }),
|
||||
);
|
||||
return { cameraTrigger, stateManager, callback };
|
||||
};
|
||||
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { describe, expect, it, Mock, vi } from 'vitest';
|
||||
import { TemplateRenderer } from '../../../../src/card-controller/templates';
|
||||
import { ConditionStateManager } from '../../../../src/condition-trigger/conditions/state-manager';
|
||||
import { ConfigTrigger } from '../../../../src/condition-trigger/triggers/triggers/config';
|
||||
import { TriggerOfType } from '../../../../src/condition-trigger/triggers/triggers/types';
|
||||
import { createConfig } from '../../../test-utils';
|
||||
import { createTriggerEvaluatorContext } from './test-utils';
|
||||
|
||||
// @vitest-environment jsdom
|
||||
describe('ConfigTrigger', () => {
|
||||
@@ -16,10 +16,10 @@ describe('ConfigTrigger', () => {
|
||||
} => {
|
||||
const stateManager = new ConditionStateManager();
|
||||
const callback = vi.fn();
|
||||
const configTrigger = new ConfigTrigger(trigger, {
|
||||
stateManager,
|
||||
templateRenderer: new TemplateRenderer(),
|
||||
});
|
||||
const configTrigger = new ConfigTrigger(
|
||||
trigger,
|
||||
createTriggerEvaluatorContext({ stateManager }),
|
||||
);
|
||||
return { configTrigger, stateManager, callback };
|
||||
};
|
||||
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { describe, expect, it, Mock, vi } from 'vitest';
|
||||
import { TemplateRenderer } from '../../../../src/card-controller/templates';
|
||||
import { ConditionStateManager } from '../../../../src/condition-trigger/conditions/state-manager';
|
||||
import { DisplayModeTrigger } from '../../../../src/condition-trigger/triggers/triggers/display-mode';
|
||||
import { TriggerOfType } from '../../../../src/condition-trigger/triggers/triggers/types';
|
||||
import { createTriggerEvaluatorContext } from './test-utils';
|
||||
|
||||
// @vitest-environment jsdom
|
||||
describe('DisplayModeTrigger', () => {
|
||||
@@ -11,10 +11,10 @@ describe('DisplayModeTrigger', () => {
|
||||
): { stateManager: ConditionStateManager; callback: Mock } => {
|
||||
const stateManager = new ConditionStateManager();
|
||||
const callback = vi.fn();
|
||||
new DisplayModeTrigger(trigger, {
|
||||
stateManager,
|
||||
templateRenderer: new TemplateRenderer(),
|
||||
}).subscribe(callback);
|
||||
new DisplayModeTrigger(
|
||||
trigger,
|
||||
createTriggerEvaluatorContext({ stateManager }),
|
||||
).subscribe(callback);
|
||||
return { stateManager, callback };
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,177 @@
|
||||
import { describe, expect, it, Mock, vi } from 'vitest';
|
||||
import { mock } from 'vitest-mock-extended';
|
||||
import {
|
||||
EventSubscriptionRequest,
|
||||
EventWatcherSubscriptionInterface,
|
||||
} from '../../../../src/card-controller/hass/event-watcher';
|
||||
import { EventTrigger } from '../../../../src/condition-trigger/triggers/triggers/event';
|
||||
import { TriggerOfType } from '../../../../src/condition-trigger/triggers/triggers/types';
|
||||
import { createHASSEvent, createHASSManager } from '../../../test-utils';
|
||||
import { createTriggerEvaluatorContext } from './test-utils';
|
||||
|
||||
interface Harness {
|
||||
trigger: EventTrigger;
|
||||
eventWatcher: EventWatcherSubscriptionInterface;
|
||||
callback: Mock;
|
||||
}
|
||||
|
||||
const create = (config: TriggerOfType<'event'>): Harness => {
|
||||
const eventWatcher = mock<EventWatcherSubscriptionInterface>();
|
||||
const hassManager = createHASSManager({ eventWatcher });
|
||||
|
||||
const callback = vi.fn();
|
||||
const trigger = new EventTrigger(
|
||||
config,
|
||||
createTriggerEvaluatorContext({ hassManager }),
|
||||
);
|
||||
return { trigger, eventWatcher, callback };
|
||||
};
|
||||
|
||||
const getLastMatcher = (
|
||||
eventWatcher: EventWatcherSubscriptionInterface,
|
||||
n = 0,
|
||||
): EventSubscriptionRequest['matcher'] =>
|
||||
vi.mocked(eventWatcher.subscribe).mock.calls[n][0].matcher;
|
||||
|
||||
const callEventCallback = (
|
||||
eventWatcher: EventWatcherSubscriptionInterface,
|
||||
event: ReturnType<typeof createHASSEvent>,
|
||||
n = 0,
|
||||
): void => {
|
||||
vi.mocked(eventWatcher.subscribe).mock.calls[n][0].callback(event);
|
||||
};
|
||||
|
||||
describe('EventTrigger', () => {
|
||||
it('should register one EventWatcher request per event_type', () => {
|
||||
const { trigger, eventWatcher, callback } = create({
|
||||
trigger: 'event',
|
||||
event_type: 'zha_event',
|
||||
});
|
||||
trigger.subscribe(callback);
|
||||
expect(eventWatcher.subscribe).toBeCalledTimes(1);
|
||||
expect(vi.mocked(eventWatcher.subscribe).mock.calls[0][0].event_type).toBe(
|
||||
'zha_event',
|
||||
);
|
||||
});
|
||||
|
||||
it('should dedupe duplicate event_types in list form', () => {
|
||||
// A repeated entry would otherwise produce two requests and fire the
|
||||
// callback twice for every matching event.
|
||||
const { trigger, eventWatcher, callback } = create({
|
||||
trigger: 'event',
|
||||
event_type: ['zha_event', 'zha_event'],
|
||||
});
|
||||
trigger.subscribe(callback);
|
||||
expect(eventWatcher.subscribe).toBeCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should expand list-form event_type into one request per type', () => {
|
||||
const { trigger, eventWatcher, callback } = create({
|
||||
trigger: 'event',
|
||||
event_type: ['zha_event', 'deconz_event'],
|
||||
});
|
||||
trigger.subscribe(callback);
|
||||
expect(eventWatcher.subscribe).toBeCalledTimes(2);
|
||||
expect(vi.mocked(eventWatcher.subscribe).mock.calls[0][0].event_type).toBe(
|
||||
'zha_event',
|
||||
);
|
||||
expect(vi.mocked(eventWatcher.subscribe).mock.calls[1][0].event_type).toBe(
|
||||
'deconz_event',
|
||||
);
|
||||
});
|
||||
|
||||
it('should fire with the full HA event on dispatch', () => {
|
||||
const { trigger, eventWatcher, callback } = create({
|
||||
trigger: 'event',
|
||||
event_type: 'zha_event',
|
||||
});
|
||||
trigger.subscribe(callback);
|
||||
const event = createHASSEvent('zha_event', { command: 'press' });
|
||||
callEventCallback(eventWatcher, event);
|
||||
expect(callback).toBeCalledWith({ platform: 'event', event });
|
||||
});
|
||||
|
||||
it('should omit the matcher when neither event_data nor context is set', () => {
|
||||
const { trigger, eventWatcher, callback } = create({
|
||||
trigger: 'event',
|
||||
event_type: 'zha_event',
|
||||
});
|
||||
trigger.subscribe(callback);
|
||||
expect(vi.mocked(eventWatcher.subscribe).mock.calls[0][0].matcher).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should attach an event_data matcher', () => {
|
||||
const { trigger, eventWatcher, callback } = create({
|
||||
trigger: 'event',
|
||||
event_type: 'zha_event',
|
||||
event_data: { command: 'press' },
|
||||
});
|
||||
trigger.subscribe(callback);
|
||||
const matcher = getLastMatcher(eventWatcher);
|
||||
expect(matcher?.(createHASSEvent('zha_event', { command: 'press' }))).toBe(true);
|
||||
expect(matcher?.(createHASSEvent('zha_event', { command: 'release' }))).toBe(false);
|
||||
});
|
||||
|
||||
it('should attach a context matcher', () => {
|
||||
const { trigger, eventWatcher, callback } = create({
|
||||
trigger: 'event',
|
||||
event_type: 'zha_event',
|
||||
context: { user_id: 'u-1' },
|
||||
});
|
||||
trigger.subscribe(callback);
|
||||
const matcher = getLastMatcher(eventWatcher);
|
||||
expect(
|
||||
matcher?.(
|
||||
createHASSEvent('zha_event', {}, { id: 'i', user_id: 'u-1', parent_id: null }),
|
||||
),
|
||||
).toBe(true);
|
||||
expect(
|
||||
matcher?.(
|
||||
createHASSEvent('zha_event', {}, { id: 'i', user_id: 'u-2', parent_id: null }),
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('should AND event_data and context filters', () => {
|
||||
const { trigger, eventWatcher, callback } = create({
|
||||
trigger: 'event',
|
||||
event_type: 'zha_event',
|
||||
event_data: { command: 'press' },
|
||||
context: { user_id: 'u-1' },
|
||||
});
|
||||
trigger.subscribe(callback);
|
||||
const matcher = getLastMatcher(eventWatcher);
|
||||
|
||||
const matchingContext = { id: 'i', user_id: 'u-1', parent_id: null };
|
||||
const nonMatchingContext = { id: 'i', user_id: 'u-2', parent_id: null };
|
||||
|
||||
expect(
|
||||
matcher?.(createHASSEvent('zha_event', { command: 'press' }, matchingContext)),
|
||||
).toBe(true);
|
||||
expect(
|
||||
matcher?.(createHASSEvent('zha_event', { command: 'press' }, nonMatchingContext)),
|
||||
).toBe(false);
|
||||
expect(
|
||||
matcher?.(createHASSEvent('zha_event', { command: 'release' }, matchingContext)),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('should unsubscribe every request on destroy', () => {
|
||||
const { trigger, eventWatcher, callback } = create({
|
||||
trigger: 'event',
|
||||
event_type: ['zha_event', 'deconz_event'],
|
||||
});
|
||||
trigger.subscribe(callback);
|
||||
trigger.destroy();
|
||||
expect(eventWatcher.unsubscribe).toBeCalledTimes(2);
|
||||
});
|
||||
|
||||
it('should be a no-op when destroyed without subscribing', () => {
|
||||
const { trigger, eventWatcher } = create({
|
||||
trigger: 'event',
|
||||
event_type: 'zha_event',
|
||||
});
|
||||
trigger.destroy();
|
||||
expect(eventWatcher.unsubscribe).not.toBeCalled();
|
||||
});
|
||||
});
|
||||
@@ -1,8 +1,8 @@
|
||||
import { describe, expect, it, Mock, vi } from 'vitest';
|
||||
import { TemplateRenderer } from '../../../../src/card-controller/templates';
|
||||
import { ConditionStateManager } from '../../../../src/condition-trigger/conditions/state-manager';
|
||||
import { ExpandTrigger } from '../../../../src/condition-trigger/triggers/triggers/expand';
|
||||
import { TriggerOfType } from '../../../../src/condition-trigger/triggers/triggers/types';
|
||||
import { createTriggerEvaluatorContext } from './test-utils';
|
||||
|
||||
// @vitest-environment jsdom
|
||||
describe('ExpandTrigger', () => {
|
||||
@@ -11,10 +11,10 @@ describe('ExpandTrigger', () => {
|
||||
): { stateManager: ConditionStateManager; callback: Mock } => {
|
||||
const stateManager = new ConditionStateManager();
|
||||
const callback = vi.fn();
|
||||
new ExpandTrigger(trigger, {
|
||||
stateManager,
|
||||
templateRenderer: new TemplateRenderer(),
|
||||
}).subscribe(callback);
|
||||
new ExpandTrigger(
|
||||
trigger,
|
||||
createTriggerEvaluatorContext({ stateManager }),
|
||||
).subscribe(callback);
|
||||
return { stateManager, callback };
|
||||
};
|
||||
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { describe, expect, it, Mock, vi } from 'vitest';
|
||||
import { TemplateRenderer } from '../../../../src/card-controller/templates';
|
||||
import { ConditionStateManager } from '../../../../src/condition-trigger/conditions/state-manager';
|
||||
import { FullscreenTrigger } from '../../../../src/condition-trigger/triggers/triggers/fullscreen';
|
||||
import { TriggerOfType } from '../../../../src/condition-trigger/triggers/triggers/types';
|
||||
import { createTriggerEvaluatorContext } from './test-utils';
|
||||
|
||||
// @vitest-environment jsdom
|
||||
describe('FullscreenTrigger', () => {
|
||||
@@ -15,10 +15,10 @@ describe('FullscreenTrigger', () => {
|
||||
} => {
|
||||
const stateManager = new ConditionStateManager();
|
||||
const callback = vi.fn();
|
||||
const fullscreenTrigger = new FullscreenTrigger(trigger, {
|
||||
stateManager,
|
||||
templateRenderer: new TemplateRenderer(),
|
||||
});
|
||||
const fullscreenTrigger = new FullscreenTrigger(
|
||||
trigger,
|
||||
createTriggerEvaluatorContext({ stateManager }),
|
||||
);
|
||||
return { fullscreenTrigger, stateManager, callback };
|
||||
};
|
||||
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { describe, expect, it, Mock, vi } from 'vitest';
|
||||
import { TemplateRenderer } from '../../../../src/card-controller/templates';
|
||||
import { ConditionStateManager } from '../../../../src/condition-trigger/conditions/state-manager';
|
||||
import { InteractionTrigger } from '../../../../src/condition-trigger/triggers/triggers/interaction';
|
||||
import { TriggerOfType } from '../../../../src/condition-trigger/triggers/triggers/types';
|
||||
import { createTriggerEvaluatorContext } from './test-utils';
|
||||
|
||||
// @vitest-environment jsdom
|
||||
describe('InteractionTrigger', () => {
|
||||
@@ -11,10 +11,10 @@ describe('InteractionTrigger', () => {
|
||||
): { stateManager: ConditionStateManager; callback: Mock } => {
|
||||
const stateManager = new ConditionStateManager();
|
||||
const callback = vi.fn();
|
||||
new InteractionTrigger(trigger, {
|
||||
stateManager,
|
||||
templateRenderer: new TemplateRenderer(),
|
||||
}).subscribe(callback);
|
||||
new InteractionTrigger(
|
||||
trigger,
|
||||
createTriggerEvaluatorContext({ stateManager }),
|
||||
).subscribe(callback);
|
||||
return { stateManager, callback };
|
||||
};
|
||||
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { describe, expect, it, Mock, vi } from 'vitest';
|
||||
import { TemplateRenderer } from '../../../../src/card-controller/templates';
|
||||
import { ConditionStateManager } from '../../../../src/condition-trigger/conditions/state-manager';
|
||||
import { MediaLoadedTrigger } from '../../../../src/condition-trigger/triggers/triggers/media-loaded';
|
||||
import { TriggerOfType } from '../../../../src/condition-trigger/triggers/triggers/types';
|
||||
import { createMediaLoadedInfo } from '../../../test-utils';
|
||||
import { createTriggerEvaluatorContext } from './test-utils';
|
||||
|
||||
// @vitest-environment jsdom
|
||||
describe('MediaLoadedTrigger', () => {
|
||||
@@ -12,10 +12,10 @@ describe('MediaLoadedTrigger', () => {
|
||||
): { stateManager: ConditionStateManager; callback: Mock } => {
|
||||
const stateManager = new ConditionStateManager();
|
||||
const callback = vi.fn();
|
||||
new MediaLoadedTrigger(trigger, {
|
||||
stateManager,
|
||||
templateRenderer: new TemplateRenderer(),
|
||||
}).subscribe(callback);
|
||||
new MediaLoadedTrigger(
|
||||
trigger,
|
||||
createTriggerEvaluatorContext({ stateManager }),
|
||||
).subscribe(callback);
|
||||
return { stateManager, callback };
|
||||
};
|
||||
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { describe, expect, it, Mock, vi } from 'vitest';
|
||||
import { TemplateRenderer } from '../../../../src/card-controller/templates';
|
||||
import { MicrophoneState } from '../../../../src/card-controller/types';
|
||||
import { ConditionStateManager } from '../../../../src/condition-trigger/conditions/state-manager';
|
||||
import { MicrophoneTrigger } from '../../../../src/condition-trigger/triggers/triggers/microphone';
|
||||
import { TriggerOfType } from '../../../../src/condition-trigger/triggers/triggers/types';
|
||||
import { createTriggerEvaluatorContext } from './test-utils';
|
||||
|
||||
// @vitest-environment jsdom
|
||||
describe('MicrophoneTrigger', () => {
|
||||
@@ -19,10 +19,10 @@ describe('MicrophoneTrigger', () => {
|
||||
): { stateManager: ConditionStateManager; callback: Mock } => {
|
||||
const stateManager = new ConditionStateManager();
|
||||
const callback = vi.fn();
|
||||
new MicrophoneTrigger(trigger, {
|
||||
stateManager,
|
||||
templateRenderer: new TemplateRenderer(),
|
||||
}).subscribe(callback);
|
||||
new MicrophoneTrigger(
|
||||
trigger,
|
||||
createTriggerEvaluatorContext({ stateManager }),
|
||||
).subscribe(callback);
|
||||
return { stateManager, callback };
|
||||
};
|
||||
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { HassEntities, HassEntity } from 'home-assistant-js-websocket';
|
||||
import { afterEach, beforeEach, describe, expect, it, Mock, vi } from 'vitest';
|
||||
import { TemplateRenderer } from '../../../../src/card-controller/templates';
|
||||
import { ConditionStateManager } from '../../../../src/condition-trigger/conditions/state-manager';
|
||||
import { NumericStateTrigger } from '../../../../src/condition-trigger/triggers/triggers/numeric-state';
|
||||
import { TriggerOfType } from '../../../../src/condition-trigger/triggers/triggers/types';
|
||||
import { createHASS, createStateEntity } from '../../../test-utils';
|
||||
import { createTriggerEvaluatorContext } from './test-utils';
|
||||
|
||||
const SENSOR = 'sensor.temperature';
|
||||
const SENSOR_TWO = 'sensor.humidity';
|
||||
@@ -21,10 +21,10 @@ describe('NumericStateTrigger', () => {
|
||||
} => {
|
||||
const stateManager = new ConditionStateManager();
|
||||
const callback = vi.fn();
|
||||
const trigger = new NumericStateTrigger(config, {
|
||||
stateManager,
|
||||
templateRenderer: new TemplateRenderer(),
|
||||
});
|
||||
const trigger = new NumericStateTrigger(
|
||||
config,
|
||||
createTriggerEvaluatorContext({ stateManager }),
|
||||
);
|
||||
return { trigger, stateManager, callback };
|
||||
};
|
||||
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { HassEntities, HassEntity } from 'home-assistant-js-websocket';
|
||||
import { afterEach, beforeEach, describe, expect, it, Mock, vi } from 'vitest';
|
||||
import { TemplateRenderer } from '../../../../src/card-controller/templates';
|
||||
import { ConditionStateManager } from '../../../../src/condition-trigger/conditions/state-manager';
|
||||
import { StateTrigger } from '../../../../src/condition-trigger/triggers/triggers/state';
|
||||
import { TriggerOfType } from '../../../../src/condition-trigger/triggers/triggers/types';
|
||||
import { createHASS, createStateEntity } from '../../../test-utils';
|
||||
import { createTriggerEvaluatorContext } from './test-utils';
|
||||
|
||||
const ENTITY = 'binary_sensor.door';
|
||||
const ENTITY_TWO = 'binary_sensor.window';
|
||||
@@ -20,10 +20,10 @@ describe('StateTrigger', () => {
|
||||
} => {
|
||||
const stateManager = new ConditionStateManager();
|
||||
const callback = vi.fn();
|
||||
const trigger = new StateTrigger(config, {
|
||||
stateManager,
|
||||
templateRenderer: new TemplateRenderer(),
|
||||
});
|
||||
const trigger = new StateTrigger(
|
||||
config,
|
||||
createTriggerEvaluatorContext({ stateManager }),
|
||||
);
|
||||
return { trigger, stateManager, callback };
|
||||
};
|
||||
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, Mock, vi } from 'vitest';
|
||||
import { TemplateRenderer } from '../../../../src/card-controller/templates';
|
||||
import { ConditionStateManager } from '../../../../src/condition-trigger/conditions/state-manager';
|
||||
import { TemplateTrigger } from '../../../../src/condition-trigger/triggers/triggers/template';
|
||||
import { TriggerOfType } from '../../../../src/condition-trigger/triggers/triggers/types';
|
||||
import { createHASS, createStateEntity } from '../../../test-utils';
|
||||
import { createTriggerEvaluatorContext } from './test-utils';
|
||||
|
||||
const ENTITY_ONE = 'sensor.foo';
|
||||
const ENTITY_TWO = 'sensor.bar';
|
||||
@@ -21,10 +21,10 @@ describe('TemplateTrigger', () => {
|
||||
} => {
|
||||
const stateManager = new ConditionStateManager();
|
||||
const callback = vi.fn();
|
||||
const trigger = new TemplateTrigger(config, {
|
||||
stateManager,
|
||||
templateRenderer: new TemplateRenderer(),
|
||||
});
|
||||
const trigger = new TemplateTrigger(
|
||||
config,
|
||||
createTriggerEvaluatorContext({ stateManager }),
|
||||
);
|
||||
return { trigger, stateManager, callback };
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
import { TemplateRenderer } from '../../../../src/card-controller/templates';
|
||||
import { ConditionStateManager } from '../../../../src/condition-trigger/conditions/state-manager';
|
||||
import { TriggerEvaluatorContext } from '../../../../src/condition-trigger/triggers/triggers/types';
|
||||
import { createHASSManager } from '../../../test-utils';
|
||||
|
||||
export const createTriggerEvaluatorContext = (
|
||||
context?: Partial<TriggerEvaluatorContext>,
|
||||
): TriggerEvaluatorContext => ({
|
||||
stateManager: new ConditionStateManager(),
|
||||
templateRenderer: new TemplateRenderer(),
|
||||
hassManager: createHASSManager(),
|
||||
...context,
|
||||
});
|
||||
@@ -1,8 +1,8 @@
|
||||
import { describe, expect, it, Mock, vi } from 'vitest';
|
||||
import { TemplateRenderer } from '../../../../src/card-controller/templates';
|
||||
import { ConditionStateManager } from '../../../../src/condition-trigger/conditions/state-manager';
|
||||
import { TriggeredTrigger } from '../../../../src/condition-trigger/triggers/triggers/triggered';
|
||||
import { TriggerOfType } from '../../../../src/condition-trigger/triggers/triggers/types';
|
||||
import { createTriggerEvaluatorContext } from './test-utils';
|
||||
|
||||
// @vitest-environment jsdom
|
||||
describe('TriggeredTrigger', () => {
|
||||
@@ -11,10 +11,10 @@ describe('TriggeredTrigger', () => {
|
||||
): { stateManager: ConditionStateManager; callback: Mock } => {
|
||||
const stateManager = new ConditionStateManager();
|
||||
const callback = vi.fn();
|
||||
new TriggeredTrigger(trigger, {
|
||||
stateManager,
|
||||
templateRenderer: new TemplateRenderer(),
|
||||
}).subscribe(callback);
|
||||
new TriggeredTrigger(
|
||||
trigger,
|
||||
createTriggerEvaluatorContext({ stateManager }),
|
||||
).subscribe(callback);
|
||||
return { stateManager, callback };
|
||||
};
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { describe, expect, it, Mock, vi } from 'vitest';
|
||||
import { TemplateRenderer } from '../../../../src/card-controller/templates';
|
||||
import { ConditionStateManager } from '../../../../src/condition-trigger/conditions/state-manager';
|
||||
import { TriggerOfType } from '../../../../src/condition-trigger/triggers/triggers/types';
|
||||
import { createTriggerEvaluatorContext } from './test-utils';
|
||||
import { ViewTrigger } from '../../../../src/condition-trigger/triggers/triggers/view';
|
||||
|
||||
// @vitest-environment jsdom
|
||||
@@ -15,10 +15,10 @@ describe('ViewTrigger', () => {
|
||||
} => {
|
||||
const stateManager = new ConditionStateManager();
|
||||
const callback = vi.fn();
|
||||
const viewTrigger = new ViewTrigger(trigger, {
|
||||
stateManager,
|
||||
templateRenderer: new TemplateRenderer(),
|
||||
});
|
||||
const viewTrigger = new ViewTrigger(
|
||||
trigger,
|
||||
createTriggerEvaluatorContext({ stateManager }),
|
||||
);
|
||||
return { viewTrigger, stateManager, callback };
|
||||
};
|
||||
|
||||
|
||||
@@ -28,8 +28,9 @@ const getTypes = (
|
||||
// composites.
|
||||
const COMPOSITES = ['or', 'and', 'not'];
|
||||
|
||||
// `config` only ever detects a change, so it is a trigger but not a condition.
|
||||
const TRIGGER_ONLY = ['config'];
|
||||
// `config` only ever detects a change, and `event` is HA-side trigger-only (HA
|
||||
// has no `condition: event` -- events are momentary).
|
||||
const TRIGGER_ONLY = ['config', 'event'];
|
||||
|
||||
// `user`/`user_agent` are static per session, so they are conditions but not
|
||||
// triggers.
|
||||
|
||||
@@ -0,0 +1,167 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { SubscriptionHealthMonitor } from '../../../src/ha/connection/subscription-health-monitor';
|
||||
import { HASSWebSocketSubscriptionStatus } from '../../../src/ha/connection/subscription-manager';
|
||||
|
||||
interface TestRequest {
|
||||
id: string;
|
||||
}
|
||||
|
||||
type State = HASSWebSocketSubscriptionStatus<string, TestRequest>['state'];
|
||||
|
||||
const status = (
|
||||
state: State,
|
||||
request: TestRequest,
|
||||
key: string,
|
||||
extra?: { error?: unknown; failureCount?: number },
|
||||
): HASSWebSocketSubscriptionStatus<string, TestRequest> => ({
|
||||
state,
|
||||
request,
|
||||
key,
|
||||
...extra,
|
||||
});
|
||||
|
||||
describe('SubscriptionHealthMonitor', () => {
|
||||
it('should report a failing key with its error and failure count', () => {
|
||||
const monitor = new SubscriptionHealthMonitor<string, TestRequest>(vi.fn());
|
||||
const error = new Error('boom');
|
||||
|
||||
monitor.update(
|
||||
status('failing', { id: 'a' }, 'zha_event', { error, failureCount: 2 }),
|
||||
);
|
||||
|
||||
expect(monitor.getFailures()).toEqual([
|
||||
{ key: 'zha_event', error, failureCount: 2 },
|
||||
]);
|
||||
});
|
||||
|
||||
it('should ignore waiting so it never clears a failing key', () => {
|
||||
const monitor = new SubscriptionHealthMonitor<string, TestRequest>(vi.fn());
|
||||
const request = { id: 'a' };
|
||||
|
||||
monitor.update(status('failing', request, 'zha_event', { failureCount: 1 }));
|
||||
monitor.update(status('waiting', request, 'zha_event'));
|
||||
|
||||
expect(monitor.getFailures().map((f) => f.key)).toEqual(['zha_event']);
|
||||
});
|
||||
|
||||
it('should clear a key once it subscribes', () => {
|
||||
const monitor = new SubscriptionHealthMonitor<string, TestRequest>(vi.fn());
|
||||
const request = { id: 'a' };
|
||||
|
||||
monitor.update(status('failing', request, 'zha_event', { failureCount: 1 }));
|
||||
monitor.update(status('subscribed', request, 'zha_event'));
|
||||
|
||||
expect(monitor.getFailures()).toEqual([]);
|
||||
});
|
||||
|
||||
it('should clear a key once its request unsubscribes', () => {
|
||||
const monitor = new SubscriptionHealthMonitor<string, TestRequest>(vi.fn());
|
||||
const request = { id: 'a' };
|
||||
|
||||
monitor.update(status('failing', request, 'zha_event', { failureCount: 1 }));
|
||||
monitor.update(status('unsubscribed', request, 'zha_event'));
|
||||
|
||||
expect(monitor.getFailures()).toEqual([]);
|
||||
});
|
||||
|
||||
it('should report each failing key once regardless of subscriber count', () => {
|
||||
const monitor = new SubscriptionHealthMonitor<string, TestRequest>(vi.fn());
|
||||
|
||||
monitor.update(status('failing', { id: 'a' }, 'zha_event', { failureCount: 1 }));
|
||||
monitor.update(status('failing', { id: 'b' }, 'zha_event', { failureCount: 1 }));
|
||||
|
||||
expect(monitor.getFailures().map((f) => f.key)).toEqual(['zha_event']);
|
||||
});
|
||||
|
||||
it('should notify listeners only when a key changes failing state', () => {
|
||||
const monitor = new SubscriptionHealthMonitor<string, TestRequest>(vi.fn());
|
||||
const listener = vi.fn();
|
||||
monitor.addListener(listener);
|
||||
const request = { id: 'a' };
|
||||
|
||||
// Healthy -> failing: one notification.
|
||||
monitor.update(status('failing', request, 'zha_event', { failureCount: 1 }));
|
||||
expect(listener).toBeCalledTimes(1);
|
||||
|
||||
// Still failing (next attempt, same key): no membership change, no notify.
|
||||
monitor.update(status('failing', request, 'zha_event', { failureCount: 2 }));
|
||||
expect(listener).toBeCalledTimes(1);
|
||||
|
||||
// Failing -> healthy: one more notification.
|
||||
monitor.update(status('subscribed', request, 'zha_event'));
|
||||
expect(listener).toBeCalledTimes(2);
|
||||
});
|
||||
|
||||
it('should stop notifying after the returned unsubscribe is called', () => {
|
||||
const monitor = new SubscriptionHealthMonitor<string, TestRequest>(vi.fn());
|
||||
const listener = vi.fn();
|
||||
const remove = monitor.addListener(listener);
|
||||
|
||||
remove();
|
||||
monitor.update(status('failing', { id: 'a' }, 'zha_event', { failureCount: 1 }));
|
||||
|
||||
expect(listener).not.toBeCalled();
|
||||
});
|
||||
|
||||
it('should retry one request per failing key and leave healthy keys alone', () => {
|
||||
const retry = vi.fn();
|
||||
const monitor = new SubscriptionHealthMonitor<string, TestRequest>(retry);
|
||||
const failingA = { id: 'a' };
|
||||
const failingB = { id: 'b' };
|
||||
const healthy = { id: 'c' };
|
||||
|
||||
// Two requests on a failing key, plus a separate healthy key.
|
||||
monitor.update(status('failing', failingA, 'zha_event', { failureCount: 1 }));
|
||||
monitor.update(status('failing', failingB, 'zha_event', { failureCount: 1 }));
|
||||
monitor.update(status('subscribed', healthy, 'deconz_event'));
|
||||
|
||||
monitor.retry();
|
||||
|
||||
// Exactly one retry, for one of the failing key's requests; never the
|
||||
// healthy key.
|
||||
expect(retry).toBeCalledTimes(1);
|
||||
expect([failingA, failingB]).toContainEqual(retry.mock.calls[0][0]);
|
||||
});
|
||||
|
||||
it('should retry the failing request even when a subscribed sibling is stored first', () => {
|
||||
const retry = vi.fn();
|
||||
const monitor = new SubscriptionHealthMonitor<string, TestRequest>(retry);
|
||||
const subscribed = { id: 'a' };
|
||||
const failing = { id: 'b' };
|
||||
|
||||
// Subscribed sibling recorded before the failing one on the same key.
|
||||
monitor.update(status('subscribed', subscribed, 'zha_event'));
|
||||
monitor.update(status('failing', failing, 'zha_event', { failureCount: 1 }));
|
||||
|
||||
monitor.retry();
|
||||
|
||||
expect(retry).toBeCalledTimes(1);
|
||||
expect(retry).toBeCalledWith(failing);
|
||||
});
|
||||
|
||||
it('should retry one request for each distinct failing key', () => {
|
||||
const retry = vi.fn();
|
||||
const monitor = new SubscriptionHealthMonitor<string, TestRequest>(retry);
|
||||
const a = { id: 'a' };
|
||||
const b = { id: 'b' };
|
||||
|
||||
monitor.update(status('failing', a, 'zha_event', { failureCount: 1 }));
|
||||
monitor.update(status('failing', b, 'deconz_event', { failureCount: 1 }));
|
||||
|
||||
monitor.retry();
|
||||
|
||||
expect(retry).toBeCalledTimes(2);
|
||||
expect(retry.mock.calls.map((c) => c[0])).toEqual(expect.arrayContaining([a, b]));
|
||||
});
|
||||
|
||||
it('should not notify when a never-failed request unsubscribes', () => {
|
||||
const monitor = new SubscriptionHealthMonitor<string, TestRequest>(vi.fn());
|
||||
const listener = vi.fn();
|
||||
monitor.addListener(listener);
|
||||
|
||||
monitor.update(status('unsubscribed', { id: 'a' }, 'zha_event'));
|
||||
|
||||
expect(listener).not.toBeCalled();
|
||||
expect(monitor.getFailures()).toEqual([]);
|
||||
});
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user