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
+87 -8
View File
@@ -1,4 +1,9 @@
import { HassEntities, HassEntity, STATE_RUNNING } from 'home-assistant-js-websocket';
import {
HassEntities,
HassEntity,
HassEvent,
STATE_RUNNING,
} from 'home-assistant-js-websocket';
import { LitElement } from 'lit';
import screenfull from 'screenfull';
import { expect, vi } from 'vitest';
@@ -41,6 +46,7 @@ import { FullscreenManager } from '../src/card-controller/fullscreen/fullscreen-
import { EventWatcherSubscriptionInterface } from '../src/card-controller/hass/event-watcher';
import { HASSManager } from '../src/card-controller/hass/hass-manager';
import { StateWatcherSubscriptionInterface } from '../src/card-controller/hass/state-watcher';
import { HASSManagerReadonlyInterface } from '../src/card-controller/hass/types';
import { InitializationManager } from '../src/card-controller/initialization-manager';
import { InteractionManager } from '../src/card-controller/interaction-manager';
import { IssueManager } from '../src/card-controller/issues/issue-manager';
@@ -77,6 +83,7 @@ import {
} from '../src/ha/browse-media/types';
import { Device } from '../src/ha/registry/device/types';
import { Entity, EntityRegistryManager } from '../src/ha/registry/entity/types';
import { HASSListener, HASSSource } from '../src/ha/source';
import { CurrentUser, HassStateDifference, HomeAssistant } from '../src/ha/types';
import { QuerySource } from '../src/query-source';
import { Severity } from '../src/severity';
@@ -124,9 +131,7 @@ export const createInitializedCamera = async (
): Promise<Camera> => {
const camera = new Camera(config, engine);
await camera.initialize({
hass: createHASS(),
stateWatcher: stateWatcher ?? mock<StateWatcherSubscriptionInterface>(),
eventWatcher: mock<EventWatcherSubscriptionInterface>(),
hassManager: createHASSManager({ stateWatcher }),
...(capabilities ? { capabilityOptions: { capabilities } } : {}),
});
return camera;
@@ -153,6 +158,60 @@ export const createHASS = (states?: HassEntities, user?: CurrentUser): HomeAssis
return hass;
};
/**
* Build a driveable HASSSource backed by a single `hass` value. The
* returned `push(hass)` synchronously updates the source's current hass and
* fans out to every registered listener with `(hass, oldHass)`.
*/
export const createHASSSource = (
initial?: HomeAssistant | null,
): {
source: HASSSource;
push: (hass: HomeAssistant) => void;
getListenerCount: () => number;
} => {
let current: HomeAssistant | null = initial ?? null;
const listeners = new Set<HASSListener>();
const source: HASSSource = {
getHASS: () => current,
addListener: (listener) => {
listeners.add(listener);
return () => {
listeners.delete(listener);
};
},
};
return {
source,
push: (hass) => {
const prev = current;
current = hass;
for (const l of listeners) {
l(hass, prev);
}
},
getListenerCount: () => listeners.size,
};
};
export const createHASSManager = (options?: {
hass?: HomeAssistant | null;
stateWatcher?: StateWatcherSubscriptionInterface;
eventWatcher?: EventWatcherSubscriptionInterface;
}): HASSManagerReadonlyInterface => {
const hassManager = mock<HASSManagerReadonlyInterface>();
hassManager.getHASS.mockReturnValue(
options?.hass === undefined ? createHASS() : options.hass,
);
hassManager.getStateWatcher.mockReturnValue(
options?.stateWatcher ?? mock<StateWatcherSubscriptionInterface>(),
);
hassManager.getEventWatcher.mockReturnValue(
options?.eventWatcher ?? mock<EventWatcherSubscriptionInterface>(),
);
return hassManager;
};
export const createUser = (user?: Partial<CurrentUser>): CurrentUser => ({
id: 'user',
is_owner: false,
@@ -285,8 +344,7 @@ export const createStore = (
cameraProps.config ?? createCameraConfig(),
cameraProps.engine ??
new GenericCameraManagerEngine(
mock<StateWatcherSubscriptionInterface>(),
mock<EventWatcherSubscriptionInterface>(),
createHASSManager(),
mock<EntityRegistryManager>(),
eventCallback,
),
@@ -744,14 +802,26 @@ export const callStateWatcherCallback = (
export const callEventWatcherCallback = (
eventWatcher: EventWatcherSubscriptionInterface,
data: unknown,
event: HassEvent,
n = 0,
): void => {
const mock = vi.mocked(eventWatcher.subscribe).mock;
expect(mock.calls.length).greaterThan(n);
mock.calls[n][1].callback(data);
mock.calls[n][0].callback(event);
};
export const createHASSEvent = (
event_type: string,
data: Record<string, unknown> = {},
context: HassEvent['context'] = { id: 'ctx', user_id: null, parent_id: null },
): HassEvent => ({
event_type,
data,
origin: 'LOCAL',
time_fired: '2026-06-19T21:12:18Z',
context,
});
/**
* Flush resolved promises.
*/
@@ -759,6 +829,15 @@ export const flushPromises = async (): Promise<void> => {
await new Promise(process.nextTick);
};
/**
* Install fake timers and pin Math.random to 1 (max jitter), so exponential
* backoff retry delays advance by exact, predictable durations.
*/
export const useDeterministicTimers = (): void => {
vi.useFakeTimers();
vi.spyOn(Math, 'random').mockReturnValue(1);
};
export const createInteractionActionEvent = (
action: string,
): CustomEvent<Interaction> => {