feat: Add event-based automation triggers (#2537)

This commit is contained in:
Dermot Duffy
2026-06-30 17:45:13 -07:00
committed by dermotduffy
parent b701366762
commit a31816c168
109 changed files with 4607 additions and 1623 deletions
+150 -84
View File
@@ -1,32 +1,38 @@
import { HassEvent } from 'home-assistant-js-websocket';
import { describe, expect, it, vi } from 'vitest';
import { Connection, HassEvent } from 'home-assistant-js-websocket';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { mock } from 'vitest-mock-extended';
import { EventWatcher } from '../../../src/card-controller/hass/event-watcher';
import { HomeAssistant } from '../../../src/ha/types';
import { createHASS } from '../../test-utils';
import {
createHASS,
createHASSEvent,
createHASSSource,
flushPromises,
useDeterministicTimers,
} from '../../test-utils';
// Drive the dispatcher registered with `hass.connection.subscribeEvents` to
// simulate an event arriving over the WS bus.
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 },
});
// @vitest-environment jsdom
describe('EventWatcher', () => {
it('opens a single WS subscription per event_type regardless of subscribers', async () => {
const watcher = new EventWatcher();
const hass = createHASS();
afterEach(() => {
vi.useRealTimers();
vi.restoreAllMocks();
});
await watcher.subscribe(hass, { event_type: 'zha_event', callback: vi.fn() });
await watcher.subscribe(hass, { event_type: 'zha_event', callback: vi.fn() });
it('should open a WS subscription keyed by event_type', async () => {
const hass = createHASS();
const { source } = createHASSSource(hass);
const watcher = new EventWatcher(source);
watcher.subscribe({ event_type: 'zha_event', callback: vi.fn() });
await flushPromises();
expect(hass.connection.subscribeEvents).toBeCalledTimes(1);
expect(vi.mocked(hass.connection.subscribeEvents).mock.calls[0][1]).toBe(
@@ -34,100 +40,160 @@ describe('EventWatcher', () => {
);
});
it('opens separate WS subscriptions for distinct event_types', async () => {
const watcher = new EventWatcher();
it('should share one WS subscription across subscribers with the same event_type', async () => {
const hass = createHASS();
const { source } = createHASSSource(hass);
const watcher = new EventWatcher(source);
await watcher.subscribe(hass, { event_type: 'zha_event', callback: vi.fn() });
await watcher.subscribe(hass, { event_type: 'deconz_event', callback: vi.fn() });
watcher.subscribe({ event_type: 'zha_event', callback: vi.fn() });
watcher.subscribe({ event_type: 'zha_event', callback: vi.fn() });
await flushPromises();
expect(hass.connection.subscribeEvents).toBeCalledTimes(1);
});
it('should open separate WS subscriptions for distinct event_types', async () => {
const hass = createHASS();
const { source } = createHASSSource(hass);
const watcher = new EventWatcher(source);
watcher.subscribe({ event_type: 'zha_event', callback: vi.fn() });
watcher.subscribe({ event_type: 'deconz_event', callback: vi.fn() });
await flushPromises();
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();
it('should dispatch to every subscriber whose event_type matches', async () => {
const hass = createHASS();
const { source } = createHASSSource(hass);
const watcher = new EventWatcher(source);
const cb1 = vi.fn();
const cb2 = vi.fn();
const event = createHASSEvent('zha_event', { command: 'press' });
await watcher.subscribe(hass, { event_type: 'zha_event', callback: cb1 });
await watcher.subscribe(hass, { event_type: 'zha_event', callback: cb2 });
watcher.subscribe({ event_type: 'zha_event', callback: cb1 });
watcher.subscribe({ event_type: 'zha_event', callback: cb2 });
await flushPromises();
fireEvent(hass, createHassEvent('zha_event', { command: 'press' }));
fireEvent(hass, event);
expect(cb1).toBeCalledWith({ command: 'press' });
expect(cb2).toBeCalledWith({ command: 'press' });
expect(cb1).toBeCalledWith(event);
expect(cb2).toBeCalledWith(event);
});
it('drops events whose event_type does not match the request', async () => {
const watcher = new EventWatcher();
it('should gate dispatch on the request matcher when provided', async () => {
const hass = createHASS();
const { source } = createHASSSource(hass);
const watcher = new EventWatcher(source);
const cb = vi.fn();
const matcher = vi.fn((event: HassEvent) => (event.data as { x?: number }).x === 1);
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 }));
const matching = createHASSEvent('zha_event', { x: 1 });
const nonMatching = createHASSEvent('zha_event', { x: 2 });
watcher.subscribe({ event_type: 'zha_event', matcher, callback: cb });
await flushPromises();
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 }));
fireEvent(hass, matching);
fireEvent(hass, nonMatching);
expect(matcher).toBeCalledTimes(2);
expect(cb).toBeCalledTimes(1);
expect(cb).toBeCalledWith({ x: 1 });
expect(cb).toBeCalledWith(matching);
});
it('handles unsubscribe during a still-pending subscribe without leaking', async () => {
const watcher = new EventWatcher();
it('should tear down the WS subscription only when the last subscriber unsubscribes', async () => {
const hass = createHASS();
const unsub = vi.fn();
vi.mocked(hass.connection.subscribeEvents).mockResolvedValue(unsub);
const { source } = createHASSSource(hass);
const watcher = new EventWatcher(source);
const req1 = { event_type: 'zha_event', callback: vi.fn() };
const req2 = { event_type: 'zha_event', callback: 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);
watcher.subscribe(req1);
watcher.subscribe(req2);
await flushPromises();
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;
watcher.unsubscribe(req1);
await flushPromises();
expect(unsub).not.toBeCalled();
watcher.unsubscribe(req2);
await flushPromises();
expect(unsub).toBeCalledTimes(1);
});
it('should drop events from an old-connection subscription after a swap', async () => {
const oldHass = createHASS();
const { source, push } = createHASSSource(oldHass);
const watcher = new EventWatcher(source);
const cb = vi.fn();
watcher.subscribe({ event_type: 'zha_event', callback: cb });
await flushPromises();
// Capture the dispatcher registered against the OLD connection BEFORE
// the swap, so it still points at the source-bound guard.
const oldDispatcher = vi.mocked(oldHass.connection.subscribeEvents).mock.calls[0][0];
const newHass = createHASS();
newHass.connection = mock<Connection>();
vi.mocked(newHass.connection.subscribeEvents).mockResolvedValue(vi.fn());
push(newHass);
await flushPromises();
// Old dispatcher fires: guard.isConnected() is now false, callback must NOT
// receive the event.
oldDispatcher?.(createHASSEvent('zha_event', { command: 'press' }));
expect(cb).not.toBeCalled();
});
it('should not dispatch to a subscriber that registers mid-dispatch', async () => {
const hass = createHASS();
const { source } = createHASSSource(hass);
const watcher = new EventWatcher(source);
const lateCallback = vi.fn();
const reentrantCallback = vi.fn(() => {
watcher.subscribe({ event_type: 'zha_event', callback: lateCallback });
});
watcher.subscribe({ event_type: 'zha_event', callback: reentrantCallback });
await flushPromises();
fireEvent(hass, createHASSEvent('zha_event', { command: 'press' }));
expect(reentrantCallback).toBeCalledTimes(1);
expect(lateCallback).not.toBeCalled();
});
describe('subscription health monitoring', () => {
it('should surface and retry failing subscriptions through getHealth', async () => {
useDeterministicTimers();
const hass = createHASS();
vi.mocked(hass.connection.subscribeEvents).mockRejectedValue(new Error('boom'));
const { source } = createHASSSource(hass);
const watcher = new EventWatcher(source);
watcher.subscribe({ event_type: 'zha_event', callback: vi.fn() });
await flushPromises();
expect(
watcher
.getHealth()
.getFailures()
.map((failure) => failure.key),
).toEqual(['zha_event']);
const before = vi.mocked(hass.connection.subscribeEvents).mock.calls.length;
watcher.getHealth().retry();
await flushPromises();
expect(vi.mocked(hass.connection.subscribeEvents).mock.calls.length).toBe(
before + 1,
);
});
});
});
+51 -16
View File
@@ -44,27 +44,62 @@ describe('HASSManager', () => {
expect(manager.hasHASS()).toBeTruthy();
});
it('should update theme upon setting hass', () => {
const api = createCardAPI();
const manager = new HASSManager(api);
describe('as a HASS source', () => {
it('should fan out to registered listeners with (hass, oldHass)', () => {
const manager = new HASSManager(createCardAPI());
const listener = vi.fn();
manager.addListener(listener);
manager.setHASS(createHASS());
const hass1 = createHASS();
manager.setHASS(hass1);
expect(listener).toBeCalledWith(hass1, null);
expect(api.getStyleManager().applyTheme).toBeCalled();
});
const hass2 = createHASS();
manager.setHASS(hass2);
expect(listener).toBeCalledWith(hass2, hass1);
});
it('should set condition manager state', () => {
const api = createCardAPI();
const manager = new HASSManager(api);
const hass = createHASS();
it('should call listeners in insertion order on every fan-out', () => {
const manager = new HASSManager(createCardAPI());
const order: string[] = [];
manager.addListener(() => order.push('first'));
manager.addListener(() => order.push('second'));
manager.setHASS(hass);
manager.setHASS(createHASS());
expect(api.getConditionStateManager().setState).toBeCalledWith(
expect.objectContaining({
hass: hass,
}),
);
expect(order).toEqual(['first', 'second']);
});
it('should detach a listener via the returned unlisten callback', () => {
const manager = new HASSManager(createCardAPI());
const listener = vi.fn();
const unlisten = manager.addListener(listener);
unlisten();
manager.setHASS(createHASS());
expect(listener).not.toBeCalled();
});
it('should not fan out on null/undefined hass', () => {
const manager = new HASSManager(createCardAPI());
const listener = vi.fn();
manager.addListener(listener);
manager.setHASS(null);
manager.setHASS();
expect(listener).not.toBeCalled();
});
it('should expose current hass via getHASS for source consumers', () => {
const manager = new HASSManager(createCardAPI());
expect(manager.getHASS()).toBeNull();
const hass = createHASS();
manager.setHASS(hass);
expect(manager.getHASS()).toBe(hass);
});
});
describe('should handle connection state change when', () => {
@@ -1,35 +1,61 @@
import { describe, expect, it, vi } from 'vitest';
import { StateWatcher } from '../../../src/card-controller/hass/state-watcher';
import { createHASS, createStateEntity } from '../../test-utils';
import { createHASS, createHASSSource, createStateEntity } from '../../test-utils';
describe('StateWatcher', () => {
it('should not subscribe with no entities', () => {
const stateWatcher = new StateWatcher();
const { source } = createHASSSource();
const stateWatcher = new StateWatcher(source);
expect(stateWatcher.subscribe(vi.fn(), [])).toBeFalsy();
});
it('should attach to the source lazily on first subscriber', () => {
const { source, getListenerCount } = createHASSSource(createHASS());
const stateWatcher = new StateWatcher(source);
expect(getListenerCount()).toBe(0);
stateWatcher.subscribe(vi.fn(), ['binary_sensor.foo']);
expect(getListenerCount()).toBe(1);
});
it('should stay attached while other subscribers remain', () => {
const { source, getListenerCount } = createHASSSource(createHASS());
const stateWatcher = new StateWatcher(source);
const cb1 = vi.fn();
const cb2 = vi.fn();
stateWatcher.subscribe(cb1, ['binary_sensor.foo']);
stateWatcher.subscribe(cb2, ['binary_sensor.bar']);
expect(getListenerCount()).toBe(1);
stateWatcher.unsubscribe(cb1);
expect(getListenerCount()).toBe(1);
});
it('should detach from the source when the last subscriber leaves', () => {
const { source, getListenerCount } = createHASSSource(createHASS());
const stateWatcher = new StateWatcher(source);
const callback = vi.fn();
expect(stateWatcher.subscribe(callback, [])).toBeFalsy();
stateWatcher.subscribe(callback, ['binary_sensor.foo']);
expect(getListenerCount()).toBe(1);
stateWatcher.unsubscribe(callback);
expect(getListenerCount()).toBe(0);
});
it('should call back with state change', () => {
const stateWatcher = new StateWatcher();
const initial = createHASS({
'binary_sensor.foo': createStateEntity({ state: 'on' }),
'binary_sensor.bar': createStateEntity({ state: 'off' }),
});
const { source, push } = createHASSSource(initial);
const stateWatcher = new StateWatcher(source);
const callback = vi.fn();
expect(stateWatcher.subscribe(callback, ['binary_sensor.foo'])).toBeTruthy();
expect(stateWatcher.subscribe(callback, ['binary_sensor.bar'])).toBeTruthy();
stateWatcher.setHASS(
null,
createHASS({
'binary_sensor.foo': createStateEntity({ state: 'on' }),
'binary_sensor.bar': createStateEntity({ state: 'off' }),
}),
);
expect(callback).not.toBeCalled();
stateWatcher.setHASS(
createHASS({
'binary_sensor.foo': createStateEntity({ state: 'on' }),
'binary_sensor.bar': createStateEntity({ state: 'off' }),
}),
push(
createHASS({
'binary_sensor.foo': createStateEntity({ state: 'on' }),
'binary_sensor.bar': createStateEntity({ state: 'on' }),
@@ -46,24 +72,33 @@ describe('StateWatcher', () => {
);
});
it('should not call back without state change', () => {
const stateWatcher = new StateWatcher();
it('should not call back when oldHass is null on first observed push', () => {
const { source, push } = createHASSSource(null);
const stateWatcher = new StateWatcher(source);
const callback = vi.fn();
expect(stateWatcher.subscribe(callback, ['binary_sensor.foo'])).toBeTruthy();
stateWatcher.setHASS(
null,
stateWatcher.subscribe(callback, ['binary_sensor.foo']);
push(
createHASS({
'binary_sensor.foo': createStateEntity({ state: 'on' }),
}),
);
expect(callback).not.toBeCalled();
});
stateWatcher.setHASS(
createHASS({
'binary_sensor.foo': createStateEntity({ state: 'on' }),
}),
it('should not call back without state change', () => {
const initial = createHASS({
'binary_sensor.foo': createStateEntity({ state: 'on' }),
});
const { source, push } = createHASSSource(initial);
const stateWatcher = new StateWatcher(source);
const callback = vi.fn();
expect(stateWatcher.subscribe(callback, ['binary_sensor.foo'])).toBeTruthy();
push(
createHASS({
'binary_sensor.foo': createStateEntity({ state: 'on' }),
}),
@@ -73,24 +108,17 @@ describe('StateWatcher', () => {
});
it('should not call back when unsubscribed', () => {
const stateWatcher = new StateWatcher();
const initial = createHASS({
'binary_sensor.foo': createStateEntity({ state: 'on' }),
});
const { source, push } = createHASSSource(initial);
const stateWatcher = new StateWatcher(source);
const callback = vi.fn();
expect(stateWatcher.subscribe(callback, ['binary_sensor.foo'])).toBeTruthy();
expect(stateWatcher.unsubscribe(callback));
stateWatcher.unsubscribe(callback);
stateWatcher.setHASS(
null,
createHASS({
'binary_sensor.foo': createStateEntity({ state: 'on' }),
}),
);
expect(callback).not.toBeCalled();
stateWatcher.setHASS(
createHASS({
'binary_sensor.foo': createStateEntity({ state: 'on' }),
}),
push(
createHASS({
'binary_sensor.foo': createStateEntity({ state: 'off' }),
}),