feat: Support HA event entities as triggers (#2506)
This commit is contained in:
committed by
dermotduffy
parent
f1fad59a89
commit
4b72fcd629
@@ -474,6 +474,77 @@ describe('Camera', () => {
|
||||
},
|
||||
);
|
||||
|
||||
it('should not dispatch when the helper returns null', async () => {
|
||||
vi.spyOn(global.console, 'warn').mockReturnValue(undefined);
|
||||
|
||||
const eventCallback = vi.fn();
|
||||
const camera = new Camera(
|
||||
createCameraConfig({
|
||||
id: 'camera_1',
|
||||
triggers: {
|
||||
entities: ['event.front_door_doorbell'],
|
||||
},
|
||||
}),
|
||||
new GenericCameraManagerEngine(mock<StateWatcherSubscriptionInterface>()),
|
||||
{
|
||||
eventCallback: eventCallback,
|
||||
},
|
||||
);
|
||||
|
||||
const stateWatcher = mock<StateWatcherSubscriptionInterface>();
|
||||
await camera.initialize({
|
||||
hass: createHASS(),
|
||||
stateWatcher: stateWatcher,
|
||||
capabilityOptions: { capabilities: createCapabilities({ trigger: true }) },
|
||||
});
|
||||
|
||||
callStateWatcherCallback(stateWatcher, {
|
||||
entityID: 'event.front_door_doorbell',
|
||||
oldState: createStateEntity({ state: 'unavailable' }),
|
||||
newState: createStateEntity({ state: '2026-05-24T12:00:05.123+00:00' }),
|
||||
});
|
||||
|
||||
expect(eventCallback).not.toBeCalled();
|
||||
});
|
||||
|
||||
it('should dispatch a signal for an event entity fire', async () => {
|
||||
vi.spyOn(global.console, 'warn').mockReturnValue(undefined);
|
||||
|
||||
const eventCallback = vi.fn();
|
||||
const camera = new Camera(
|
||||
createCameraConfig({
|
||||
id: 'camera_1',
|
||||
triggers: {
|
||||
entities: ['event.front_door_doorbell'],
|
||||
},
|
||||
}),
|
||||
new GenericCameraManagerEngine(mock<StateWatcherSubscriptionInterface>()),
|
||||
{
|
||||
eventCallback: eventCallback,
|
||||
},
|
||||
);
|
||||
|
||||
const stateWatcher = mock<StateWatcherSubscriptionInterface>();
|
||||
await camera.initialize({
|
||||
hass: createHASS(),
|
||||
stateWatcher: stateWatcher,
|
||||
capabilityOptions: { capabilities: createCapabilities({ trigger: true }) },
|
||||
});
|
||||
|
||||
callStateWatcherCallback(stateWatcher, {
|
||||
entityID: 'event.front_door_doorbell',
|
||||
oldState: createStateEntity({ state: '2026-05-24T12:00:00.000+00:00' }),
|
||||
newState: createStateEntity({ state: '2026-05-24T12:00:05.123+00:00' }),
|
||||
});
|
||||
|
||||
expect(eventCallback).toBeCalledTimes(1);
|
||||
expect(eventCallback).toBeCalledWith({
|
||||
cameraID: 'camera_1',
|
||||
id: 'event.front_door_doorbell',
|
||||
type: 'signal',
|
||||
});
|
||||
});
|
||||
|
||||
it('should not trigger without trigger capability', async () => {
|
||||
const eventCallback = vi.fn();
|
||||
const camera = new Camera(
|
||||
|
||||
@@ -808,6 +808,82 @@ describe('TriggersManager', () => {
|
||||
expect(manager.isTriggered()).toBeFalsy();
|
||||
expect(api.getViewManager().setViewDefaultWithNewQuery).toBeCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should auto-untrigger after the delay on a signal event', async () => {
|
||||
const api = createTriggerAPI({
|
||||
config: {
|
||||
actions: { trigger: 'none', untrigger: 'default' },
|
||||
},
|
||||
});
|
||||
const manager = new TriggersManager(api);
|
||||
|
||||
await manager.handleCameraEvent({
|
||||
cameraID: 'camera_1',
|
||||
id: 'event.doorbell',
|
||||
type: 'signal',
|
||||
});
|
||||
|
||||
expect(manager.isTriggered()).toBeTruthy();
|
||||
expect(api.getViewManager().setViewDefaultWithNewQuery).not.toBeCalled();
|
||||
|
||||
vi.setSystemTime(add(start, { seconds: 10 }));
|
||||
vi.runOnlyPendingTimers();
|
||||
await flushPromises();
|
||||
|
||||
expect(manager.isTriggered()).toBeFalsy();
|
||||
expect(api.getViewManager().setViewDefaultWithNewQuery).toBeCalled();
|
||||
});
|
||||
|
||||
it('should auto-untrigger immediately on a signal when delay is 0', async () => {
|
||||
const api = createTriggerAPI({
|
||||
config: {
|
||||
untrigger_delay_seconds: 0,
|
||||
actions: { trigger: 'none', untrigger: 'default' },
|
||||
},
|
||||
});
|
||||
const manager = new TriggersManager(api);
|
||||
|
||||
await manager.handleCameraEvent({
|
||||
cameraID: 'camera_1',
|
||||
id: 'event.doorbell',
|
||||
type: 'signal',
|
||||
});
|
||||
await flushPromises();
|
||||
|
||||
expect(manager.isTriggered()).toBeFalsy();
|
||||
expect(api.getViewManager().setViewDefaultWithNewQuery).toBeCalled();
|
||||
});
|
||||
|
||||
it('should not auto-untrigger from a signal while a continuous source remains active', async () => {
|
||||
const api = createTriggerAPI({
|
||||
config: {
|
||||
actions: { trigger: 'none', untrigger: 'default' },
|
||||
},
|
||||
});
|
||||
const manager = new TriggersManager(api);
|
||||
|
||||
// Continuous source comes on first.
|
||||
await manager.handleCameraEvent({
|
||||
cameraID: 'camera_1',
|
||||
id: 'binary_sensor.motion',
|
||||
type: 'new',
|
||||
});
|
||||
|
||||
// Signal fires while motion is still on.
|
||||
await manager.handleCameraEvent({
|
||||
cameraID: 'camera_1',
|
||||
id: 'event.doorbell',
|
||||
type: 'signal',
|
||||
});
|
||||
|
||||
vi.setSystemTime(add(start, { seconds: 10 }));
|
||||
vi.runOnlyPendingTimers();
|
||||
await flushPromises();
|
||||
|
||||
// Continuous source keeps the trigger alive.
|
||||
expect(manager.isTriggered()).toBeTruthy();
|
||||
expect(api.getViewManager().setViewDefaultWithNewQuery).not.toBeCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('condition state management', () => {
|
||||
@@ -1164,6 +1240,56 @@ describe('TriggersManager', () => {
|
||||
});
|
||||
expect(manager.isTriggered()).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should not reset the untrigger timer when a filtered-out signal arrives', async () => {
|
||||
// Guards the `if (handled)` branch on the signal synthesis: a
|
||||
// filter-rejected signal must not call the internal 'end', which would
|
||||
// otherwise reset an already-running untrigger-delay timer.
|
||||
const api = createTriggerAPI({
|
||||
config: {
|
||||
filter_selected_camera: true,
|
||||
actions: { trigger: 'none', untrigger: 'default' },
|
||||
},
|
||||
});
|
||||
const manager = new TriggersManager(api);
|
||||
|
||||
// Trigger camera_1 normally, then end it -- starts the delay timer.
|
||||
await manager.handleCameraEvent({
|
||||
cameraID: 'camera_1',
|
||||
id: 'binary_sensor.motion',
|
||||
type: 'new',
|
||||
});
|
||||
await manager.handleCameraEvent({
|
||||
cameraID: 'camera_1',
|
||||
id: 'binary_sensor.motion',
|
||||
type: 'end',
|
||||
});
|
||||
expect(manager.isTriggered()).toBeTruthy();
|
||||
|
||||
// 5s in, switch the view to an unrelated camera so the filter
|
||||
// will reject events for camera_1.
|
||||
vi.setSystemTime(add(start, { seconds: 5 }));
|
||||
vi.mocked(api.getViewManager().getView).mockReturnValue(
|
||||
createView({ camera: 'camera_OTHER' as const }),
|
||||
);
|
||||
|
||||
// Signal for camera_1 -- rejected by the filter.
|
||||
await manager.handleCameraEvent({
|
||||
cameraID: 'camera_1',
|
||||
id: 'event.doorbell',
|
||||
type: 'signal',
|
||||
});
|
||||
|
||||
// At t=10 (the original delay's deadline), the timer should fire.
|
||||
// If the guard were missing, the internal 'end' would have restarted
|
||||
// the timer at t=5, so it'd still be triggered here.
|
||||
vi.setSystemTime(add(start, { seconds: 10 }));
|
||||
vi.runOnlyPendingTimers();
|
||||
await flushPromises();
|
||||
|
||||
expect(manager.isTriggered()).toBeFalsy();
|
||||
expect(api.getViewManager().setViewDefaultWithNewQuery).toBeCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('should handle initial camera triggers', () => {
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { getTriggerEventType } from '../../src/ha/get-trigger-event-type';
|
||||
import { createStateEntity } from '../test-utils';
|
||||
|
||||
describe('getTriggerEventType', () => {
|
||||
describe('for non-event entities', () => {
|
||||
it.each([['on'], ['open'], ['unlocked']])(
|
||||
'returns "new" when the new state is %s',
|
||||
(state: string) => {
|
||||
expect(
|
||||
getTriggerEventType({
|
||||
entityID: 'binary_sensor.motion',
|
||||
oldState: createStateEntity({ state: 'off' }),
|
||||
newState: createStateEntity({ state }),
|
||||
}),
|
||||
).toBe('new');
|
||||
},
|
||||
);
|
||||
|
||||
it.each([
|
||||
['off'],
|
||||
['closed'],
|
||||
['locked'],
|
||||
['unavailable'],
|
||||
['unknown'],
|
||||
['anything-else'],
|
||||
])('returns "end" when the new state is %s', (state: string) => {
|
||||
expect(
|
||||
getTriggerEventType({
|
||||
entityID: 'binary_sensor.motion',
|
||||
oldState: createStateEntity({ state: 'on' }),
|
||||
newState: createStateEntity({ state }),
|
||||
}),
|
||||
).toBe('end');
|
||||
});
|
||||
});
|
||||
|
||||
describe('for event entities', () => {
|
||||
it('returns "signal" for a transition between two real timestamps', () => {
|
||||
expect(
|
||||
getTriggerEventType({
|
||||
entityID: 'event.front_door_doorbell',
|
||||
oldState: createStateEntity({ state: '2026-05-24T12:00:00.000+00:00' }),
|
||||
newState: createStateEntity({ state: '2026-05-24T12:00:05.123+00:00' }),
|
||||
}),
|
||||
).toBe('signal');
|
||||
});
|
||||
|
||||
it('returns null when there is no old state', () => {
|
||||
expect(
|
||||
getTriggerEventType({
|
||||
entityID: 'event.front_door_doorbell',
|
||||
newState: createStateEntity({ state: '2026-05-24T12:00:05.123+00:00' }),
|
||||
}),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null when the old state is unavailable (entity reconnecting)', () => {
|
||||
expect(
|
||||
getTriggerEventType({
|
||||
entityID: 'event.front_door_doorbell',
|
||||
oldState: createStateEntity({ state: 'unavailable' }),
|
||||
newState: createStateEntity({ state: '2026-05-24T12:00:05.123+00:00' }),
|
||||
}),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it('returns "signal" when the old state is unknown (first fire after startup)', () => {
|
||||
expect(
|
||||
getTriggerEventType({
|
||||
entityID: 'event.front_door_doorbell',
|
||||
oldState: createStateEntity({ state: 'unknown' }),
|
||||
newState: createStateEntity({ state: '2026-05-24T12:00:05.123+00:00' }),
|
||||
}),
|
||||
).toBe('signal');
|
||||
});
|
||||
|
||||
it.each([['unavailable'], ['unknown']])(
|
||||
'returns null when the new state is %s',
|
||||
(state: string) => {
|
||||
expect(
|
||||
getTriggerEventType({
|
||||
entityID: 'event.front_door_doorbell',
|
||||
oldState: createStateEntity({ state: '2026-05-24T12:00:00.000+00:00' }),
|
||||
newState: createStateEntity({ state }),
|
||||
}),
|
||||
).toBeNull();
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user