@ajclarkson/homerun 0.0.1-edge.3ff83fe → 0.0.1-edge.465375
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/src/framework/action-runtime.d.ts +8 -1
- package/dist/src/framework/action-runtime.js +110 -10
- package/dist/src/framework/config.d.ts +7 -0
- package/dist/src/framework/config.js +13 -0
- package/dist/src/framework/event-publisher.d.ts +39 -11
- package/dist/src/framework/event-publisher.js +49 -11
- package/dist/src/framework/ha-client.d.ts +17 -0
- package/dist/src/framework/ha-client.js +32 -2
- package/dist/src/framework/pipeline.js +21 -8
- package/dist/src/index.js +2 -1
- package/dist/src/lib.d.ts +1 -1
- package/dist/src/lib.js +1 -1
- package/dist/src/testing.d.ts +1 -0
- package/dist/src/testing.js +28 -5
- package/dist/src/types/actions.d.ts +3 -2
- package/dist/src/types/automation.d.ts +7 -1
- package/dist/src/types/automation.js +35 -0
- package/dist/src/types/triggers.d.ts +23 -0
- package/dist/src/types/triggers.js +26 -1
- package/package.json +1 -1
|
@@ -20,13 +20,20 @@ interface Deps {
|
|
|
20
20
|
eventPublisher: EventPublisher;
|
|
21
21
|
dryRun: boolean;
|
|
22
22
|
metrics?: MetricsBackend;
|
|
23
|
+
commandAck?: {
|
|
24
|
+
enabled: boolean;
|
|
25
|
+
timeoutMs: number;
|
|
26
|
+
};
|
|
23
27
|
}
|
|
24
28
|
export declare class ActionRuntime {
|
|
25
29
|
private readonly deps;
|
|
26
30
|
constructor(deps: Deps);
|
|
31
|
+
private handleAckTimeout;
|
|
27
32
|
execute(actions: Action[], ctx: ExecutionContext): Promise<void>;
|
|
28
33
|
private runAction;
|
|
29
34
|
private dispatch;
|
|
30
|
-
private
|
|
35
|
+
private baseFields;
|
|
36
|
+
private makeStartedEvent;
|
|
37
|
+
private makeResultEvent;
|
|
31
38
|
}
|
|
32
39
|
export {};
|
|
@@ -6,10 +6,85 @@ function safeStringify(err) {
|
|
|
6
6
|
return String(err);
|
|
7
7
|
}
|
|
8
8
|
}
|
|
9
|
+
// HA `data` keys are conventionally the same name as the attribute they end up setting
|
|
10
|
+
// (temperature, brightness, position, ...) — true across core and third-party integrations
|
|
11
|
+
// as a platform-wide naming convention, not something homerun needs to hand-map per service.
|
|
12
|
+
// These are the known exceptions: call parameters that don't name a resulting attribute.
|
|
13
|
+
const NON_ATTRIBUTE_DATA_KEYS = new Set(['transition', 'entity_id']);
|
|
14
|
+
// `set_value` on these domains sets the entity's top-level `state` directly — unlike most
|
|
15
|
+
// services, where a `data` key names the attribute it ends up on, these have no attribute
|
|
16
|
+
// called `value` at all. Found the hard way (#153): computeExpectedAck's generic convention
|
|
17
|
+
// falsely flagged every number/input_text room-sensor feed call as an ack timeout, since
|
|
18
|
+
// `attributes.value` never existed to satisfy.
|
|
19
|
+
const SET_VALUE_TO_STATE_DOMAINS = new Set(['number', 'input_number', 'input_text', 'input_select', 'text']);
|
|
20
|
+
// Domains/services excluded from tracking entirely rather than guessing at a convention that
|
|
21
|
+
// doesn't hold cleanly — group membership attributes don't reliably come back as an exact
|
|
22
|
+
// match of what was passed (ordering, or reflecting the whole group rather than the delta).
|
|
23
|
+
// scene.turn_on is here for a different reason (#156): a scene entity's `state` is a
|
|
24
|
+
// last-activated timestamp, not 'on'/'off', so the generic turn_on special-case never
|
|
25
|
+
// matches — and even a timestamp-based check would only confirm HA accepted the call, not
|
|
26
|
+
// that the scene's member entities (the things that actually matter) responded. Real
|
|
27
|
+
// coverage would mean tracking each member entity individually — see #155.
|
|
28
|
+
const UNTRACKED_SERVICES = new Set(['media_player.join', 'media_player.unjoin', 'scene.turn_on']);
|
|
29
|
+
// Computes the field/attribute -> value map a dispatched call is expected to produce, or
|
|
30
|
+
// undefined if there's nothing to track (no inferable fields, an excluded service, or the
|
|
31
|
+
// entity already matches every expected field — an idempotent call that legitimately produces
|
|
32
|
+
// no state_changed at all). See #55's design discussion for why this is convention-based
|
|
33
|
+
// rather than a per-service table.
|
|
34
|
+
function computeExpectedAck(action, current) {
|
|
35
|
+
if (UNTRACKED_SERVICES.has(`${action.domain}.${action.service}`))
|
|
36
|
+
return undefined;
|
|
37
|
+
const expected = {};
|
|
38
|
+
if (action.service === 'turn_on') {
|
|
39
|
+
expected.state = 'on';
|
|
40
|
+
}
|
|
41
|
+
else if (action.service === 'turn_off') {
|
|
42
|
+
expected.state = 'off';
|
|
43
|
+
}
|
|
44
|
+
else if (action.service === 'set_value' && SET_VALUE_TO_STATE_DOMAINS.has(action.domain) && action.data?.value !== undefined) {
|
|
45
|
+
expected.state = action.data.value;
|
|
46
|
+
}
|
|
47
|
+
for (const [key, value] of Object.entries(action.data ?? {})) {
|
|
48
|
+
if (NON_ATTRIBUTE_DATA_KEYS.has(key))
|
|
49
|
+
continue;
|
|
50
|
+
if ('state' in expected && key === 'value' && SET_VALUE_TO_STATE_DOMAINS.has(action.domain))
|
|
51
|
+
continue;
|
|
52
|
+
expected[key] = value;
|
|
53
|
+
}
|
|
54
|
+
if (Object.keys(expected).length === 0)
|
|
55
|
+
return undefined;
|
|
56
|
+
// See ha-client.ts's ackSatisfied for why `state` needs string coercion (#158) — the same
|
|
57
|
+
// number-vs-string gap applies here to the idempotency pre-check.
|
|
58
|
+
const alreadySatisfied = Object.entries(expected).every(([key, value]) => key === 'state' ? current !== undefined && String(current.state) === String(value) : current?.attributes[key] === value);
|
|
59
|
+
if (alreadySatisfied)
|
|
60
|
+
return undefined;
|
|
61
|
+
return expected;
|
|
62
|
+
}
|
|
9
63
|
export class ActionRuntime {
|
|
10
64
|
deps;
|
|
11
65
|
constructor(deps) {
|
|
12
66
|
this.deps = deps;
|
|
67
|
+
this.deps.haClient.on('action_ack_timeout', (e) => this.handleAckTimeout(e));
|
|
68
|
+
}
|
|
69
|
+
handleAckTimeout(e) {
|
|
70
|
+
this.deps.metrics?.incrementCounter('homerun_action_ack_timeout_total', {
|
|
71
|
+
location: e.location,
|
|
72
|
+
action_type: e.action.type,
|
|
73
|
+
});
|
|
74
|
+
this.deps.eventPublisher.publishActionEvent({
|
|
75
|
+
schema: 'home.events.v2',
|
|
76
|
+
correlation_id: e.correlationId,
|
|
77
|
+
root_correlation_id: e.rootCorrelationId ?? e.correlationId,
|
|
78
|
+
automation_id: e.automationId,
|
|
79
|
+
location: e.location,
|
|
80
|
+
subsystem: e.subsystem,
|
|
81
|
+
timestamp: new Date().toISOString(),
|
|
82
|
+
...(this.deps.dryRun ? { dry_run: true } : {}),
|
|
83
|
+
event_type: 'action_ack_timeout',
|
|
84
|
+
action: e.action,
|
|
85
|
+
entity: e.entity_id,
|
|
86
|
+
expected: e.expected,
|
|
87
|
+
});
|
|
13
88
|
}
|
|
14
89
|
async execute(actions, ctx) {
|
|
15
90
|
for (const action of actions) {
|
|
@@ -18,7 +93,7 @@ export class ActionRuntime {
|
|
|
18
93
|
}
|
|
19
94
|
async runAction(action, ctx) {
|
|
20
95
|
const labels = { location: ctx.location, action_type: action.type };
|
|
21
|
-
this.deps.eventPublisher.publishActionEvent(this.
|
|
96
|
+
this.deps.eventPublisher.publishActionEvent(this.makeStartedEvent(ctx, action));
|
|
22
97
|
this.deps.metrics?.incrementCounter('homerun_actions_dispatched_total', labels);
|
|
23
98
|
const start = performance.now();
|
|
24
99
|
try {
|
|
@@ -28,29 +103,45 @@ export class ActionRuntime {
|
|
|
28
103
|
const duration = (performance.now() - start) / 1000;
|
|
29
104
|
this.deps.metrics?.observeHistogram('homerun_action_duration_seconds', duration, labels);
|
|
30
105
|
this.deps.metrics?.incrementCounter('homerun_actions_succeeded_total', labels);
|
|
31
|
-
this.deps.eventPublisher.publishActionEvent(this.
|
|
106
|
+
this.deps.eventPublisher.publishActionEvent(this.makeResultEvent(ctx, action, 'ok'));
|
|
32
107
|
}
|
|
33
108
|
catch (err) {
|
|
34
109
|
const duration = (performance.now() - start) / 1000;
|
|
35
110
|
this.deps.metrics?.observeHistogram('homerun_action_duration_seconds', duration, labels);
|
|
36
111
|
this.deps.metrics?.incrementCounter('homerun_actions_failed_total', labels);
|
|
37
|
-
const
|
|
112
|
+
const error = err instanceof Error
|
|
38
113
|
? err.message
|
|
39
114
|
: typeof err === 'object' && err !== null
|
|
40
115
|
? safeStringify(err)
|
|
41
116
|
: String(err);
|
|
42
|
-
this.deps.eventPublisher.publishActionEvent(this.
|
|
117
|
+
this.deps.eventPublisher.publishActionEvent(this.makeResultEvent(ctx, action, 'error', error));
|
|
43
118
|
}
|
|
44
119
|
}
|
|
45
120
|
async dispatch(action, ctx) {
|
|
46
121
|
switch (action.type) {
|
|
47
|
-
case 'ha.call_service':
|
|
122
|
+
case 'ha.call_service': {
|
|
123
|
+
const entityId = action.target?.entity_id;
|
|
124
|
+
if (this.deps.commandAck?.enabled && entityId) {
|
|
125
|
+
const expected = computeExpectedAck(action, this.deps.haClient.state(entityId));
|
|
126
|
+
if (expected) {
|
|
127
|
+
this.deps.haClient.registerPendingAck(entityId, {
|
|
128
|
+
correlationId: ctx.correlationId,
|
|
129
|
+
rootCorrelationId: ctx.rootCorrelationId,
|
|
130
|
+
automationId: ctx.automationId,
|
|
131
|
+
location: ctx.location,
|
|
132
|
+
subsystem: ctx.subsystem,
|
|
133
|
+
action,
|
|
134
|
+
expected,
|
|
135
|
+
}, this.deps.commandAck.timeoutMs);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
48
138
|
await this.deps.haClient.callService(action.domain, action.service, action.target, action.data, {
|
|
49
139
|
correlationId: ctx.correlationId,
|
|
50
140
|
rootCorrelationId: ctx.rootCorrelationId,
|
|
51
141
|
automationId: ctx.automationId,
|
|
52
142
|
});
|
|
53
143
|
break;
|
|
144
|
+
}
|
|
54
145
|
case 'mqtt.publish':
|
|
55
146
|
if (action.impliesEntity) {
|
|
56
147
|
this.deps.haClient.registerPendingWrite(action.impliesEntity, {
|
|
@@ -73,21 +164,30 @@ export class ActionRuntime {
|
|
|
73
164
|
}
|
|
74
165
|
}
|
|
75
166
|
}
|
|
76
|
-
|
|
167
|
+
baseFields(ctx) {
|
|
77
168
|
return {
|
|
78
|
-
schema: 'home.events.
|
|
169
|
+
schema: 'home.events.v2',
|
|
79
170
|
correlation_id: ctx.correlationId,
|
|
80
171
|
root_correlation_id: ctx.rootCorrelationId ?? ctx.correlationId,
|
|
81
172
|
automation_id: ctx.automationId,
|
|
82
173
|
location: ctx.location,
|
|
83
174
|
subsystem: ctx.subsystem,
|
|
84
|
-
event_type,
|
|
85
|
-
actions: [action],
|
|
86
175
|
timestamp: new Date().toISOString(),
|
|
87
176
|
...(this.deps.dryRun ? { dry_run: true } : {}),
|
|
88
177
|
...(ctx.parentCorrelationId && { parent_correlation_id: ctx.parentCorrelationId }),
|
|
89
178
|
...(ctx.parentAutomationId && { parent_automation_id: ctx.parentAutomationId }),
|
|
90
|
-
|
|
179
|
+
};
|
|
180
|
+
}
|
|
181
|
+
makeStartedEvent(ctx, action) {
|
|
182
|
+
return { ...this.baseFields(ctx), event_type: 'action_started', action };
|
|
183
|
+
}
|
|
184
|
+
makeResultEvent(ctx, action, status, error) {
|
|
185
|
+
return {
|
|
186
|
+
...this.baseFields(ctx),
|
|
187
|
+
event_type: 'action_result',
|
|
188
|
+
action,
|
|
189
|
+
status,
|
|
190
|
+
...(error !== undefined && { error }),
|
|
91
191
|
};
|
|
92
192
|
}
|
|
93
193
|
}
|
|
@@ -20,6 +20,13 @@ declare const ConfigSchema: z.ZodObject<{
|
|
|
20
20
|
metrics: z.ZodDefault<z.ZodObject<{
|
|
21
21
|
enabled: z.ZodDefault<z.ZodBoolean>;
|
|
22
22
|
}, z.core.$strip>>;
|
|
23
|
+
events: z.ZodDefault<z.ZodObject<{
|
|
24
|
+
enabled: z.ZodDefault<z.ZodBoolean>;
|
|
25
|
+
}, z.core.$strip>>;
|
|
26
|
+
commandAck: z.ZodDefault<z.ZodObject<{
|
|
27
|
+
enabled: z.ZodDefault<z.ZodBoolean>;
|
|
28
|
+
timeoutMs: z.ZodDefault<z.ZodCoercedNumber<unknown>>;
|
|
29
|
+
}, z.core.$strip>>;
|
|
23
30
|
}, z.core.$strip>;
|
|
24
31
|
export type HomerunConfig = z.infer<typeof ConfigSchema>;
|
|
25
32
|
export declare function parseConfig(configContent: string, secretsContent?: string): HomerunConfig;
|
|
@@ -9,6 +9,8 @@ function isSecretRef(v) {
|
|
|
9
9
|
const secretTag = defineScalarTag('!secret', {
|
|
10
10
|
implicit: false,
|
|
11
11
|
resolve: (source) => ({ __secret: source }),
|
|
12
|
+
// Load-only tag — config is never dumped back to YAML, so identify() is unreachable.
|
|
13
|
+
identify: () => false,
|
|
12
14
|
});
|
|
13
15
|
const SCHEMA_WITH_SECRET = CORE_SCHEMA.withTags(secretTag);
|
|
14
16
|
const ConfigSchema = z.object({
|
|
@@ -32,6 +34,17 @@ const ConfigSchema = z.object({
|
|
|
32
34
|
metrics: z.object({
|
|
33
35
|
enabled: z.boolean().default(false),
|
|
34
36
|
}).default({ enabled: false }),
|
|
37
|
+
events: z.object({
|
|
38
|
+
// Defaults to true — preserves existing behaviour for deployments that don't set this.
|
|
39
|
+
enabled: z.boolean().default(true),
|
|
40
|
+
}).default({ enabled: true }),
|
|
41
|
+
commandAck: z.object({
|
|
42
|
+
// Opt-in: unlike events.enabled, this is new/experimental behaviour with no prior
|
|
43
|
+
// installed base to preserve, so it defaults off rather than needing a reason to disable.
|
|
44
|
+
enabled: z.boolean().default(false),
|
|
45
|
+
// Needs to accommodate slow Zigbee mesh round-trips, not just fast local calls.
|
|
46
|
+
timeoutMs: z.coerce.number().int().min(0).default(8_000),
|
|
47
|
+
}).default({ enabled: false, timeoutMs: 8_000 }),
|
|
35
48
|
});
|
|
36
49
|
function resolveSecrets(value, secrets) {
|
|
37
50
|
if (isSecretRef(value)) {
|
|
@@ -1,22 +1,46 @@
|
|
|
1
1
|
import type { MqttClient } from 'mqtt';
|
|
2
2
|
import type { Action } from '../types/actions.js';
|
|
3
|
-
|
|
4
|
-
|
|
3
|
+
import type { TriggerSummary } from '../types/triggers.js';
|
|
4
|
+
type ObsEventBase = {
|
|
5
|
+
schema: 'home.events.v2';
|
|
5
6
|
correlation_id: string;
|
|
6
|
-
root_correlation_id
|
|
7
|
+
root_correlation_id: string;
|
|
7
8
|
parent_correlation_id?: string;
|
|
8
9
|
parent_automation_id?: string;
|
|
9
10
|
automation_id: string;
|
|
10
11
|
location: string;
|
|
11
12
|
subsystem: string;
|
|
12
|
-
event_type: 'decision' | 'abort' | 'action_started' | 'action_result';
|
|
13
|
-
decision?: string;
|
|
14
|
-
reason?: string;
|
|
15
|
-
inputs?: Record<string, unknown>;
|
|
16
|
-
actions?: Action[];
|
|
17
|
-
dry_run?: boolean;
|
|
18
13
|
timestamp: string;
|
|
19
|
-
|
|
14
|
+
dry_run?: boolean;
|
|
15
|
+
};
|
|
16
|
+
export type ObsEvent = ObsEventBase & ({
|
|
17
|
+
event_type: 'decision';
|
|
18
|
+
trigger: TriggerSummary;
|
|
19
|
+
decision: string;
|
|
20
|
+
reason?: string;
|
|
21
|
+
conditions?: Record<string, unknown>;
|
|
22
|
+
actions: Action[];
|
|
23
|
+
hasAction: boolean;
|
|
24
|
+
} | {
|
|
25
|
+
event_type: 'abort';
|
|
26
|
+
trigger: TriggerSummary;
|
|
27
|
+
abort_kind: 'disabled' | 'unhandled_error' | 'guard' | 'unavailable_input';
|
|
28
|
+
reason?: string;
|
|
29
|
+
entity?: string;
|
|
30
|
+
} | {
|
|
31
|
+
event_type: 'action_started';
|
|
32
|
+
action: Action;
|
|
33
|
+
} | {
|
|
34
|
+
event_type: 'action_result';
|
|
35
|
+
action: Action;
|
|
36
|
+
status: 'ok' | 'error';
|
|
37
|
+
error?: string;
|
|
38
|
+
} | {
|
|
39
|
+
event_type: 'action_ack_timeout';
|
|
40
|
+
action: Action;
|
|
41
|
+
entity: string;
|
|
42
|
+
expected: Record<string, unknown>;
|
|
43
|
+
});
|
|
20
44
|
export type LifecycleEventType = 'server_started' | 'server_stopping' | 'rescan_complete' | 'ha_reconnected';
|
|
21
45
|
export interface LifecycleEvent {
|
|
22
46
|
schema: 'home.lifecycle.v1';
|
|
@@ -27,11 +51,15 @@ export interface LifecycleEvent {
|
|
|
27
51
|
}
|
|
28
52
|
export declare class EventPublisher {
|
|
29
53
|
private readonly mqtt;
|
|
54
|
+
private readonly enabled;
|
|
30
55
|
private readonly listeners;
|
|
31
|
-
constructor(mqtt: MqttClient);
|
|
56
|
+
constructor(mqtt: MqttClient, enabled?: boolean);
|
|
32
57
|
subscribe(listener: (event: ObsEvent) => void): () => void;
|
|
33
58
|
publishDecision(event: ObsEvent): void;
|
|
34
59
|
publishActionEvent(event: ObsEvent): void;
|
|
35
60
|
publishLifecycle(type: LifecycleEventType, automationCount: number, dryRun?: boolean): void;
|
|
36
61
|
private publish;
|
|
62
|
+
private safeSerialize;
|
|
63
|
+
private notifyListeners;
|
|
37
64
|
}
|
|
65
|
+
export {};
|
|
@@ -1,8 +1,14 @@
|
|
|
1
1
|
export class EventPublisher {
|
|
2
2
|
mqtt;
|
|
3
|
+
enabled;
|
|
3
4
|
listeners = [];
|
|
4
|
-
|
|
5
|
+
// `enabled` gates MQTT publishing of decision/action ObsEvents only (mirrors metrics.enabled;
|
|
6
|
+
// see #144) — publishLifecycle (status/online heartbeat) is unaffected, and listeners (the
|
|
7
|
+
// /events SSE endpoint, any future persistent store) are always notified regardless, so
|
|
8
|
+
// consumers that don't want the MQTT stream aren't forced to lose in-process observability too.
|
|
9
|
+
constructor(mqtt, enabled = true) {
|
|
5
10
|
this.mqtt = mqtt;
|
|
11
|
+
this.enabled = enabled;
|
|
6
12
|
}
|
|
7
13
|
subscribe(listener) {
|
|
8
14
|
this.listeners.push(listener);
|
|
@@ -13,18 +19,25 @@ export class EventPublisher {
|
|
|
13
19
|
};
|
|
14
20
|
}
|
|
15
21
|
publishDecision(event) {
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
+
if (this.enabled) {
|
|
23
|
+
const payload = this.safeSerialize(event);
|
|
24
|
+
if (payload !== undefined) {
|
|
25
|
+
const ns = event.dry_run ? 'homerun/dev' : 'homerun';
|
|
26
|
+
this.publish(`${ns}/events`, payload, false);
|
|
27
|
+
this.publish(`${ns}/${event.location}/${event.subsystem}/decision`, payload, true);
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
this.notifyListeners(event);
|
|
22
31
|
}
|
|
23
32
|
publishActionEvent(event) {
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
33
|
+
if (this.enabled) {
|
|
34
|
+
const payload = this.safeSerialize(event);
|
|
35
|
+
if (payload !== undefined) {
|
|
36
|
+
const ns = event.dry_run ? 'homerun/dev' : 'homerun';
|
|
37
|
+
this.publish(`${ns}/events`, payload, false);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
this.notifyListeners(event);
|
|
28
41
|
}
|
|
29
42
|
publishLifecycle(type, automationCount, dryRun = false) {
|
|
30
43
|
const event = {
|
|
@@ -44,4 +57,29 @@ export class EventPublisher {
|
|
|
44
57
|
console.error(`[EventPublisher] MQTT publish failed on ${topic}:`, err);
|
|
45
58
|
});
|
|
46
59
|
}
|
|
60
|
+
// Observability must never affect automation behaviour — a decision/action event that fails
|
|
61
|
+
// to serialize (e.g. conditions defaulting to a context object with a circular reference) must
|
|
62
|
+
// not throw, since runPipeline awaits this alongside actionRuntime.execute() in the same
|
|
63
|
+
// Promise.all; a synchronous throw here would silently prevent real actions from ever running.
|
|
64
|
+
safeSerialize(event) {
|
|
65
|
+
try {
|
|
66
|
+
return JSON.stringify(event);
|
|
67
|
+
}
|
|
68
|
+
catch (err) {
|
|
69
|
+
console.error(`[EventPublisher] failed to serialize ${event.event_type} event for ${event.automation_id} — MQTT publish skipped:`, err);
|
|
70
|
+
return undefined;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
// Isolates listeners from each other and from the publisher — the same reliability principle
|
|
74
|
+
// applied to subscribers (e.g. the /events SSE endpoint) as to serialization above.
|
|
75
|
+
notifyListeners(event) {
|
|
76
|
+
for (const l of this.listeners) {
|
|
77
|
+
try {
|
|
78
|
+
l(event);
|
|
79
|
+
}
|
|
80
|
+
catch (err) {
|
|
81
|
+
console.error('[EventPublisher] listener threw, continuing:', err);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
}
|
|
47
85
|
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { type HassServiceTarget } from 'home-assistant-js-websocket';
|
|
2
2
|
import { EventEmitter } from 'node:events';
|
|
3
|
+
import type { Action } from '../types/actions.js';
|
|
3
4
|
export interface EntityState {
|
|
4
5
|
entity_id: string;
|
|
5
6
|
state: string;
|
|
@@ -32,13 +33,27 @@ export interface WriteOrigin {
|
|
|
32
33
|
rootCorrelationId?: string;
|
|
33
34
|
automationId: string;
|
|
34
35
|
}
|
|
36
|
+
export interface AckOrigin {
|
|
37
|
+
correlationId: string;
|
|
38
|
+
rootCorrelationId?: string;
|
|
39
|
+
automationId: string;
|
|
40
|
+
location: string;
|
|
41
|
+
subsystem: string;
|
|
42
|
+
action: Action;
|
|
43
|
+
expected: Record<string, unknown>;
|
|
44
|
+
}
|
|
45
|
+
export interface AckTimeoutEvent extends AckOrigin {
|
|
46
|
+
entity_id: string;
|
|
47
|
+
}
|
|
35
48
|
export declare interface HAClient {
|
|
36
49
|
on(event: 'state_changed', listener: (e: StateChangedEvent) => void): this;
|
|
37
50
|
on(event: 'ready', listener: () => void): this;
|
|
38
51
|
on(event: 'reconnected', listener: () => void): this;
|
|
52
|
+
on(event: 'action_ack_timeout', listener: (e: AckTimeoutEvent) => void): this;
|
|
39
53
|
emit(event: 'state_changed', e: StateChangedEvent): boolean;
|
|
40
54
|
emit(event: 'ready'): boolean;
|
|
41
55
|
emit(event: 'reconnected'): boolean;
|
|
56
|
+
emit(event: 'action_ack_timeout', e: AckTimeoutEvent): boolean;
|
|
42
57
|
}
|
|
43
58
|
export declare class HAClient extends EventEmitter {
|
|
44
59
|
private readonly stateCache;
|
|
@@ -46,6 +61,7 @@ export declare class HAClient extends EventEmitter {
|
|
|
46
61
|
private readonly entityToLabels;
|
|
47
62
|
private readonly areaToEntities;
|
|
48
63
|
private readonly pendingWrites;
|
|
64
|
+
private readonly pendingAcks;
|
|
49
65
|
private connection;
|
|
50
66
|
private reconnecting;
|
|
51
67
|
private _readyResolve;
|
|
@@ -59,6 +75,7 @@ export declare class HAClient extends EventEmitter {
|
|
|
59
75
|
};
|
|
60
76
|
callService(domain: string, service: string, target?: HassServiceTarget, data?: Record<string, unknown>, origin?: WriteOrigin): Promise<void>;
|
|
61
77
|
registerPendingWrite(entityId: string, origin: WriteOrigin): void;
|
|
78
|
+
registerPendingAck(entityId: string, origin: AckOrigin, timeoutMs: number): void;
|
|
62
79
|
disconnect(): void;
|
|
63
80
|
connect(url: string, token: string): Promise<void>;
|
|
64
81
|
private repopulateCache;
|
|
@@ -1,6 +1,12 @@
|
|
|
1
1
|
import { callService, createConnection, createLongLivedTokenAuth, subscribeEntities, } from 'home-assistant-js-websocket';
|
|
2
2
|
import { EventEmitter } from 'node:events';
|
|
3
3
|
const PENDING_WRITE_TTL_MS = 2000;
|
|
4
|
+
function ackSatisfied(expected, state) {
|
|
5
|
+
// HA represents every entity's state as a string, even numeric domains (number,
|
|
6
|
+
// input_number, ...) — a dispatched call's data.value is typically a plain JS number, so
|
|
7
|
+
// comparing `state` with strict equality never matches regardless of what HA does. See #158.
|
|
8
|
+
return Object.entries(expected).every(([key, value]) => key === 'state' ? String(state.state) === String(value) : state.attributes[key] === value);
|
|
9
|
+
}
|
|
4
10
|
export class HAClient extends EventEmitter {
|
|
5
11
|
stateCache = new Map();
|
|
6
12
|
labelToEntities = new Map();
|
|
@@ -9,6 +15,10 @@ export class HAClient extends EventEmitter {
|
|
|
9
15
|
// entity_id -> origin of the write that's expected to produce a state_changed for it.
|
|
10
16
|
// Self-evicts after PENDING_WRITE_TTL_MS; consumed (deleted) the moment it's matched.
|
|
11
17
|
pendingWrites = new Map();
|
|
18
|
+
// entity_id -> ack tracking for a dispatched call awaiting its expected state/attribute
|
|
19
|
+
// change. Consumed (timer cleared, deleted) the moment diffAndUpdate sees a satisfying
|
|
20
|
+
// state_changed; otherwise self-fires action_ack_timeout after its own timeout.
|
|
21
|
+
pendingAcks = new Map();
|
|
12
22
|
connection = null;
|
|
13
23
|
// True between a disconnect event and the next subscribeEntities callback.
|
|
14
24
|
// During this window we repopulate silently — no state_changed events emitted.
|
|
@@ -47,6 +57,18 @@ export class HAClient extends EventEmitter {
|
|
|
47
57
|
this.pendingWrites.set(entityId, origin);
|
|
48
58
|
setTimeout(() => this.pendingWrites.delete(entityId), PENDING_WRITE_TTL_MS);
|
|
49
59
|
}
|
|
60
|
+
// Registers that `entityId` is expected to reach `origin.expected` (a field/attribute ->
|
|
61
|
+
// value map) within `timeoutMs`. Consumed (timer cleared, deleted) by diffAndUpdate the
|
|
62
|
+
// moment a satisfying state_changed arrives; otherwise emits action_ack_timeout on expiry
|
|
63
|
+
// with the same origin, so a listener has everything needed to publish a self-sufficient
|
|
64
|
+
// ObsEvent without HAClient itself knowing about observability. See #55.
|
|
65
|
+
registerPendingAck(entityId, origin, timeoutMs) {
|
|
66
|
+
const timer = setTimeout(() => {
|
|
67
|
+
this.pendingAcks.delete(entityId);
|
|
68
|
+
this.emit('action_ack_timeout', { ...origin, entity_id: entityId });
|
|
69
|
+
}, timeoutMs);
|
|
70
|
+
this.pendingAcks.set(entityId, { ...origin, timer });
|
|
71
|
+
}
|
|
50
72
|
disconnect() {
|
|
51
73
|
this.connection?.close();
|
|
52
74
|
this.connection = null;
|
|
@@ -104,9 +126,17 @@ export class HAClient extends EventEmitter {
|
|
|
104
126
|
for (const [id, rawEntity] of Object.entries(entities)) {
|
|
105
127
|
const old_state = this.stateCache.get(id);
|
|
106
128
|
const new_state = toEntityState(id, rawEntity);
|
|
107
|
-
// last_updated changes whenever state or attributes change in HA.
|
|
108
|
-
|
|
129
|
+
// last_updated changes whenever state or attributes change in HA. It's also compared as
|
|
130
|
+
// a millisecond-precision string (see toEntityState), so two distinct state changes to
|
|
131
|
+
// the same entity within the same millisecond collide on last_updated alone — compare
|
|
132
|
+
// state directly too so a genuine transition is never silently dropped. See #170.
|
|
133
|
+
if (!old_state || old_state.state !== new_state.state || old_state.last_updated !== new_state.last_updated) {
|
|
109
134
|
this.stateCache.set(id, new_state);
|
|
135
|
+
const ack = this.pendingAcks.get(id);
|
|
136
|
+
if (ack && ackSatisfied(ack.expected, new_state)) {
|
|
137
|
+
clearTimeout(ack.timer);
|
|
138
|
+
this.pendingAcks.delete(id);
|
|
139
|
+
}
|
|
110
140
|
const correlation_id = crypto.randomUUID();
|
|
111
141
|
const origin = this.pendingWrites.get(id);
|
|
112
142
|
if (origin)
|
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { summarizeTrigger } from '../types/triggers.js';
|
|
2
|
+
import { isAbort, UnavailableInputError } from '../types/automation.js';
|
|
2
3
|
export async function runPipeline(automation, event, haClient, deps) {
|
|
3
4
|
deps.metrics?.incrementCounter('homerun_pipeline_runs_total', {
|
|
4
5
|
location: automation.location,
|
|
@@ -7,8 +8,9 @@ export async function runPipeline(automation, event, haClient, deps) {
|
|
|
7
8
|
const correlationId = event.correlation_id;
|
|
8
9
|
const rootCorrelationId = event.root_correlation_id ?? correlationId;
|
|
9
10
|
const timestamp = new Date().toISOString();
|
|
11
|
+
const trigger = summarizeTrigger(event);
|
|
10
12
|
const base = {
|
|
11
|
-
schema: 'home.events.
|
|
13
|
+
schema: 'home.events.v2',
|
|
12
14
|
correlation_id: correlationId,
|
|
13
15
|
root_correlation_id: rootCorrelationId,
|
|
14
16
|
automation_id: automation.id,
|
|
@@ -21,7 +23,7 @@ export async function runPipeline(automation, event, haClient, deps) {
|
|
|
21
23
|
};
|
|
22
24
|
// Step 1: Enabled check
|
|
23
25
|
if (automation.enabled === false) {
|
|
24
|
-
deps.eventPublisher.publishDecision({ ...base, event_type: 'abort',
|
|
26
|
+
deps.eventPublisher.publishDecision({ ...base, event_type: 'abort', abort_kind: 'disabled', trigger });
|
|
25
27
|
return;
|
|
26
28
|
}
|
|
27
29
|
// Step 2: Context
|
|
@@ -29,12 +31,17 @@ export async function runPipeline(automation, event, haClient, deps) {
|
|
|
29
31
|
try {
|
|
30
32
|
ctx = automation.context(haClient.state, haClient.context, event);
|
|
31
33
|
}
|
|
32
|
-
catch {
|
|
33
|
-
|
|
34
|
+
catch (err) {
|
|
35
|
+
if (err instanceof UnavailableInputError) {
|
|
36
|
+
deps.eventPublisher.publishDecision({ ...base, event_type: 'abort', abort_kind: 'unavailable_input', entity: err.entityId, trigger });
|
|
37
|
+
}
|
|
38
|
+
else {
|
|
39
|
+
deps.eventPublisher.publishDecision({ ...base, event_type: 'abort', abort_kind: 'unhandled_error', trigger });
|
|
40
|
+
}
|
|
34
41
|
return;
|
|
35
42
|
}
|
|
36
43
|
if (isAbort(ctx)) {
|
|
37
|
-
deps.eventPublisher.publishDecision({ ...base, event_type: 'abort', reason: ctx.reason });
|
|
44
|
+
deps.eventPublisher.publishDecision({ ...base, event_type: 'abort', abort_kind: 'guard', reason: ctx.reason, trigger });
|
|
38
45
|
return;
|
|
39
46
|
}
|
|
40
47
|
// Step 3: Reduce
|
|
@@ -43,18 +50,24 @@ export async function runPipeline(automation, event, haClient, deps) {
|
|
|
43
50
|
result = automation.reduce(ctx);
|
|
44
51
|
}
|
|
45
52
|
catch {
|
|
46
|
-
deps.eventPublisher.publishDecision({ ...base, event_type: 'abort',
|
|
53
|
+
deps.eventPublisher.publishDecision({ ...base, event_type: 'abort', abort_kind: 'unhandled_error', trigger });
|
|
47
54
|
return;
|
|
48
55
|
}
|
|
49
56
|
// Step 4: Validate — safe defaults
|
|
50
57
|
const actions = result.actions ?? [];
|
|
58
|
+
// Defaults to the full context object so authors get observability "for free" without
|
|
59
|
+
// hand-duplicating context fields into conditions — reduce() can still override with its
|
|
60
|
+
// own (e.g. trimmed) conditions when the full context isn't what should be published.
|
|
61
|
+
const conditions = result.conditions ?? ctx;
|
|
51
62
|
const decision = {
|
|
52
63
|
...base,
|
|
53
64
|
event_type: 'decision',
|
|
65
|
+
trigger,
|
|
54
66
|
decision: result.decision,
|
|
55
67
|
reason: result.reason,
|
|
56
|
-
|
|
68
|
+
conditions,
|
|
57
69
|
actions,
|
|
70
|
+
hasAction: actions.length > 0,
|
|
58
71
|
};
|
|
59
72
|
// Step 5: Fanout
|
|
60
73
|
await Promise.all([
|
package/dist/src/index.js
CHANGED
|
@@ -38,7 +38,7 @@ await new Promise((resolve, reject) => {
|
|
|
38
38
|
// `engine` is assigned before any timer can fire.
|
|
39
39
|
const haClient = new HAClient();
|
|
40
40
|
const registry = new AutomationRegistry();
|
|
41
|
-
const eventPublisher = new EventPublisher(mqtt);
|
|
41
|
+
const eventPublisher = new EventPublisher(mqtt, config.events.enabled);
|
|
42
42
|
let engine;
|
|
43
43
|
const timerManager = new TimerManager((e) => engine.dispatch(e));
|
|
44
44
|
const actionRuntime = new ActionRuntime({
|
|
@@ -48,6 +48,7 @@ const actionRuntime = new ActionRuntime({
|
|
|
48
48
|
eventPublisher,
|
|
49
49
|
dryRun,
|
|
50
50
|
metrics: metricsBackend,
|
|
51
|
+
commandAck: config.commandAck,
|
|
51
52
|
});
|
|
52
53
|
// 3. Initial automation load — must complete before the engine and scheduler start.
|
|
53
54
|
const automationsDir = path.resolve(config.automations.dir);
|
package/dist/src/lib.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
export { defineAutomation, abort } from './types/automation.js';
|
|
1
|
+
export { defineAutomation, abort, requireState, requireNumericState, UnavailableInputError } from './types/automation.js';
|
|
2
2
|
export type { Automation, Decision, Abort, HAState, HAContext } from './types/automation.js';
|
|
3
3
|
export type { Trigger, TriggerEvent } from './types/triggers.js';
|
|
4
4
|
export type { Action } from './types/actions.js';
|
package/dist/src/lib.js
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
export { defineAutomation, abort } from './types/automation.js';
|
|
1
|
+
export { defineAutomation, abort, requireState, requireNumericState, UnavailableInputError } from './types/automation.js';
|
|
2
2
|
export { HomeAssistant } from './services.js';
|
package/dist/src/testing.d.ts
CHANGED
|
@@ -14,4 +14,5 @@ interface TestOptions {
|
|
|
14
14
|
}
|
|
15
15
|
export declare function testAutomation<C>(automation: Automation<C>, options: TestOptions): Decision;
|
|
16
16
|
export declare function testAbort<C>(automation: Automation<C>, options: TestOptions): Abort;
|
|
17
|
+
export declare function testUnavailable<C>(automation: Automation<C>, options: TestOptions): string;
|
|
17
18
|
export {};
|
package/dist/src/testing.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
import { isAbort } from './types/automation.js';
|
|
2
|
-
function
|
|
3
|
-
const {
|
|
1
|
+
import { isAbort, UnavailableInputError } from './types/automation.js';
|
|
2
|
+
function buildStateAndHa(options) {
|
|
3
|
+
const { state = {}, ha = {} } = options;
|
|
4
4
|
const stateFunc = (entityId) => {
|
|
5
5
|
const entry = state[entityId];
|
|
6
6
|
if (!entry)
|
|
@@ -18,10 +18,17 @@ function run(automation, options) {
|
|
|
18
18
|
labelsFor: ha.labelsFor ?? (() => []),
|
|
19
19
|
entitiesByArea: ha.entitiesByArea ?? (() => []),
|
|
20
20
|
};
|
|
21
|
-
|
|
21
|
+
return { stateFunc, haContext };
|
|
22
|
+
}
|
|
23
|
+
function run(automation, options) {
|
|
24
|
+
const { stateFunc, haContext } = buildStateAndHa(options);
|
|
25
|
+
const ctx = automation.context(stateFunc, haContext, options.event);
|
|
22
26
|
if (isAbort(ctx))
|
|
23
27
|
return ctx;
|
|
24
|
-
|
|
28
|
+
const result = automation.reduce(ctx);
|
|
29
|
+
// Mirrors runPipeline's default in src/framework/pipeline.ts, so tests observe the same
|
|
30
|
+
// `conditions` a real run would publish.
|
|
31
|
+
return { ...result, conditions: result.conditions ?? ctx };
|
|
25
32
|
}
|
|
26
33
|
export function testAutomation(automation, options) {
|
|
27
34
|
const result = run(automation, options);
|
|
@@ -35,3 +42,19 @@ export function testAbort(automation, options) {
|
|
|
35
42
|
throw new Error(`expected abort but got decision: ${result.decision}`);
|
|
36
43
|
return result;
|
|
37
44
|
}
|
|
45
|
+
// For automations using requireState()/requireNumericState(), which throw
|
|
46
|
+
// UnavailableInputError rather than returning Abort — testAbort() can't observe these since
|
|
47
|
+
// run() never catches an exception thrown out of context(). Returns the entity id the
|
|
48
|
+
// automation required, so tests can assert on which input was missing.
|
|
49
|
+
export function testUnavailable(automation, options) {
|
|
50
|
+
const { stateFunc, haContext } = buildStateAndHa(options);
|
|
51
|
+
try {
|
|
52
|
+
automation.context(stateFunc, haContext, options.event);
|
|
53
|
+
}
|
|
54
|
+
catch (err) {
|
|
55
|
+
if (err instanceof UnavailableInputError)
|
|
56
|
+
return err.entityId;
|
|
57
|
+
throw err;
|
|
58
|
+
}
|
|
59
|
+
throw new Error('expected UnavailableInputError but context() completed normally');
|
|
60
|
+
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
export
|
|
1
|
+
export interface HaCallServiceAction {
|
|
2
2
|
type: 'ha.call_service';
|
|
3
3
|
domain: string;
|
|
4
4
|
service: string;
|
|
@@ -6,7 +6,8 @@ export type Action = {
|
|
|
6
6
|
entity_id: string;
|
|
7
7
|
};
|
|
8
8
|
data?: Record<string, unknown>;
|
|
9
|
-
}
|
|
9
|
+
}
|
|
10
|
+
export type Action = HaCallServiceAction | {
|
|
10
11
|
type: 'mqtt.publish';
|
|
11
12
|
topic: string;
|
|
12
13
|
payload: string;
|
|
@@ -6,7 +6,7 @@ export interface Decision {
|
|
|
6
6
|
decision: string;
|
|
7
7
|
reason?: string;
|
|
8
8
|
actions: Action[];
|
|
9
|
-
|
|
9
|
+
conditions?: Record<string, unknown>;
|
|
10
10
|
}
|
|
11
11
|
export type Abort = {
|
|
12
12
|
abort: true;
|
|
@@ -14,6 +14,12 @@ export type Abort = {
|
|
|
14
14
|
};
|
|
15
15
|
export declare const abort: (reason: string) => Abort;
|
|
16
16
|
export declare function isAbort(value: unknown): value is Abort;
|
|
17
|
+
export declare class UnavailableInputError extends Error {
|
|
18
|
+
readonly entityId: string;
|
|
19
|
+
constructor(entityId: string);
|
|
20
|
+
}
|
|
21
|
+
export declare function requireState(state: HAState, entityId: Parameters<HAState>[0]): string;
|
|
22
|
+
export declare function requireNumericState(state: HAState, entityId: Parameters<HAState>[0]): number;
|
|
17
23
|
export interface Automation<C> {
|
|
18
24
|
id: string;
|
|
19
25
|
location: string;
|
|
@@ -2,6 +2,41 @@ export const abort = (reason) => ({ abort: true, reason });
|
|
|
2
2
|
export function isAbort(value) {
|
|
3
3
|
return typeof value === 'object' && value !== null && value.abort === true;
|
|
4
4
|
}
|
|
5
|
+
// ---------- Required state ----------
|
|
6
|
+
// Covers the dominant abort() pattern found across real automations (~70% of call sites,
|
|
7
|
+
// per #142's audit): a required entity's state is missing or invalid, so context() can't
|
|
8
|
+
// proceed. Thrown, not returned — pipeline.ts already wraps context() in try/catch, so this
|
|
9
|
+
// collapses `const x = state(id)?.state; if (x === undefined) return abort(...)` at every call
|
|
10
|
+
// site down to one line, with no per-call guard needed. Caught specially in pipeline.ts and
|
|
11
|
+
// classified as abort_kind: 'unavailable_input' with the entity name, distinct from a genuine
|
|
12
|
+
// bug (abort_kind: 'unhandled_error').
|
|
13
|
+
export class UnavailableInputError extends Error {
|
|
14
|
+
entityId;
|
|
15
|
+
constructor(entityId) {
|
|
16
|
+
super(`required entity unavailable: ${entityId}`);
|
|
17
|
+
this.entityId = entityId;
|
|
18
|
+
this.name = 'UnavailableInputError';
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
// HA's own sentinel values for "entity is registered but not producing readings" — an
|
|
22
|
+
// offline zigbee device, an integration that hasn't polled yet. Distinct from the entity
|
|
23
|
+
// key being absent from the state cache entirely, but the same "unavailable" in practice:
|
|
24
|
+
// every real audited call site treated these three cases identically.
|
|
25
|
+
const HA_UNAVAILABLE_STATES = new Set(['unavailable', 'unknown']);
|
|
26
|
+
export function requireState(state, entityId) {
|
|
27
|
+
const value = state(entityId)?.state;
|
|
28
|
+
if (value === undefined || HA_UNAVAILABLE_STATES.has(value)) {
|
|
29
|
+
throw new UnavailableInputError(String(entityId));
|
|
30
|
+
}
|
|
31
|
+
return value;
|
|
32
|
+
}
|
|
33
|
+
export function requireNumericState(state, entityId) {
|
|
34
|
+
const raw = requireState(state, entityId);
|
|
35
|
+
const parsed = parseFloat(raw);
|
|
36
|
+
if (!Number.isFinite(parsed))
|
|
37
|
+
throw new UnavailableInputError(String(entityId));
|
|
38
|
+
return parsed;
|
|
39
|
+
}
|
|
5
40
|
// Identity function — provides type inference on C so the reduce argument
|
|
6
41
|
// is typed correctly without the user annotating the context shape explicitly.
|
|
7
42
|
export function defineAutomation(automation) {
|
|
@@ -51,4 +51,27 @@ export type TriggerEvent = TriggerEventBase & ({
|
|
|
51
51
|
topic: string;
|
|
52
52
|
payload: string;
|
|
53
53
|
});
|
|
54
|
+
export type TriggerSummary = {
|
|
55
|
+
type: 'state_changed';
|
|
56
|
+
entity_id: string;
|
|
57
|
+
to: string;
|
|
58
|
+
from?: string;
|
|
59
|
+
} | {
|
|
60
|
+
type: 'schedule';
|
|
61
|
+
cron: string;
|
|
62
|
+
} | {
|
|
63
|
+
type: 'on_start';
|
|
64
|
+
} | {
|
|
65
|
+
type: 'timer_expired';
|
|
66
|
+
timerKey: string;
|
|
67
|
+
} | {
|
|
68
|
+
type: 'button';
|
|
69
|
+
entity_id: string;
|
|
70
|
+
gesture: 'single_press' | 'double_press' | 'hold';
|
|
71
|
+
button?: string;
|
|
72
|
+
} | {
|
|
73
|
+
type: 'mqtt_in';
|
|
74
|
+
topic: string;
|
|
75
|
+
};
|
|
76
|
+
export declare function summarizeTrigger(event: TriggerEvent): TriggerSummary;
|
|
54
77
|
export {};
|
|
@@ -1 +1,26 @@
|
|
|
1
|
-
export {
|
|
1
|
+
export function summarizeTrigger(event) {
|
|
2
|
+
switch (event.type) {
|
|
3
|
+
case 'state_changed':
|
|
4
|
+
return {
|
|
5
|
+
type: 'state_changed',
|
|
6
|
+
entity_id: event.entity_id,
|
|
7
|
+
to: event.new_state.state,
|
|
8
|
+
...(event.old_state && { from: event.old_state.state }),
|
|
9
|
+
};
|
|
10
|
+
case 'schedule':
|
|
11
|
+
return { type: 'schedule', cron: event.cron };
|
|
12
|
+
case 'on_start':
|
|
13
|
+
return { type: 'on_start' };
|
|
14
|
+
case 'timer_expired':
|
|
15
|
+
return { type: 'timer_expired', timerKey: event.timerKey };
|
|
16
|
+
case 'button':
|
|
17
|
+
return {
|
|
18
|
+
type: 'button',
|
|
19
|
+
entity_id: event.entity_id,
|
|
20
|
+
gesture: event.gesture,
|
|
21
|
+
...(event.button !== undefined && { button: event.button }),
|
|
22
|
+
};
|
|
23
|
+
case 'mqtt_in':
|
|
24
|
+
return { type: 'mqtt_in', topic: event.topic };
|
|
25
|
+
}
|
|
26
|
+
}
|