feat: Add automatic doorbell detection (#2507)
This commit is contained in:
committed by
dermotduffy
parent
4b72fcd629
commit
773cee95a5
@@ -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', () => {
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
@@ -282,6 +282,7 @@ export const createStore = (
|
||||
cameraProps.engine ??
|
||||
new GenericCameraManagerEngine(
|
||||
mock<StateWatcherSubscriptionInterface>(),
|
||||
mock<EntityRegistryManager>(),
|
||||
eventCallback,
|
||||
),
|
||||
{ eventCallback, capabilities },
|
||||
|
||||
Reference in New Issue
Block a user