feat: Trigger cameras on HA bus events (#2512)

This commit is contained in:
Dermot Duffy
2026-06-30 17:45:13 -07:00
committed by dermotduffy
parent 406176eebc
commit 0a36358394
49 changed files with 1376 additions and 269 deletions
@@ -0,0 +1,133 @@
import { HassEvent } from 'home-assistant-js-websocket';
import { describe, expect, it, vi } from 'vitest';
import { EventWatcher } from '../../../src/card-controller/hass/event-watcher';
import { HomeAssistant } from '../../../src/ha/types';
import { createHASS } from '../../test-utils';
const fireEvent = (hass: HomeAssistant, event: HassEvent, n = 0): void => {
const mock = vi.mocked(hass.connection.subscribeEvents).mock;
expect(mock.calls.length).greaterThan(n);
// subscribeEvents(callback, event_type) -- callback is the first argument.
mock.calls[n][0]?.(event);
};
const createHassEvent = (event_type: string, data: object = {}): HassEvent => ({
event_type,
data: data as { [key: string]: string },
origin: 'LOCAL',
time_fired: '2026-05-25T00:00:00Z',
context: { id: 'ctx', user_id: null, parent_id: null },
});
describe('EventWatcher', () => {
it('opens a single WS subscription per event_type regardless of subscribers', async () => {
const watcher = new EventWatcher();
const hass = createHASS();
await watcher.subscribe(hass, { event_type: 'zha_event', callback: vi.fn() });
await watcher.subscribe(hass, { event_type: 'zha_event', callback: vi.fn() });
expect(hass.connection.subscribeEvents).toBeCalledTimes(1);
expect(vi.mocked(hass.connection.subscribeEvents).mock.calls[0][1]).toBe(
'zha_event',
);
});
it('opens separate WS subscriptions for distinct event_types', async () => {
const watcher = new EventWatcher();
const hass = createHASS();
await watcher.subscribe(hass, { event_type: 'zha_event', callback: vi.fn() });
await watcher.subscribe(hass, { event_type: 'deconz_event', callback: vi.fn() });
expect(hass.connection.subscribeEvents).toBeCalledTimes(2);
});
it('only tears down the WS subscription when the last subscriber unsubscribes', async () => {
const watcher = new EventWatcher();
const hass = createHASS();
const unsub = vi.fn();
vi.mocked(hass.connection.subscribeEvents).mockResolvedValue(unsub);
const req1 = { event_type: 'zha_event', callback: vi.fn() };
const req2 = { event_type: 'zha_event', callback: vi.fn() };
await watcher.subscribe(hass, req1);
await watcher.subscribe(hass, req2);
await watcher.unsubscribe(req1);
expect(unsub).not.toBeCalled();
await watcher.unsubscribe(req2);
expect(unsub).toBeCalledTimes(1);
});
it('dispatches to all subscribers whose event_type matches', async () => {
const watcher = new EventWatcher();
const hass = createHASS();
const cb1 = vi.fn();
const cb2 = vi.fn();
await watcher.subscribe(hass, { event_type: 'zha_event', callback: cb1 });
await watcher.subscribe(hass, { event_type: 'zha_event', callback: cb2 });
fireEvent(hass, createHassEvent('zha_event', { command: 'press' }));
expect(cb1).toBeCalledWith({ command: 'press' });
expect(cb2).toBeCalledWith({ command: 'press' });
});
it('drops events whose event_type does not match the request', async () => {
const watcher = new EventWatcher();
const hass = createHASS();
const cb = vi.fn();
await watcher.subscribe(hass, { event_type: 'zha_event', callback: cb });
// Inject an unrelated event into the shared dispatcher.
fireEvent(hass, createHassEvent('other_event', { x: 1 }));
expect(cb).not.toBeCalled();
});
it('gates dispatch on the request matcher when provided', async () => {
const watcher = new EventWatcher();
const hass = createHASS();
const cb = vi.fn();
const matcher = vi.fn((data: unknown) => (data as { x?: number }).x === 1);
await watcher.subscribe(hass, { event_type: 'zha_event', matcher, callback: cb });
fireEvent(hass, createHassEvent('zha_event', { x: 1 }));
fireEvent(hass, createHassEvent('zha_event', { x: 2 }));
expect(matcher).toBeCalledTimes(2);
expect(cb).toBeCalledTimes(1);
expect(cb).toBeCalledWith({ x: 1 });
});
it('handles unsubscribe during a still-pending subscribe without leaking', async () => {
const watcher = new EventWatcher();
const hass = createHASS();
const unsub = vi.fn();
let resolveSubscription: ((cb: () => Promise<void>) => void) | undefined;
const subscriptionPromise = new Promise<() => Promise<void>>((resolve) => {
resolveSubscription = resolve;
});
vi.mocked(hass.connection.subscribeEvents).mockReturnValue(subscriptionPromise);
const req = { event_type: 'zha_event', callback: vi.fn() };
const subscribePromise = watcher.subscribe(hass, req);
// Unsubscribe before the underlying connection has resolved.
const unsubscribePromise = watcher.unsubscribe(req);
// Resolve the connection -- the watcher should now have the unsub fn and
// call it as part of completing the unsubscribe.
resolveSubscription?.(unsub);
await subscribePromise;
await unsubscribePromise;
expect(unsub).toBeCalledTimes(1);
});
});
@@ -1,5 +1,6 @@
import { STATE_RUNNING, STATE_STARTING } from 'home-assistant-js-websocket';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { EventWatcher } from '../../../src/card-controller/hass/event-watcher';
import { HASSManager } from '../../../src/card-controller/hass/hass-manager';
import { StateWatcher } from '../../../src/card-controller/hass/state-watcher';
import {
@@ -29,6 +30,11 @@ describe('HASSManager', () => {
expect(manager.getStateWatcher()).toEqual(expect.any(StateWatcher));
});
it('should get event watcher', () => {
const manager = new HASSManager(createCardAPI());
expect(manager.getEventWatcher()).toEqual(expect.any(EventWatcher));
});
it('should get hass after set', () => {
const manager = new HASSManager(createCardAPI());
const hass = createHASS();
+16 -16
View File
@@ -25,7 +25,7 @@ vi.mock('lodash-es', async () => ({
const baseTriggersConfig: TriggersOptions = {
untrigger_delay_seconds: 10,
untrigger_force_seconds: 0,
signal_hold_seconds: 0,
event_hold_seconds: 0,
filter_selected_camera: false,
show_trigger_status: false,
actions: {
@@ -810,7 +810,7 @@ describe('TriggersManager', () => {
expect(api.getViewManager().setViewDefaultWithNewQuery).toBeCalledTimes(1);
});
it('should auto-untrigger after the delay on a signal event', async () => {
it('should auto-untrigger after the delay on a momentary event', async () => {
const api = createTriggerAPI({
config: {
actions: { trigger: 'none', untrigger: 'default' },
@@ -821,7 +821,7 @@ describe('TriggersManager', () => {
await manager.handleCameraEvent({
cameraID: 'camera_1',
id: 'event.doorbell',
type: 'signal',
type: 'momentary',
});
expect(manager.isTriggered()).toBeTruthy();
@@ -835,7 +835,7 @@ describe('TriggersManager', () => {
expect(api.getViewManager().setViewDefaultWithNewQuery).toBeCalled();
});
it('should auto-untrigger immediately on a signal when delay is 0', async () => {
it('should auto-untrigger immediately on a momentary event when delay is 0', async () => {
const api = createTriggerAPI({
config: {
untrigger_delay_seconds: 0,
@@ -847,7 +847,7 @@ describe('TriggersManager', () => {
await manager.handleCameraEvent({
cameraID: 'camera_1',
id: 'event.doorbell',
type: 'signal',
type: 'momentary',
});
await flushPromises();
@@ -855,7 +855,7 @@ describe('TriggersManager', () => {
expect(api.getViewManager().setViewDefaultWithNewQuery).toBeCalled();
});
it('should not auto-untrigger from a signal while a continuous source remains active', async () => {
it('should not auto-untrigger from a momentary event while a continuous source remains active', async () => {
const api = createTriggerAPI({
config: {
actions: { trigger: 'none', untrigger: 'default' },
@@ -874,7 +874,7 @@ describe('TriggersManager', () => {
await manager.handleCameraEvent({
cameraID: 'camera_1',
id: 'event.doorbell',
type: 'signal',
type: 'momentary',
});
vi.setSystemTime(add(start, { seconds: 10 }));
@@ -886,11 +886,11 @@ describe('TriggersManager', () => {
expect(api.getViewManager().setViewDefaultWithNewQuery).not.toBeCalled();
});
it('should add signal_hold_seconds on top of untrigger_delay_seconds for signals', async () => {
it('should add event_hold_seconds on top of untrigger_delay_seconds for momentary events', async () => {
const api = createTriggerAPI({
config: {
untrigger_delay_seconds: 5,
signal_hold_seconds: 30,
event_hold_seconds: 30,
actions: { trigger: 'none', untrigger: 'default' },
},
});
@@ -899,7 +899,7 @@ describe('TriggersManager', () => {
await manager.handleCameraEvent({
cameraID: 'camera_1',
id: 'event.doorbell',
type: 'signal',
type: 'momentary',
});
// At 34s the additive 35s window is still active.
@@ -916,11 +916,11 @@ describe('TriggersManager', () => {
expect(api.getViewManager().setViewDefaultWithNewQuery).toBeCalled();
});
it('should not apply signal_hold_seconds to non-signal events', async () => {
it('should not apply event_hold_seconds to non-momentary events', async () => {
const api = createTriggerAPI({
config: {
untrigger_delay_seconds: 5,
signal_hold_seconds: 30,
event_hold_seconds: 30,
actions: { trigger: 'none', untrigger: 'default' },
},
});
@@ -1305,9 +1305,9 @@ 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
it('should not reset the untrigger timer when a filtered-out momentary event arrives', async () => {
// Guards the `if (handled)` branch on the momentary-event synthesis: a
// filter-rejected momentary event must not call the internal 'end', which would
// otherwise reset an already-running untrigger-delay timer.
const api = createTriggerAPI({
config: {
@@ -1341,7 +1341,7 @@ describe('TriggersManager', () => {
await manager.handleCameraEvent({
cameraID: 'camera_1',
id: 'event.doorbell',
type: 'signal',
type: 'momentary',
});
// At t=10 (the original delay's deadline), the timer should fire.