@ajclarkson/homerun 0.0.1-edge.c7ffb3a → 0.0.1-edge.ced2eaa
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 +3 -0
- package/dist/src/framework/action-runtime.js +30 -4
- package/dist/src/framework/api-server.js +2 -1
- package/dist/src/framework/event-publisher.d.ts +3 -0
- package/dist/src/framework/ha-client.d.ts +15 -1
- package/dist/src/framework/ha-client.js +32 -2
- package/dist/src/framework/pipeline.js +7 -0
- package/dist/src/framework/registry.js +4 -1
- package/dist/src/framework/scheduler.js +19 -12
- package/dist/src/framework/timer-manager.js +2 -1
- package/dist/src/framework/trigger-engine.d.ts +3 -0
- package/dist/src/framework/trigger-engine.js +54 -20
- package/dist/src/index.js +2 -1
- package/dist/src/types/actions.d.ts +1 -0
- package/dist/src/types/triggers.d.ts +2 -0
- package/package.json +1 -1
|
@@ -1,3 +1,11 @@
|
|
|
1
|
+
function safeStringify(err) {
|
|
2
|
+
try {
|
|
3
|
+
return JSON.stringify(err);
|
|
4
|
+
}
|
|
5
|
+
catch {
|
|
6
|
+
return String(err);
|
|
7
|
+
}
|
|
8
|
+
}
|
|
1
9
|
export class ActionRuntime {
|
|
2
10
|
deps;
|
|
3
11
|
constructor(deps) {
|
|
@@ -15,7 +23,7 @@ export class ActionRuntime {
|
|
|
15
23
|
const start = performance.now();
|
|
16
24
|
try {
|
|
17
25
|
if (!this.deps.dryRun) {
|
|
18
|
-
await this.dispatch(action);
|
|
26
|
+
await this.dispatch(action, ctx);
|
|
19
27
|
}
|
|
20
28
|
const duration = (performance.now() - start) / 1000;
|
|
21
29
|
this.deps.metrics?.observeHistogram('homerun_action_duration_seconds', duration, labels);
|
|
@@ -26,16 +34,31 @@ export class ActionRuntime {
|
|
|
26
34
|
const duration = (performance.now() - start) / 1000;
|
|
27
35
|
this.deps.metrics?.observeHistogram('homerun_action_duration_seconds', duration, labels);
|
|
28
36
|
this.deps.metrics?.incrementCounter('homerun_actions_failed_total', labels);
|
|
29
|
-
const reason = err instanceof Error
|
|
37
|
+
const reason = err instanceof Error
|
|
38
|
+
? err.message
|
|
39
|
+
: typeof err === 'object' && err !== null
|
|
40
|
+
? safeStringify(err)
|
|
41
|
+
: String(err);
|
|
30
42
|
this.deps.eventPublisher.publishActionEvent(this.makeEvent(ctx, 'action_result', action, { reason }));
|
|
31
43
|
}
|
|
32
44
|
}
|
|
33
|
-
async dispatch(action) {
|
|
45
|
+
async dispatch(action, ctx) {
|
|
34
46
|
switch (action.type) {
|
|
35
47
|
case 'ha.call_service':
|
|
36
|
-
await this.deps.haClient.callService(action.domain, action.service, action.target, action.data
|
|
48
|
+
await this.deps.haClient.callService(action.domain, action.service, action.target, action.data, {
|
|
49
|
+
correlationId: ctx.correlationId,
|
|
50
|
+
rootCorrelationId: ctx.rootCorrelationId,
|
|
51
|
+
automationId: ctx.automationId,
|
|
52
|
+
});
|
|
37
53
|
break;
|
|
38
54
|
case 'mqtt.publish':
|
|
55
|
+
if (action.impliesEntity) {
|
|
56
|
+
this.deps.haClient.registerPendingWrite(action.impliesEntity, {
|
|
57
|
+
correlationId: ctx.correlationId,
|
|
58
|
+
rootCorrelationId: ctx.rootCorrelationId,
|
|
59
|
+
automationId: ctx.automationId,
|
|
60
|
+
});
|
|
61
|
+
}
|
|
39
62
|
await this.deps.mqttClient.publishAsync(action.topic, action.payload, { retain: action.retain ?? false });
|
|
40
63
|
break;
|
|
41
64
|
case 'timer.start':
|
|
@@ -54,6 +77,7 @@ export class ActionRuntime {
|
|
|
54
77
|
return {
|
|
55
78
|
schema: 'home.events.v1',
|
|
56
79
|
correlation_id: ctx.correlationId,
|
|
80
|
+
root_correlation_id: ctx.rootCorrelationId ?? ctx.correlationId,
|
|
57
81
|
automation_id: ctx.automationId,
|
|
58
82
|
location: ctx.location,
|
|
59
83
|
subsystem: ctx.subsystem,
|
|
@@ -61,6 +85,8 @@ export class ActionRuntime {
|
|
|
61
85
|
actions: [action],
|
|
62
86
|
timestamp: new Date().toISOString(),
|
|
63
87
|
...(this.deps.dryRun ? { dry_run: true } : {}),
|
|
88
|
+
...(ctx.parentCorrelationId && { parent_correlation_id: ctx.parentCorrelationId }),
|
|
89
|
+
...(ctx.parentAutomationId && { parent_automation_id: ctx.parentAutomationId }),
|
|
64
90
|
...extra,
|
|
65
91
|
};
|
|
66
92
|
}
|
|
@@ -64,7 +64,8 @@ export class ApiServer {
|
|
|
64
64
|
json(res, 404, { error: `no automation with id "${id}"` });
|
|
65
65
|
return;
|
|
66
66
|
}
|
|
67
|
-
|
|
67
|
+
const correlation_id = crypto.randomUUID();
|
|
68
|
+
this.deps.onTrigger(automation, { type: 'on_start', correlation_id, root_correlation_id: correlation_id });
|
|
68
69
|
json(res, 200, { ok: true });
|
|
69
70
|
}
|
|
70
71
|
postReload(res) {
|
|
@@ -3,6 +3,9 @@ import type { Action } from '../types/actions.js';
|
|
|
3
3
|
export interface ObsEvent {
|
|
4
4
|
schema: 'home.events.v1';
|
|
5
5
|
correlation_id: string;
|
|
6
|
+
root_correlation_id?: string;
|
|
7
|
+
parent_correlation_id?: string;
|
|
8
|
+
parent_automation_id?: string;
|
|
6
9
|
automation_id: string;
|
|
7
10
|
location: string;
|
|
8
11
|
subsystem: string;
|
|
@@ -23,6 +23,14 @@ export interface StateChangedEvent {
|
|
|
23
23
|
old_state: EntityState | undefined;
|
|
24
24
|
new_state: EntityState;
|
|
25
25
|
correlation_id: string;
|
|
26
|
+
parent_correlation_id?: string;
|
|
27
|
+
root_correlation_id?: string;
|
|
28
|
+
parent_automation_id?: string;
|
|
29
|
+
}
|
|
30
|
+
export interface WriteOrigin {
|
|
31
|
+
correlationId: string;
|
|
32
|
+
rootCorrelationId?: string;
|
|
33
|
+
automationId: string;
|
|
26
34
|
}
|
|
27
35
|
export declare interface HAClient {
|
|
28
36
|
on(event: 'state_changed', listener: (e: StateChangedEvent) => void): this;
|
|
@@ -37,6 +45,7 @@ export declare class HAClient extends EventEmitter {
|
|
|
37
45
|
private readonly labelToEntities;
|
|
38
46
|
private readonly entityToLabels;
|
|
39
47
|
private readonly areaToEntities;
|
|
48
|
+
private readonly pendingWrites;
|
|
40
49
|
private connection;
|
|
41
50
|
private reconnecting;
|
|
42
51
|
private _readyResolve;
|
|
@@ -44,7 +53,12 @@ export declare class HAClient extends EventEmitter {
|
|
|
44
53
|
readonly state: HAState;
|
|
45
54
|
readonly context: HAContext;
|
|
46
55
|
get entityCount(): number;
|
|
47
|
-
|
|
56
|
+
get registryStats(): {
|
|
57
|
+
labels: number;
|
|
58
|
+
areas: number;
|
|
59
|
+
};
|
|
60
|
+
callService(domain: string, service: string, target?: HassServiceTarget, data?: Record<string, unknown>, origin?: WriteOrigin): Promise<void>;
|
|
61
|
+
registerPendingWrite(entityId: string, origin: WriteOrigin): void;
|
|
48
62
|
disconnect(): void;
|
|
49
63
|
connect(url: string, token: string): Promise<void>;
|
|
50
64
|
private repopulateCache;
|
|
@@ -1,10 +1,14 @@
|
|
|
1
1
|
import { callService, createConnection, createLongLivedTokenAuth, subscribeEntities, } from 'home-assistant-js-websocket';
|
|
2
2
|
import { EventEmitter } from 'node:events';
|
|
3
|
+
const PENDING_WRITE_TTL_MS = 2000;
|
|
3
4
|
export class HAClient extends EventEmitter {
|
|
4
5
|
stateCache = new Map();
|
|
5
6
|
labelToEntities = new Map();
|
|
6
7
|
entityToLabels = new Map();
|
|
7
8
|
areaToEntities = new Map();
|
|
9
|
+
// entity_id -> origin of the write that's expected to produce a state_changed for it.
|
|
10
|
+
// Self-evicts after PENDING_WRITE_TTL_MS; consumed (deleted) the moment it's matched.
|
|
11
|
+
pendingWrites = new Map();
|
|
8
12
|
connection = null;
|
|
9
13
|
// True between a disconnect event and the next subscribeEntities callback.
|
|
10
14
|
// During this window we repopulate silently — no state_changed events emitted.
|
|
@@ -23,11 +27,26 @@ export class HAClient extends EventEmitter {
|
|
|
23
27
|
get entityCount() {
|
|
24
28
|
return this.stateCache.size;
|
|
25
29
|
}
|
|
26
|
-
|
|
30
|
+
get registryStats() {
|
|
31
|
+
return { labels: this.labelToEntities.size, areas: this.areaToEntities.size };
|
|
32
|
+
}
|
|
33
|
+
async callService(domain, service, target, data, origin) {
|
|
27
34
|
if (!this.connection)
|
|
28
35
|
throw new Error('HAClient not connected');
|
|
36
|
+
const entityId = target?.entity_id;
|
|
37
|
+
if (origin && entityId)
|
|
38
|
+
this.registerPendingWrite(entityId, origin);
|
|
29
39
|
await callService(this.connection, domain, service, data, target);
|
|
30
40
|
}
|
|
41
|
+
// Registers that `origin` is expected to produce a state_changed for `entityId` — consumed
|
|
42
|
+
// (and stamped as parent_correlation_id/parent_automation_id) by diffAndUpdate on match, or
|
|
43
|
+
// self-evicted after PENDING_WRITE_TTL_MS if nothing arrives. Used by ha.call_service writes
|
|
44
|
+
// (automatically, via target.entity_id) and by mqtt.publish writes that declare `impliesEntity`
|
|
45
|
+
// (explicitly, since a topic alone carries no entity information — see #28, #138).
|
|
46
|
+
registerPendingWrite(entityId, origin) {
|
|
47
|
+
this.pendingWrites.set(entityId, origin);
|
|
48
|
+
setTimeout(() => this.pendingWrites.delete(entityId), PENDING_WRITE_TTL_MS);
|
|
49
|
+
}
|
|
31
50
|
disconnect() {
|
|
32
51
|
this.connection?.close();
|
|
33
52
|
this.connection = null;
|
|
@@ -88,7 +107,18 @@ export class HAClient extends EventEmitter {
|
|
|
88
107
|
// last_updated changes whenever state or attributes change in HA.
|
|
89
108
|
if (!old_state || old_state.last_updated !== new_state.last_updated) {
|
|
90
109
|
this.stateCache.set(id, new_state);
|
|
91
|
-
|
|
110
|
+
const correlation_id = crypto.randomUUID();
|
|
111
|
+
const origin = this.pendingWrites.get(id);
|
|
112
|
+
if (origin)
|
|
113
|
+
this.pendingWrites.delete(id);
|
|
114
|
+
this.emit('state_changed', {
|
|
115
|
+
entity_id: id,
|
|
116
|
+
old_state,
|
|
117
|
+
new_state,
|
|
118
|
+
correlation_id,
|
|
119
|
+
root_correlation_id: origin ? (origin.rootCorrelationId ?? origin.correlationId) : correlation_id,
|
|
120
|
+
...(origin && { parent_correlation_id: origin.correlationId, parent_automation_id: origin.automationId }),
|
|
121
|
+
});
|
|
92
122
|
}
|
|
93
123
|
}
|
|
94
124
|
// Prune entities that disappeared from the snapshot.
|
|
@@ -5,15 +5,19 @@ export async function runPipeline(automation, event, haClient, deps) {
|
|
|
5
5
|
trigger_type: event.type,
|
|
6
6
|
});
|
|
7
7
|
const correlationId = event.correlation_id;
|
|
8
|
+
const rootCorrelationId = event.root_correlation_id ?? correlationId;
|
|
8
9
|
const timestamp = new Date().toISOString();
|
|
9
10
|
const base = {
|
|
10
11
|
schema: 'home.events.v1',
|
|
11
12
|
correlation_id: correlationId,
|
|
13
|
+
root_correlation_id: rootCorrelationId,
|
|
12
14
|
automation_id: automation.id,
|
|
13
15
|
location: automation.location,
|
|
14
16
|
subsystem: automation.subsystem,
|
|
15
17
|
timestamp,
|
|
16
18
|
...(deps.dryRun ? { dry_run: true } : {}),
|
|
19
|
+
...(event.parent_correlation_id && { parent_correlation_id: event.parent_correlation_id }),
|
|
20
|
+
...(event.parent_automation_id && { parent_automation_id: event.parent_automation_id }),
|
|
17
21
|
};
|
|
18
22
|
// Step 1: Enabled check
|
|
19
23
|
if (automation.enabled === false) {
|
|
@@ -60,6 +64,9 @@ export async function runPipeline(automation, event, haClient, deps) {
|
|
|
60
64
|
automationId: automation.id,
|
|
61
65
|
location: automation.location,
|
|
62
66
|
subsystem: automation.subsystem,
|
|
67
|
+
rootCorrelationId,
|
|
68
|
+
...(event.parent_correlation_id && { parentCorrelationId: event.parent_correlation_id }),
|
|
69
|
+
...(event.parent_automation_id && { parentAutomationId: event.parent_automation_id }),
|
|
63
70
|
}),
|
|
64
71
|
]);
|
|
65
72
|
}
|
|
@@ -10,7 +10,10 @@ export class AutomationRegistry {
|
|
|
10
10
|
cb();
|
|
11
11
|
}
|
|
12
12
|
unregister(id) {
|
|
13
|
-
this.automations.delete(id)
|
|
13
|
+
if (!this.automations.delete(id))
|
|
14
|
+
return;
|
|
15
|
+
for (const cb of this.changeCallbacks)
|
|
16
|
+
cb();
|
|
14
17
|
}
|
|
15
18
|
getAll() {
|
|
16
19
|
return Array.from(this.automations.values());
|
|
@@ -12,7 +12,8 @@ export class Scheduler {
|
|
|
12
12
|
start() {
|
|
13
13
|
this.registerCronTriggers(this.automations);
|
|
14
14
|
this.ready.then(() => {
|
|
15
|
-
|
|
15
|
+
const correlation_id = crypto.randomUUID();
|
|
16
|
+
this.dispatch({ type: 'on_start', correlation_id, root_correlation_id: correlation_id });
|
|
16
17
|
}).catch((err) => {
|
|
17
18
|
console.error('[scheduler] ready promise rejected:', err);
|
|
18
19
|
});
|
|
@@ -27,22 +28,28 @@ export class Scheduler {
|
|
|
27
28
|
this.cleanups.length = 0;
|
|
28
29
|
}
|
|
29
30
|
registerCronTriggers(automations) {
|
|
31
|
+
const automationIdsByExpression = new Map();
|
|
30
32
|
for (const automation of automations) {
|
|
31
33
|
for (const trigger of automation.triggers) {
|
|
32
34
|
if (trigger.type === 'schedule') {
|
|
33
|
-
const
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
this.dispatch({ type: 'schedule', cron: expression, correlation_id: crypto.randomUUID() });
|
|
37
|
-
});
|
|
38
|
-
this.cleanups.push(() => task.stop());
|
|
39
|
-
console.log(`[scheduler] registered cron "${expression}" for ${automation.id}`);
|
|
40
|
-
}
|
|
41
|
-
catch (err) {
|
|
42
|
-
console.error(`[scheduler] failed to register cron "${expression}" for ${automation.id}:`, err);
|
|
43
|
-
}
|
|
35
|
+
const ids = automationIdsByExpression.get(trigger.cron) ?? [];
|
|
36
|
+
ids.push(automation.id);
|
|
37
|
+
automationIdsByExpression.set(trigger.cron, ids);
|
|
44
38
|
}
|
|
45
39
|
}
|
|
46
40
|
}
|
|
41
|
+
for (const [expression, automationIds] of automationIdsByExpression) {
|
|
42
|
+
try {
|
|
43
|
+
const task = cron.schedule(expression, () => {
|
|
44
|
+
const correlation_id = crypto.randomUUID();
|
|
45
|
+
this.dispatch({ type: 'schedule', cron: expression, correlation_id, root_correlation_id: correlation_id });
|
|
46
|
+
});
|
|
47
|
+
this.cleanups.push(() => task.stop());
|
|
48
|
+
console.log(`[scheduler] registered cron "${expression}" for ${automationIds.join(', ')}`);
|
|
49
|
+
}
|
|
50
|
+
catch (err) {
|
|
51
|
+
console.error(`[scheduler] failed to register cron "${expression}" for ${automationIds.join(', ')}:`, err);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
47
54
|
}
|
|
48
55
|
}
|
|
@@ -8,7 +8,8 @@ export class TimerManager {
|
|
|
8
8
|
this.cancel(timerKey);
|
|
9
9
|
const handle = setTimeout(() => {
|
|
10
10
|
this.timers.delete(timerKey);
|
|
11
|
-
|
|
11
|
+
const correlation_id = crypto.randomUUID();
|
|
12
|
+
this.dispatch({ type: 'timer_expired', timerKey, correlation_id, root_correlation_id: correlation_id });
|
|
12
13
|
}, delayMs);
|
|
13
14
|
this.timers.set(timerKey, handle);
|
|
14
15
|
}
|
|
@@ -17,7 +17,10 @@ export declare class TriggerEngine {
|
|
|
17
17
|
private readonly buttonHandlers;
|
|
18
18
|
private regexButtonTriggers;
|
|
19
19
|
private readonly durationTimers;
|
|
20
|
+
private readonly subscribedTopics;
|
|
21
|
+
private started;
|
|
20
22
|
constructor(registry: AutomationRegistry, haClient: HAClient, onMatch: (automation: Automation<unknown>, event: TriggerEvent) => void, mqttClient?: MqttClient | undefined, metrics?: MetricsBackend | undefined);
|
|
23
|
+
private syncMqttSubscriptions;
|
|
21
24
|
private rebuildButtonHandlers;
|
|
22
25
|
start(): void;
|
|
23
26
|
dispatch(event: TriggerEvent): void;
|
|
@@ -14,7 +14,7 @@ class ButtonGestureHandler {
|
|
|
14
14
|
this.dispatch = dispatch;
|
|
15
15
|
this.supportsDoublePress = supportsDoublePress;
|
|
16
16
|
}
|
|
17
|
-
handle(actionState,
|
|
17
|
+
handle(actionState, corr) {
|
|
18
18
|
if (actionState === '')
|
|
19
19
|
return;
|
|
20
20
|
const parsed = parseButtonAction(actionState);
|
|
@@ -31,7 +31,7 @@ class ButtonGestureHandler {
|
|
|
31
31
|
}
|
|
32
32
|
if (!this.holdFired) {
|
|
33
33
|
this.holdFired = true;
|
|
34
|
-
this.dispatch({ type: 'button', entity_id: this.entityId, gesture: 'hold', button,
|
|
34
|
+
this.dispatch({ type: 'button', entity_id: this.entityId, gesture: 'hold', button, ...corr });
|
|
35
35
|
}
|
|
36
36
|
return;
|
|
37
37
|
}
|
|
@@ -50,14 +50,14 @@ class ButtonGestureHandler {
|
|
|
50
50
|
this.shortFired = true;
|
|
51
51
|
if (this.gestureState === 'idle') {
|
|
52
52
|
if (!this.supportsDoublePress) {
|
|
53
|
-
this.dispatch({ type: 'button', entity_id: this.entityId, gesture: 'single_press', button,
|
|
53
|
+
this.dispatch({ type: 'button', entity_id: this.entityId, gesture: 'single_press', button, ...corr });
|
|
54
54
|
}
|
|
55
55
|
else {
|
|
56
56
|
this.gestureState = 'resolving';
|
|
57
57
|
this.resolveTimer = setTimeout(() => {
|
|
58
58
|
this.resolveTimer = null;
|
|
59
59
|
this.gestureState = 'idle';
|
|
60
|
-
this.dispatch({ type: 'button', entity_id: this.entityId, gesture: 'single_press', button,
|
|
60
|
+
this.dispatch({ type: 'button', entity_id: this.entityId, gesture: 'single_press', button, ...corr });
|
|
61
61
|
}, DOUBLE_PRESS_WINDOW_MS);
|
|
62
62
|
}
|
|
63
63
|
}
|
|
@@ -66,7 +66,7 @@ class ButtonGestureHandler {
|
|
|
66
66
|
clearTimeout(this.resolveTimer);
|
|
67
67
|
this.resolveTimer = null;
|
|
68
68
|
this.gestureState = 'idle';
|
|
69
|
-
this.dispatch({ type: 'button', entity_id: this.entityId, gesture: 'double_press', button,
|
|
69
|
+
this.dispatch({ type: 'button', entity_id: this.entityId, gesture: 'double_press', button, ...corr });
|
|
70
70
|
}
|
|
71
71
|
}
|
|
72
72
|
}
|
|
@@ -95,6 +95,8 @@ export class TriggerEngine {
|
|
|
95
95
|
buttonHandlers = new Map();
|
|
96
96
|
regexButtonTriggers = [];
|
|
97
97
|
durationTimers = new Map();
|
|
98
|
+
subscribedTopics = new Set();
|
|
99
|
+
started = false;
|
|
98
100
|
constructor(registry, haClient, onMatch, mqttClient, metrics) {
|
|
99
101
|
this.registry = registry;
|
|
100
102
|
this.haClient = haClient;
|
|
@@ -102,7 +104,38 @@ export class TriggerEngine {
|
|
|
102
104
|
this.mqttClient = mqttClient;
|
|
103
105
|
this.metrics = metrics;
|
|
104
106
|
this.rebuildButtonHandlers();
|
|
105
|
-
registry.onChange(() =>
|
|
107
|
+
registry.onChange(() => {
|
|
108
|
+
this.rebuildButtonHandlers();
|
|
109
|
+
this.syncMqttSubscriptions();
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
// Reconciles the set of subscribed mqtt_in topics against the registry's current
|
|
113
|
+
// automations. Called on start() and again whenever the registry changes (hot
|
|
114
|
+
// reload / git-sync rescan) so topics added or removed after startup take effect.
|
|
115
|
+
syncMqttSubscriptions() {
|
|
116
|
+
if (!this.mqttClient || !this.started)
|
|
117
|
+
return;
|
|
118
|
+
const desired = new Set();
|
|
119
|
+
for (const automation of this.registry.getAll()) {
|
|
120
|
+
for (const trigger of automation.triggers) {
|
|
121
|
+
if (trigger.type === 'mqtt_in')
|
|
122
|
+
desired.add(trigger.topic);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
for (const topic of desired) {
|
|
126
|
+
if (!this.subscribedTopics.has(topic)) {
|
|
127
|
+
this.mqttClient.subscribe(topic);
|
|
128
|
+
this.subscribedTopics.add(topic);
|
|
129
|
+
console.log(`[trigger-engine] mqtt subscribed: ${topic}`);
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
for (const topic of this.subscribedTopics) {
|
|
133
|
+
if (!desired.has(topic)) {
|
|
134
|
+
this.mqttClient.unsubscribe(topic);
|
|
135
|
+
this.subscribedTopics.delete(topic);
|
|
136
|
+
console.log(`[trigger-engine] mqtt unsubscribed: ${topic}`);
|
|
137
|
+
}
|
|
138
|
+
}
|
|
106
139
|
}
|
|
107
140
|
rebuildButtonHandlers() {
|
|
108
141
|
this.buttonHandlers.clear();
|
|
@@ -137,6 +170,12 @@ export class TriggerEngine {
|
|
|
137
170
|
.then(() => {
|
|
138
171
|
this.haClient.on('state_changed', (event) => {
|
|
139
172
|
this.metrics?.incrementCounter('homerun_ha_events_received_total', { event_type: 'state_changed' });
|
|
173
|
+
const corr = {
|
|
174
|
+
correlation_id: event.correlation_id,
|
|
175
|
+
...(event.parent_correlation_id && { parent_correlation_id: event.parent_correlation_id }),
|
|
176
|
+
...(event.root_correlation_id && { root_correlation_id: event.root_correlation_id }),
|
|
177
|
+
...(event.parent_automation_id && { parent_automation_id: event.parent_automation_id }),
|
|
178
|
+
};
|
|
140
179
|
let handler = this.buttonHandlers.get(event.entity_id);
|
|
141
180
|
if (!handler) {
|
|
142
181
|
const matchingGestures = new Set();
|
|
@@ -152,7 +191,7 @@ export class TriggerEngine {
|
|
|
152
191
|
}
|
|
153
192
|
}
|
|
154
193
|
if (handler) {
|
|
155
|
-
handler.handle(event.new_state.state,
|
|
194
|
+
handler.handle(event.new_state.state, corr);
|
|
156
195
|
}
|
|
157
196
|
else {
|
|
158
197
|
this.dispatch({
|
|
@@ -160,7 +199,7 @@ export class TriggerEngine {
|
|
|
160
199
|
entity_id: event.entity_id,
|
|
161
200
|
old_state: event.old_state,
|
|
162
201
|
new_state: event.new_state,
|
|
163
|
-
|
|
202
|
+
...corr,
|
|
164
203
|
});
|
|
165
204
|
}
|
|
166
205
|
});
|
|
@@ -169,16 +208,8 @@ export class TriggerEngine {
|
|
|
169
208
|
console.error('[trigger-engine] failed to start:', err);
|
|
170
209
|
});
|
|
171
210
|
if (this.mqttClient) {
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
for (const trigger of automation.triggers) {
|
|
175
|
-
if (trigger.type === 'mqtt_in')
|
|
176
|
-
topics.add(trigger.topic);
|
|
177
|
-
}
|
|
178
|
-
}
|
|
179
|
-
for (const topic of topics) {
|
|
180
|
-
this.mqttClient.subscribe(topic);
|
|
181
|
-
}
|
|
211
|
+
this.started = true;
|
|
212
|
+
this.syncMqttSubscriptions();
|
|
182
213
|
this.mqttClient.subscribe('homerun/trigger/+');
|
|
183
214
|
this.mqttClient.on('message', (topic, payload) => {
|
|
184
215
|
if (topic.startsWith('homerun/trigger/')) {
|
|
@@ -188,14 +219,17 @@ export class TriggerEngine {
|
|
|
188
219
|
console.warn(`[trigger-engine] manual trigger: no automation with id "${automationId}"`);
|
|
189
220
|
return;
|
|
190
221
|
}
|
|
191
|
-
|
|
222
|
+
const correlation_id = crypto.randomUUID();
|
|
223
|
+
this.onMatch(automation, { type: 'on_start', correlation_id, root_correlation_id: correlation_id });
|
|
192
224
|
return;
|
|
193
225
|
}
|
|
226
|
+
const correlation_id = `mqtt-${Date.now()}`;
|
|
194
227
|
this.dispatch({
|
|
195
228
|
type: 'mqtt_in',
|
|
196
229
|
topic,
|
|
197
230
|
payload: payload.toString(),
|
|
198
|
-
correlation_id
|
|
231
|
+
correlation_id,
|
|
232
|
+
root_correlation_id: correlation_id,
|
|
199
233
|
});
|
|
200
234
|
});
|
|
201
235
|
}
|
package/dist/src/index.js
CHANGED
|
@@ -115,7 +115,8 @@ haClient.on('reconnected', () => {
|
|
|
115
115
|
await haClient.connect(config.homeassistant.url, config.homeassistant.token);
|
|
116
116
|
await haClient.ready;
|
|
117
117
|
haReady = true;
|
|
118
|
-
|
|
118
|
+
const { labels, areas } = haClient.registryStats;
|
|
119
|
+
console.log(`[homerun] ready — ${haClient.entityCount} entities cached (${labels} labels, ${areas} areas)`);
|
|
119
120
|
eventPublisher.publishLifecycle('server_started', registry.getAll().length, dryRun);
|
|
120
121
|
// 8. Graceful SIGTERM shutdown.
|
|
121
122
|
process.on('SIGTERM', () => {
|
|
@@ -25,6 +25,8 @@ export type Trigger = {
|
|
|
25
25
|
type TriggerEventBase = {
|
|
26
26
|
correlation_id: string;
|
|
27
27
|
parent_correlation_id?: string;
|
|
28
|
+
root_correlation_id?: string;
|
|
29
|
+
parent_automation_id?: string;
|
|
28
30
|
};
|
|
29
31
|
export type TriggerEvent = TriggerEventBase & ({
|
|
30
32
|
type: 'state_changed';
|