fix: Ensure no camera outlives a failed initialization (#2657)
This commit is contained in:
@@ -17,7 +17,7 @@ import type { Entity, EntityRegistryManager } from '../ha/registry/entity/types'
|
|||||||
import type { HassStateDifference, HomeAssistant } from '../ha/types';
|
import type { HassStateDifference, HomeAssistant } from '../ha/types';
|
||||||
import { localize } from '../localize/localize';
|
import { localize } from '../localize/localize';
|
||||||
import type { CapabilitiesRaw, CapabilityKey, Endpoint } from '../types';
|
import type { CapabilitiesRaw, CapabilityKey, Endpoint } from '../types';
|
||||||
import { arrayify } from '../utils/basic';
|
import { arrayify, errorToConsole } from '../utils/basic';
|
||||||
import {
|
import {
|
||||||
isGo2RTCLiveProvider,
|
isGo2RTCLiveProvider,
|
||||||
liveProviderSupports2WayAudio,
|
liveProviderSupports2WayAudio,
|
||||||
@@ -56,7 +56,9 @@ export interface CameraInitializationOptions {
|
|||||||
|
|
||||||
type DestroyCallback = () => void | Promise<void>;
|
type DestroyCallback = () => void | Promise<void>;
|
||||||
|
|
||||||
export class Camera {
|
export class Camera<
|
||||||
|
Options extends CameraInitializationOptions = CameraInitializationOptions,
|
||||||
|
> {
|
||||||
protected _config: CameraConfig;
|
protected _config: CameraConfig;
|
||||||
protected _engine: CameraManagerEngine;
|
protected _engine: CameraManagerEngine;
|
||||||
protected _capabilities?: Capabilities;
|
protected _capabilities?: Capabilities;
|
||||||
@@ -87,7 +89,7 @@ export class Camera {
|
|||||||
return this._initialized;
|
return this._initialized;
|
||||||
}
|
}
|
||||||
|
|
||||||
async initialize(options: CameraInitializationOptions): Promise<Camera> {
|
async initialize(options: Options): Promise<this> {
|
||||||
// Freeze a single HASS snapshot for the whole (async, multi-step)
|
// Freeze a single HASS snapshot for the whole (async, multi-step)
|
||||||
// initialization so every step observes a consistent entity world; live
|
// initialization so every step observes a consistent entity world; live
|
||||||
// subscriptions below still use the manager's current watchers.
|
// subscriptions below still use the manager's current watchers.
|
||||||
@@ -96,8 +98,11 @@ export class Camera {
|
|||||||
return this;
|
return this;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Subscriptions are registered part-way through, so a later failure would
|
||||||
|
// otherwise strand them on a camera nobody holds a reference to.
|
||||||
|
try {
|
||||||
this._entity = await this._resolveEntity(hass, options);
|
this._entity = await this._resolveEntity(hass, options);
|
||||||
await this._initialize(hass, options);
|
await this._initializeBeforeCapabilities(hass, options);
|
||||||
|
|
||||||
this._capabilities =
|
this._capabilities =
|
||||||
options.capabilityOptions?.capabilities ??
|
options.capabilityOptions?.capabilities ??
|
||||||
@@ -132,6 +137,18 @@ export class Camera {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await this._initializeAfterCapabilities(options);
|
||||||
|
} catch (e) {
|
||||||
|
try {
|
||||||
|
await this.destroy();
|
||||||
|
} catch (destroyError: unknown) {
|
||||||
|
// A camera that cannot clean up must not replace the failure that is
|
||||||
|
// actually worth reporting.
|
||||||
|
errorToConsole(destroyError);
|
||||||
|
}
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
|
||||||
this._initialized = true;
|
this._initialized = true;
|
||||||
return this;
|
return this;
|
||||||
}
|
}
|
||||||
@@ -160,7 +177,7 @@ export class Camera {
|
|||||||
|
|
||||||
private async _resolveEntity(
|
private async _resolveEntity(
|
||||||
hass: HomeAssistant,
|
hass: HomeAssistant,
|
||||||
options: CameraInitializationOptions,
|
options: Options,
|
||||||
): Promise<Entity | null> {
|
): Promise<Entity | null> {
|
||||||
const cameraEntityID = getCameraEntityFromConfig(this._config);
|
const cameraEntityID = getCameraEntityFromConfig(this._config);
|
||||||
if (!cameraEntityID || !options.entityRegistryManager) {
|
if (!cameraEntityID || !options.entityRegistryManager) {
|
||||||
@@ -175,14 +192,14 @@ export class Camera {
|
|||||||
*/
|
*/
|
||||||
protected async _getTriggerEntities(
|
protected async _getTriggerEntities(
|
||||||
hass: HomeAssistant,
|
hass: HomeAssistant,
|
||||||
options: CameraInitializationOptions,
|
options: Options,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
await this._getDoorbellEntities(hass, options);
|
await this._getDoorbellEntities(hass, options);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async _getDoorbellEntities(
|
private async _getDoorbellEntities(
|
||||||
hass: HomeAssistant,
|
hass: HomeAssistant,
|
||||||
options: CameraInitializationOptions,
|
options: Options,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
if (
|
if (
|
||||||
!this._config.triggers.doorbell ||
|
!this._config.triggers.doorbell ||
|
||||||
@@ -216,16 +233,26 @@ export class Camera {
|
|||||||
/**
|
/**
|
||||||
* Subclass initialization hook. Override for async initialization work.
|
* Subclass initialization hook. Override for async initialization work.
|
||||||
*/
|
*/
|
||||||
protected async _initialize(
|
protected async _initializeBeforeCapabilities(
|
||||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||||
_hass: HomeAssistant,
|
_hass: HomeAssistant,
|
||||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||||
_options: CameraInitializationOptions,
|
_options: Options,
|
||||||
|
): Promise<void> {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Subclass initialization hook for work that needs the built capabilities.
|
||||||
|
* Runs inside the initialization guard, so whatever it registers is released
|
||||||
|
* if it, or anything after it, throws.
|
||||||
|
*/
|
||||||
|
protected async _initializeAfterCapabilities(
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||||
|
_options: Options,
|
||||||
): Promise<void> {}
|
): Promise<void> {}
|
||||||
|
|
||||||
protected async _buildCapabilities(
|
protected async _buildCapabilities(
|
||||||
hass: HomeAssistant,
|
hass: HomeAssistant,
|
||||||
options: CameraInitializationOptions,
|
options: Options,
|
||||||
): Promise<Capabilities> {
|
): Promise<Capabilities> {
|
||||||
const rawCapabilities = await this._getRawCapabilities(hass, options);
|
const rawCapabilities = await this._getRawCapabilities(hass, options);
|
||||||
const config = this.getConfig();
|
const config = this.getConfig();
|
||||||
@@ -271,7 +298,7 @@ export class Camera {
|
|||||||
*/
|
*/
|
||||||
protected async _getRawCapabilities(
|
protected async _getRawCapabilities(
|
||||||
_hass: HomeAssistant,
|
_hass: HomeAssistant,
|
||||||
options: CameraInitializationOptions,
|
options: Options,
|
||||||
): Promise<CapabilitiesRaw> {
|
): Promise<CapabilitiesRaw> {
|
||||||
return {
|
return {
|
||||||
live: true,
|
live: true,
|
||||||
|
|||||||
@@ -8,14 +8,16 @@ import { CameraNoEntityError } from './error';
|
|||||||
* subclass turns absence into an error for engines that cannot function
|
* subclass turns absence into an error for engines that cannot function
|
||||||
* without it (motionEye, Reolink, TPLink).
|
* without it (motionEye, Reolink, TPLink).
|
||||||
*/
|
*/
|
||||||
export class EntityCamera extends Camera {
|
export class EntityCamera<
|
||||||
protected override async _initialize(
|
Options extends CameraInitializationOptions = CameraInitializationOptions,
|
||||||
|
> extends Camera<Options> {
|
||||||
|
protected override async _initializeBeforeCapabilities(
|
||||||
hass: HomeAssistant,
|
hass: HomeAssistant,
|
||||||
options: CameraInitializationOptions,
|
options: Options,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
if (!this._entity) {
|
if (!this._entity) {
|
||||||
throw new CameraNoEntityError(this.getConfig());
|
throw new CameraNoEntityError(this.getConfig());
|
||||||
}
|
}
|
||||||
await super._initialize(hass, options);
|
await super._initializeBeforeCapabilities(hass, options);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -46,27 +46,25 @@ export const isBirdseye = (cameraConfig: CameraConfig): boolean => {
|
|||||||
return cameraConfig.frigate.camera_name === CAMERA_BIRDSEYE;
|
return cameraConfig.frigate.camera_name === CAMERA_BIRDSEYE;
|
||||||
};
|
};
|
||||||
|
|
||||||
export class FrigateCamera extends Camera {
|
export class FrigateCamera extends Camera<FrigateCameraInitializationOptions> {
|
||||||
// Short-circuits subscription when destroy() was invoked while base
|
// Short-circuits subscription when destroy() was invoked while base
|
||||||
// initialization was still awaiting. Set BEFORE awaiting `super.destroy()` so
|
// initialization was still awaiting. Set BEFORE awaiting `super.destroy()` so
|
||||||
// an in-flight initialize() sees the flip immediately.
|
// an in-flight initialize() sees the flip immediately.
|
||||||
private _destroyed = false;
|
private _destroyed = false;
|
||||||
|
|
||||||
public async initialize(options: FrigateCameraInitializationOptions): Promise<Camera> {
|
protected override async _initializeAfterCapabilities(
|
||||||
await super.initialize(options);
|
options: FrigateCameraInitializationOptions,
|
||||||
|
): Promise<void> {
|
||||||
// A destroy() during the await above means the camera is being torn down;
|
// A destroy() while the base class was still initializing means the camera
|
||||||
// it must not register live subscriptions afterward.
|
// is being torn down; it must not register live subscriptions afterward.
|
||||||
if (this._destroyed) {
|
if (this._destroyed) {
|
||||||
return this;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (this._capabilities?.has('trigger')) {
|
if (this._capabilities?.has('trigger')) {
|
||||||
this._subscribeToEvents(options.frigateEventWatcher);
|
this._subscribeToEvents(options.frigateEventWatcher);
|
||||||
this._subscribeToReviews(options.frigateReviewWatcher);
|
this._subscribeToReviews(options.frigateReviewWatcher);
|
||||||
}
|
}
|
||||||
|
|
||||||
return this;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public override async destroy(): Promise<void> {
|
public override async destroy(): Promise<void> {
|
||||||
@@ -124,7 +122,9 @@ export class FrigateCamera extends Camera {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
protected override async _initialize(hass: HomeAssistant): Promise<void> {
|
protected override async _initializeBeforeCapabilities(
|
||||||
|
hass: HomeAssistant,
|
||||||
|
): Promise<void> {
|
||||||
const config = this.getConfig();
|
const config = this.getConfig();
|
||||||
const hasCameraName = !!config.frigate?.camera_name;
|
const hasCameraName = !!config.frigate?.camera_name;
|
||||||
const cameraEntity = getCameraEntityFromConfig(config);
|
const cameraEntity = getCameraEntityFromConfig(config);
|
||||||
|
|||||||
+100
-39
@@ -16,15 +16,18 @@ import type { Endpoint } from '../types.js';
|
|||||||
import {
|
import {
|
||||||
allPromises,
|
allPromises,
|
||||||
arrayify,
|
arrayify,
|
||||||
|
errorToConsole,
|
||||||
isTruthy,
|
isTruthy,
|
||||||
recursivelyMergeObjectsNotArrays,
|
recursivelyMergeObjectsNotArrays,
|
||||||
setify,
|
setify,
|
||||||
} from '../utils/basic.js';
|
} from '../utils/basic.js';
|
||||||
import { getCameraID } from '../utils/camera.js';
|
import { getCameraID } from '../utils/camera.js';
|
||||||
|
import { Generation } from '../utils/concurrency/generation.js';
|
||||||
import { log } from '../utils/debug.js';
|
import { log } from '../utils/debug.js';
|
||||||
import { ViewItemClassifier } from '../view/item-classifier.js';
|
import { ViewItemClassifier } from '../view/item-classifier.js';
|
||||||
import type { ViewItem, ViewMedia } from '../view/item.js';
|
import type { ViewItem, ViewMedia } from '../view/item.js';
|
||||||
import type { ViewItemCapabilities } from '../view/types.js';
|
import type { ViewItemCapabilities } from '../view/types.js';
|
||||||
|
import type { Camera } from './camera.js';
|
||||||
import { Capabilities } from './capabilities.js';
|
import { Capabilities } from './capabilities.js';
|
||||||
import { CameraManagerEngineFactory } from './engine-factory.js';
|
import { CameraManagerEngineFactory } from './engine-factory.js';
|
||||||
import type { CameraManagerEngine } from './engine.js';
|
import type { CameraManagerEngine } from './engine.js';
|
||||||
@@ -137,6 +140,14 @@ export class CameraManager {
|
|||||||
private _store: CameraManagerStore;
|
private _store: CameraManagerStore;
|
||||||
private _requestLimit = new PQueue();
|
private _requestLimit = new PQueue();
|
||||||
|
|
||||||
|
// Cameras take time to build, so a teardown or a newer initialization can
|
||||||
|
// arrive mid-build and leave the finished cameras with no owner.
|
||||||
|
private _generation = new Generation();
|
||||||
|
|
||||||
|
// Handing cameras to the store is not atomic, so commits and teardowns run
|
||||||
|
// one at a time and cannot observe each other half-applied.
|
||||||
|
private _storeCommits = new PQueue({ concurrency: 1 });
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
api: CardCameraAPI,
|
api: CardCameraAPI,
|
||||||
options?: {
|
options?: {
|
||||||
@@ -155,6 +166,11 @@ export class CameraManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public async initializeCamerasFromConfig(): Promise<void> {
|
public async initializeCamerasFromConfig(): Promise<void> {
|
||||||
|
// Taken before the early return below: a call that cannot proceed still
|
||||||
|
// supersedes an older one whose cameras are being built from a
|
||||||
|
// configuration that no longer applies.
|
||||||
|
const generation = this._generation.next();
|
||||||
|
|
||||||
const config = this._api.getConfigManager().getConfig();
|
const config = this._api.getConfigManager().getConfig();
|
||||||
const hass = this._api.getHASSManager().getHASS();
|
const hass = this._api.getHASSManager().getHASS();
|
||||||
|
|
||||||
@@ -173,11 +189,12 @@ export class CameraManager {
|
|||||||
recursivelyMergeObjectsNotArrays(config?.cameras_global, camera),
|
recursivelyMergeObjectsNotArrays(config?.cameras_global, camera),
|
||||||
);
|
);
|
||||||
|
|
||||||
await this._initializeCameras(cameras);
|
await this._initializeCameras(cameras, generation);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async destroy(): Promise<void> {
|
public async destroy(): Promise<void> {
|
||||||
await this._store.reset();
|
this._generation.invalidate();
|
||||||
|
await this._storeCommits.add(() => this._store.reset());
|
||||||
}
|
}
|
||||||
|
|
||||||
private async _getEnginesForCameras(
|
private async _getEnginesForCameras(
|
||||||
@@ -221,44 +238,33 @@ export class CameraManager {
|
|||||||
return output;
|
return output;
|
||||||
}
|
}
|
||||||
|
|
||||||
private async _initializeCameras(camerasConfig: CameraConfig[]): Promise<void> {
|
/**
|
||||||
const initializationStartTime = new Date();
|
* Create a camera for each engine, and assign each its ID. An initialized
|
||||||
const hass = this._api.getHASSManager().getHASS();
|
* camera holds live subscriptions, so either every camera is returned ready
|
||||||
|
* for the store to own, or none survive: any failure destroys all of them
|
||||||
|
* before throwing.
|
||||||
|
*/
|
||||||
|
private async _createCameras(
|
||||||
|
engineByConfig: Map<CameraConfig, CameraManagerEngine>,
|
||||||
|
): Promise<Camera[]> {
|
||||||
|
// A camera that fails is taken out of the results rather than abandoning
|
||||||
|
// the others mid-flight, which would leave an initialized camera with
|
||||||
|
// nobody holding a reference to it.
|
||||||
|
const failures: unknown[] = [];
|
||||||
|
const cameras = (
|
||||||
|
await allPromises(engineByConfig, ([cameraConfig, engine]) =>
|
||||||
|
engine.createCamera(cameraConfig).catch((error: unknown) => {
|
||||||
|
failures.push(error);
|
||||||
|
return null;
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
).filter(isTruthy);
|
||||||
|
|
||||||
/* v8 ignore if: the if path cannot be reached -- @preserve */
|
try {
|
||||||
if (!hass) {
|
if (failures.length) {
|
||||||
return;
|
throw failures[0];
|
||||||
}
|
}
|
||||||
|
|
||||||
const hasAutoTriggers = (config: CameraConfig): boolean => {
|
|
||||||
return (
|
|
||||||
config.triggers.motion || config.triggers.occupancy || config.triggers.doorbell
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
if (
|
|
||||||
// If any camera requires automatic trigger detection ...
|
|
||||||
camerasConfig.some((config) => hasAutoTriggers(config))
|
|
||||||
) {
|
|
||||||
// ... then we need to populate the entity cache by fetching all entities
|
|
||||||
// from Home Assistant. Attempt to do this once upfront, to avoid each
|
|
||||||
// camera doing needing to fetch entity state.
|
|
||||||
await this._api.getEntityRegistryManager().fetchEntityList(hass);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Engines are created sequentially, to avoid duplicate creation of the same
|
|
||||||
// engine. See: https://github.com/dermotduffy/advanced-camera-card/issues/941
|
|
||||||
const engineByConfig = await this._getEnginesForCameras(camerasConfig);
|
|
||||||
|
|
||||||
// Configuration is initialized in parallel.
|
|
||||||
const cameras = await allPromises(
|
|
||||||
engineByConfig.entries(),
|
|
||||||
async ([cameraConfig, engine]) => await engine.createCamera(cameraConfig),
|
|
||||||
);
|
|
||||||
|
|
||||||
const destroyCameras = async () => {
|
|
||||||
await allPromises(cameras, (camera) => camera.destroy());
|
|
||||||
};
|
|
||||||
const cameraIDs: Set<string> = new Set();
|
const cameraIDs: Set<string> = new Set();
|
||||||
|
|
||||||
// Do the additions based off the result-order, to ensure the map order is
|
// Do the additions based off the result-order, to ensure the map order is
|
||||||
@@ -267,12 +273,10 @@ export class CameraManager {
|
|||||||
const cameraID = getCameraID(camera.getConfig());
|
const cameraID = getCameraID(camera.getConfig());
|
||||||
|
|
||||||
if (!cameraID) {
|
if (!cameraID) {
|
||||||
await destroyCameras();
|
|
||||||
throw new CameraNoIDError(camera.getConfig());
|
throw new CameraNoIDError(camera.getConfig());
|
||||||
}
|
}
|
||||||
|
|
||||||
if (cameraIDs.has(cameraID)) {
|
if (cameraIDs.has(cameraID)) {
|
||||||
await destroyCameras();
|
|
||||||
throw new CameraDuplicateIDError(camera.getConfig());
|
throw new CameraDuplicateIDError(camera.getConfig());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -280,8 +284,65 @@ export class CameraManager {
|
|||||||
camera.setID(cameraID);
|
camera.setID(cameraID);
|
||||||
cameraIDs.add(cameraID);
|
cameraIDs.add(cameraID);
|
||||||
}
|
}
|
||||||
|
} catch (e) {
|
||||||
|
await this._destroyCameras(cameras);
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
|
||||||
|
return cameras;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async _destroyCameras(cameras: Camera[]): Promise<void> {
|
||||||
|
await allPromises(cameras, async (camera) => {
|
||||||
|
try {
|
||||||
|
await camera.destroy();
|
||||||
|
} catch (error: unknown) {
|
||||||
|
errorToConsole(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private async _initializeCameras(
|
||||||
|
camerasConfig: CameraConfig[],
|
||||||
|
generation: number,
|
||||||
|
): Promise<void> {
|
||||||
|
const initializationStartTime = new Date();
|
||||||
|
const hass = this._api.getHASSManager().getHASS();
|
||||||
|
|
||||||
|
/* v8 ignore if: the if path cannot be reached -- @preserve */
|
||||||
|
if (!hass) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const requiresAutoTriggerDetection = camerasConfig.some(
|
||||||
|
({ triggers }) => triggers.motion || triggers.occupancy || triggers.doorbell,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (requiresAutoTriggerDetection) {
|
||||||
|
// Populate the entity cache by fetching all entities from Home Assistant
|
||||||
|
// once upfront, to avoid each camera needing to fetch entity state.
|
||||||
|
await this._api.getEntityRegistryManager().fetchEntityList(hass);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Engines are created sequentially, to avoid duplicate creation of the same
|
||||||
|
// engine. See: https://github.com/dermotduffy/advanced-camera-card/issues/941
|
||||||
|
const engineByConfig = await this._getEnginesForCameras(camerasConfig);
|
||||||
|
|
||||||
|
const cameras = await this._createCameras(engineByConfig);
|
||||||
|
|
||||||
|
// The store mutates incrementally, so staleness is re-checked inside the
|
||||||
|
// queue rather than before it: a teardown or a later initialization that
|
||||||
|
// arrives mid-commit would otherwise interleave with this one.
|
||||||
|
await this._storeCommits.add(async () => {
|
||||||
|
// Nothing will ever own these cameras, so they are destroyed instead of
|
||||||
|
// being handed to a store that has moved on.
|
||||||
|
if (!this._generation.isCurrent(generation)) {
|
||||||
|
await this._destroyCameras(cameras);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
await this._store.setCameras(cameras);
|
await this._store.setCameras(cameras);
|
||||||
|
});
|
||||||
|
|
||||||
log(
|
log(
|
||||||
this._api.getConfigManager().getCardWideConfig(),
|
this._api.getConfigManager().getCardWideConfig(),
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ import {
|
|||||||
type PTZCapabilities,
|
type PTZCapabilities,
|
||||||
} from '../../types';
|
} from '../../types';
|
||||||
import { createSelectOptionAction } from '../../utils/action.js';
|
import { createSelectOptionAction } from '../../utils/action.js';
|
||||||
import type { Camera, CameraInitializationOptions } from '../camera';
|
import type { CameraInitializationOptions } from '../camera';
|
||||||
import { EntityCamera } from '../entity-camera';
|
import { EntityCamera } from '../entity-camera';
|
||||||
import { ReolinkInitializationError } from '../error';
|
import { ReolinkInitializationError } from '../error';
|
||||||
import type { CameraEndpointsContext, CameraProxyConfig } from '../types';
|
import type { CameraEndpointsContext, CameraProxyConfig } from '../types';
|
||||||
@@ -77,7 +77,7 @@ const PTZ_BUTTON_ENTITY_KEYS: readonly (keyof PTZButtonEntities)[] = [
|
|||||||
'zoom_out',
|
'zoom_out',
|
||||||
];
|
];
|
||||||
|
|
||||||
export class ReolinkCamera extends EntityCamera {
|
export class ReolinkCamera extends EntityCamera<ReolinkCameraInitializationOptions> {
|
||||||
// The HostID identifying the camera or NVR.
|
// The HostID identifying the camera or NVR.
|
||||||
private _reolinkHostID: string | null = null;
|
private _reolinkHostID: string | null = null;
|
||||||
|
|
||||||
@@ -90,16 +90,6 @@ export class ReolinkCamera extends EntityCamera {
|
|||||||
// Entities used for PTZ control.
|
// Entities used for PTZ control.
|
||||||
private _ptzEntities: PTZEntities | null = null;
|
private _ptzEntities: PTZEntities | null = null;
|
||||||
|
|
||||||
/**
|
|
||||||
* Reolink cameras require additional options not present in the base class
|
|
||||||
* initialization options, so this ~empty method is used to expand the type
|
|
||||||
* expectations. Without this, callers cannot specify objects (e.g. the device
|
|
||||||
* registry) without TypeScript errors.
|
|
||||||
*/
|
|
||||||
public async initialize(options: ReolinkCameraInitializationOptions): Promise<Camera> {
|
|
||||||
return super.initialize(options);
|
|
||||||
}
|
|
||||||
|
|
||||||
private async _getChannelFromConfigurationURL(
|
private async _getChannelFromConfigurationURL(
|
||||||
hass: HomeAssistant,
|
hass: HomeAssistant,
|
||||||
deviceRegistryManager: DeviceRegistryManager,
|
deviceRegistryManager: DeviceRegistryManager,
|
||||||
@@ -171,11 +161,11 @@ export class ReolinkCamera extends EntityCamera {
|
|||||||
this._reolinkCameraUID = reolinkCameraUID;
|
this._reolinkCameraUID = reolinkCameraUID;
|
||||||
}
|
}
|
||||||
|
|
||||||
protected async _initialize(
|
protected async _initializeBeforeCapabilities(
|
||||||
hass: HomeAssistant,
|
hass: HomeAssistant,
|
||||||
options: ReolinkCameraInitializationOptions,
|
options: ReolinkCameraInitializationOptions,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
await super._initialize(hass, options);
|
await super._initializeBeforeCapabilities(hass, options);
|
||||||
await this._initializeChannel(hass, options.deviceRegistryManager);
|
await this._initializeChannel(hass, options.deviceRegistryManager);
|
||||||
this._ptzEntities = await this._getPTZEntities(hass, options.entityRegistryManager);
|
this._ptzEntities = await this._getPTZEntities(hass, options.entityRegistryManager);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,14 +23,14 @@ interface PTZEntities {
|
|||||||
}
|
}
|
||||||
type PTZEntity = keyof PTZEntities;
|
type PTZEntity = keyof PTZEntities;
|
||||||
|
|
||||||
export class TPLinkCamera extends EntityCamera {
|
export class TPLinkCamera extends EntityCamera<TPLinkCameraInitializationOptions> {
|
||||||
private _ptzEntities: PTZEntities | null = null;
|
private _ptzEntities: PTZEntities | null = null;
|
||||||
|
|
||||||
protected async _initialize(
|
protected async _initializeBeforeCapabilities(
|
||||||
hass: HomeAssistant,
|
hass: HomeAssistant,
|
||||||
options: TPLinkCameraInitializationOptions,
|
options: TPLinkCameraInitializationOptions,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
await super._initialize(hass, options);
|
await super._initializeBeforeCapabilities(hass, options);
|
||||||
this._ptzEntities = await this._getPTZEntities(hass, options.entityRegistryManager);
|
this._ptzEntities = await this._getPTZEntities(hass, options.entityRegistryManager);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -12,6 +12,11 @@ import {
|
|||||||
import { TPLinkCamera } from './camera';
|
import { TPLinkCamera } from './camera';
|
||||||
|
|
||||||
export class TPLinkCameraManagerEngine extends GenericCameraManagerEngine {
|
export class TPLinkCameraManagerEngine extends GenericCameraManagerEngine {
|
||||||
|
// TPLink cameras require a registry manager to resolve their PTZ entities,
|
||||||
|
// which the constructor below guarantees; the base engine only optionally
|
||||||
|
// has one.
|
||||||
|
protected declare _entityRegistryManager: EntityRegistryManager;
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
entityRegistryManager: EntityRegistryManager,
|
entityRegistryManager: EntityRegistryManager,
|
||||||
hassManager: HASSManagerReadonlyInterface,
|
hassManager: HASSManagerReadonlyInterface,
|
||||||
|
|||||||
@@ -101,6 +101,7 @@ export class FakeHASS {
|
|||||||
private _isAdmin: boolean;
|
private _isAdmin: boolean;
|
||||||
private _handlers = new Map<string, WSCommandHandler>();
|
private _handlers = new Map<string, WSCommandHandler>();
|
||||||
private _commandLog: MessageBase[] = [];
|
private _commandLog: MessageBase[] = [];
|
||||||
|
private _openEventSubscriptions = 0;
|
||||||
|
|
||||||
constructor(options?: FakeHASSOptions) {
|
constructor(options?: FakeHASSOptions) {
|
||||||
this._language = options?.language ?? 'en';
|
this._language = options?.language ?? 'en';
|
||||||
@@ -129,6 +130,13 @@ export class FakeHASS {
|
|||||||
this._handlers.set(type, handler);
|
this._handlers.set(type, handler);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Number of event subscriptions not yet released.
|
||||||
|
*/
|
||||||
|
public getOpenEventSubscriptionCount(): number {
|
||||||
|
return this._openEventSubscriptions;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Every WebSocket command the card has issued, in order.
|
* Every WebSocket command the card has issued, in order.
|
||||||
*/
|
*/
|
||||||
@@ -202,7 +210,13 @@ export class FakeHASS {
|
|||||||
private _createConnection(): Connection {
|
private _createConnection(): Connection {
|
||||||
const connection = mock<Connection>();
|
const connection = mock<Connection>();
|
||||||
connection.subscribeMessage.mockResolvedValue(() => Promise.resolve());
|
connection.subscribeMessage.mockResolvedValue(() => Promise.resolve());
|
||||||
connection.subscribeEvents.mockResolvedValue(() => Promise.resolve());
|
connection.subscribeEvents.mockImplementation(async () => {
|
||||||
|
this._openEventSubscriptions++;
|
||||||
|
return () => {
|
||||||
|
this._openEventSubscriptions--;
|
||||||
|
return Promise.resolve();
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
// `callWS` and `sendMessagePromise` are the same request/response channel,
|
// `callWS` and `sendMessagePromise` are the same request/response channel,
|
||||||
// so both go through one handler table. Given two tables, a command
|
// so both go through one handler table. Given two tables, a command
|
||||||
|
|||||||
@@ -491,6 +491,13 @@ export class MountedCard {
|
|||||||
this.card.hass = this._hass.getHASS();
|
this.card.hass = this._hass.getHASS();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* How many Home Assistant event subscriptions the card currently holds open.
|
||||||
|
*/
|
||||||
|
public getOpenEventSubscriptionCount(): number {
|
||||||
|
return this._hass.getOpenEventSubscriptionCount();
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Hand the card a new `hass` with nothing in it changed.
|
* Hand the card a new `hass` with nothing in it changed.
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -726,6 +726,66 @@ describe('Camera', () => {
|
|||||||
expect(eventWatcher.unsubscribe).toHaveBeenCalled();
|
expect(eventWatcher.unsubscribe).toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should release earlier subscriptions when a later subscription throws', async () => {
|
||||||
|
const camera = new Camera(
|
||||||
|
createCameraConfig({
|
||||||
|
id: 'camera_1',
|
||||||
|
triggers: {
|
||||||
|
events: [{ event_type: 'zha_event' }],
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
new GenericCameraManagerEngine(createHASSManager()),
|
||||||
|
);
|
||||||
|
|
||||||
|
const error = new Error('subscribe failed');
|
||||||
|
const stateWatcher = mock<StateWatcherSubscriptionInterface>();
|
||||||
|
const eventWatcher = mock<EventWatcherSubscriptionInterface>();
|
||||||
|
vi.mocked(eventWatcher.subscribe).mockImplementation(() => {
|
||||||
|
throw error;
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
camera.initialize({
|
||||||
|
hassManager: createHASSManager({ stateWatcher, eventWatcher }),
|
||||||
|
capabilityOptions: { capabilities: createCapabilities({ trigger: true }) },
|
||||||
|
}),
|
||||||
|
).rejects.toThrow(error);
|
||||||
|
|
||||||
|
// The state subscription was registered before the failure, so nothing
|
||||||
|
// else can release it.
|
||||||
|
expect(stateWatcher.subscribe).toHaveBeenCalled();
|
||||||
|
expect(stateWatcher.unsubscribe).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should report the initialization failure when cleanup also fails', async () => {
|
||||||
|
const camera = new Camera(
|
||||||
|
createCameraConfig({
|
||||||
|
id: 'camera_1',
|
||||||
|
triggers: {
|
||||||
|
events: [{ event_type: 'zha_event' }],
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
new GenericCameraManagerEngine(createHASSManager()),
|
||||||
|
);
|
||||||
|
|
||||||
|
const error = new Error('subscribe failed');
|
||||||
|
const stateWatcher = mock<StateWatcherSubscriptionInterface>();
|
||||||
|
vi.mocked(stateWatcher.unsubscribe).mockRejectedValue(new Error('destroy failed'));
|
||||||
|
const eventWatcher = mock<EventWatcherSubscriptionInterface>();
|
||||||
|
vi.mocked(eventWatcher.subscribe).mockImplementation(() => {
|
||||||
|
throw error;
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
camera.initialize({
|
||||||
|
hassManager: createHASSManager({ stateWatcher, eventWatcher }),
|
||||||
|
capabilityOptions: { capabilities: createCapabilities({ trigger: true }) },
|
||||||
|
}),
|
||||||
|
).rejects.toThrow(error);
|
||||||
|
|
||||||
|
expect(stateWatcher.unsubscribe).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
it('should attach a context-only matcher when only a context filter is set', async () => {
|
it('should attach a context-only matcher when only a context filter is set', async () => {
|
||||||
const camera = new Camera(
|
const camera = new Camera(
|
||||||
createCameraConfig({
|
createCameraConfig({
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import type {
|
|||||||
FrigateReviewWatcher,
|
FrigateReviewWatcher,
|
||||||
} from '../../../src/camera-manager/frigate/watcher';
|
} from '../../../src/camera-manager/frigate/watcher';
|
||||||
import type { ActionsExecutor } from '../../../src/card-controller/actions/types';
|
import type { ActionsExecutor } from '../../../src/card-controller/actions/types';
|
||||||
|
import type { StateWatcherSubscriptionInterface } from '../../../src/card-controller/hass/state-watcher';
|
||||||
import type { PTZAction } from '../../../src/config/schema/actions/custom/ptz';
|
import type { PTZAction } from '../../../src/config/schema/actions/custom/ptz';
|
||||||
import type { CameraTriggerMediaEventType } from '../../../src/config/schema/cameras';
|
import type { CameraTriggerMediaEventType } from '../../../src/config/schema/cameras';
|
||||||
import type {
|
import type {
|
||||||
@@ -831,6 +832,46 @@ describe('FrigateCamera', () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should release base class subscriptions when subscribing throws', async () => {
|
||||||
|
const camera = new FrigateCamera(
|
||||||
|
createCameraConfig({
|
||||||
|
frigate: {
|
||||||
|
client_id: 'CLIENT_ID',
|
||||||
|
camera_name: 'CAMERA',
|
||||||
|
},
|
||||||
|
triggers: {
|
||||||
|
media_events: ['events'],
|
||||||
|
entities: ['binary_sensor.motion'],
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
mock<CameraManagerEngine>(),
|
||||||
|
);
|
||||||
|
|
||||||
|
const error = new Error('subscribe failed');
|
||||||
|
const eventWatcher = mock<FrigateEventWatcher>();
|
||||||
|
vi.mocked(eventWatcher.subscribe).mockImplementation(() => {
|
||||||
|
throw error;
|
||||||
|
});
|
||||||
|
|
||||||
|
const stateWatcher = mock<StateWatcherSubscriptionInterface>();
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
camera.initialize({
|
||||||
|
hassManager: createHASSManager({ stateWatcher }),
|
||||||
|
entityRegistryManager: mock<EntityRegistryManager>(),
|
||||||
|
frigateEventWatcher: eventWatcher,
|
||||||
|
frigateReviewWatcher: mock<FrigateReviewWatcher>(),
|
||||||
|
}),
|
||||||
|
).rejects.toThrow(error);
|
||||||
|
|
||||||
|
// The state subscription belongs to the base class, which registered it
|
||||||
|
// before `_initializeAfterCapabilities` ran and therefore before this
|
||||||
|
// failure. Nothing here knows about it, so destroying the whole camera is
|
||||||
|
// the only thing that can release it.
|
||||||
|
expect(stateWatcher.subscribe).toHaveBeenCalled();
|
||||||
|
expect(stateWatcher.unsubscribe).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
it('should not subscribe with no trigger events', async () => {
|
it('should not subscribe with no trigger events', async () => {
|
||||||
const camera = new FrigateCamera(
|
const camera = new FrigateCamera(
|
||||||
createCameraConfig({
|
createCameraConfig({
|
||||||
@@ -969,8 +1010,8 @@ describe('FrigateCamera', () => {
|
|||||||
|
|
||||||
await camera.destroy();
|
await camera.destroy();
|
||||||
|
|
||||||
// `_destroyed` short-circuits initialize() after the pending await, so
|
// `_destroyed` short-circuits `_initializeAfterCapabilities`, so neither
|
||||||
// neither watcher is ever subscribed.
|
// watcher is ever subscribed.
|
||||||
expect(eventWatcher.subscribe).not.toHaveBeenCalled();
|
expect(eventWatcher.subscribe).not.toHaveBeenCalled();
|
||||||
expect(reviewWatcher.subscribe).not.toHaveBeenCalled();
|
expect(reviewWatcher.subscribe).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,78 @@
|
|||||||
|
import { describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
|
import type { RawAdvancedCameraCardConfig } from '../../src/config/types';
|
||||||
|
import { MountedCardFactory, type MountedCard } from '../browser/mounted-card';
|
||||||
|
import {
|
||||||
|
createStillCameraHASS,
|
||||||
|
createStillImageCameraConfig,
|
||||||
|
createStillImageCardConfig,
|
||||||
|
getBlockNotificationText,
|
||||||
|
} from '../browser/test-utils';
|
||||||
|
|
||||||
|
const INIT_FAILED_ISSUE_HEADING = 'Initialization failed';
|
||||||
|
|
||||||
|
const TRIGGERING_CAMERA_ENTITY = 'camera.triggering';
|
||||||
|
const OTHER_TRIGGERING_CAMERA_ENTITY = 'camera.triggering_too';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A camera that subscribes to a Home Assistant event once initialized, so that
|
||||||
|
* whether it was cleaned up is externally observable.
|
||||||
|
*/
|
||||||
|
const createSubscribingCameraConfig = (
|
||||||
|
cameraEntity: string,
|
||||||
|
cameraID?: string,
|
||||||
|
): RawAdvancedCameraCardConfig => ({
|
||||||
|
...createStillImageCameraConfig(cameraEntity),
|
||||||
|
...(cameraID && { id: cameraID }),
|
||||||
|
triggers: {
|
||||||
|
events: [{ event_type: 'acc_test_event' }],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('CameraManager', () => {
|
||||||
|
it('should release the subscriptions of cameras that initialized before initialization failed', async () => {
|
||||||
|
// Duplicate identifiers are rejected only after every camera has been
|
||||||
|
// built, so both cameras are live and subscribed when the failure happens.
|
||||||
|
const DUPLICATE_ID = 'duplicate';
|
||||||
|
|
||||||
|
const card = await MountedCardFactory.createFromSource(
|
||||||
|
createStillImageCardConfig({
|
||||||
|
cameras: [
|
||||||
|
createSubscribingCameraConfig(TRIGGERING_CAMERA_ENTITY, DUPLICATE_ID),
|
||||||
|
createSubscribingCameraConfig(OTHER_TRIGGERING_CAMERA_ENTITY, DUPLICATE_ID),
|
||||||
|
],
|
||||||
|
view: { issues: { retry_seconds: 0 } },
|
||||||
|
}),
|
||||||
|
createStillCameraHASS({
|
||||||
|
cameras: [TRIGGERING_CAMERA_ENTITY, OTHER_TRIGGERING_CAMERA_ENTITY],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
await vi.waitFor(() =>
|
||||||
|
expect(getBlockNotificationText(card.card)).toContain(INIT_FAILED_ISSUE_HEADING),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Both cameras are unreachable once initialization has failed, so nothing
|
||||||
|
// else could ever release what they subscribed to.
|
||||||
|
await vi.waitFor(() => expect(card.getOpenEventSubscriptionCount()).toBe(0));
|
||||||
|
|
||||||
|
card.destroy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should release camera subscriptions when the card is taken off the page', async () => {
|
||||||
|
const card: MountedCard = await MountedCardFactory.createFromSource(
|
||||||
|
createStillImageCardConfig({
|
||||||
|
cameras: [createSubscribingCameraConfig(TRIGGERING_CAMERA_ENTITY)],
|
||||||
|
}),
|
||||||
|
createStillCameraHASS({ cameras: [TRIGGERING_CAMERA_ENTITY] }),
|
||||||
|
);
|
||||||
|
|
||||||
|
await vi.waitFor(() => expect(card.getOpenEventSubscriptionCount()).toBe(1));
|
||||||
|
|
||||||
|
card.detach();
|
||||||
|
|
||||||
|
await vi.waitFor(() => expect(card.getOpenEventSubscriptionCount()).toBe(0));
|
||||||
|
|
||||||
|
card.destroy();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -16,6 +16,7 @@ import {
|
|||||||
CameraQueryClassifier,
|
CameraQueryClassifier,
|
||||||
QueryResultClassifier,
|
QueryResultClassifier,
|
||||||
} from '../../src/camera-manager/manager.js';
|
} from '../../src/camera-manager/manager.js';
|
||||||
|
import type { CameraManagerStore } from '../../src/camera-manager/store.js';
|
||||||
import {
|
import {
|
||||||
Engine,
|
Engine,
|
||||||
QueryResultsType,
|
QueryResultsType,
|
||||||
@@ -266,6 +267,11 @@ describe('CameraManager', () => {
|
|||||||
engine?: CameraManagerEngine,
|
engine?: CameraManagerEngine,
|
||||||
cameras: {
|
cameras: {
|
||||||
config?: CameraConfig;
|
config?: CameraConfig;
|
||||||
|
|
||||||
|
// Replaces what the engine does for this camera, for cases the default
|
||||||
|
// path cannot express (e.g. failing, or completing out of order).
|
||||||
|
createCamera?: (cameraConfig: CameraConfig) => Promise<Camera>;
|
||||||
|
|
||||||
engineType?: Engine | null;
|
engineType?: Engine | null;
|
||||||
capabilties?: Capabilities;
|
capabilties?: Capabilities;
|
||||||
stateWatcher?: StateWatcherSubscriptionInterface;
|
stateWatcher?: StateWatcherSubscriptionInterface;
|
||||||
@@ -290,13 +296,14 @@ describe('CameraManager', () => {
|
|||||||
camera.engineType === undefined ? Engine.Generic : camera.engineType;
|
camera.engineType === undefined ? Engine.Generic : camera.engineType;
|
||||||
if (engineType) {
|
if (engineType) {
|
||||||
vi.mocked(mockEngine.createCamera).mockImplementationOnce(
|
vi.mocked(mockEngine.createCamera).mockImplementationOnce(
|
||||||
async (cameraConfig: CameraConfig): Promise<Camera> =>
|
camera.createCamera ??
|
||||||
|
(async (cameraConfig: CameraConfig): Promise<Camera> =>
|
||||||
await createInitializedCamera(
|
await createInitializedCamera(
|
||||||
cameraConfig,
|
cameraConfig,
|
||||||
mockEngine,
|
mockEngine,
|
||||||
camera.capabilties ?? createCapabilities(),
|
camera.capabilties ?? createCapabilities(),
|
||||||
camera.stateWatcher,
|
camera.stateWatcher,
|
||||||
),
|
)),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
vi.mocked(mockFactory.getEngineForCamera).mockResolvedValueOnce(engineType);
|
vi.mocked(mockFactory.getEngineForCamera).mockResolvedValueOnce(engineType);
|
||||||
@@ -437,6 +444,237 @@ describe('CameraManager', () => {
|
|||||||
expect(order).toEqual(['destroy-done', 'destroy-done', 'throw']);
|
expect(order).toEqual(['destroy-done', 'destroy-done', 'throw']);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('should handle a camera that fails to initialize', () => {
|
||||||
|
const createAPI = (): CardController => {
|
||||||
|
const api = createCardAPI();
|
||||||
|
vi.mocked(api.getHASSManager().getHASS).mockReturnValue(createHASS());
|
||||||
|
return api;
|
||||||
|
};
|
||||||
|
|
||||||
|
it('should destroy a camera that initialized before the failure', async () => {
|
||||||
|
const stateWatcher = mock<StateWatcherSubscriptionInterface>();
|
||||||
|
const error = new Error('initialization failed');
|
||||||
|
|
||||||
|
const manager = createCameraManager(createAPI(), mock<CameraManagerEngine>(), [
|
||||||
|
{ capabilties: createCapabilities({ trigger: true }), stateWatcher },
|
||||||
|
{ createCamera: () => Promise.reject(error) },
|
||||||
|
]);
|
||||||
|
|
||||||
|
await expect(manager.initializeCamerasFromConfig()).rejects.toThrow(error);
|
||||||
|
|
||||||
|
expect(stateWatcher.unsubscribe).toHaveBeenCalled();
|
||||||
|
expect(manager.getStore().getCameraCount()).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should destroy a camera that initialized after the failure', async () => {
|
||||||
|
const engine = mock<CameraManagerEngine>();
|
||||||
|
const stateWatcher = mock<StateWatcherSubscriptionInterface>();
|
||||||
|
const error = new Error('initialization failed');
|
||||||
|
|
||||||
|
let releaseSlowCamera: () => void = () => {};
|
||||||
|
const slowCameraReady = new Promise<void>((resolve) => {
|
||||||
|
releaseSlowCamera = resolve;
|
||||||
|
});
|
||||||
|
|
||||||
|
const manager = createCameraManager(createAPI(), engine, [
|
||||||
|
{ createCamera: () => Promise.reject(error) },
|
||||||
|
{
|
||||||
|
createCamera: async (cameraConfig) => {
|
||||||
|
await slowCameraReady;
|
||||||
|
return await createInitializedCamera(
|
||||||
|
cameraConfig,
|
||||||
|
engine,
|
||||||
|
createCapabilities({ trigger: true }),
|
||||||
|
stateWatcher,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
const initialization = manager.initializeCamerasFromConfig();
|
||||||
|
releaseSlowCamera();
|
||||||
|
|
||||||
|
await expect(initialization).rejects.toThrow(error);
|
||||||
|
|
||||||
|
expect(stateWatcher.unsubscribe).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should report the initialization failure when a camera cannot be destroyed', async () => {
|
||||||
|
const error = new Error('initialization failed');
|
||||||
|
|
||||||
|
const unluckyWatcher = mock<StateWatcherSubscriptionInterface>();
|
||||||
|
vi.mocked(unluckyWatcher.unsubscribe).mockRejectedValue(
|
||||||
|
new Error('destroy failed'),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Destroy completion is observable through the trigger-path
|
||||||
|
// unsubscribe, without spying on any Camera method.
|
||||||
|
const slowWatcher = mock<StateWatcherSubscriptionInterface>();
|
||||||
|
let releaseSlowDestroy: () => void = () => {};
|
||||||
|
vi.mocked(slowWatcher.unsubscribe).mockImplementation(
|
||||||
|
() =>
|
||||||
|
new Promise<void>((resolve) => {
|
||||||
|
releaseSlowDestroy = resolve;
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const cameraEntry = { capabilties: createCapabilities({ trigger: true }) };
|
||||||
|
const manager = createCameraManager(createAPI(), mock<CameraManagerEngine>(), [
|
||||||
|
{ ...cameraEntry, stateWatcher: unluckyWatcher },
|
||||||
|
{ ...cameraEntry, stateWatcher: slowWatcher },
|
||||||
|
{ createCamera: () => Promise.reject(error) },
|
||||||
|
]);
|
||||||
|
|
||||||
|
let settled = false;
|
||||||
|
const initialization = manager
|
||||||
|
.initializeCamerasFromConfig()
|
||||||
|
.catch((e: unknown) => {
|
||||||
|
settled = true;
|
||||||
|
throw e;
|
||||||
|
});
|
||||||
|
|
||||||
|
await vi.waitFor(() => expect(slowWatcher.unsubscribe).toHaveBeenCalled());
|
||||||
|
expect(settled).toBe(false);
|
||||||
|
|
||||||
|
releaseSlowDestroy();
|
||||||
|
|
||||||
|
// The failing destroy neither masks the initialization error nor
|
||||||
|
// prevents the other camera from being destroyed.
|
||||||
|
await expect(initialization).rejects.toThrow(error);
|
||||||
|
expect(unluckyWatcher.unsubscribe).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('should discard cameras nothing will own', () => {
|
||||||
|
const createSlowManager = (): {
|
||||||
|
manager: CameraManager;
|
||||||
|
stateWatcher: StateWatcherSubscriptionInterface;
|
||||||
|
releaseCamera: () => void;
|
||||||
|
} => {
|
||||||
|
const api = createCardAPI();
|
||||||
|
vi.mocked(api.getHASSManager().getHASS).mockReturnValue(createHASS());
|
||||||
|
|
||||||
|
const engine = mock<CameraManagerEngine>();
|
||||||
|
const stateWatcher = mock<StateWatcherSubscriptionInterface>();
|
||||||
|
|
||||||
|
let releaseCamera: () => void = () => {};
|
||||||
|
const cameraReady = new Promise<void>((resolve) => {
|
||||||
|
releaseCamera = resolve;
|
||||||
|
});
|
||||||
|
|
||||||
|
const manager = createCameraManager(api, engine, [
|
||||||
|
{
|
||||||
|
createCamera: async (cameraConfig) => {
|
||||||
|
await cameraReady;
|
||||||
|
return await createInitializedCamera(
|
||||||
|
cameraConfig,
|
||||||
|
engine,
|
||||||
|
createCapabilities({ trigger: true }),
|
||||||
|
stateWatcher,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
return { manager, stateWatcher, releaseCamera: () => releaseCamera() };
|
||||||
|
};
|
||||||
|
|
||||||
|
it('should destroy cameras built after the manager was destroyed', async () => {
|
||||||
|
const { manager, stateWatcher, releaseCamera } = createSlowManager();
|
||||||
|
|
||||||
|
const initialization = manager.initializeCamerasFromConfig();
|
||||||
|
await manager.destroy();
|
||||||
|
releaseCamera();
|
||||||
|
await initialization;
|
||||||
|
|
||||||
|
expect(stateWatcher.unsubscribe).toHaveBeenCalled();
|
||||||
|
expect(manager.getStore().getCameraCount()).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should destroy cameras built by a superseded initialization', async () => {
|
||||||
|
const { manager, stateWatcher, releaseCamera } = createSlowManager();
|
||||||
|
|
||||||
|
const superseded = manager.initializeCamerasFromConfig();
|
||||||
|
|
||||||
|
// A second initialization takes over. It cannot build cameras of its
|
||||||
|
// own from the exhausted mocks; what is under test is that the first
|
||||||
|
// initialization's cameras are discarded rather than stored.
|
||||||
|
const current = manager.initializeCamerasFromConfig();
|
||||||
|
releaseCamera();
|
||||||
|
|
||||||
|
await expect(current).rejects.toThrow(CameraNoEngineError);
|
||||||
|
await superseded;
|
||||||
|
|
||||||
|
expect(stateWatcher.unsubscribe).toHaveBeenCalled();
|
||||||
|
expect(manager.getStore().getCameraCount()).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should discard cameras superseded by a call that cannot proceed', async () => {
|
||||||
|
const { manager, stateWatcher, releaseCamera } = createSlowManager();
|
||||||
|
|
||||||
|
const superseded = manager.initializeCamerasFromConfig();
|
||||||
|
|
||||||
|
// A newer call that returns early still supersedes: the cameras being
|
||||||
|
// built belong to a configuration that no longer applies.
|
||||||
|
vi.mocked(manager['_api'].getConfigManager().getConfig).mockReturnValue(null);
|
||||||
|
await manager.initializeCamerasFromConfig();
|
||||||
|
|
||||||
|
releaseCamera();
|
||||||
|
await superseded;
|
||||||
|
|
||||||
|
expect(stateWatcher.unsubscribe).toHaveBeenCalled();
|
||||||
|
expect(manager.getStore().getCameraCount()).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not reset the store while a commit is in flight', async () => {
|
||||||
|
const api = createCardAPI();
|
||||||
|
vi.mocked(api.getHASSManager().getHASS).mockReturnValue(createHASS());
|
||||||
|
vi.mocked(api.getConfigManager().getConfig).mockReturnValue(
|
||||||
|
createConfig({ cameras: [{ id: 'id', engine: 'generic' }] }),
|
||||||
|
);
|
||||||
|
|
||||||
|
const engine = mock<CameraManagerEngine>();
|
||||||
|
const factory = mock<CameraManagerEngineFactory>();
|
||||||
|
vi.mocked(factory.createEngine).mockResolvedValue(engine);
|
||||||
|
vi.mocked(factory.getEngineForCamera).mockResolvedValue(Engine.Generic);
|
||||||
|
vi.mocked(engine.createCamera).mockImplementation(
|
||||||
|
async (cameraConfig: CameraConfig) =>
|
||||||
|
await createInitializedCamera(cameraConfig, engine, createCapabilities()),
|
||||||
|
);
|
||||||
|
|
||||||
|
// The real store mutates incrementally; a mock makes the commit window
|
||||||
|
// externally controllable.
|
||||||
|
const order: string[] = [];
|
||||||
|
const store = mock<CameraManagerStore>();
|
||||||
|
let releaseCommit: () => void = () => {};
|
||||||
|
vi.mocked(store.setCameras).mockImplementation(
|
||||||
|
() =>
|
||||||
|
new Promise<void>((resolve) => {
|
||||||
|
order.push('commit-start');
|
||||||
|
releaseCommit = () => {
|
||||||
|
order.push('commit-end');
|
||||||
|
resolve();
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
vi.mocked(store.reset).mockImplementation(async () => {
|
||||||
|
order.push('reset');
|
||||||
|
});
|
||||||
|
|
||||||
|
const manager = new CameraManager(api, { factory, store });
|
||||||
|
|
||||||
|
const initialization = manager.initializeCamerasFromConfig();
|
||||||
|
await vi.waitFor(() => expect(store.setCameras).toHaveBeenCalled());
|
||||||
|
|
||||||
|
const destruction = manager.destroy();
|
||||||
|
releaseCommit();
|
||||||
|
await Promise.all([initialization, destruction]);
|
||||||
|
|
||||||
|
// The reset waits for the commit rather than interleaving with it.
|
||||||
|
expect(order).toEqual(['commit-start', 'commit-end', 'reset']);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it('should reject missing engine', async () => {
|
it('should reject missing engine', async () => {
|
||||||
const api = createCardAPI();
|
const api = createCardAPI();
|
||||||
vi.mocked(api.getHASSManager().getHASS).mockReturnValue(createHASS());
|
vi.mocked(api.getHASSManager().getHASS).mockReturnValue(createHASS());
|
||||||
|
|||||||
Reference in New Issue
Block a user