@ajclarkson/homerun 0.0.1-edge.0131fc6
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/LICENSE +21 -0
- package/README.md +276 -0
- package/dist/scripts/generate-ha-services.d.ts +16 -0
- package/dist/scripts/generate-ha-services.js +111 -0
- package/dist/scripts/generate-ha-types.d.ts +8 -0
- package/dist/scripts/generate-ha-types.js +75 -0
- package/dist/src/framework/action-runtime.d.ts +39 -0
- package/dist/src/framework/action-runtime.js +193 -0
- package/dist/src/framework/api-server.d.ts +35 -0
- package/dist/src/framework/api-server.js +132 -0
- package/dist/src/framework/config.d.ts +34 -0
- package/dist/src/framework/config.js +93 -0
- package/dist/src/framework/event-publisher.d.ts +65 -0
- package/dist/src/framework/event-publisher.js +85 -0
- package/dist/src/framework/ha-client.d.ts +85 -0
- package/dist/src/framework/ha-client.js +201 -0
- package/dist/src/framework/hot-reload.d.ts +9 -0
- package/dist/src/framework/hot-reload.js +88 -0
- package/dist/src/framework/metrics-prom.d.ts +18 -0
- package/dist/src/framework/metrics-prom.js +93 -0
- package/dist/src/framework/metrics.d.ts +10 -0
- package/dist/src/framework/metrics.js +5 -0
- package/dist/src/framework/pipeline.d.ts +14 -0
- package/dist/src/framework/pipeline.js +85 -0
- package/dist/src/framework/registry.d.ts +10 -0
- package/dist/src/framework/registry.js +24 -0
- package/dist/src/framework/scheduler.d.ts +13 -0
- package/dist/src/framework/scheduler.js +55 -0
- package/dist/src/framework/timer-manager.d.ts +9 -0
- package/dist/src/framework/timer-manager.js +28 -0
- package/dist/src/framework/trigger-engine.d.ts +28 -0
- package/dist/src/framework/trigger-engine.js +321 -0
- package/dist/src/index.d.ts +1 -0
- package/dist/src/index.js +157 -0
- package/dist/src/lib.d.ts +6 -0
- package/dist/src/lib.js +2 -0
- package/dist/src/services.d.ts +178 -0
- package/dist/src/services.js +106 -0
- package/dist/src/testing.d.ts +18 -0
- package/dist/src/testing.js +60 -0
- package/dist/src/types/actions.d.ts +23 -0
- package/dist/src/types/actions.js +1 -0
- package/dist/src/types/automation.d.ts +32 -0
- package/dist/src/types/automation.js +44 -0
- package/dist/src/types/triggers.d.ts +77 -0
- package/dist/src/types/triggers.js +26 -0
- package/package.json +61 -0
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
function safeStringify(err) {
|
|
2
|
+
try {
|
|
3
|
+
return JSON.stringify(err);
|
|
4
|
+
}
|
|
5
|
+
catch {
|
|
6
|
+
return String(err);
|
|
7
|
+
}
|
|
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
|
+
}
|
|
63
|
+
export class ActionRuntime {
|
|
64
|
+
deps;
|
|
65
|
+
constructor(deps) {
|
|
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
|
+
});
|
|
88
|
+
}
|
|
89
|
+
async execute(actions, ctx) {
|
|
90
|
+
for (const action of actions) {
|
|
91
|
+
await this.runAction(action, ctx);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
async runAction(action, ctx) {
|
|
95
|
+
const labels = { location: ctx.location, action_type: action.type };
|
|
96
|
+
this.deps.eventPublisher.publishActionEvent(this.makeStartedEvent(ctx, action));
|
|
97
|
+
this.deps.metrics?.incrementCounter('homerun_actions_dispatched_total', labels);
|
|
98
|
+
const start = performance.now();
|
|
99
|
+
try {
|
|
100
|
+
if (!this.deps.dryRun) {
|
|
101
|
+
await this.dispatch(action, ctx);
|
|
102
|
+
}
|
|
103
|
+
const duration = (performance.now() - start) / 1000;
|
|
104
|
+
this.deps.metrics?.observeHistogram('homerun_action_duration_seconds', duration, labels);
|
|
105
|
+
this.deps.metrics?.incrementCounter('homerun_actions_succeeded_total', labels);
|
|
106
|
+
this.deps.eventPublisher.publishActionEvent(this.makeResultEvent(ctx, action, 'ok'));
|
|
107
|
+
}
|
|
108
|
+
catch (err) {
|
|
109
|
+
const duration = (performance.now() - start) / 1000;
|
|
110
|
+
this.deps.metrics?.observeHistogram('homerun_action_duration_seconds', duration, labels);
|
|
111
|
+
this.deps.metrics?.incrementCounter('homerun_actions_failed_total', labels);
|
|
112
|
+
const error = err instanceof Error
|
|
113
|
+
? err.message
|
|
114
|
+
: typeof err === 'object' && err !== null
|
|
115
|
+
? safeStringify(err)
|
|
116
|
+
: String(err);
|
|
117
|
+
this.deps.eventPublisher.publishActionEvent(this.makeResultEvent(ctx, action, 'error', error));
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
async dispatch(action, ctx) {
|
|
121
|
+
switch (action.type) {
|
|
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
|
+
}
|
|
138
|
+
await this.deps.haClient.callService(action.domain, action.service, action.target, action.data, {
|
|
139
|
+
correlationId: ctx.correlationId,
|
|
140
|
+
rootCorrelationId: ctx.rootCorrelationId,
|
|
141
|
+
automationId: ctx.automationId,
|
|
142
|
+
});
|
|
143
|
+
break;
|
|
144
|
+
}
|
|
145
|
+
case 'mqtt.publish':
|
|
146
|
+
if (action.impliesEntity) {
|
|
147
|
+
this.deps.haClient.registerPendingWrite(action.impliesEntity, {
|
|
148
|
+
correlationId: ctx.correlationId,
|
|
149
|
+
rootCorrelationId: ctx.rootCorrelationId,
|
|
150
|
+
automationId: ctx.automationId,
|
|
151
|
+
});
|
|
152
|
+
}
|
|
153
|
+
await this.deps.mqttClient.publishAsync(action.topic, action.payload, { retain: action.retain ?? false });
|
|
154
|
+
break;
|
|
155
|
+
case 'timer.start':
|
|
156
|
+
this.deps.timerManager.start(action.timerKey, action.delayMs);
|
|
157
|
+
break;
|
|
158
|
+
case 'timer.cancel':
|
|
159
|
+
this.deps.timerManager.cancel(action.timerKey);
|
|
160
|
+
break;
|
|
161
|
+
default: {
|
|
162
|
+
const unknown = action.type;
|
|
163
|
+
throw new Error(`unknown action type: ${unknown}`);
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
baseFields(ctx) {
|
|
168
|
+
return {
|
|
169
|
+
schema: 'home.events.v2',
|
|
170
|
+
correlation_id: ctx.correlationId,
|
|
171
|
+
root_correlation_id: ctx.rootCorrelationId ?? ctx.correlationId,
|
|
172
|
+
automation_id: ctx.automationId,
|
|
173
|
+
location: ctx.location,
|
|
174
|
+
subsystem: ctx.subsystem,
|
|
175
|
+
timestamp: new Date().toISOString(),
|
|
176
|
+
...(this.deps.dryRun ? { dry_run: true } : {}),
|
|
177
|
+
...(ctx.parentCorrelationId && { parent_correlation_id: ctx.parentCorrelationId }),
|
|
178
|
+
...(ctx.parentAutomationId && { parent_automation_id: ctx.parentAutomationId }),
|
|
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 }),
|
|
191
|
+
};
|
|
192
|
+
}
|
|
193
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import type { AutomationRegistry } from './registry.js';
|
|
2
|
+
import type { EventPublisher } from './event-publisher.js';
|
|
3
|
+
import type { Automation } from '../types/automation.js';
|
|
4
|
+
import type { TriggerEvent } from '../types/triggers.js';
|
|
5
|
+
export interface MetricsProvider {
|
|
6
|
+
getMetrics(): Promise<string>;
|
|
7
|
+
contentType: string;
|
|
8
|
+
}
|
|
9
|
+
export interface ApiServerDeps {
|
|
10
|
+
registry: AutomationRegistry;
|
|
11
|
+
onTrigger: (automation: Automation<unknown>, event: TriggerEvent) => void;
|
|
12
|
+
onReload: () => Promise<void>;
|
|
13
|
+
isReady: () => boolean;
|
|
14
|
+
entityCount: () => number;
|
|
15
|
+
eventPublisher: EventPublisher;
|
|
16
|
+
dryRun?: boolean;
|
|
17
|
+
metrics?: MetricsProvider;
|
|
18
|
+
}
|
|
19
|
+
export declare class ApiServer {
|
|
20
|
+
private readonly deps;
|
|
21
|
+
private server;
|
|
22
|
+
private _port;
|
|
23
|
+
constructor(deps: ApiServerDeps);
|
|
24
|
+
get port(): number | null;
|
|
25
|
+
start(port?: number): Promise<void>;
|
|
26
|
+
stop(): Promise<void>;
|
|
27
|
+
private handle;
|
|
28
|
+
private getAutomations;
|
|
29
|
+
private postTrigger;
|
|
30
|
+
private postReload;
|
|
31
|
+
private getHealthLive;
|
|
32
|
+
private getHealthReady;
|
|
33
|
+
private getMetrics;
|
|
34
|
+
private getEvents;
|
|
35
|
+
}
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import { createServer } from 'node:http';
|
|
2
|
+
export class ApiServer {
|
|
3
|
+
deps;
|
|
4
|
+
server = null;
|
|
5
|
+
_port = null;
|
|
6
|
+
constructor(deps) {
|
|
7
|
+
this.deps = deps;
|
|
8
|
+
}
|
|
9
|
+
get port() {
|
|
10
|
+
return this._port;
|
|
11
|
+
}
|
|
12
|
+
start(port = 7070) {
|
|
13
|
+
return new Promise((resolve, reject) => {
|
|
14
|
+
this.server = createServer((req, res) => this.handle(req, res));
|
|
15
|
+
this.server.listen(port, '0.0.0.0', () => {
|
|
16
|
+
const addr = this.server.address();
|
|
17
|
+
this._port = typeof addr === 'object' && addr ? addr.port : port;
|
|
18
|
+
console.log(`[homerun] API server listening on port ${this._port}`);
|
|
19
|
+
resolve();
|
|
20
|
+
});
|
|
21
|
+
this.server.once('error', reject);
|
|
22
|
+
});
|
|
23
|
+
}
|
|
24
|
+
stop() {
|
|
25
|
+
return new Promise((resolve, reject) => {
|
|
26
|
+
if (!this.server)
|
|
27
|
+
return resolve();
|
|
28
|
+
this.server.close((err) => (err ? reject(err) : resolve()));
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
handle(req, res) {
|
|
32
|
+
const method = req.method ?? 'GET';
|
|
33
|
+
const url = req.url ?? '/';
|
|
34
|
+
if (method === 'GET' && url === '/automations')
|
|
35
|
+
return this.getAutomations(res);
|
|
36
|
+
if (method === 'POST' && url === '/reload')
|
|
37
|
+
return this.postReload(res);
|
|
38
|
+
if (method === 'GET' && url === '/health/live')
|
|
39
|
+
return this.getHealthLive(res);
|
|
40
|
+
if (method === 'GET' && url === '/health/ready')
|
|
41
|
+
return this.getHealthReady(res);
|
|
42
|
+
if (method === 'GET' && url === '/events')
|
|
43
|
+
return this.getEvents(req, res);
|
|
44
|
+
if (method === 'GET' && url === '/metrics')
|
|
45
|
+
return this.getMetrics(res);
|
|
46
|
+
const triggerMatch = method === 'POST' && url.match(/^\/automations\/(.+)\/trigger$/);
|
|
47
|
+
if (triggerMatch)
|
|
48
|
+
return this.postTrigger(triggerMatch[1], res);
|
|
49
|
+
json(res, 404, { error: 'not found' });
|
|
50
|
+
}
|
|
51
|
+
getAutomations(res) {
|
|
52
|
+
const automations = this.deps.registry.getAll().map((a) => ({
|
|
53
|
+
id: a.id,
|
|
54
|
+
location: a.location,
|
|
55
|
+
subsystem: a.subsystem,
|
|
56
|
+
enabled: a.enabled ?? true,
|
|
57
|
+
triggers: a.triggers.map(serializeTrigger),
|
|
58
|
+
}));
|
|
59
|
+
json(res, 200, automations);
|
|
60
|
+
}
|
|
61
|
+
postTrigger(id, res) {
|
|
62
|
+
const automation = this.deps.registry.getById(id);
|
|
63
|
+
if (!automation) {
|
|
64
|
+
json(res, 404, { error: `no automation with id "${id}"` });
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
const correlation_id = crypto.randomUUID();
|
|
68
|
+
this.deps.onTrigger(automation, { type: 'on_start', correlation_id, root_correlation_id: correlation_id });
|
|
69
|
+
json(res, 200, { ok: true });
|
|
70
|
+
}
|
|
71
|
+
postReload(res) {
|
|
72
|
+
this.deps.onReload()
|
|
73
|
+
.then(() => json(res, 200, { ok: true }))
|
|
74
|
+
.catch((err) => {
|
|
75
|
+
console.error('[ApiServer] reload failed:', err);
|
|
76
|
+
json(res, 500, { error: 'reload failed' });
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
getHealthLive(res) {
|
|
80
|
+
json(res, 200, { status: 'live' });
|
|
81
|
+
}
|
|
82
|
+
getHealthReady(res) {
|
|
83
|
+
if (!this.deps.isReady()) {
|
|
84
|
+
json(res, 503, { status: 'starting' });
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
json(res, 200, {
|
|
88
|
+
status: 'ready',
|
|
89
|
+
entities: this.deps.entityCount(),
|
|
90
|
+
automations: this.deps.registry.getAll().length,
|
|
91
|
+
...(this.deps.dryRun && { dry_run: true }),
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
getMetrics(res) {
|
|
95
|
+
if (!this.deps.metrics) {
|
|
96
|
+
json(res, 404, { error: 'metrics not enabled' });
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
this.deps.metrics.getMetrics()
|
|
100
|
+
.then((output) => {
|
|
101
|
+
res.writeHead(200, { 'Content-Type': this.deps.metrics.contentType });
|
|
102
|
+
res.end(output);
|
|
103
|
+
})
|
|
104
|
+
.catch((err) => {
|
|
105
|
+
console.error('[ApiServer] metrics scrape failed:', err);
|
|
106
|
+
json(res, 500, { error: 'metrics scrape failed' });
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
getEvents(req, res) {
|
|
110
|
+
res.writeHead(200, {
|
|
111
|
+
'Content-Type': 'text/event-stream',
|
|
112
|
+
'Cache-Control': 'no-cache',
|
|
113
|
+
Connection: 'keep-alive',
|
|
114
|
+
});
|
|
115
|
+
res.flushHeaders();
|
|
116
|
+
const unsubscribe = this.deps.eventPublisher.subscribe((event) => {
|
|
117
|
+
res.write(`data: ${JSON.stringify(event)}\n\n`);
|
|
118
|
+
});
|
|
119
|
+
req.on('close', unsubscribe);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
function serializeTrigger(trigger) {
|
|
123
|
+
if (trigger.type === 'state_changed' && trigger.entity instanceof RegExp) {
|
|
124
|
+
return { ...trigger, entity: trigger.entity.toString() };
|
|
125
|
+
}
|
|
126
|
+
return trigger;
|
|
127
|
+
}
|
|
128
|
+
function json(res, status, body) {
|
|
129
|
+
const payload = JSON.stringify(body);
|
|
130
|
+
res.writeHead(status, { 'Content-Type': 'application/json' });
|
|
131
|
+
res.end(payload);
|
|
132
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
declare const ConfigSchema: z.ZodObject<{
|
|
3
|
+
homeassistant: z.ZodObject<{
|
|
4
|
+
url: z.ZodString;
|
|
5
|
+
token: z.ZodString;
|
|
6
|
+
}, z.core.$strip>;
|
|
7
|
+
mqtt: z.ZodObject<{
|
|
8
|
+
url: z.ZodString;
|
|
9
|
+
}, z.core.$strip>;
|
|
10
|
+
automations: z.ZodObject<{
|
|
11
|
+
dir: z.ZodString;
|
|
12
|
+
}, z.core.$strip>;
|
|
13
|
+
server: z.ZodDefault<z.ZodObject<{
|
|
14
|
+
port: z.ZodDefault<z.ZodCoercedNumber<unknown>>;
|
|
15
|
+
shutdown_timeout_ms: z.ZodDefault<z.ZodCoercedNumber<unknown>>;
|
|
16
|
+
}, z.core.$strip>>;
|
|
17
|
+
options: z.ZodDefault<z.ZodObject<{
|
|
18
|
+
dry_run: z.ZodDefault<z.ZodBoolean>;
|
|
19
|
+
}, z.core.$strip>>;
|
|
20
|
+
metrics: z.ZodDefault<z.ZodObject<{
|
|
21
|
+
enabled: z.ZodDefault<z.ZodBoolean>;
|
|
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>>;
|
|
30
|
+
}, z.core.$strip>;
|
|
31
|
+
export type HomerunConfig = z.infer<typeof ConfigSchema>;
|
|
32
|
+
export declare function parseConfig(configContent: string, secretsContent?: string): HomerunConfig;
|
|
33
|
+
export declare function loadConfig(configPath?: string): Promise<HomerunConfig>;
|
|
34
|
+
export {};
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import { readFile } from 'node:fs/promises';
|
|
2
|
+
import { existsSync } from 'node:fs';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { load, CORE_SCHEMA, defineScalarTag } from 'js-yaml';
|
|
5
|
+
import { z } from 'zod';
|
|
6
|
+
function isSecretRef(v) {
|
|
7
|
+
return typeof v === 'object' && v !== null && '__secret' in v;
|
|
8
|
+
}
|
|
9
|
+
const secretTag = defineScalarTag('!secret', {
|
|
10
|
+
implicit: false,
|
|
11
|
+
resolve: (source) => ({ __secret: source }),
|
|
12
|
+
// Load-only tag — config is never dumped back to YAML, so identify() is unreachable.
|
|
13
|
+
identify: () => false,
|
|
14
|
+
});
|
|
15
|
+
const SCHEMA_WITH_SECRET = CORE_SCHEMA.withTags(secretTag);
|
|
16
|
+
const ConfigSchema = z.object({
|
|
17
|
+
homeassistant: z.object({
|
|
18
|
+
url: z.string().min(1, 'homeassistant.url is required'),
|
|
19
|
+
token: z.string().min(1, 'homeassistant.token is required'),
|
|
20
|
+
}),
|
|
21
|
+
mqtt: z.object({
|
|
22
|
+
url: z.string().min(1, 'mqtt.url is required'),
|
|
23
|
+
}),
|
|
24
|
+
automations: z.object({
|
|
25
|
+
dir: z.string().min(1, 'automations.dir is required'),
|
|
26
|
+
}),
|
|
27
|
+
server: z.object({
|
|
28
|
+
port: z.coerce.number().int().min(1).max(65535).default(7070),
|
|
29
|
+
shutdown_timeout_ms: z.coerce.number().int().min(0).default(10_000),
|
|
30
|
+
}).default({ port: 7070, shutdown_timeout_ms: 10_000 }),
|
|
31
|
+
options: z.object({
|
|
32
|
+
dry_run: z.boolean().default(false),
|
|
33
|
+
}).default({ dry_run: false }),
|
|
34
|
+
metrics: z.object({
|
|
35
|
+
enabled: z.boolean().default(false),
|
|
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 }),
|
|
48
|
+
});
|
|
49
|
+
function resolveSecrets(value, secrets) {
|
|
50
|
+
if (isSecretRef(value)) {
|
|
51
|
+
const key = value.__secret;
|
|
52
|
+
if (!(key in secrets)) {
|
|
53
|
+
throw new Error(`!secret '${key}' not found in secrets.yaml`);
|
|
54
|
+
}
|
|
55
|
+
return secrets[key];
|
|
56
|
+
}
|
|
57
|
+
if (Array.isArray(value)) {
|
|
58
|
+
return value.map((item) => resolveSecrets(item, secrets));
|
|
59
|
+
}
|
|
60
|
+
if (typeof value === 'object' && value !== null) {
|
|
61
|
+
return Object.fromEntries(Object.entries(value).map(([k, v]) => [k, resolveSecrets(v, secrets)]));
|
|
62
|
+
}
|
|
63
|
+
return value;
|
|
64
|
+
}
|
|
65
|
+
export function parseConfig(configContent, secretsContent) {
|
|
66
|
+
const raw = (load(configContent, { schema: SCHEMA_WITH_SECRET }) ?? {});
|
|
67
|
+
const secrets = secretsContent
|
|
68
|
+
? (load(secretsContent) ?? {})
|
|
69
|
+
: {};
|
|
70
|
+
const resolved = resolveSecrets(raw, secrets);
|
|
71
|
+
const result = ConfigSchema.safeParse(resolved);
|
|
72
|
+
if (!result.success) {
|
|
73
|
+
const errors = result.error.issues
|
|
74
|
+
.map((i) => ` ${i.path.join('.') || '(root)'}: ${i.message}`)
|
|
75
|
+
.join('\n');
|
|
76
|
+
throw new Error(`[homerun] Invalid configuration:\n${errors}`);
|
|
77
|
+
}
|
|
78
|
+
return result.data;
|
|
79
|
+
}
|
|
80
|
+
export async function loadConfig(configPath) {
|
|
81
|
+
const absPath = path.resolve(configPath ?? process.env.HOMERUN_CONFIG ?? './configuration.yaml');
|
|
82
|
+
if (!existsSync(absPath)) {
|
|
83
|
+
throw new Error(`[homerun] Config file not found: ${absPath}\n` +
|
|
84
|
+
'Create configuration.yaml or set HOMERUN_CONFIG to point to your config file.\n' +
|
|
85
|
+
'See configuration.yaml.example for reference.');
|
|
86
|
+
}
|
|
87
|
+
const configContent = await readFile(absPath, 'utf8');
|
|
88
|
+
const secretsPath = path.join(path.dirname(absPath), 'secrets.yaml');
|
|
89
|
+
const secretsContent = existsSync(secretsPath)
|
|
90
|
+
? await readFile(secretsPath, 'utf8')
|
|
91
|
+
: undefined;
|
|
92
|
+
return parseConfig(configContent, secretsContent);
|
|
93
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import type { MqttClient } from 'mqtt';
|
|
2
|
+
import type { Action } from '../types/actions.js';
|
|
3
|
+
import type { TriggerSummary } from '../types/triggers.js';
|
|
4
|
+
type ObsEventBase = {
|
|
5
|
+
schema: 'home.events.v2';
|
|
6
|
+
correlation_id: string;
|
|
7
|
+
root_correlation_id: string;
|
|
8
|
+
parent_correlation_id?: string;
|
|
9
|
+
parent_automation_id?: string;
|
|
10
|
+
automation_id: string;
|
|
11
|
+
location: string;
|
|
12
|
+
subsystem: string;
|
|
13
|
+
timestamp: string;
|
|
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
|
+
});
|
|
44
|
+
export type LifecycleEventType = 'server_started' | 'server_stopping' | 'rescan_complete' | 'ha_reconnected';
|
|
45
|
+
export interface LifecycleEvent {
|
|
46
|
+
schema: 'home.lifecycle.v1';
|
|
47
|
+
type: LifecycleEventType;
|
|
48
|
+
automation_count: number;
|
|
49
|
+
timestamp: string;
|
|
50
|
+
dry_run?: boolean;
|
|
51
|
+
}
|
|
52
|
+
export declare class EventPublisher {
|
|
53
|
+
private readonly mqtt;
|
|
54
|
+
private readonly enabled;
|
|
55
|
+
private readonly listeners;
|
|
56
|
+
constructor(mqtt: MqttClient, enabled?: boolean);
|
|
57
|
+
subscribe(listener: (event: ObsEvent) => void): () => void;
|
|
58
|
+
publishDecision(event: ObsEvent): void;
|
|
59
|
+
publishActionEvent(event: ObsEvent): void;
|
|
60
|
+
publishLifecycle(type: LifecycleEventType, automationCount: number, dryRun?: boolean): void;
|
|
61
|
+
private publish;
|
|
62
|
+
private safeSerialize;
|
|
63
|
+
private notifyListeners;
|
|
64
|
+
}
|
|
65
|
+
export {};
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
export class EventPublisher {
|
|
2
|
+
mqtt;
|
|
3
|
+
enabled;
|
|
4
|
+
listeners = [];
|
|
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) {
|
|
10
|
+
this.mqtt = mqtt;
|
|
11
|
+
this.enabled = enabled;
|
|
12
|
+
}
|
|
13
|
+
subscribe(listener) {
|
|
14
|
+
this.listeners.push(listener);
|
|
15
|
+
return () => {
|
|
16
|
+
const idx = this.listeners.indexOf(listener);
|
|
17
|
+
if (idx !== -1)
|
|
18
|
+
this.listeners.splice(idx, 1);
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
publishDecision(event) {
|
|
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);
|
|
31
|
+
}
|
|
32
|
+
publishActionEvent(event) {
|
|
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);
|
|
41
|
+
}
|
|
42
|
+
publishLifecycle(type, automationCount, dryRun = false) {
|
|
43
|
+
const event = {
|
|
44
|
+
schema: 'home.lifecycle.v1',
|
|
45
|
+
type,
|
|
46
|
+
automation_count: automationCount,
|
|
47
|
+
timestamp: new Date().toISOString(),
|
|
48
|
+
...(dryRun && { dry_run: true }),
|
|
49
|
+
};
|
|
50
|
+
const ns = dryRun ? 'homerun/dev' : 'homerun';
|
|
51
|
+
const payload = JSON.stringify(event);
|
|
52
|
+
this.publish(`${ns}/lifecycle`, payload, false);
|
|
53
|
+
this.publish(`${ns}/status`, JSON.stringify({ status: 'online', automation_count: automationCount, timestamp: event.timestamp }), true);
|
|
54
|
+
}
|
|
55
|
+
publish(topic, payload, retain) {
|
|
56
|
+
this.mqtt.publishAsync(topic, payload, { retain }).catch((err) => {
|
|
57
|
+
console.error(`[EventPublisher] MQTT publish failed on ${topic}:`, err);
|
|
58
|
+
});
|
|
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
|
+
}
|
|
85
|
+
}
|