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
+199
View File
@@ -5,12 +5,14 @@ import { GenericCameraManagerEngine } from '../../src/camera-manager/generic/eng
import { CameraProxyConfig } from '../../src/camera-manager/types.js';
import { StateWatcherSubscriptionInterface } from '../../src/card-controller/hass/state-watcher.js';
import { liveProviderSupports2WayAudio } from '../../src/utils/live-provider.js';
import { EntityRegistryManagerMock } from '../ha/registry/entity/mock.js';
import {
callStateWatcherCallback,
createCameraConfig,
createCapabilities,
createHASS,
createInitializedCamera,
createRegistryEntity,
createStateEntity,
} from '../test-utils.js';
@@ -421,6 +423,203 @@ describe('Camera', () => {
expect(liveProviderSupports2WayAudio).toHaveBeenCalled();
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', () => {
@@ -2012,6 +2012,30 @@ describe('FrigateCamera', () => {
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', () => {
@@ -25,6 +25,7 @@ vi.mock('lodash-es', async () => ({
const baseTriggersConfig: TriggersOptions = {
untrigger_delay_seconds: 10,
untrigger_force_seconds: 0,
signal_hold_seconds: 0,
filter_selected_camera: false,
show_trigger_status: false,
actions: {
@@ -884,6 +885,69 @@ describe('TriggersManager', () => {
expect(manager.isTriggered()).toBeTruthy();
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', () => {
+2
View File
@@ -54,6 +54,7 @@ describe('config defaults', () => {
media_resolution: 'low',
},
triggers: {
doorbell: false,
entities: [],
events: [],
motion: false,
@@ -549,6 +550,7 @@ describe('config defaults', () => {
},
filter_selected_camera: true,
show_trigger_status: false,
signal_hold_seconds: 30,
untrigger_delay_seconds: 0,
untrigger_force_seconds: 0,
},
+1
View File
@@ -282,6 +282,7 @@ export const createStore = (
cameraProps.engine ??
new GenericCameraManagerEngine(
mock<StateWatcherSubscriptionInterface>(),
mock<EntityRegistryManager>(),
eventCallback,
),
{ eventCallback, capabilities },