feat: Add automatic doorbell detection (#2507)

This commit is contained in:
Dermot Duffy
2026-06-30 17:45:13 -07:00
committed by dermotduffy
parent 4b72fcd629
commit 773cee95a5
24 changed files with 527 additions and 123 deletions
@@ -42,7 +42,7 @@ export class BrowseMediaCameraManagerEngine
requestCache: CameraManagerRequestCache,
eventCallback?: CameraEventCallback,
) {
super(stateWatcher, eventCallback);
super(stateWatcher, entityRegistryManager, eventCallback);
this._entityRegistryManager = entityRegistryManager;
this._browseMediaWalker = browseMediaManager;
this._resolvedMediaCache = resolvedMediaCache;
+77 -10
View File
@@ -1,9 +1,12 @@
import { uniq } from 'lodash-es';
import { ActionsExecutor } from '../card-controller/actions/types';
import { StateWatcherSubscriptionInterface } from '../card-controller/hass/state-watcher';
import { PTZAction, PTZActionPhase } from '../config/schema/actions/custom/ptz';
import { CameraConfig } from '../config/schema/cameras';
import { EnabledProxyConfig, resolveProxyConfig } from '../config/schema/common/proxy';
import { computeDomain } from '../ha/compute-domain';
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';
@@ -17,6 +20,7 @@ import {
CameraEventCallback,
CameraProxyConfig,
} from './types';
import { getCameraEntityFromConfig } from './utils/camera-entity-from-config';
import {
getGo2RTCMetadataEndpoint,
getGo2RTCStreamEndpoint,
@@ -37,6 +41,7 @@ export interface CameraInitializationOptions {
hass: HomeAssistant;
stateWatcher: StateWatcherSubscriptionInterface;
capabilityOptions?: CapabilityOptions;
entityRegistryManager?: EntityRegistryManager;
}
type DestroyCallback = () => void | Promise<void>;
@@ -47,6 +52,7 @@ export class Camera {
protected _capabilities?: Capabilities;
protected _eventCallback?: CameraEventCallback;
protected _destroyCallbacks: DestroyCallback[] = [];
protected _entity: Entity | null = null;
constructor(
config: CameraConfig,
@@ -62,17 +68,86 @@ export class Camera {
this._capabilities = options?.capabilities;
}
public getEntity(): Entity | null {
return this._entity;
}
async initialize(options: CameraInitializationOptions): Promise<Camera> {
this._entity = await this._resolveEntity(options);
await this._initialize(options);
this._capabilities =
options.capabilityOptions?.capabilities ??
this._capabilities ??
(await this._buildCapabilities(options));
this._subscribeBasedOnCapabilities(options.stateWatcher);
this._onDestroy(() => options.stateWatcher.unsubscribe(this._stateChangeHandler));
if (this._capabilities.has('trigger')) {
await this._getTriggerEntities(options);
this._config.triggers.entities = uniq(this._config.triggers.entities);
options.stateWatcher.subscribe(
this._stateChangeHandler,
this._config.triggers.entities,
);
this._onDestroy(() => options.stateWatcher.unsubscribe(this._stateChangeHandler));
}
return this;
}
private async _resolveEntity(
options: CameraInitializationOptions,
): Promise<Entity | null> {
const cameraEntityID = getCameraEntityFromConfig(this._config);
if (!cameraEntityID || !options.entityRegistryManager) {
return null;
}
return await options.entityRegistryManager.getEntity(options.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(
options: CameraInitializationOptions,
): Promise<void> {
await this._getDoorbellEntities(options);
}
private async _getDoorbellEntities(
options: CameraInitializationOptions,
): 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(
options.hass,
(ent) =>
ent.device_id === deviceID &&
!ent.disabled_by &&
computeDomain(ent.entity_id) === 'event',
);
const doorbells = candidates
.filter(
(ent) =>
options.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.
*/
@@ -276,12 +351,4 @@ export class Camera {
protected _onDestroy(callback: DestroyCallback): void {
this._destroyCallbacks.push(callback);
}
private _subscribeBasedOnCapabilities(
stateWatcher: StateWatcherSubscriptionInterface,
): void {
if (this._capabilities?.has('trigger')) {
stateWatcher.subscribe(this._stateChangeHandler, this._config.triggers.entities);
}
}
}
+1
View File
@@ -39,6 +39,7 @@ export class CameraManagerEngineFactory {
const { GenericCameraManagerEngine } = await import('./generic/engine-generic');
cameraManagerEngine = new GenericCameraManagerEngine(
options.stateWatcher,
this._entityRegistryManager,
options.eventCallback,
);
break;
+12 -23
View File
@@ -1,30 +1,19 @@
import { Entity, EntityRegistryManager } from '../ha/registry/entity/types';
import { Camera, CameraInitializationOptions } from './camera';
import { CameraNoEntityError } from './error';
import { getCameraEntityFromConfig } from './utils/camera-entity-from-config';
export interface EntityCameraInitializationOptions extends CameraInitializationOptions {
entityRegistryManager: EntityRegistryManager;
}
/**
* Camera variant that requires a `camera_entity` to be present in the HA
* entity registry. Base `Camera` resolves `_entity` opportunistically; this
* subclass turns absence into an error for engines that cannot function
* without it (motionEye, Reolink, TPLink).
*/
export class EntityCamera extends Camera {
protected _entity: Entity | null = null;
public async initialize(options: EntityCameraInitializationOptions): Promise<Camera> {
const config = this.getConfig();
const cameraEntityID = getCameraEntityFromConfig(config);
const entity = cameraEntityID
? await options.entityRegistryManager.getEntity(options.hass, cameraEntityID)
: null;
if (!entity || !cameraEntityID) {
throw new CameraNoEntityError(config);
protected override async _initialize(
options: CameraInitializationOptions,
): Promise<void> {
if (!this._entity) {
throw new CameraNoEntityError(this.getConfig());
}
this._entity = entity;
return await super.initialize(options);
}
public getEntity(): Entity | null {
return this._entity;
await super._initialize(options);
}
}
+56 -47
View File
@@ -1,5 +1,4 @@
import { format } from 'date-fns';
import { uniq } from 'lodash-es';
import { ActionsExecutor } from '../../card-controller/actions/types';
import { PTZAction, PTZActionPhase } from '../../config/schema/actions/custom/ptz';
import { CameraConfig } from '../../config/schema/cameras';
@@ -45,7 +44,6 @@ export const isBirdseye = (cameraConfig: CameraConfig): boolean => {
export class FrigateCamera extends Camera {
public async initialize(options: FrigateCameraInitializationOptions): Promise<Camera> {
await this._initializeConfig(options.hass, options.entityRegistryManager);
await super.initialize(options);
if (this._capabilities?.has('trigger')) {
@@ -106,35 +104,29 @@ export class FrigateCamera extends Camera {
return true;
}
private async _initializeConfig(
hass: HomeAssistant,
entityRegistryManager: EntityRegistryManager,
protected override async _initialize(
options: FrigateCameraInitializationOptions,
): Promise<void> {
const config = this.getConfig();
const hasCameraName = !!config.frigate?.camera_name;
const hasAutoTriggers = config.triggers.motion || config.triggers.occupancy;
let entity: Entity | null = null;
const cameraEntity = getCameraEntityFromConfig(config);
// Entity information is required if the Frigate camera name is missing, or
// if the entity requires automatic resolution of motion/occupancy sensors.
if (cameraEntity && (!hasCameraName || hasAutoTriggers)) {
entity = await entityRegistryManager.getEntity(hass, cameraEntity);
if (!entity) {
throw new CameraNoEntityError(config);
}
// Frigate needs the entity to derive `camera_name` when one isn't set. The
// entity is resolved by base Camera; throw here only when its absence
// breaks Frigate setup.
if (cameraEntity && !hasCameraName && !this._entity) {
throw new CameraNoEntityError(config);
}
if (entity && !hasCameraName) {
const resolvedName = this._getFrigateCameraNameFromEntity(entity);
if (this._entity && !hasCameraName) {
const resolvedName = this._getFrigateCameraNameFromEntity(this._entity);
if (resolvedName) {
this._config.frigate.camera_name = resolvedName;
}
}
if (!this._config.frigate.client_id) {
const stateEntity = cameraEntity ? hass.states[cameraEntity] : undefined;
const stateEntity = cameraEntity ? options.hass.states[cameraEntity] : undefined;
const clientID = stateEntity?.attributes?.client_id;
if (typeof clientID === 'string' && clientID) {
this._config.frigate.client_id = clientID;
@@ -142,40 +134,57 @@ export class FrigateCamera extends Camera {
this._config.frigate.client_id = 'frigate';
}
}
}
if (hasAutoTriggers) {
// Try to find the correct entities for the motion & occupancy sensors.
// We know they are binary_sensors, and that they'll have the same
// config entry ID as the camera. Searching via unique_id ensures this
// search still works if the user renames the entity_id.
const binarySensorEntities = await entityRegistryManager.getMatchingEntities(
hass,
(ent) =>
ent.config_entry_id === entity?.config_entry_id &&
!ent.disabled_by &&
ent.entity_id.startsWith('binary_sensor.'),
);
protected override async _getTriggerEntities(
options: FrigateCameraInitializationOptions,
): Promise<void> {
await this._getFrigateMotionAndOccupancyEntities(options);
await super._getTriggerEntities(options);
}
if (config.triggers.motion) {
const motionEntity = this._getMotionSensor(config, [
...binarySensorEntities.values(),
]);
if (motionEntity) {
config.triggers.entities.push(motionEntity);
}
private async _getFrigateMotionAndOccupancyEntities(
options: FrigateCameraInitializationOptions,
): Promise<void> {
const config = this.getConfig();
if (!config.triggers.motion && !config.triggers.occupancy) {
return;
}
// Motion/occupancy auto-discovery requires the camera entity to derive
// the matching binary_sensor unique_ids.
if (getCameraEntityFromConfig(config) && !this._entity) {
throw new CameraNoEntityError(config);
}
// Find the correct entities for the motion & occupancy sensors. They
// are binary_sensors with the same config entry ID as the camera;
// searching via unique_id ensures this still works if the user renames
// the entity_id.
const binarySensorEntities = await options.entityRegistryManager.getMatchingEntities(
options.hass,
(ent) =>
ent.config_entry_id === this._entity?.config_entry_id &&
!ent.disabled_by &&
ent.entity_id.startsWith('binary_sensor.'),
);
if (config.triggers.motion) {
const motionEntity = this._getMotionSensor(config, [
...binarySensorEntities.values(),
]);
if (motionEntity) {
config.triggers.entities.push(motionEntity);
}
}
if (config.triggers.occupancy) {
const occupancyEntities = this._getOccupancySensor(config, [
...binarySensorEntities.values(),
]);
if (occupancyEntities) {
config.triggers.entities.push(...occupancyEntities);
}
if (config.triggers.occupancy) {
const occupancyEntities = this._getOccupancySensor(config, [
...binarySensorEntities.values(),
]);
if (occupancyEntities) {
config.triggers.entities.push(...occupancyEntities);
}
// De-duplicate triggering entities.
config.triggers.entities = uniq(config.triggers.entities);
}
}
+2 -2
View File
@@ -120,7 +120,7 @@ export class FrigateCameraManagerEngine
extends GenericCameraManagerEngine
implements CameraManagerEngine
{
private _entityRegistryManager: EntityRegistryManager;
protected override _entityRegistryManager: EntityRegistryManager;
private _frigateEventWatcher: FrigateEventWatcher;
private _frigateReviewWatcher: FrigateReviewWatcher;
private _recordingSegmentsCache: RecordingSegmentsCache;
@@ -140,7 +140,7 @@ export class FrigateCameraManagerEngine
requestCache: CameraManagerRequestCache,
eventCallback?: CameraEventCallback,
) {
super(stateWatcher, eventCallback);
super(stateWatcher, entityRegistryManager, eventCallback);
this._entityRegistryManager = entityRegistryManager;
this._frigateEventWatcher = new FrigateEventWatcher();
this._frigateReviewWatcher = new FrigateReviewWatcher();
@@ -3,6 +3,7 @@
import { StateWatcherSubscriptionInterface } from '../../card-controller/hass/state-watcher';
import { CameraConfig } from '../../config/schema/cameras';
import { getEntityTitle } from '../../ha/get-entity-title';
import { EntityRegistryManager } from '../../ha/registry/entity/types';
import { HomeAssistant } from '../../ha/types';
import { Endpoint } from '../../types';
import { ViewMedia } from '../../view/item';
@@ -40,12 +41,15 @@ import { getPTZCapabilitiesFromCameraConfig } from '../utils/ptz';
export class GenericCameraManagerEngine implements CameraManagerEngine {
protected _eventCallback?: CameraEventCallback;
protected _stateWatcher: StateWatcherSubscriptionInterface;
protected _entityRegistryManager?: EntityRegistryManager;
constructor(
stateWatcher: StateWatcherSubscriptionInterface,
entityRegistryManager?: EntityRegistryManager,
eventCallback?: CameraEventCallback,
) {
this._stateWatcher = stateWatcher;
this._entityRegistryManager = entityRegistryManager;
this._eventCallback = eventCallback;
}
@@ -62,6 +66,7 @@ export class GenericCameraManagerEngine implements CameraManagerEngine {
}).initialize({
hass,
stateWatcher: this._stateWatcher,
entityRegistryManager: this._entityRegistryManager,
capabilityOptions: {
raw: {
ptz: getPTZCapabilitiesFromCameraConfig(cameraConfig) ?? undefined,
+1
View File
@@ -174,6 +174,7 @@ export class ReolinkCamera extends EntityCamera {
protected async _initialize(
options: ReolinkCameraInitializationOptions,
): Promise<void> {
await super._initialize(options);
await this._initializeChannel(options.hass, options.deviceRegistryManager);
this._ptzEntities = await this._getPTZEntities(
options.hass,
+6 -2
View File
@@ -3,10 +3,13 @@ import { PTZAction, PTZActionPhase } from '../../config/schema/actions/custom/pt
import { Entity, EntityRegistryManager } from '../../ha/registry/entity/types';
import { HomeAssistant } from '../../ha/types';
import { CapabilitiesRaw, PTZCapabilities, PTZMovementType } from '../../types';
import { EntityCamera, EntityCameraInitializationOptions } from '../entity-camera';
import { CameraInitializationOptions } from '../camera';
import { EntityCamera } from '../entity-camera';
import { getPTZCapabilitiesFromCameraConfig } from '../utils/ptz';
type TPLinkCameraInitializationOptions = EntityCameraInitializationOptions;
interface TPLinkCameraInitializationOptions extends CameraInitializationOptions {
entityRegistryManager: EntityRegistryManager;
}
interface PTZEntities {
left?: string;
@@ -22,6 +25,7 @@ export class TPLinkCamera extends EntityCamera {
protected async _initialize(
options: TPLinkCameraInitializationOptions,
): Promise<void> {
await super._initialize(options);
this._ptzEntities = await this._getPTZEntities(
options.hass,
options.entityRegistryManager,
+1 -3
View File
@@ -8,14 +8,12 @@ import { CameraEventCallback, CameraManagerCameraMetadata, Engine } from '../typ
import { TPLinkCamera } from './camera';
export class TPLinkCameraManagerEngine extends GenericCameraManagerEngine {
private _entityRegistryManager: EntityRegistryManager;
constructor(
entityRegistryManager: EntityRegistryManager,
stateWatcher: StateWatcherSubscriptionInterface,
eventCallback?: CameraEventCallback,
) {
super(stateWatcher, eventCallback);
super(stateWatcher, entityRegistryManager, eventCallback);
this._entityRegistryManager = entityRegistryManager;
}