@ajclarkson/homerun 0.0.1-edge.f81eb62 → 0.0.1-edge.fcf7bc5
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/README.md +17 -1
- package/dist/src/framework/action-runtime.js +2 -2
- package/dist/src/framework/api-server.d.ts +1 -0
- package/dist/src/framework/api-server.js +9 -1
- package/dist/src/framework/hot-reload.js +2 -1
- package/dist/src/framework/observability.d.ts +10 -1
- package/dist/src/framework/observability.js +13 -0
- package/dist/src/framework/pipeline.js +9 -4
- package/dist/src/framework/trigger-engine.d.ts +1 -0
- package/dist/src/framework/trigger-engine.js +78 -27
- package/dist/src/index.js +15 -5
- package/dist/src/testing.d.ts +2 -0
- package/dist/src/testing.js +2 -2
- package/dist/src/types/automation.d.ts +1 -0
- package/dist/src/types/triggers.d.ts +1 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -131,6 +131,7 @@ const ctx = kitchenLights.context(mockState, mockHAContext);
|
|
|
131
131
|
| `on_start` | The system is ready and the state cache is fully populated. |
|
|
132
132
|
| `timer_expired` | A named timer set by a previous `timer.start` action expires. |
|
|
133
133
|
| `button` | A Zigbee button entity emits a `single_press`, `double_press`, or `hold` gesture. |
|
|
134
|
+
| `mqtt_in` | A message arrives on a subscribed MQTT topic. |
|
|
134
135
|
|
|
135
136
|
```typescript
|
|
136
137
|
triggers: [
|
|
@@ -192,7 +193,7 @@ Every pipeline run publishes a decision snapshot to MQTT — whether it complete
|
|
|
192
193
|
"automation_id": "kitchen:lighting",
|
|
193
194
|
"location": "kitchen",
|
|
194
195
|
"subsystem": "lighting",
|
|
195
|
-
"
|
|
196
|
+
"event_type": "decision",
|
|
196
197
|
"decision": "lights_on",
|
|
197
198
|
"inputs": { "motion": true, "lux": 20, "luxThreshold": 40 },
|
|
198
199
|
"actions": [{ "type": "ha.call_service", "domain": "light", "service": "turn_on" }],
|
|
@@ -230,6 +231,21 @@ Set `DRY_RUN=true` to run the full pipeline — context, reduce, observability
|
|
|
230
231
|
|
|
231
232
|
---
|
|
232
233
|
|
|
234
|
+
## API
|
|
235
|
+
|
|
236
|
+
The server exposes an HTTP API on port `7070` by default.
|
|
237
|
+
|
|
238
|
+
| Method | Path | Description |
|
|
239
|
+
|--------|------|-------------|
|
|
240
|
+
| `GET` | `/health/live` | Always returns `200 { status: "live" }` — used for liveness probes. |
|
|
241
|
+
| `GET` | `/health/ready` | Returns `200` when the HA state cache is populated and automations are loaded, `503` while starting. Response includes `entities`, `automations` counts, and `dry_run: true` if running in dry-run mode. |
|
|
242
|
+
| `GET` | `/automations` | Lists all registered automations with their `id`, `location`, `subsystem`, and trigger types. |
|
|
243
|
+
| `POST` | `/automations/:id/trigger` | Manually fires an `on_start` event for the given automation. |
|
|
244
|
+
| `POST` | `/reload` | Rescans the automations directory and hot-reloads changed files. |
|
|
245
|
+
| `GET` | `/events` | Server-sent event stream of all pipeline decisions and action events in real time. |
|
|
246
|
+
|
|
247
|
+
---
|
|
248
|
+
|
|
233
249
|
## Architecture
|
|
234
250
|
|
|
235
251
|
```
|
|
@@ -41,14 +41,14 @@ export class ActionRuntime {
|
|
|
41
41
|
}
|
|
42
42
|
}
|
|
43
43
|
}
|
|
44
|
-
makeEvent(ctx,
|
|
44
|
+
makeEvent(ctx, event_type, action, extra = {}) {
|
|
45
45
|
return {
|
|
46
46
|
schema: 'home.events.v1',
|
|
47
47
|
correlation_id: ctx.correlationId,
|
|
48
48
|
automation_id: ctx.automationId,
|
|
49
49
|
location: ctx.location,
|
|
50
50
|
subsystem: ctx.subsystem,
|
|
51
|
-
|
|
51
|
+
event_type,
|
|
52
52
|
actions: [action],
|
|
53
53
|
timestamp: new Date().toISOString(),
|
|
54
54
|
...(this.deps.dryRun ? { dry_run: true } : {}),
|
|
@@ -51,7 +51,8 @@ export class ApiServer {
|
|
|
51
51
|
id: a.id,
|
|
52
52
|
location: a.location,
|
|
53
53
|
subsystem: a.subsystem,
|
|
54
|
-
|
|
54
|
+
enabled: a.enabled ?? true,
|
|
55
|
+
triggers: a.triggers.map(serializeTrigger),
|
|
55
56
|
}));
|
|
56
57
|
json(res, 200, automations);
|
|
57
58
|
}
|
|
@@ -84,6 +85,7 @@ export class ApiServer {
|
|
|
84
85
|
status: 'ready',
|
|
85
86
|
entities: this.deps.entityCount(),
|
|
86
87
|
automations: this.deps.registry.getAll().length,
|
|
88
|
+
...(this.deps.dryRun && { dry_run: true }),
|
|
87
89
|
});
|
|
88
90
|
}
|
|
89
91
|
getEvents(req, res) {
|
|
@@ -99,6 +101,12 @@ export class ApiServer {
|
|
|
99
101
|
req.on('close', unsubscribe);
|
|
100
102
|
}
|
|
101
103
|
}
|
|
104
|
+
function serializeTrigger(trigger) {
|
|
105
|
+
if (trigger.type === 'state_changed' && trigger.entity instanceof RegExp) {
|
|
106
|
+
return { ...trigger, entity: trigger.entity.toString() };
|
|
107
|
+
}
|
|
108
|
+
return trigger;
|
|
109
|
+
}
|
|
102
110
|
function json(res, status, body) {
|
|
103
111
|
const payload = JSON.stringify(body);
|
|
104
112
|
res.writeHead(status, { 'Content-Type': 'application/json' });
|
|
@@ -45,11 +45,12 @@ export function _deleteFile(filePath, registry, fileToIds = moduleFileToIds) {
|
|
|
45
45
|
}
|
|
46
46
|
fileToIds.delete(filePath);
|
|
47
47
|
}
|
|
48
|
+
const EXCLUDED_DIRS = new Set(['types', 'lib']);
|
|
48
49
|
const isAutomationFile = (f) => f.endsWith('.ts') &&
|
|
49
50
|
!f.endsWith('.test.ts') &&
|
|
50
51
|
!f.includes('node_modules') &&
|
|
51
52
|
!f.includes('.d.ts') &&
|
|
52
|
-
!f.split(path.sep).
|
|
53
|
+
!f.split(path.sep).some(seg => EXCLUDED_DIRS.has(seg));
|
|
53
54
|
export async function rescanAutomations(automationsDir, registry, fileToIds = moduleFileToIds, importer = defaultImporter) {
|
|
54
55
|
let files = [];
|
|
55
56
|
try {
|
|
@@ -6,7 +6,7 @@ export interface ObsEvent {
|
|
|
6
6
|
automation_id: string;
|
|
7
7
|
location: string;
|
|
8
8
|
subsystem: string;
|
|
9
|
-
|
|
9
|
+
event_type: 'decision' | 'abort' | 'action_started' | 'action_result';
|
|
10
10
|
decision?: string;
|
|
11
11
|
reason?: string;
|
|
12
12
|
inputs?: Record<string, unknown>;
|
|
@@ -14,6 +14,14 @@ export interface ObsEvent {
|
|
|
14
14
|
dry_run?: boolean;
|
|
15
15
|
timestamp: string;
|
|
16
16
|
}
|
|
17
|
+
export type LifecycleEventType = 'server_started' | 'rescan_complete' | 'ha_reconnected';
|
|
18
|
+
export interface LifecycleEvent {
|
|
19
|
+
schema: 'home.lifecycle.v1';
|
|
20
|
+
type: LifecycleEventType;
|
|
21
|
+
automation_count: number;
|
|
22
|
+
timestamp: string;
|
|
23
|
+
dry_run?: boolean;
|
|
24
|
+
}
|
|
17
25
|
export declare class Observability {
|
|
18
26
|
private readonly mqtt;
|
|
19
27
|
private readonly listeners;
|
|
@@ -21,5 +29,6 @@ export declare class Observability {
|
|
|
21
29
|
subscribe(listener: (event: ObsEvent) => void): () => void;
|
|
22
30
|
publishDecision(event: ObsEvent): void;
|
|
23
31
|
publishActionEvent(event: ObsEvent): void;
|
|
32
|
+
publishLifecycle(type: LifecycleEventType, automationCount: number, dryRun?: boolean): void;
|
|
24
33
|
private publish;
|
|
25
34
|
}
|
|
@@ -26,6 +26,19 @@ export class Observability {
|
|
|
26
26
|
for (const l of this.listeners)
|
|
27
27
|
l(event);
|
|
28
28
|
}
|
|
29
|
+
publishLifecycle(type, automationCount, dryRun = false) {
|
|
30
|
+
const event = {
|
|
31
|
+
schema: 'home.lifecycle.v1',
|
|
32
|
+
type,
|
|
33
|
+
automation_count: automationCount,
|
|
34
|
+
timestamp: new Date().toISOString(),
|
|
35
|
+
...(dryRun && { dry_run: true }),
|
|
36
|
+
};
|
|
37
|
+
const ns = dryRun ? 'homerun/dev' : 'homerun';
|
|
38
|
+
const payload = JSON.stringify(event);
|
|
39
|
+
this.publish(`${ns}/lifecycle`, payload, false);
|
|
40
|
+
this.publish(`${ns}/status`, JSON.stringify({ status: 'online', automation_count: automationCount, timestamp: event.timestamp }), true);
|
|
41
|
+
}
|
|
29
42
|
publish(topic, payload, retain) {
|
|
30
43
|
this.mqtt.publishAsync(topic, payload, { retain }).catch((err) => {
|
|
31
44
|
console.error(`[Observability] MQTT publish failed on ${topic}:`, err);
|
|
@@ -11,17 +11,22 @@ export async function runPipeline(automation, event, haClient, deps) {
|
|
|
11
11
|
timestamp,
|
|
12
12
|
...(deps.dryRun ? { dry_run: true } : {}),
|
|
13
13
|
};
|
|
14
|
+
// Step 1: Enabled check
|
|
15
|
+
if (automation.enabled === false) {
|
|
16
|
+
deps.observability.publishDecision({ ...base, event_type: 'abort', reason: 'disabled' });
|
|
17
|
+
return;
|
|
18
|
+
}
|
|
14
19
|
// Step 2: Context
|
|
15
20
|
let ctx;
|
|
16
21
|
try {
|
|
17
22
|
ctx = automation.context(haClient.state, haClient.context, event);
|
|
18
23
|
}
|
|
19
24
|
catch {
|
|
20
|
-
deps.observability.publishDecision({ ...base,
|
|
25
|
+
deps.observability.publishDecision({ ...base, event_type: 'abort', reason: 'unhandled_error' });
|
|
21
26
|
return;
|
|
22
27
|
}
|
|
23
28
|
if (isAbort(ctx)) {
|
|
24
|
-
deps.observability.publishDecision({ ...base,
|
|
29
|
+
deps.observability.publishDecision({ ...base, event_type: 'abort', reason: ctx.reason });
|
|
25
30
|
return;
|
|
26
31
|
}
|
|
27
32
|
// Step 3: Reduce
|
|
@@ -30,14 +35,14 @@ export async function runPipeline(automation, event, haClient, deps) {
|
|
|
30
35
|
result = automation.reduce(ctx);
|
|
31
36
|
}
|
|
32
37
|
catch {
|
|
33
|
-
deps.observability.publishDecision({ ...base,
|
|
38
|
+
deps.observability.publishDecision({ ...base, event_type: 'abort', reason: 'unhandled_error' });
|
|
34
39
|
return;
|
|
35
40
|
}
|
|
36
41
|
// Step 4: Validate — safe defaults
|
|
37
42
|
const actions = result.actions ?? [];
|
|
38
43
|
const decision = {
|
|
39
44
|
...base,
|
|
40
|
-
|
|
45
|
+
event_type: 'decision',
|
|
41
46
|
decision: result.decision,
|
|
42
47
|
reason: result.reason,
|
|
43
48
|
inputs: result.inputs,
|
|
@@ -13,6 +13,7 @@ export declare class TriggerEngine {
|
|
|
13
13
|
private readonly onMatch;
|
|
14
14
|
private readonly mqttClient?;
|
|
15
15
|
private readonly buttonHandlers;
|
|
16
|
+
private regexButtonTriggers;
|
|
16
17
|
private readonly durationTimers;
|
|
17
18
|
constructor(registry: AutomationRegistry, haClient: HAClient, onMatch: (automation: Automation<unknown>, event: TriggerEvent) => void, mqttClient?: MqttClient | undefined);
|
|
18
19
|
private rebuildButtonHandlers;
|
|
@@ -1,49 +1,72 @@
|
|
|
1
|
-
const DOUBLE_PRESS_WINDOW_MS =
|
|
1
|
+
const DOUBLE_PRESS_WINDOW_MS = 250;
|
|
2
2
|
class ButtonGestureHandler {
|
|
3
3
|
entityId;
|
|
4
4
|
dispatch;
|
|
5
5
|
supportsDoublePress;
|
|
6
6
|
gestureState = 'idle';
|
|
7
7
|
resolveTimer = null;
|
|
8
|
+
holdFired = false;
|
|
9
|
+
// Set when 'release' fires a short press; suppresses the trailing confirmation
|
|
10
|
+
// event ('on', 'toggle', etc.) so the same physical press doesn't fire twice.
|
|
11
|
+
shortFired = false;
|
|
8
12
|
constructor(entityId, dispatch, supportsDoublePress) {
|
|
9
13
|
this.entityId = entityId;
|
|
10
14
|
this.dispatch = dispatch;
|
|
11
15
|
this.supportsDoublePress = supportsDoublePress;
|
|
12
16
|
}
|
|
13
17
|
handle(actionState, correlationId) {
|
|
18
|
+
if (actionState === '')
|
|
19
|
+
return;
|
|
14
20
|
const parsed = parseButtonAction(actionState);
|
|
15
21
|
if (!parsed)
|
|
16
22
|
return;
|
|
17
23
|
const { button, pressType } = parsed;
|
|
24
|
+
const isRelease = actionState.toLowerCase() === 'release';
|
|
25
|
+
if (pressType === 'hold') {
|
|
26
|
+
// Cancel any pending double-press window and fire hold exactly once per physical hold.
|
|
27
|
+
if (this.resolveTimer) {
|
|
28
|
+
clearTimeout(this.resolveTimer);
|
|
29
|
+
this.resolveTimer = null;
|
|
30
|
+
this.gestureState = 'idle';
|
|
31
|
+
}
|
|
32
|
+
if (!this.holdFired) {
|
|
33
|
+
this.holdFired = true;
|
|
34
|
+
this.dispatch({ type: 'button', entity_id: this.entityId, gesture: 'hold', button, correlation_id: correlationId });
|
|
35
|
+
}
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
38
|
+
// pressType === 'short'
|
|
39
|
+
if (isRelease && this.holdFired) {
|
|
40
|
+
// Physical release after a hold — reset hold state, don't fire short press.
|
|
41
|
+
this.holdFired = false;
|
|
42
|
+
return;
|
|
43
|
+
}
|
|
44
|
+
if (!isRelease && this.shortFired) {
|
|
45
|
+
// Confirmation event ('on', 'toggle', etc.) after 'release' already fired — suppress.
|
|
46
|
+
this.shortFired = false;
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
if (isRelease)
|
|
50
|
+
this.shortFired = true;
|
|
18
51
|
if (this.gestureState === 'idle') {
|
|
19
|
-
if (
|
|
20
|
-
|
|
21
|
-
this.dispatch({ type: 'button', entity_id: this.entityId, gesture: 'single_press', button, correlation_id: correlationId });
|
|
22
|
-
}
|
|
23
|
-
else {
|
|
24
|
-
this.gestureState = 'resolving';
|
|
25
|
-
this.resolveTimer = setTimeout(() => {
|
|
26
|
-
this.resolveTimer = null;
|
|
27
|
-
this.gestureState = 'idle';
|
|
28
|
-
this.dispatch({ type: 'button', entity_id: this.entityId, gesture: 'single_press', button, correlation_id: correlationId });
|
|
29
|
-
}, DOUBLE_PRESS_WINDOW_MS);
|
|
30
|
-
}
|
|
52
|
+
if (!this.supportsDoublePress) {
|
|
53
|
+
this.dispatch({ type: 'button', entity_id: this.entityId, gesture: 'single_press', button, correlation_id: correlationId });
|
|
31
54
|
}
|
|
32
55
|
else {
|
|
33
|
-
this.
|
|
56
|
+
this.gestureState = 'resolving';
|
|
57
|
+
this.resolveTimer = setTimeout(() => {
|
|
58
|
+
this.resolveTimer = null;
|
|
59
|
+
this.gestureState = 'idle';
|
|
60
|
+
this.dispatch({ type: 'button', entity_id: this.entityId, gesture: 'single_press', button, correlation_id: correlationId });
|
|
61
|
+
}, DOUBLE_PRESS_WINDOW_MS);
|
|
34
62
|
}
|
|
35
63
|
}
|
|
36
64
|
else {
|
|
37
|
-
// resolving —
|
|
65
|
+
// resolving — second short press = double press
|
|
38
66
|
clearTimeout(this.resolveTimer);
|
|
39
67
|
this.resolveTimer = null;
|
|
40
68
|
this.gestureState = 'idle';
|
|
41
|
-
|
|
42
|
-
this.dispatch({ type: 'button', entity_id: this.entityId, gesture: 'double_press', button, correlation_id: correlationId });
|
|
43
|
-
}
|
|
44
|
-
else {
|
|
45
|
-
this.dispatch({ type: 'button', entity_id: this.entityId, gesture: 'hold', button, correlation_id: correlationId });
|
|
46
|
-
}
|
|
69
|
+
this.dispatch({ type: 'button', entity_id: this.entityId, gesture: 'double_press', button, correlation_id: correlationId });
|
|
47
70
|
}
|
|
48
71
|
}
|
|
49
72
|
}
|
|
@@ -57,7 +80,7 @@ export function parseButtonAction(state) {
|
|
|
57
80
|
const action = prefixed ? prefixed[2] : s;
|
|
58
81
|
if (/long|hold/.test(action))
|
|
59
82
|
return { button, pressType: 'hold' };
|
|
60
|
-
if (/short|click|single/.test(action) || action === '
|
|
83
|
+
if (/short|click|single/.test(action) || action === 'on' || action === 'toggle' || action === 'release') {
|
|
61
84
|
return { button, pressType: 'short' };
|
|
62
85
|
}
|
|
63
86
|
return null;
|
|
@@ -69,6 +92,7 @@ export class TriggerEngine {
|
|
|
69
92
|
onMatch;
|
|
70
93
|
mqttClient;
|
|
71
94
|
buttonHandlers = new Map();
|
|
95
|
+
regexButtonTriggers = [];
|
|
72
96
|
durationTimers = new Map();
|
|
73
97
|
constructor(registry, haClient, onMatch, mqttClient) {
|
|
74
98
|
this.registry = registry;
|
|
@@ -80,26 +104,50 @@ export class TriggerEngine {
|
|
|
80
104
|
}
|
|
81
105
|
rebuildButtonHandlers() {
|
|
82
106
|
this.buttonHandlers.clear();
|
|
107
|
+
this.regexButtonTriggers = [];
|
|
83
108
|
const entityGestures = new Map();
|
|
109
|
+
const regexGestures = new Map();
|
|
84
110
|
for (const automation of this.registry.getAll()) {
|
|
85
111
|
for (const trigger of automation.triggers) {
|
|
86
112
|
if (trigger.type === 'button') {
|
|
87
|
-
if (
|
|
88
|
-
entityGestures.
|
|
113
|
+
if (typeof trigger.entity === 'string') {
|
|
114
|
+
if (!entityGestures.has(trigger.entity))
|
|
115
|
+
entityGestures.set(trigger.entity, new Set());
|
|
116
|
+
entityGestures.get(trigger.entity).add(trigger.gesture);
|
|
117
|
+
}
|
|
118
|
+
else {
|
|
119
|
+
if (!regexGestures.has(trigger.entity))
|
|
120
|
+
regexGestures.set(trigger.entity, new Set());
|
|
121
|
+
regexGestures.get(trigger.entity).add(trigger.gesture);
|
|
89
122
|
}
|
|
90
|
-
entityGestures.get(trigger.entity).add(trigger.gesture);
|
|
91
123
|
}
|
|
92
124
|
}
|
|
93
125
|
}
|
|
94
126
|
for (const [entity, gestures] of entityGestures) {
|
|
95
127
|
this.buttonHandlers.set(entity, new ButtonGestureHandler(entity, (e) => this.dispatch(e), gestures.has('double_press')));
|
|
96
128
|
}
|
|
129
|
+
for (const [pattern, gestures] of regexGestures) {
|
|
130
|
+
this.regexButtonTriggers.push({ pattern, gestures });
|
|
131
|
+
}
|
|
97
132
|
}
|
|
98
133
|
start() {
|
|
99
134
|
this.haClient.ready
|
|
100
135
|
.then(() => {
|
|
101
136
|
this.haClient.on('state_changed', (event) => {
|
|
102
|
-
|
|
137
|
+
let handler = this.buttonHandlers.get(event.entity_id);
|
|
138
|
+
if (!handler) {
|
|
139
|
+
const matchingGestures = new Set();
|
|
140
|
+
for (const { pattern, gestures } of this.regexButtonTriggers) {
|
|
141
|
+
if (pattern.test(event.entity_id)) {
|
|
142
|
+
for (const g of gestures)
|
|
143
|
+
matchingGestures.add(g);
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
if (matchingGestures.size > 0) {
|
|
147
|
+
handler = new ButtonGestureHandler(event.entity_id, (e) => this.dispatch(e), matchingGestures.has('double_press'));
|
|
148
|
+
this.buttonHandlers.set(event.entity_id, handler);
|
|
149
|
+
}
|
|
150
|
+
}
|
|
103
151
|
if (handler) {
|
|
104
152
|
handler.handle(event.new_state.state, event.correlation_id);
|
|
105
153
|
}
|
|
@@ -200,7 +248,10 @@ function matchesTrigger(trigger, event) {
|
|
|
200
248
|
case 'button': {
|
|
201
249
|
if (event.type !== 'button')
|
|
202
250
|
return false;
|
|
203
|
-
|
|
251
|
+
const entityMatch = typeof trigger.entity === 'string'
|
|
252
|
+
? trigger.entity === event.entity_id
|
|
253
|
+
: trigger.entity.test(event.entity_id);
|
|
254
|
+
return (entityMatch &&
|
|
204
255
|
trigger.gesture === event.gesture &&
|
|
205
256
|
(trigger.button === undefined || trigger.button === event.button));
|
|
206
257
|
}
|
package/dist/src/index.js
CHANGED
|
@@ -17,8 +17,13 @@ process.on('uncaughtException', (err) => {
|
|
|
17
17
|
process.on('unhandledRejection', (reason) => {
|
|
18
18
|
console.error('[homerun] unhandledRejection:', reason);
|
|
19
19
|
});
|
|
20
|
+
const dryRun = process.env.DRY_RUN === 'true';
|
|
21
|
+
const lwtTopic = dryRun ? 'homerun/dev/status' : 'homerun/status';
|
|
22
|
+
const lwtPayload = JSON.stringify({ status: 'offline', timestamp: new Date().toISOString() });
|
|
20
23
|
// 1. Connect MQTT before anything else (Observability and ActionRuntime need it).
|
|
21
|
-
const mqtt = connect(process.env.MQTT_URL
|
|
24
|
+
const mqtt = connect(process.env.MQTT_URL, {
|
|
25
|
+
will: { topic: lwtTopic, payload: lwtPayload, qos: 1, retain: true },
|
|
26
|
+
});
|
|
22
27
|
await new Promise((resolve, reject) => {
|
|
23
28
|
mqtt.once('connect', () => resolve());
|
|
24
29
|
mqtt.once('error', reject);
|
|
@@ -36,7 +41,7 @@ const actionRuntime = new ActionRuntime({
|
|
|
36
41
|
mqttClient: mqtt,
|
|
37
42
|
timerManager,
|
|
38
43
|
observability,
|
|
39
|
-
dryRun
|
|
44
|
+
dryRun,
|
|
40
45
|
});
|
|
41
46
|
// 3. Initial automation load — must complete before the engine and scheduler start.
|
|
42
47
|
const automationsDir = path.resolve(process.env.AUTOMATIONS_DIR);
|
|
@@ -44,7 +49,7 @@ await rescanAutomations(automationsDir, registry);
|
|
|
44
49
|
console.log(`[homerun] loaded ${registry.getAll().length} automation(s)`);
|
|
45
50
|
// 4. Wire up the engine and scheduler.
|
|
46
51
|
engine = new TriggerEngine(registry, haClient, (automation, event) => {
|
|
47
|
-
runPipeline(automation, event, haClient, { observability, actionRuntime, dryRun
|
|
52
|
+
runPipeline(automation, event, haClient, { observability, actionRuntime, dryRun }).catch((err) => {
|
|
48
53
|
console.error('[homerun] pipeline error:', err);
|
|
49
54
|
});
|
|
50
55
|
}, mqtt);
|
|
@@ -55,7 +60,9 @@ scheduler.start();
|
|
|
55
60
|
startHotReload(automationsDir, registry);
|
|
56
61
|
async function reload() {
|
|
57
62
|
await rescanAutomations(automationsDir, registry);
|
|
58
|
-
|
|
63
|
+
const count = registry.getAll().length;
|
|
64
|
+
console.log(`[homerun] rescan complete — ${count} automation(s) registered`);
|
|
65
|
+
observability.publishLifecycle('rescan_complete', count, dryRun);
|
|
59
66
|
}
|
|
60
67
|
process.on('SIGUSR1', () => {
|
|
61
68
|
console.log('[homerun] SIGUSR1 received — rescanning automations');
|
|
@@ -68,7 +75,7 @@ let haReady = false;
|
|
|
68
75
|
const apiServer = new ApiServer({
|
|
69
76
|
registry,
|
|
70
77
|
onTrigger: (automation, event) => {
|
|
71
|
-
runPipeline(automation, event, haClient, { observability, actionRuntime, dryRun
|
|
78
|
+
runPipeline(automation, event, haClient, { observability, actionRuntime, dryRun }).catch((err) => {
|
|
72
79
|
console.error('[homerun] pipeline error (http trigger):', err);
|
|
73
80
|
});
|
|
74
81
|
},
|
|
@@ -76,13 +83,16 @@ const apiServer = new ApiServer({
|
|
|
76
83
|
isReady: () => haReady,
|
|
77
84
|
entityCount: () => haClient.entityCount,
|
|
78
85
|
observability,
|
|
86
|
+
dryRun,
|
|
79
87
|
});
|
|
80
88
|
await apiServer.start(Number(process.env.API_PORT ?? 7070));
|
|
81
89
|
// 7. Connect to HA last — state_changed events start flowing once ready resolves.
|
|
82
90
|
haClient.on('reconnected', () => {
|
|
83
91
|
console.log(`[homerun] reconnected — ${haClient.entityCount} entities refreshed`);
|
|
92
|
+
observability.publishLifecycle('ha_reconnected', registry.getAll().length, dryRun);
|
|
84
93
|
});
|
|
85
94
|
await haClient.connect(process.env.HA_URL, process.env.HA_TOKEN);
|
|
86
95
|
await haClient.ready;
|
|
87
96
|
haReady = true;
|
|
88
97
|
console.log(`[homerun] ready — ${haClient.entityCount} entities cached`);
|
|
98
|
+
observability.publishLifecycle('server_started', registry.getAll().length, dryRun);
|
package/dist/src/testing.d.ts
CHANGED
package/dist/src/testing.js
CHANGED
|
@@ -9,8 +9,8 @@ export function testAutomation(automation, options) {
|
|
|
9
9
|
entity_id: entityId,
|
|
10
10
|
state: entry.state,
|
|
11
11
|
attributes: entry.attributes ?? {},
|
|
12
|
-
last_changed: '',
|
|
13
|
-
last_updated: '',
|
|
12
|
+
last_changed: entry.last_changed ?? '',
|
|
13
|
+
last_updated: entry.last_updated ?? '',
|
|
14
14
|
};
|
|
15
15
|
};
|
|
16
16
|
const haContext = {
|