feat: Add automatic doorbell detection (#2507)
This commit is contained in:
committed by
dermotduffy
parent
4b72fcd629
commit
773cee95a5
@@ -383,7 +383,8 @@ cameras:
|
|||||||
|
|
||||||
| Option | Default | Description |
|
| Option | Default | Description |
|
||||||
| ----------- | ------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
| ----------- | ------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
| `entities` | | Whether to not to trigger the camera when the state of any Home Assistant entity becomes active (i.e. state becomes `on` or `open`). This works for Frigate or non-Frigate cameras. |
|
| `doorbell` | `false` | Whether to trigger the camera by automatically detecting an [HA `event.*` entity](https://www.home-assistant.io/integrations/event/#device-class) with `device_class: doorbell` on the same HA device as the camera entity. Requires `camera_entity` to be set. |
|
||||||
|
| `entities` | | Whether to not to trigger the camera when the state of any Home Assistant entity becomes active (i.e. state becomes `on` or `open`). |
|
||||||
| `events` | `[]` | Whether to trigger the camera when `events` occur (whether or not media is available) or whenever updated `clips` or `snapshots` are detected. Detection support varies by camera [engine](engine.md). |
|
| `events` | `[]` | Whether to trigger the camera when `events` occur (whether or not media is available) or whenever updated `clips` or `snapshots` are detected. Detection support varies by camera [engine](engine.md). |
|
||||||
| `motion` | `false` | Whether to not to trigger the camera by automatically detecting and using the motion `binary_sensor` for this camera. This autodetection only works for Frigate cameras, and only when the motion `binary_sensor` entity has been enabled in Home Assistant. |
|
| `motion` | `false` | Whether to not to trigger the camera by automatically detecting and using the motion `binary_sensor` for this camera. This autodetection only works for Frigate cameras, and only when the motion `binary_sensor` entity has been enabled in Home Assistant. |
|
||||||
| `occupancy` | `false` | Whether to not to trigger the camera by automatically detecting and using the occupancy `binary_sensor` for this camera and its configured zones and labels. This autodetection only works for Frigate cameras, and only when the occupancy `binary_sensor` entity has been enabled in Home Assistant. If this camera has configured zones, only occupancy sensors for those zones are used -- if the overall _camera_ occupancy sensor is also required, it can be manually added to `entities`. If this camera has configured labels, only occupancy sensors for those labels are used. |
|
| `occupancy` | `false` | Whether to not to trigger the camera by automatically detecting and using the occupancy `binary_sensor` for this camera and its configured zones and labels. This autodetection only works for Frigate cameras, and only when the occupancy `binary_sensor` entity has been enabled in Home Assistant. If this camera has configured zones, only occupancy sensors for those zones are used -- if the overall _camera_ occupancy sensor is also required, it can be manually added to `entities`. If this camera has configured labels, only occupancy sensors for those labels are used. |
|
||||||
|
|||||||
@@ -149,11 +149,12 @@ human interaction with the card; this behavior can be configured via the
|
|||||||
> are all marked as triggered, but the action is only taken for the first one.
|
> are all marked as triggered, but the action is only taken for the first one.
|
||||||
|
|
||||||
| Option | Default | Description |
|
| Option | Default | Description |
|
||||||
| ------------------------- | ------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
| ------------------------- | ------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
| `actions` | | The actions to take when a camera is triggered. See [Trigger action configuration](#trigger-action-configuration). |
|
| `actions` | | The actions to take when a camera is triggered. See [Trigger action configuration](#trigger-action-configuration). |
|
||||||
| `filter_selected_camera` | `true` | If set to `true` will only trigger on the currently selected camera. |
|
| `filter_selected_camera` | `true` | If set to `true` will only trigger on the currently selected camera. |
|
||||||
| `show_trigger_status` | `false` | Whether or not the `live` view should show a visual indication that it is triggered (a pulsing border around the camera edge). |
|
| `show_trigger_status` | `false` | Whether or not the `live` view should show a visual indication that it is triggered (a pulsing border around the camera edge). |
|
||||||
| `untrigger_delay_seconds` | `0` | The number of seconds to continue to consider the camera triggered after the source ends, before taking the configured `untrigger` action. For instantaneous trigger sources (e.g. HA `event.*` entities, or a doorbell button that only briefly pulses on) this is effectively the entire visible "active" duration — the source itself provides no on-period of its own. |
|
| `signal_hold_seconds` | `30` | The synthesized on-period for momentary trigger sources that have no native on/off state (e.g. HA `event.*` entities or anything that fires as a single signal). For a doorbell press paired with `trigger: call`, this is effectively the ring window during which the call can be answered. Added _on top of_ `untrigger_delay_seconds`. Ignored for stateful sources (`binary_sensor`, `switch`, etc.). |
|
||||||
|
| `untrigger_delay_seconds` | `0` | The number of seconds to continue to consider the camera triggered after the source ends, before taking the configured `untrigger` action. |
|
||||||
| `untrigger_force_seconds` | `0` | The number of seconds after a camera first triggers before force untriggering that camera. Set to `0` to disable. |
|
| `untrigger_force_seconds` | `0` | The number of seconds after a camera first triggers before force untriggering that camera. Set to `0` to disable. |
|
||||||
|
|
||||||
> [!WARNING] If `untrigger_force_seconds` is used to untrigger a camera, the
|
> [!WARNING] If `untrigger_force_seconds` is used to untrigger a camera, the
|
||||||
@@ -162,10 +163,9 @@ human interaction with the card; this behavior can be configured via the
|
|||||||
|
|
||||||
> [!TIP] When pairing `trigger: call` with `untrigger: call` (the
|
> [!TIP] When pairing `trigger: call` with `untrigger: call` (the
|
||||||
> "ring-then-end-if-unanswered" pattern), the ring lasts until the source ends
|
> "ring-then-end-if-unanswered" pattern), the ring lasts until the source ends
|
||||||
> plus `untrigger_delay_seconds`. With an instantaneous trigger source (event
|
> plus `untrigger_delay_seconds`. For momentary sources (HA `event.*` entities,
|
||||||
> entity, brief switch pulse) and the default of `0`, the call would start and
|
> a doorbell press), the source ends instantly so the ring window is
|
||||||
> end in the same tick. In these cases, set a positive value (e.g. `30`) for a
|
> `signal_hold_seconds` (default `30`s) plus `untrigger_delay_seconds`.
|
||||||
> meaningful ring duration.
|
|
||||||
|
|
||||||
### Trigger action configuration
|
### Trigger action configuration
|
||||||
|
|
||||||
|
|||||||
+13
-12
@@ -363,9 +363,18 @@ elements:
|
|||||||
|
|
||||||
This example uses [`view.triggers.actions.trigger: call`](configuration/view.md?id=triggers) to turn a dashboard into a phone-like ringer when somebody presses the doorbell. The intended deployment is a wall-mounted tablet sitting on the dashboard.
|
This example uses [`view.triggers.actions.trigger: call`](configuration/view.md?id=triggers) to turn a dashboard into a phone-like ringer when somebody presses the doorbell. The intended deployment is a wall-mounted tablet sitting on the dashboard.
|
||||||
|
|
||||||
The trigger entity is an [HA `event.*` entity](https://www.home-assistant.io/integrations/event/#device-class) with `device_class: doorbell` — the officially supported way modern integrations (ONVIF, UniFi Protect, Reolink, MQTT, etc.) expose a doorbell press. A press fires the entity instantaneously; the card treats it as a momentary signal and rings for `untrigger_delay_seconds`. A `switch.*` or `binary_sensor.*` entity that goes on/off with each press works will also work fine.
|
Setting `triggers.doorbell: true` opts the camera into auto-discovery of [HA
|
||||||
|
`event.*`
|
||||||
|
entities](https://www.home-assistant.io/integrations/event/#device-class) with
|
||||||
|
`device_class: doorbell` on the camera's device — the officially supported way
|
||||||
|
modern integrations (Ring, UniFi Protect, Nest, DoorBird, Reolink, etc.) expose
|
||||||
|
a doorbell press -- no need to list the entity explicitly under
|
||||||
|
`triggers.entities`. If your doorbell exposes a `binary_sensor.*` or `switch.*`
|
||||||
|
instead, list it under `triggers.entities` manually.
|
||||||
|
|
||||||
The Frigate camera's stock event triggers (`occupancy`, `motion`, `events`) are explicitly turned off so casual motion doesn't make the card ring — only an actual doorbell press does.
|
A doorbell press is instantaneous, so the card synthesises a ring window from [`view.triggers.signal_hold_seconds`](configuration/view.md?id=triggers) (default `30`s) — long enough for a typical phone-style answer window. `untrigger_delay_seconds` then lingers past that, same as for any stateful trigger.
|
||||||
|
|
||||||
|
`triggers.motion`, `triggers.occupancy`, and `triggers.events` are off by default — only the explicit doorbell press triggers the call, so casual motion won't make the card ring.
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
type: custom:advanced-camera-card
|
type: custom:advanced-camera-card
|
||||||
@@ -376,23 +385,15 @@ cameras:
|
|||||||
modes:
|
modes:
|
||||||
- webrtc
|
- webrtc
|
||||||
triggers:
|
triggers:
|
||||||
occupancy: false
|
doorbell: true
|
||||||
motion: false
|
|
||||||
events: []
|
|
||||||
entities:
|
|
||||||
- event.front_door_doorbell
|
|
||||||
view:
|
view:
|
||||||
default: live
|
default: live
|
||||||
triggers:
|
triggers:
|
||||||
show_trigger_status: true
|
show_trigger_status: true
|
||||||
# How long the chime keeps ringing after a press before the call is
|
|
||||||
# auto-ended (if still unanswered). A doorbell press is instantaneous,
|
|
||||||
# so this value is effectively the ring duration.
|
|
||||||
untrigger_delay_seconds: 30
|
|
||||||
actions:
|
actions:
|
||||||
# Call when triggered.
|
# Call when triggered.
|
||||||
trigger: call
|
trigger: call
|
||||||
# When the trigger naturally ends (after untrigger_delay_seconds): end
|
# When the trigger naturally ends (after signal_hold_seconds): end
|
||||||
# the call if it's still ringing. An answered call survives this and
|
# the call if it's still ringing. An answered call survives this and
|
||||||
# must be ended manually.
|
# must be ended manually.
|
||||||
untrigger: call
|
untrigger: call
|
||||||
|
|||||||
@@ -42,7 +42,7 @@ export class BrowseMediaCameraManagerEngine
|
|||||||
requestCache: CameraManagerRequestCache,
|
requestCache: CameraManagerRequestCache,
|
||||||
eventCallback?: CameraEventCallback,
|
eventCallback?: CameraEventCallback,
|
||||||
) {
|
) {
|
||||||
super(stateWatcher, eventCallback);
|
super(stateWatcher, entityRegistryManager, eventCallback);
|
||||||
this._entityRegistryManager = entityRegistryManager;
|
this._entityRegistryManager = entityRegistryManager;
|
||||||
this._browseMediaWalker = browseMediaManager;
|
this._browseMediaWalker = browseMediaManager;
|
||||||
this._resolvedMediaCache = resolvedMediaCache;
|
this._resolvedMediaCache = resolvedMediaCache;
|
||||||
|
|||||||
@@ -1,9 +1,12 @@
|
|||||||
|
import { uniq } from 'lodash-es';
|
||||||
import { ActionsExecutor } from '../card-controller/actions/types';
|
import { ActionsExecutor } from '../card-controller/actions/types';
|
||||||
import { StateWatcherSubscriptionInterface } from '../card-controller/hass/state-watcher';
|
import { StateWatcherSubscriptionInterface } from '../card-controller/hass/state-watcher';
|
||||||
import { PTZAction, PTZActionPhase } from '../config/schema/actions/custom/ptz';
|
import { PTZAction, PTZActionPhase } from '../config/schema/actions/custom/ptz';
|
||||||
import { CameraConfig } from '../config/schema/cameras';
|
import { CameraConfig } from '../config/schema/cameras';
|
||||||
import { EnabledProxyConfig, resolveProxyConfig } from '../config/schema/common/proxy';
|
import { EnabledProxyConfig, resolveProxyConfig } from '../config/schema/common/proxy';
|
||||||
|
import { computeDomain } from '../ha/compute-domain';
|
||||||
import { getTriggerEventType } from '../ha/get-trigger-event-type';
|
import { getTriggerEventType } from '../ha/get-trigger-event-type';
|
||||||
|
import { Entity, EntityRegistryManager } from '../ha/registry/entity/types';
|
||||||
import { HassStateDifference, HomeAssistant } from '../ha/types';
|
import { HassStateDifference, HomeAssistant } from '../ha/types';
|
||||||
import { localize } from '../localize/localize';
|
import { localize } from '../localize/localize';
|
||||||
import { CapabilitiesRaw, CapabilityKey, Endpoint } from '../types';
|
import { CapabilitiesRaw, CapabilityKey, Endpoint } from '../types';
|
||||||
@@ -17,6 +20,7 @@ import {
|
|||||||
CameraEventCallback,
|
CameraEventCallback,
|
||||||
CameraProxyConfig,
|
CameraProxyConfig,
|
||||||
} from './types';
|
} from './types';
|
||||||
|
import { getCameraEntityFromConfig } from './utils/camera-entity-from-config';
|
||||||
import {
|
import {
|
||||||
getGo2RTCMetadataEndpoint,
|
getGo2RTCMetadataEndpoint,
|
||||||
getGo2RTCStreamEndpoint,
|
getGo2RTCStreamEndpoint,
|
||||||
@@ -37,6 +41,7 @@ export interface CameraInitializationOptions {
|
|||||||
hass: HomeAssistant;
|
hass: HomeAssistant;
|
||||||
stateWatcher: StateWatcherSubscriptionInterface;
|
stateWatcher: StateWatcherSubscriptionInterface;
|
||||||
capabilityOptions?: CapabilityOptions;
|
capabilityOptions?: CapabilityOptions;
|
||||||
|
entityRegistryManager?: EntityRegistryManager;
|
||||||
}
|
}
|
||||||
|
|
||||||
type DestroyCallback = () => void | Promise<void>;
|
type DestroyCallback = () => void | Promise<void>;
|
||||||
@@ -47,6 +52,7 @@ export class Camera {
|
|||||||
protected _capabilities?: Capabilities;
|
protected _capabilities?: Capabilities;
|
||||||
protected _eventCallback?: CameraEventCallback;
|
protected _eventCallback?: CameraEventCallback;
|
||||||
protected _destroyCallbacks: DestroyCallback[] = [];
|
protected _destroyCallbacks: DestroyCallback[] = [];
|
||||||
|
protected _entity: Entity | null = null;
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
config: CameraConfig,
|
config: CameraConfig,
|
||||||
@@ -62,17 +68,86 @@ export class Camera {
|
|||||||
this._capabilities = options?.capabilities;
|
this._capabilities = options?.capabilities;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public getEntity(): Entity | null {
|
||||||
|
return this._entity;
|
||||||
|
}
|
||||||
|
|
||||||
async initialize(options: CameraInitializationOptions): Promise<Camera> {
|
async initialize(options: CameraInitializationOptions): Promise<Camera> {
|
||||||
|
this._entity = await this._resolveEntity(options);
|
||||||
await this._initialize(options);
|
await this._initialize(options);
|
||||||
|
|
||||||
this._capabilities =
|
this._capabilities =
|
||||||
options.capabilityOptions?.capabilities ??
|
options.capabilityOptions?.capabilities ??
|
||||||
this._capabilities ??
|
this._capabilities ??
|
||||||
(await this._buildCapabilities(options));
|
(await this._buildCapabilities(options));
|
||||||
this._subscribeBasedOnCapabilities(options.stateWatcher);
|
|
||||||
|
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));
|
this._onDestroy(() => options.stateWatcher.unsubscribe(this._stateChangeHandler));
|
||||||
|
}
|
||||||
|
|
||||||
return this;
|
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.
|
* Subclass initialization hook. Override for async initialization work.
|
||||||
*/
|
*/
|
||||||
@@ -276,12 +351,4 @@ export class Camera {
|
|||||||
protected _onDestroy(callback: DestroyCallback): void {
|
protected _onDestroy(callback: DestroyCallback): void {
|
||||||
this._destroyCallbacks.push(callback);
|
this._destroyCallbacks.push(callback);
|
||||||
}
|
}
|
||||||
|
|
||||||
private _subscribeBasedOnCapabilities(
|
|
||||||
stateWatcher: StateWatcherSubscriptionInterface,
|
|
||||||
): void {
|
|
||||||
if (this._capabilities?.has('trigger')) {
|
|
||||||
stateWatcher.subscribe(this._stateChangeHandler, this._config.triggers.entities);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ export class CameraManagerEngineFactory {
|
|||||||
const { GenericCameraManagerEngine } = await import('./generic/engine-generic');
|
const { GenericCameraManagerEngine } = await import('./generic/engine-generic');
|
||||||
cameraManagerEngine = new GenericCameraManagerEngine(
|
cameraManagerEngine = new GenericCameraManagerEngine(
|
||||||
options.stateWatcher,
|
options.stateWatcher,
|
||||||
|
this._entityRegistryManager,
|
||||||
options.eventCallback,
|
options.eventCallback,
|
||||||
);
|
);
|
||||||
break;
|
break;
|
||||||
|
|||||||
@@ -1,30 +1,19 @@
|
|||||||
import { Entity, EntityRegistryManager } from '../ha/registry/entity/types';
|
|
||||||
import { Camera, CameraInitializationOptions } from './camera';
|
import { Camera, CameraInitializationOptions } from './camera';
|
||||||
import { CameraNoEntityError } from './error';
|
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 {
|
export class EntityCamera extends Camera {
|
||||||
protected _entity: Entity | null = null;
|
protected override async _initialize(
|
||||||
|
options: CameraInitializationOptions,
|
||||||
public async initialize(options: EntityCameraInitializationOptions): Promise<Camera> {
|
): Promise<void> {
|
||||||
const config = this.getConfig();
|
if (!this._entity) {
|
||||||
const cameraEntityID = getCameraEntityFromConfig(config);
|
throw new CameraNoEntityError(this.getConfig());
|
||||||
const entity = cameraEntityID
|
|
||||||
? await options.entityRegistryManager.getEntity(options.hass, cameraEntityID)
|
|
||||||
: null;
|
|
||||||
|
|
||||||
if (!entity || !cameraEntityID) {
|
|
||||||
throw new CameraNoEntityError(config);
|
|
||||||
}
|
}
|
||||||
this._entity = entity;
|
await super._initialize(options);
|
||||||
return await super.initialize(options);
|
|
||||||
}
|
|
||||||
|
|
||||||
public getEntity(): Entity | null {
|
|
||||||
return this._entity;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import { format } from 'date-fns';
|
import { format } from 'date-fns';
|
||||||
import { uniq } from 'lodash-es';
|
|
||||||
import { ActionsExecutor } from '../../card-controller/actions/types';
|
import { ActionsExecutor } from '../../card-controller/actions/types';
|
||||||
import { PTZAction, PTZActionPhase } from '../../config/schema/actions/custom/ptz';
|
import { PTZAction, PTZActionPhase } from '../../config/schema/actions/custom/ptz';
|
||||||
import { CameraConfig } from '../../config/schema/cameras';
|
import { CameraConfig } from '../../config/schema/cameras';
|
||||||
@@ -45,7 +44,6 @@ export const isBirdseye = (cameraConfig: CameraConfig): boolean => {
|
|||||||
|
|
||||||
export class FrigateCamera extends Camera {
|
export class FrigateCamera extends Camera {
|
||||||
public async initialize(options: FrigateCameraInitializationOptions): Promise<Camera> {
|
public async initialize(options: FrigateCameraInitializationOptions): Promise<Camera> {
|
||||||
await this._initializeConfig(options.hass, options.entityRegistryManager);
|
|
||||||
await super.initialize(options);
|
await super.initialize(options);
|
||||||
|
|
||||||
if (this._capabilities?.has('trigger')) {
|
if (this._capabilities?.has('trigger')) {
|
||||||
@@ -106,35 +104,29 @@ export class FrigateCamera extends Camera {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
private async _initializeConfig(
|
protected override async _initialize(
|
||||||
hass: HomeAssistant,
|
options: FrigateCameraInitializationOptions,
|
||||||
entityRegistryManager: EntityRegistryManager,
|
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const config = this.getConfig();
|
const config = this.getConfig();
|
||||||
const hasCameraName = !!config.frigate?.camera_name;
|
const hasCameraName = !!config.frigate?.camera_name;
|
||||||
const hasAutoTriggers = config.triggers.motion || config.triggers.occupancy;
|
|
||||||
|
|
||||||
let entity: Entity | null = null;
|
|
||||||
const cameraEntity = getCameraEntityFromConfig(config);
|
const cameraEntity = getCameraEntityFromConfig(config);
|
||||||
|
|
||||||
// Entity information is required if the Frigate camera name is missing, or
|
// Frigate needs the entity to derive `camera_name` when one isn't set. The
|
||||||
// if the entity requires automatic resolution of motion/occupancy sensors.
|
// entity is resolved by base Camera; throw here only when its absence
|
||||||
if (cameraEntity && (!hasCameraName || hasAutoTriggers)) {
|
// breaks Frigate setup.
|
||||||
entity = await entityRegistryManager.getEntity(hass, cameraEntity);
|
if (cameraEntity && !hasCameraName && !this._entity) {
|
||||||
if (!entity) {
|
|
||||||
throw new CameraNoEntityError(config);
|
throw new CameraNoEntityError(config);
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
if (entity && !hasCameraName) {
|
if (this._entity && !hasCameraName) {
|
||||||
const resolvedName = this._getFrigateCameraNameFromEntity(entity);
|
const resolvedName = this._getFrigateCameraNameFromEntity(this._entity);
|
||||||
if (resolvedName) {
|
if (resolvedName) {
|
||||||
this._config.frigate.camera_name = resolvedName;
|
this._config.frigate.camera_name = resolvedName;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!this._config.frigate.client_id) {
|
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;
|
const clientID = stateEntity?.attributes?.client_id;
|
||||||
if (typeof clientID === 'string' && clientID) {
|
if (typeof clientID === 'string' && clientID) {
|
||||||
this._config.frigate.client_id = clientID;
|
this._config.frigate.client_id = clientID;
|
||||||
@@ -142,16 +134,37 @@ export class FrigateCamera extends Camera {
|
|||||||
this._config.frigate.client_id = 'frigate';
|
this._config.frigate.client_id = 'frigate';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (hasAutoTriggers) {
|
protected override async _getTriggerEntities(
|
||||||
// Try to find the correct entities for the motion & occupancy sensors.
|
options: FrigateCameraInitializationOptions,
|
||||||
// We know they are binary_sensors, and that they'll have the same
|
): Promise<void> {
|
||||||
// config entry ID as the camera. Searching via unique_id ensures this
|
await this._getFrigateMotionAndOccupancyEntities(options);
|
||||||
// search still works if the user renames the entity_id.
|
await super._getTriggerEntities(options);
|
||||||
const binarySensorEntities = await entityRegistryManager.getMatchingEntities(
|
}
|
||||||
hass,
|
|
||||||
|
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) =>
|
||||||
ent.config_entry_id === entity?.config_entry_id &&
|
ent.config_entry_id === this._entity?.config_entry_id &&
|
||||||
!ent.disabled_by &&
|
!ent.disabled_by &&
|
||||||
ent.entity_id.startsWith('binary_sensor.'),
|
ent.entity_id.startsWith('binary_sensor.'),
|
||||||
);
|
);
|
||||||
@@ -173,10 +186,6 @@ export class FrigateCamera extends Camera {
|
|||||||
config.triggers.entities.push(...occupancyEntities);
|
config.triggers.entities.push(...occupancyEntities);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// De-duplicate triggering entities.
|
|
||||||
config.triggers.entities = uniq(config.triggers.entities);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
protected async _getRawCapabilities(
|
protected async _getRawCapabilities(
|
||||||
|
|||||||
@@ -120,7 +120,7 @@ export class FrigateCameraManagerEngine
|
|||||||
extends GenericCameraManagerEngine
|
extends GenericCameraManagerEngine
|
||||||
implements CameraManagerEngine
|
implements CameraManagerEngine
|
||||||
{
|
{
|
||||||
private _entityRegistryManager: EntityRegistryManager;
|
protected override _entityRegistryManager: EntityRegistryManager;
|
||||||
private _frigateEventWatcher: FrigateEventWatcher;
|
private _frigateEventWatcher: FrigateEventWatcher;
|
||||||
private _frigateReviewWatcher: FrigateReviewWatcher;
|
private _frigateReviewWatcher: FrigateReviewWatcher;
|
||||||
private _recordingSegmentsCache: RecordingSegmentsCache;
|
private _recordingSegmentsCache: RecordingSegmentsCache;
|
||||||
@@ -140,7 +140,7 @@ export class FrigateCameraManagerEngine
|
|||||||
requestCache: CameraManagerRequestCache,
|
requestCache: CameraManagerRequestCache,
|
||||||
eventCallback?: CameraEventCallback,
|
eventCallback?: CameraEventCallback,
|
||||||
) {
|
) {
|
||||||
super(stateWatcher, eventCallback);
|
super(stateWatcher, entityRegistryManager, eventCallback);
|
||||||
this._entityRegistryManager = entityRegistryManager;
|
this._entityRegistryManager = entityRegistryManager;
|
||||||
this._frigateEventWatcher = new FrigateEventWatcher();
|
this._frigateEventWatcher = new FrigateEventWatcher();
|
||||||
this._frigateReviewWatcher = new FrigateReviewWatcher();
|
this._frigateReviewWatcher = new FrigateReviewWatcher();
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
import { StateWatcherSubscriptionInterface } from '../../card-controller/hass/state-watcher';
|
import { StateWatcherSubscriptionInterface } from '../../card-controller/hass/state-watcher';
|
||||||
import { CameraConfig } from '../../config/schema/cameras';
|
import { CameraConfig } from '../../config/schema/cameras';
|
||||||
import { getEntityTitle } from '../../ha/get-entity-title';
|
import { getEntityTitle } from '../../ha/get-entity-title';
|
||||||
|
import { EntityRegistryManager } from '../../ha/registry/entity/types';
|
||||||
import { HomeAssistant } from '../../ha/types';
|
import { HomeAssistant } from '../../ha/types';
|
||||||
import { Endpoint } from '../../types';
|
import { Endpoint } from '../../types';
|
||||||
import { ViewMedia } from '../../view/item';
|
import { ViewMedia } from '../../view/item';
|
||||||
@@ -40,12 +41,15 @@ import { getPTZCapabilitiesFromCameraConfig } from '../utils/ptz';
|
|||||||
export class GenericCameraManagerEngine implements CameraManagerEngine {
|
export class GenericCameraManagerEngine implements CameraManagerEngine {
|
||||||
protected _eventCallback?: CameraEventCallback;
|
protected _eventCallback?: CameraEventCallback;
|
||||||
protected _stateWatcher: StateWatcherSubscriptionInterface;
|
protected _stateWatcher: StateWatcherSubscriptionInterface;
|
||||||
|
protected _entityRegistryManager?: EntityRegistryManager;
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
stateWatcher: StateWatcherSubscriptionInterface,
|
stateWatcher: StateWatcherSubscriptionInterface,
|
||||||
|
entityRegistryManager?: EntityRegistryManager,
|
||||||
eventCallback?: CameraEventCallback,
|
eventCallback?: CameraEventCallback,
|
||||||
) {
|
) {
|
||||||
this._stateWatcher = stateWatcher;
|
this._stateWatcher = stateWatcher;
|
||||||
|
this._entityRegistryManager = entityRegistryManager;
|
||||||
this._eventCallback = eventCallback;
|
this._eventCallback = eventCallback;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -62,6 +66,7 @@ export class GenericCameraManagerEngine implements CameraManagerEngine {
|
|||||||
}).initialize({
|
}).initialize({
|
||||||
hass,
|
hass,
|
||||||
stateWatcher: this._stateWatcher,
|
stateWatcher: this._stateWatcher,
|
||||||
|
entityRegistryManager: this._entityRegistryManager,
|
||||||
capabilityOptions: {
|
capabilityOptions: {
|
||||||
raw: {
|
raw: {
|
||||||
ptz: getPTZCapabilitiesFromCameraConfig(cameraConfig) ?? undefined,
|
ptz: getPTZCapabilitiesFromCameraConfig(cameraConfig) ?? undefined,
|
||||||
|
|||||||
@@ -174,6 +174,7 @@ export class ReolinkCamera extends EntityCamera {
|
|||||||
protected async _initialize(
|
protected async _initialize(
|
||||||
options: ReolinkCameraInitializationOptions,
|
options: ReolinkCameraInitializationOptions,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
|
await super._initialize(options);
|
||||||
await this._initializeChannel(options.hass, options.deviceRegistryManager);
|
await this._initializeChannel(options.hass, options.deviceRegistryManager);
|
||||||
this._ptzEntities = await this._getPTZEntities(
|
this._ptzEntities = await this._getPTZEntities(
|
||||||
options.hass,
|
options.hass,
|
||||||
|
|||||||
@@ -3,10 +3,13 @@ import { PTZAction, PTZActionPhase } from '../../config/schema/actions/custom/pt
|
|||||||
import { Entity, EntityRegistryManager } from '../../ha/registry/entity/types';
|
import { Entity, EntityRegistryManager } from '../../ha/registry/entity/types';
|
||||||
import { HomeAssistant } from '../../ha/types';
|
import { HomeAssistant } from '../../ha/types';
|
||||||
import { CapabilitiesRaw, PTZCapabilities, PTZMovementType } from '../../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';
|
import { getPTZCapabilitiesFromCameraConfig } from '../utils/ptz';
|
||||||
|
|
||||||
type TPLinkCameraInitializationOptions = EntityCameraInitializationOptions;
|
interface TPLinkCameraInitializationOptions extends CameraInitializationOptions {
|
||||||
|
entityRegistryManager: EntityRegistryManager;
|
||||||
|
}
|
||||||
|
|
||||||
interface PTZEntities {
|
interface PTZEntities {
|
||||||
left?: string;
|
left?: string;
|
||||||
@@ -22,6 +25,7 @@ export class TPLinkCamera extends EntityCamera {
|
|||||||
protected async _initialize(
|
protected async _initialize(
|
||||||
options: TPLinkCameraInitializationOptions,
|
options: TPLinkCameraInitializationOptions,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
|
await super._initialize(options);
|
||||||
this._ptzEntities = await this._getPTZEntities(
|
this._ptzEntities = await this._getPTZEntities(
|
||||||
options.hass,
|
options.hass,
|
||||||
options.entityRegistryManager,
|
options.entityRegistryManager,
|
||||||
|
|||||||
@@ -8,14 +8,12 @@ import { CameraEventCallback, CameraManagerCameraMetadata, Engine } from '../typ
|
|||||||
import { TPLinkCamera } from './camera';
|
import { TPLinkCamera } from './camera';
|
||||||
|
|
||||||
export class TPLinkCameraManagerEngine extends GenericCameraManagerEngine {
|
export class TPLinkCameraManagerEngine extends GenericCameraManagerEngine {
|
||||||
private _entityRegistryManager: EntityRegistryManager;
|
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
entityRegistryManager: EntityRegistryManager,
|
entityRegistryManager: EntityRegistryManager,
|
||||||
stateWatcher: StateWatcherSubscriptionInterface,
|
stateWatcher: StateWatcherSubscriptionInterface,
|
||||||
eventCallback?: CameraEventCallback,
|
eventCallback?: CameraEventCallback,
|
||||||
) {
|
) {
|
||||||
super(stateWatcher, eventCallback);
|
super(stateWatcher, entityRegistryManager, eventCallback);
|
||||||
this._entityRegistryManager = entityRegistryManager;
|
this._entityRegistryManager = entityRegistryManager;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -111,12 +111,14 @@ export class TriggersManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (ev.type === 'signal') {
|
if (ev.type === 'signal') {
|
||||||
// A signal is momentary -- handled as a matched new+end so the existing
|
|
||||||
// untrigger_delay_seconds machinery gives it visible duration, and so
|
|
||||||
// concurrent continuous sources still gate untriggering correctly.
|
|
||||||
const handled = await this.handleCameraEvent({ ...ev, type: 'new' }, options);
|
const handled = await this.handleCameraEvent({ ...ev, type: 'new' }, options);
|
||||||
if (handled) {
|
if (handled) {
|
||||||
await this.handleCameraEvent({ ...ev, type: 'end' });
|
// A signal is momentary -- handled as a matched new+end so concurrent
|
||||||
|
// continuous sources still gate untriggering correctly. The end leg is
|
||||||
|
// tagged `{ signal: true }` so `_startUntrigger` adds the synthesized
|
||||||
|
// signal on-period (`signal_hold_seconds`) on top of the usual
|
||||||
|
// post-source-end linger (`untrigger_delay_seconds`).
|
||||||
|
await this._handleEndEvent(ev, { signal: true });
|
||||||
}
|
}
|
||||||
return handled;
|
return handled;
|
||||||
}
|
}
|
||||||
@@ -160,13 +162,16 @@ export class TriggersManager {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
private async _handleEndEvent(ev: CameraEvent): Promise<boolean> {
|
private async _handleEndEvent(
|
||||||
|
ev: CameraEvent,
|
||||||
|
options?: { signal?: boolean },
|
||||||
|
): Promise<boolean> {
|
||||||
this._deleteIgnoredEventID(ev.cameraID, ev.id);
|
this._deleteIgnoredEventID(ev.cameraID, ev.id);
|
||||||
|
|
||||||
const state = this._states.get(ev.cameraID);
|
const state = this._states.get(ev.cameraID);
|
||||||
state?.sources.delete(ev.id);
|
state?.sources.delete(ev.id);
|
||||||
if (!state?.sources.size) {
|
if (!state?.sources.size) {
|
||||||
await this._startUntrigger(ev.cameraID);
|
await this._startUntrigger(ev.cameraID, options);
|
||||||
}
|
}
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
@@ -306,7 +311,10 @@ export class TriggersManager {
|
|||||||
this._api.getCardElementManager().update();
|
this._api.getCardElementManager().update();
|
||||||
}
|
}
|
||||||
|
|
||||||
private async _startUntrigger(cameraID: string): Promise<void> {
|
private async _startUntrigger(
|
||||||
|
cameraID: string,
|
||||||
|
options?: { signal?: boolean },
|
||||||
|
): Promise<void> {
|
||||||
this._deleteUntriggerDelayTimer(cameraID);
|
this._deleteUntriggerDelayTimer(cameraID);
|
||||||
this._deleteForceUntriggerTimer(cameraID);
|
this._deleteForceUntriggerTimer(cameraID);
|
||||||
|
|
||||||
@@ -315,12 +323,18 @@ export class TriggersManager {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const config = this._api.getConfigManager().getConfig();
|
const triggersConfig = this._api.getConfigManager().getConfig()?.view?.triggers;
|
||||||
const untriggerDelaySeconds = config?.view?.triggers.untrigger_delay_seconds ?? 0;
|
const untriggerDelaySeconds = triggersConfig?.untrigger_delay_seconds ?? 0;
|
||||||
|
// For signals, add the synthesized on-period (signals have no native
|
||||||
|
// on/off, so hold them visible for `signal_hold_seconds` before the usual
|
||||||
|
// post-source-end linger kicks in).
|
||||||
|
const signalHoldSeconds = triggersConfig?.signal_hold_seconds ?? 0;
|
||||||
|
const effectiveDelaySeconds =
|
||||||
|
untriggerDelaySeconds + (options?.signal ? signalHoldSeconds : 0);
|
||||||
|
|
||||||
if (untriggerDelaySeconds > 0) {
|
if (effectiveDelaySeconds > 0) {
|
||||||
state.untriggerDelayTimer = new Timer();
|
state.untriggerDelayTimer = new Timer();
|
||||||
state.untriggerDelayTimer.start(untriggerDelaySeconds, async () => {
|
state.untriggerDelayTimer.start(effectiveDelaySeconds, async () => {
|
||||||
await this._untriggerAction(cameraID);
|
await this._untriggerAction(cameraID);
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -148,6 +148,7 @@ export const cameraConfigDefault = {
|
|||||||
triggers: {
|
triggers: {
|
||||||
motion: false,
|
motion: false,
|
||||||
occupancy: false,
|
occupancy: false,
|
||||||
|
doorbell: false,
|
||||||
events: [],
|
events: [],
|
||||||
entities: [],
|
entities: [],
|
||||||
reviews: {
|
reviews: {
|
||||||
@@ -248,6 +249,7 @@ export const cameraConfigSchema = z
|
|||||||
.object({
|
.object({
|
||||||
motion: z.boolean().default(cameraConfigDefault.triggers.motion),
|
motion: z.boolean().default(cameraConfigDefault.triggers.motion),
|
||||||
occupancy: z.boolean().default(cameraConfigDefault.triggers.occupancy),
|
occupancy: z.boolean().default(cameraConfigDefault.triggers.occupancy),
|
||||||
|
doorbell: z.boolean().default(cameraConfigDefault.triggers.doorbell),
|
||||||
entities: z.string().array().default(cameraConfigDefault.triggers.entities),
|
entities: z.string().array().default(cameraConfigDefault.triggers.entities),
|
||||||
events: z
|
events: z
|
||||||
.enum(CAMERA_TRIGGER_EVENT_TYPES)
|
.enum(CAMERA_TRIGGER_EVENT_TYPES)
|
||||||
|
|||||||
@@ -72,6 +72,7 @@ export const viewConfigDefault = {
|
|||||||
},
|
},
|
||||||
untrigger_delay_seconds: 0,
|
untrigger_delay_seconds: 0,
|
||||||
untrigger_force_seconds: 0,
|
untrigger_force_seconds: 0,
|
||||||
|
signal_hold_seconds: 30,
|
||||||
},
|
},
|
||||||
keyboard_shortcuts: keyboardShortcutsDefault,
|
keyboard_shortcuts: keyboardShortcutsDefault,
|
||||||
issues: {
|
issues: {
|
||||||
@@ -109,6 +110,9 @@ export const triggersSchema = z.object({
|
|||||||
untrigger_force_seconds: z
|
untrigger_force_seconds: z
|
||||||
.number()
|
.number()
|
||||||
.default(viewConfigDefault.triggers.untrigger_force_seconds),
|
.default(viewConfigDefault.triggers.untrigger_force_seconds),
|
||||||
|
signal_hold_seconds: z
|
||||||
|
.number()
|
||||||
|
.default(viewConfigDefault.triggers.signal_hold_seconds),
|
||||||
});
|
});
|
||||||
export type TriggersOptions = z.infer<typeof triggersSchema>;
|
export type TriggersOptions = z.infer<typeof triggersSchema>;
|
||||||
|
|
||||||
|
|||||||
@@ -111,6 +111,8 @@ export const CONF_CAMERAS_ARRAY_TRIGGERS_MOTION =
|
|||||||
`${CONF_CAMERAS}.#.triggers.motion` as const;
|
`${CONF_CAMERAS}.#.triggers.motion` as const;
|
||||||
export const CONF_CAMERAS_ARRAY_TRIGGERS_OCCUPANCY =
|
export const CONF_CAMERAS_ARRAY_TRIGGERS_OCCUPANCY =
|
||||||
`${CONF_CAMERAS}.#.triggers.occupancy` as const;
|
`${CONF_CAMERAS}.#.triggers.occupancy` as const;
|
||||||
|
export const CONF_CAMERAS_ARRAY_TRIGGERS_DOORBELL =
|
||||||
|
`${CONF_CAMERAS}.#.triggers.doorbell` as const;
|
||||||
export const CONF_CAMERAS_ARRAY_TRIGGERS_ENTITIES =
|
export const CONF_CAMERAS_ARRAY_TRIGGERS_ENTITIES =
|
||||||
`${CONF_CAMERAS}.#.triggers.entities` as const;
|
`${CONF_CAMERAS}.#.triggers.entities` as const;
|
||||||
export const CONF_CAMERAS_ARRAY_TRIGGERS_EVENTS =
|
export const CONF_CAMERAS_ARRAY_TRIGGERS_EVENTS =
|
||||||
@@ -195,6 +197,8 @@ export const CONF_VIEW_TRIGGERS_UNTRIGGER_DELAY_SECONDS =
|
|||||||
`${CONF_VIEW_TRIGGERS}.untrigger_delay_seconds` as const;
|
`${CONF_VIEW_TRIGGERS}.untrigger_delay_seconds` as const;
|
||||||
export const CONF_VIEW_TRIGGERS_UNTRIGGER_FORCE_SECONDS =
|
export const CONF_VIEW_TRIGGERS_UNTRIGGER_FORCE_SECONDS =
|
||||||
`${CONF_VIEW_TRIGGERS}.untrigger_force_seconds` as const;
|
`${CONF_VIEW_TRIGGERS}.untrigger_force_seconds` as const;
|
||||||
|
export const CONF_VIEW_TRIGGERS_SIGNAL_HOLD_SECONDS =
|
||||||
|
`${CONF_VIEW_TRIGGERS}.signal_hold_seconds` as const;
|
||||||
export const CONF_VIEW_TRIGGERS_ACTIONS = `${CONF_VIEW_TRIGGERS}.actions` as const;
|
export const CONF_VIEW_TRIGGERS_ACTIONS = `${CONF_VIEW_TRIGGERS}.actions` as const;
|
||||||
export const CONF_VIEW_TRIGGERS_ACTIONS_INTERACTION_MODE =
|
export const CONF_VIEW_TRIGGERS_ACTIONS_INTERACTION_MODE =
|
||||||
`${CONF_VIEW_TRIGGERS_ACTIONS}.interaction_mode` as const;
|
`${CONF_VIEW_TRIGGERS_ACTIONS}.interaction_mode` as const;
|
||||||
|
|||||||
@@ -90,6 +90,7 @@ import {
|
|||||||
CONF_CAMERAS_ARRAY_REOLINK_MEDIA_RESOLUTION,
|
CONF_CAMERAS_ARRAY_REOLINK_MEDIA_RESOLUTION,
|
||||||
CONF_CAMERAS_ARRAY_REOLINK_URL,
|
CONF_CAMERAS_ARRAY_REOLINK_URL,
|
||||||
CONF_CAMERAS_ARRAY_TITLE,
|
CONF_CAMERAS_ARRAY_TITLE,
|
||||||
|
CONF_CAMERAS_ARRAY_TRIGGERS_DOORBELL,
|
||||||
CONF_CAMERAS_ARRAY_TRIGGERS_ENTITIES,
|
CONF_CAMERAS_ARRAY_TRIGGERS_ENTITIES,
|
||||||
CONF_CAMERAS_ARRAY_TRIGGERS_EVENTS,
|
CONF_CAMERAS_ARRAY_TRIGGERS_EVENTS,
|
||||||
CONF_CAMERAS_ARRAY_TRIGGERS_MOTION,
|
CONF_CAMERAS_ARRAY_TRIGGERS_MOTION,
|
||||||
@@ -278,6 +279,7 @@ import {
|
|||||||
CONF_VIEW_TRIGGERS_ACTIONS_UNTRIGGER,
|
CONF_VIEW_TRIGGERS_ACTIONS_UNTRIGGER,
|
||||||
CONF_VIEW_TRIGGERS_FILTER_SELECTED_CAMERA,
|
CONF_VIEW_TRIGGERS_FILTER_SELECTED_CAMERA,
|
||||||
CONF_VIEW_TRIGGERS_SHOW_TRIGGER_STATUS,
|
CONF_VIEW_TRIGGERS_SHOW_TRIGGER_STATUS,
|
||||||
|
CONF_VIEW_TRIGGERS_SIGNAL_HOLD_SECONDS,
|
||||||
CONF_VIEW_TRIGGERS_UNTRIGGER_DELAY_SECONDS,
|
CONF_VIEW_TRIGGERS_UNTRIGGER_DELAY_SECONDS,
|
||||||
CONF_VIEW_TRIGGERS_UNTRIGGER_FORCE_SECONDS,
|
CONF_VIEW_TRIGGERS_UNTRIGGER_FORCE_SECONDS,
|
||||||
DOCS_URL,
|
DOCS_URL,
|
||||||
@@ -1596,6 +1598,9 @@ export class AdvancedCameraCardEditor extends LitElement implements LovelaceCard
|
|||||||
${this._renderNumberInput(CONF_VIEW_TRIGGERS_UNTRIGGER_FORCE_SECONDS, {
|
${this._renderNumberInput(CONF_VIEW_TRIGGERS_UNTRIGGER_FORCE_SECONDS, {
|
||||||
default: this._defaults.view.triggers.untrigger_force_seconds,
|
default: this._defaults.view.triggers.untrigger_force_seconds,
|
||||||
})}
|
})}
|
||||||
|
${this._renderNumberInput(CONF_VIEW_TRIGGERS_SIGNAL_HOLD_SECONDS, {
|
||||||
|
default: this._defaults.view.triggers.signal_hold_seconds,
|
||||||
|
})}
|
||||||
${this._putInSubmenu(
|
${this._putInSubmenu(
|
||||||
MENU_VIEW_TRIGGERS_ACTIONS,
|
MENU_VIEW_TRIGGERS_ACTIONS,
|
||||||
true,
|
true,
|
||||||
@@ -2786,6 +2791,13 @@ export class AdvancedCameraCardEditor extends LitElement implements LovelaceCard
|
|||||||
getArrayConfigPath(CONF_CAMERAS_ARRAY_TRIGGERS_MOTION, cameraIndex),
|
getArrayConfigPath(CONF_CAMERAS_ARRAY_TRIGGERS_MOTION, cameraIndex),
|
||||||
this._defaults.cameras.triggers.motion,
|
this._defaults.cameras.triggers.motion,
|
||||||
)}
|
)}
|
||||||
|
${this._renderSwitch(
|
||||||
|
getArrayConfigPath(
|
||||||
|
CONF_CAMERAS_ARRAY_TRIGGERS_DOORBELL,
|
||||||
|
cameraIndex,
|
||||||
|
),
|
||||||
|
this._defaults.cameras.triggers.doorbell,
|
||||||
|
)}
|
||||||
${this._renderOptionSelector(
|
${this._renderOptionSelector(
|
||||||
getArrayConfigPath(
|
getArrayConfigPath(
|
||||||
CONF_CAMERAS_ARRAY_TRIGGERS_ENTITIES,
|
CONF_CAMERAS_ARRAY_TRIGGERS_ENTITIES,
|
||||||
|
|||||||
@@ -193,6 +193,7 @@
|
|||||||
"events": "All events",
|
"events": "All events",
|
||||||
"snapshots": "Events with new snapshots"
|
"snapshots": "Events with new snapshots"
|
||||||
},
|
},
|
||||||
|
"doorbell": "Trigger by auto-detecting doorbell event entities",
|
||||||
"motion": "Trigger by auto-detecting the motion sensor",
|
"motion": "Trigger by auto-detecting the motion sensor",
|
||||||
"occupancy": "Trigger by auto-detecting the occupancy sensor",
|
"occupancy": "Trigger by auto-detecting the occupancy sensor",
|
||||||
"reviews": {
|
"reviews": {
|
||||||
@@ -678,6 +679,7 @@
|
|||||||
"editor_label": "Trigger behavior",
|
"editor_label": "Trigger behavior",
|
||||||
"filter_selected_camera": "Only trigger on selected camera",
|
"filter_selected_camera": "Only trigger on selected camera",
|
||||||
"show_trigger_status": "Show pulsing border when triggered",
|
"show_trigger_status": "Show pulsing border when triggered",
|
||||||
|
"signal_hold_seconds": "Seconds to hold a momentary (signal) trigger visible (e.g. a doorbell press) before the post-end untrigger delay",
|
||||||
"untrigger_delay_seconds": "Seconds delay after trigger state change before untrigger",
|
"untrigger_delay_seconds": "Seconds delay after trigger state change before untrigger",
|
||||||
"untrigger_force_seconds": "Seconds before forced untrigger"
|
"untrigger_force_seconds": "Seconds before forced untrigger"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -5,12 +5,14 @@ import { GenericCameraManagerEngine } from '../../src/camera-manager/generic/eng
|
|||||||
import { CameraProxyConfig } from '../../src/camera-manager/types.js';
|
import { CameraProxyConfig } from '../../src/camera-manager/types.js';
|
||||||
import { StateWatcherSubscriptionInterface } from '../../src/card-controller/hass/state-watcher.js';
|
import { StateWatcherSubscriptionInterface } from '../../src/card-controller/hass/state-watcher.js';
|
||||||
import { liveProviderSupports2WayAudio } from '../../src/utils/live-provider.js';
|
import { liveProviderSupports2WayAudio } from '../../src/utils/live-provider.js';
|
||||||
|
import { EntityRegistryManagerMock } from '../ha/registry/entity/mock.js';
|
||||||
import {
|
import {
|
||||||
callStateWatcherCallback,
|
callStateWatcherCallback,
|
||||||
createCameraConfig,
|
createCameraConfig,
|
||||||
createCapabilities,
|
createCapabilities,
|
||||||
createHASS,
|
createHASS,
|
||||||
createInitializedCamera,
|
createInitializedCamera,
|
||||||
|
createRegistryEntity,
|
||||||
createStateEntity,
|
createStateEntity,
|
||||||
} from '../test-utils.js';
|
} from '../test-utils.js';
|
||||||
|
|
||||||
@@ -421,6 +423,203 @@ describe('Camera', () => {
|
|||||||
expect(liveProviderSupports2WayAudio).toHaveBeenCalled();
|
expect(liveProviderSupports2WayAudio).toHaveBeenCalled();
|
||||||
expect(camera.getCapabilities()?.has('2-way-audio')).toBe(true);
|
expect(camera.getCapabilities()?.has('2-way-audio')).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('entity resolution', () => {
|
||||||
|
it('should resolve entity when camera_entity and registry manager are provided', async () => {
|
||||||
|
const cameraEntity = createRegistryEntity({
|
||||||
|
entity_id: 'camera.front_door',
|
||||||
|
device_id: 'device_1',
|
||||||
|
});
|
||||||
|
const camera = new Camera(
|
||||||
|
createCameraConfig({ camera_entity: 'camera.front_door' }),
|
||||||
|
new GenericCameraManagerEngine(mock<StateWatcherSubscriptionInterface>()),
|
||||||
|
);
|
||||||
|
|
||||||
|
await camera.initialize({
|
||||||
|
hass: createHASS(),
|
||||||
|
stateWatcher: mock<StateWatcherSubscriptionInterface>(),
|
||||||
|
entityRegistryManager: new EntityRegistryManagerMock([cameraEntity]),
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(camera.getEntity()).toEqual(cameraEntity);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should leave entity null when camera_entity is unset', async () => {
|
||||||
|
const camera = new Camera(
|
||||||
|
createCameraConfig(),
|
||||||
|
new GenericCameraManagerEngine(mock<StateWatcherSubscriptionInterface>()),
|
||||||
|
);
|
||||||
|
|
||||||
|
await camera.initialize({
|
||||||
|
hass: createHASS(),
|
||||||
|
stateWatcher: mock<StateWatcherSubscriptionInterface>(),
|
||||||
|
entityRegistryManager: new EntityRegistryManagerMock(),
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(camera.getEntity()).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
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>()),
|
||||||
|
);
|
||||||
|
|
||||||
|
await camera.initialize({
|
||||||
|
hass: createHASS(),
|
||||||
|
stateWatcher: mock<StateWatcherSubscriptionInterface>(),
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(camera.getEntity()).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('trigger discovery', () => {
|
||||||
|
describe('doorbell', () => {
|
||||||
|
const initializeDoorbellCamera = async (options?: {
|
||||||
|
doorbell?: boolean;
|
||||||
|
deviceID?: string | null;
|
||||||
|
triggerCapability?: boolean;
|
||||||
|
userEntities?: string[];
|
||||||
|
omitRegistryManager?: boolean;
|
||||||
|
registryEntities?: ReturnType<typeof createRegistryEntity>[];
|
||||||
|
stateEntities?: Parameters<typeof createHASS>[0];
|
||||||
|
}): Promise<{
|
||||||
|
camera: Camera;
|
||||||
|
stateWatcher: StateWatcherSubscriptionInterface;
|
||||||
|
}> => {
|
||||||
|
const cameraEntity = createRegistryEntity({
|
||||||
|
entity_id: 'camera.front_door',
|
||||||
|
device_id: options?.deviceID === undefined ? 'device_1' : options.deviceID,
|
||||||
|
});
|
||||||
|
const doorbellEntity = createRegistryEntity({
|
||||||
|
entity_id: 'event.front_door_doorbell',
|
||||||
|
device_id: 'device_1',
|
||||||
|
});
|
||||||
|
const camera = new Camera(
|
||||||
|
createCameraConfig({
|
||||||
|
camera_entity: 'camera.front_door',
|
||||||
|
triggers: {
|
||||||
|
doorbell: options?.doorbell ?? true,
|
||||||
|
...(options?.userEntities && { entities: options.userEntities }),
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
new GenericCameraManagerEngine(mock<StateWatcherSubscriptionInterface>()),
|
||||||
|
);
|
||||||
|
const stateWatcher = mock<StateWatcherSubscriptionInterface>();
|
||||||
|
const hass = createHASS(
|
||||||
|
options?.stateEntities ?? {
|
||||||
|
'event.front_door_doorbell': createStateEntity({
|
||||||
|
entity_id: 'event.front_door_doorbell',
|
||||||
|
attributes: { device_class: 'doorbell' },
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
await camera.initialize({
|
||||||
|
hass,
|
||||||
|
stateWatcher,
|
||||||
|
...(!options?.omitRegistryManager && {
|
||||||
|
entityRegistryManager: new EntityRegistryManagerMock(
|
||||||
|
options?.registryEntities ?? [cameraEntity, doorbellEntity],
|
||||||
|
),
|
||||||
|
}),
|
||||||
|
capabilityOptions: {
|
||||||
|
capabilities: createCapabilities({
|
||||||
|
trigger: options?.triggerCapability ?? true,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return { camera, stateWatcher };
|
||||||
|
};
|
||||||
|
|
||||||
|
it('should auto-include doorbell event entity from camera device', async () => {
|
||||||
|
const { camera } = await initializeDoorbellCamera();
|
||||||
|
expect(camera.getConfig().triggers.entities).toEqual([
|
||||||
|
'event.front_door_doorbell',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should skip discovery when triggers.doorbell is false', async () => {
|
||||||
|
const { camera } = await initializeDoorbellCamera({ doorbell: false });
|
||||||
|
expect(camera.getConfig().triggers.entities).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should skip discovery when camera entity has no device_id', async () => {
|
||||||
|
const { camera } = await initializeDoorbellCamera({ deviceID: null });
|
||||||
|
expect(camera.getConfig().triggers.entities).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should skip discovery when trigger capability is disabled', async () => {
|
||||||
|
const { camera } = await initializeDoorbellCamera({
|
||||||
|
triggerCapability: false,
|
||||||
|
});
|
||||||
|
expect(camera.getConfig().triggers.entities).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should skip discovery when entityRegistryManager is not provided', async () => {
|
||||||
|
const { camera } = await initializeDoorbellCamera({
|
||||||
|
omitRegistryManager: true,
|
||||||
|
});
|
||||||
|
expect(camera.getConfig().triggers.entities).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should de-duplicate against user-supplied entities', async () => {
|
||||||
|
const { camera } = await initializeDoorbellCamera({
|
||||||
|
userEntities: ['event.front_door_doorbell', 'binary_sensor.driveway'],
|
||||||
|
});
|
||||||
|
expect(camera.getConfig().triggers.entities).toEqual([
|
||||||
|
'event.front_door_doorbell',
|
||||||
|
'binary_sensor.driveway',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should skip disabled event entities', async () => {
|
||||||
|
const { camera } = await initializeDoorbellCamera({
|
||||||
|
registryEntities: [
|
||||||
|
createRegistryEntity({
|
||||||
|
entity_id: 'camera.front_door',
|
||||||
|
device_id: 'device_1',
|
||||||
|
}),
|
||||||
|
createRegistryEntity({
|
||||||
|
entity_id: 'event.front_door_doorbell',
|
||||||
|
device_id: 'device_1',
|
||||||
|
disabled_by: 'user',
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
});
|
||||||
|
expect(camera.getConfig().triggers.entities).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should ignore non-doorbell event entities on the device', async () => {
|
||||||
|
const { camera } = await initializeDoorbellCamera({
|
||||||
|
registryEntities: [
|
||||||
|
createRegistryEntity({
|
||||||
|
entity_id: 'camera.front_door',
|
||||||
|
device_id: 'device_1',
|
||||||
|
}),
|
||||||
|
createRegistryEntity({
|
||||||
|
entity_id: 'event.front_door_button',
|
||||||
|
device_id: 'device_1',
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
stateEntities: {
|
||||||
|
'event.front_door_button': createStateEntity({
|
||||||
|
entity_id: 'event.front_door_button',
|
||||||
|
attributes: { device_class: 'button' },
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(camera.getConfig().triggers.entities).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should subscribe to discovered doorbell entities for state changes', async () => {
|
||||||
|
const { stateWatcher } = await initializeDoorbellCamera();
|
||||||
|
expect(stateWatcher.subscribe).toBeCalledWith(expect.any(Function), [
|
||||||
|
'event.front_door_doorbell',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('should handle trigger state changes', () => {
|
describe('should handle trigger state changes', () => {
|
||||||
|
|||||||
@@ -2012,6 +2012,30 @@ describe('FrigateCamera', () => {
|
|||||||
|
|
||||||
expect(camera.getConfig().triggers.entities).toEqual([]);
|
expect(camera.getConfig().triggers.entities).toEqual([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should throw when camera_entity is configured but registry has no match', async () => {
|
||||||
|
const camera = new FrigateCamera(
|
||||||
|
createCameraConfig({
|
||||||
|
camera_entity: 'camera.front_door',
|
||||||
|
frigate: {
|
||||||
|
camera_name: 'front_door',
|
||||||
|
},
|
||||||
|
triggers: {
|
||||||
|
motion: true,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
mock<CameraManagerEngine>(),
|
||||||
|
);
|
||||||
|
await expect(
|
||||||
|
camera.initialize({
|
||||||
|
hass: createHASS(),
|
||||||
|
entityRegistryManager: new EntityRegistryManagerMock(),
|
||||||
|
stateWatcher: mock<StateWatcher>(),
|
||||||
|
frigateEventWatcher: mock<FrigateEventWatcher>(),
|
||||||
|
frigateReviewWatcher: mock<FrigateReviewWatcher>(),
|
||||||
|
}),
|
||||||
|
).rejects.toThrowError(/Could not find camera entity/);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('should detect occupancy sensor', () => {
|
describe('should detect occupancy sensor', () => {
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ vi.mock('lodash-es', async () => ({
|
|||||||
const baseTriggersConfig: TriggersOptions = {
|
const baseTriggersConfig: TriggersOptions = {
|
||||||
untrigger_delay_seconds: 10,
|
untrigger_delay_seconds: 10,
|
||||||
untrigger_force_seconds: 0,
|
untrigger_force_seconds: 0,
|
||||||
|
signal_hold_seconds: 0,
|
||||||
filter_selected_camera: false,
|
filter_selected_camera: false,
|
||||||
show_trigger_status: false,
|
show_trigger_status: false,
|
||||||
actions: {
|
actions: {
|
||||||
@@ -884,6 +885,69 @@ describe('TriggersManager', () => {
|
|||||||
expect(manager.isTriggered()).toBeTruthy();
|
expect(manager.isTriggered()).toBeTruthy();
|
||||||
expect(api.getViewManager().setViewDefaultWithNewQuery).not.toBeCalled();
|
expect(api.getViewManager().setViewDefaultWithNewQuery).not.toBeCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should add signal_hold_seconds on top of untrigger_delay_seconds for signals', async () => {
|
||||||
|
const api = createTriggerAPI({
|
||||||
|
config: {
|
||||||
|
untrigger_delay_seconds: 5,
|
||||||
|
signal_hold_seconds: 30,
|
||||||
|
actions: { trigger: 'none', untrigger: 'default' },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const manager = new TriggersManager(api);
|
||||||
|
|
||||||
|
await manager.handleCameraEvent({
|
||||||
|
cameraID: 'camera_1',
|
||||||
|
id: 'event.doorbell',
|
||||||
|
type: 'signal',
|
||||||
|
});
|
||||||
|
|
||||||
|
// At 34s the additive 35s window is still active.
|
||||||
|
vi.setSystemTime(add(start, { seconds: 34 }));
|
||||||
|
vi.advanceTimersByTime(34_000);
|
||||||
|
await flushPromises();
|
||||||
|
expect(manager.isTriggered()).toBeTruthy();
|
||||||
|
|
||||||
|
// At 35s the window expires and untrigger fires.
|
||||||
|
vi.setSystemTime(add(start, { seconds: 35 }));
|
||||||
|
vi.advanceTimersByTime(1_000);
|
||||||
|
await flushPromises();
|
||||||
|
expect(manager.isTriggered()).toBeFalsy();
|
||||||
|
expect(api.getViewManager().setViewDefaultWithNewQuery).toBeCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not apply signal_hold_seconds to non-signal events', async () => {
|
||||||
|
const api = createTriggerAPI({
|
||||||
|
config: {
|
||||||
|
untrigger_delay_seconds: 5,
|
||||||
|
signal_hold_seconds: 30,
|
||||||
|
actions: { trigger: 'none', untrigger: 'default' },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const manager = new TriggersManager(api);
|
||||||
|
|
||||||
|
await manager.handleCameraEvent({
|
||||||
|
cameraID: 'camera_1',
|
||||||
|
id: 'binary_sensor.motion',
|
||||||
|
type: 'new',
|
||||||
|
});
|
||||||
|
await manager.handleCameraEvent({
|
||||||
|
cameraID: 'camera_1',
|
||||||
|
id: 'binary_sensor.motion',
|
||||||
|
type: 'end',
|
||||||
|
});
|
||||||
|
|
||||||
|
// Only untrigger_delay_seconds (5s) applies to a stateful end event.
|
||||||
|
vi.setSystemTime(add(start, { seconds: 4 }));
|
||||||
|
vi.advanceTimersByTime(4_000);
|
||||||
|
await flushPromises();
|
||||||
|
expect(manager.isTriggered()).toBeTruthy();
|
||||||
|
|
||||||
|
vi.setSystemTime(add(start, { seconds: 5 }));
|
||||||
|
vi.advanceTimersByTime(1_000);
|
||||||
|
await flushPromises();
|
||||||
|
expect(manager.isTriggered()).toBeFalsy();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('condition state management', () => {
|
describe('condition state management', () => {
|
||||||
|
|||||||
@@ -54,6 +54,7 @@ describe('config defaults', () => {
|
|||||||
media_resolution: 'low',
|
media_resolution: 'low',
|
||||||
},
|
},
|
||||||
triggers: {
|
triggers: {
|
||||||
|
doorbell: false,
|
||||||
entities: [],
|
entities: [],
|
||||||
events: [],
|
events: [],
|
||||||
motion: false,
|
motion: false,
|
||||||
@@ -549,6 +550,7 @@ describe('config defaults', () => {
|
|||||||
},
|
},
|
||||||
filter_selected_camera: true,
|
filter_selected_camera: true,
|
||||||
show_trigger_status: false,
|
show_trigger_status: false,
|
||||||
|
signal_hold_seconds: 30,
|
||||||
untrigger_delay_seconds: 0,
|
untrigger_delay_seconds: 0,
|
||||||
untrigger_force_seconds: 0,
|
untrigger_force_seconds: 0,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -282,6 +282,7 @@ export const createStore = (
|
|||||||
cameraProps.engine ??
|
cameraProps.engine ??
|
||||||
new GenericCameraManagerEngine(
|
new GenericCameraManagerEngine(
|
||||||
mock<StateWatcherSubscriptionInterface>(),
|
mock<StateWatcherSubscriptionInterface>(),
|
||||||
|
mock<EntityRegistryManager>(),
|
||||||
eventCallback,
|
eventCallback,
|
||||||
),
|
),
|
||||||
{ eventCallback, capabilities },
|
{ eventCallback, capabilities },
|
||||||
|
|||||||
Reference in New Issue
Block a user