Files
advanced-camera-card/src/camera-manager/camera.ts
T

454 lines
15 KiB
TypeScript

import { uniq } from 'lodash-es';
import type { ActionsExecutor } from '../card-controller/actions/types';
import type { EventSubscriptionRequest } from '../card-controller/hass/event-watcher';
import type { HASSManagerReadonlyInterface } from '../card-controller/hass/types';
import type { PTZAction, PTZActionPhase } from '../config/schema/actions/custom/ptz';
import type { CameraConfig } from '../config/schema/cameras';
import type { HAEvent } from '../config/schema/common/ha-event';
import {
resolveProxyConfig,
type EnabledProxyConfig,
} from '../config/schema/common/proxy';
import { computeDomain } from '../ha/compute-domain';
import { matchesEventContext, matchesEventData } from '../ha/event-match';
import { getTriggerEventType } from '../ha/get-trigger-event-type';
import type { Entity, EntityRegistryManager } from '../ha/registry/entity/types';
import type { HassStateDifference, HomeAssistant } from '../ha/types';
import { localize } from '../localize/localize';
import type { CapabilitiesRaw, CapabilityKey, Endpoint } from '../types';
import { arrayify, errorToConsole } from '../utils/basic';
import {
isGo2RTCLiveProvider,
liveProviderSupports2WayAudio,
} from '../utils/live-provider';
import { Capabilities } from './capabilities';
import type { CameraManagerEngine } from './engine';
import { CameraNoIDError } from './error';
import type {
CameraEndpoints,
CameraEndpointsContext,
CameraEventCallback,
CameraProxyConfig,
} from './types';
import { getCameraEntityFromConfig } from './utils/camera-entity-from-config';
import {
getGo2RTCMetadataEndpoint,
getGo2RTCStreamEndpoint,
} from './utils/go2rtc/endpoint';
import { getConfiguredPTZAction } from './utils/ptz';
interface CapabilityOptions {
// Pre-built Capabilities object.
capabilities?: Capabilities;
// Raw capabilities for construction.
raw?: CapabilitiesRaw;
disable?: CapabilityKey[];
disableExcept?: CapabilityKey[];
}
export interface CameraInitializationOptions {
hassManager: HASSManagerReadonlyInterface;
capabilityOptions?: CapabilityOptions;
entityRegistryManager?: EntityRegistryManager;
}
type DestroyCallback = () => void | Promise<void>;
export class Camera<
Options extends CameraInitializationOptions = CameraInitializationOptions,
> {
protected _config: CameraConfig;
protected _engine: CameraManagerEngine;
protected _capabilities?: Capabilities;
protected _eventCallback?: CameraEventCallback;
protected _destroyCallbacks: DestroyCallback[] = [];
protected _entity: Entity | null = null;
protected _initialized = false;
constructor(
config: CameraConfig,
engine: CameraManagerEngine,
options?: {
eventCallback?: CameraEventCallback;
capabilities?: Capabilities;
},
) {
this._config = config;
this._engine = engine;
this._eventCallback = options?.eventCallback;
this._capabilities = options?.capabilities;
}
public getEntity(): Entity | null {
return this._entity;
}
public isInitialized(): boolean {
return this._initialized;
}
async initialize(options: Options): Promise<this> {
// 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;
}
// 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);
await this._initializeBeforeCapabilities(hass, options);
this._capabilities =
options.capabilityOptions?.capabilities ??
this._capabilities ??
(await this._buildCapabilities(hass, options));
// The else path is tested, but the `v8` coverage provider miscounts it: a
// missing `else` is given the count of the `if` statement minus the count
// of its body, and the engine only counts code after an `await` for the
// calls that actually paused there. Calls that took an earlier `??` value
// above skipped the `await`, which makes the first number the smaller one
// and the result negative.
// See: https://github.com/AriPerkkio/ast-v8-to-istanbul/issues/148
/* v8 ignore else -- @preserve */
if (this._capabilities.has('trigger')) {
await this._getTriggerEntities(hass, options);
this._config.triggers.entities = uniq(this._config.triggers.entities);
// Subscribe to state based triggers (sync; no race with destroy).
const stateWatcher = options.hassManager.getStateWatcher();
stateWatcher.subscribe(this._stateChangeHandler, this._config.triggers.entities);
this._onDestroy(() => stateWatcher.unsubscribe(this._stateChangeHandler));
// 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) {
for (const request of this._buildEventSubscriptionRequests(event)) {
eventWatcher.subscribe(request);
this._onDestroy(() => eventWatcher.unsubscribe(request));
}
}
}
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;
return this;
}
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 {
this._eventCallback?.({
cameraID: this.getID(),
id: `event:${eventType}`,
type: 'momentary',
});
}
private async _resolveEntity(
hass: HomeAssistant,
options: Options,
): Promise<Entity | null> {
const cameraEntityID = getCameraEntityFromConfig(this._config);
if (!cameraEntityID || !options.entityRegistryManager) {
return null;
}
return await options.entityRegistryManager.getEntity(hass, cameraEntityID);
}
/**
* Get trigger entities (specified or auto-detected). Subclasses may override
* to add engine-specific discovery; call `super` to keep the base discoveries.
*/
protected async _getTriggerEntities(
hass: HomeAssistant,
options: Options,
): Promise<void> {
await this._getDoorbellEntities(hass, options);
}
private async _getDoorbellEntities(
hass: HomeAssistant,
options: Options,
): Promise<void> {
if (
!this._config.triggers.doorbell ||
!this._entity?.device_id ||
!options.entityRegistryManager
) {
return;
}
const deviceID = this._entity.device_id;
// `device_class` lives on state attributes (not the registry entry), so
// narrow by `device_id` + domain first and filter by device_class against
// `hass.states` second.
const candidates = await options.entityRegistryManager.getMatchingEntities(
hass,
(ent) =>
ent.device_id === deviceID &&
!ent.disabled_by &&
computeDomain(ent.entity_id) === 'event',
);
const doorbells = candidates
.filter(
(ent) => hass.states[ent.entity_id]?.attributes?.device_class === 'doorbell',
)
.map((ent) => ent.entity_id);
this._config.triggers.entities.push(...doorbells);
}
/**
* Subclass initialization hook. Override for async initialization work.
*/
protected async _initializeBeforeCapabilities(
// eslint-disable-next-line @typescript-eslint/no-unused-vars
_hass: HomeAssistant,
// eslint-disable-next-line @typescript-eslint/no-unused-vars
_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> {}
protected async _buildCapabilities(
hass: HomeAssistant,
options: Options,
): Promise<Capabilities> {
const rawCapabilities = await this._getRawCapabilities(hass, options);
const config = this.getConfig();
const has2WayAudio = await this._has2WayAudioCapability(hass);
return new Capabilities(
{
...rawCapabilities,
'2-way-audio': has2WayAudio,
},
{
disable: config.capabilities?.disable,
disableExcept: config.capabilities?.disable_except,
},
);
}
protected async _has2WayAudioCapability(hass: HomeAssistant): Promise<boolean> {
// Check disable/disableExcept/force early to short-circuit the expensive
// network call to fetch go2rtc metadata.
if (this._config.capabilities?.disable?.includes('2-way-audio')) {
return false;
}
const disableExcept = this._config.capabilities?.disable_except;
if (disableExcept?.length && !disableExcept.includes('2-way-audio')) {
return false;
}
if (this._config.capabilities?.force?.includes('2-way-audio')) {
return true;
}
return await liveProviderSupports2WayAudio(
hass,
this.getConfig(),
this.getConfig().go2rtc.metadata_fetch_timeout_seconds,
this._getGo2RTCMetadataEndpoint(),
this.getLiveProxyConfig(),
);
}
/**
* Get raw capabilities for this camera. Subclasses should override
* and call super._getRawCapabilities() to extend defaults.
*/
protected async _getRawCapabilities(
_hass: HomeAssistant,
options: Options,
): Promise<CapabilitiesRaw> {
return {
live: true,
menu: true,
substream: true,
trigger: true,
'remote-control-entity': true,
...options.capabilityOptions?.raw,
};
}
public async destroy(): Promise<void> {
const callbacks = this._destroyCallbacks;
this._destroyCallbacks = [];
await Promise.all(callbacks.map((callback) => callback()));
}
public getConfig(): CameraConfig {
return this._config;
}
public setID(cameraID: string): void {
this._config.id = cameraID;
}
public getID(): string {
if (this._config.id) {
return this._config.id;
}
throw new CameraNoIDError(localize('error.no_camera_id'));
}
public getEngine(): CameraManagerEngine {
return this._engine;
}
public getCapabilities(): Capabilities | null {
return this._capabilities ?? null;
}
/**
* Get camera endpoints. Subclasses should override to add engine-specific endpoints.
* @param _context Optional context for dynamic endpoints (e.g., UI URLs based on current view).
*/
public getEndpoints(context?: CameraEndpointsContext): CameraEndpoints | null {
const ui = this._getUIEndpoint(context);
const go2rtc = this._getGo2RTCStreamEndpoint();
const webrtcCard = this._getWebRTCCardEndpoint();
return ui || go2rtc || webrtcCard
? {
...(ui && { ui }),
...(go2rtc && { go2rtc }),
...(webrtcCard && { webrtcCard }),
}
: null;
}
/**
* Get the go2rtc metadata endpoint for capability detection.
* Subclasses should override if they have custom go2rtc URL or stream resolution.
*/
protected _getGo2RTCMetadataEndpoint(): Endpoint | null {
return getGo2RTCMetadataEndpoint(this._config);
}
protected _getGo2RTCStreamEndpoint(): Endpoint | null {
return getGo2RTCStreamEndpoint(this._config);
}
protected _getWebRTCCardEndpoint(): Endpoint | null {
return this._config.camera_entity ? { endpoint: this._config.camera_entity } : null;
}
// eslint-disable-next-line @typescript-eslint/no-unused-vars
protected _getUIEndpoint(_context?: CameraEndpointsContext): Endpoint | null {
return null;
}
public getProxyConfig(): CameraProxyConfig {
return {
...resolveProxyConfig(this._config.proxy),
live:
this._config.proxy.live === 'auto'
? // Live is proxied if the live provider streams from go2rtc and a
// go2rtc URL is manually set.
isGo2RTCLiveProvider(this._config.live_provider) &&
!!this._config.go2rtc?.url
: this._config.proxy.live,
media: this._config.proxy.media === 'auto' ? false : this._config.proxy.media,
};
}
public getLiveProxyConfig(): EnabledProxyConfig {
const config = this.getProxyConfig();
return {
...config,
// `enabled` uses the resolved engine decision (so `auto` may become
// true), whereas `enforce` uses the raw user setting so only an explicit
// `true` means "fail instead of falling back" if the proxy is unavailable.
enabled: config.live,
enforce: this._config.proxy.live === true,
};
}
public getMediaProxyConfig(): EnabledProxyConfig {
const config = this.getProxyConfig();
return {
...config,
// `enabled` uses the resolved engine decision (so `auto` may become
// true), whereas `enforce` uses the raw user setting so only an explicit
// `true` means "fail instead of falling back" if the proxy is unavailable.
enabled: config.media,
enforce: this._config.proxy.media === true,
};
}
public async executePTZAction(
executor: ActionsExecutor,
action: PTZAction,
options?: {
hass?: HomeAssistant;
phase?: PTZActionPhase;
preset?: string;
},
): Promise<boolean> {
const configuredAction = getConfiguredPTZAction(this.getConfig(), action, options);
if (configuredAction) {
await executor.executeActions({ actions: configuredAction });
return true;
}
return false;
}
protected _stateChangeHandler = (difference: HassStateDifference): void => {
const type = getTriggerEventType(difference);
if (type === null) {
return;
}
this._eventCallback?.({
cameraID: this.getID(),
id: difference.entityID,
type,
});
};
protected _onDestroy(callback: DestroyCallback): void {
this._destroyCallbacks.push(callback);
}
}