@ajclarkson/homerun 0.0.1-edge.f81eb62 → 0.0.1-edge.fc9ccb3
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/scripts/generate-ha-services.d.ts +16 -0
- package/dist/scripts/generate-ha-services.js +111 -0
- package/dist/scripts/generate-ha-types.js +7 -13
- package/dist/src/framework/action-runtime.d.ts +15 -3
- package/dist/src/framework/action-runtime.js +148 -13
- package/dist/src/framework/api-server.d.ts +9 -2
- package/dist/src/framework/api-server.js +29 -3
- 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 +40 -2
- package/dist/src/framework/ha-client.js +81 -5
- package/dist/src/framework/hot-reload.js +2 -1
- 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 +4 -2
- package/dist/src/framework/pipeline.js +38 -9
- package/dist/src/framework/registry.js +4 -1
- package/dist/src/framework/scheduler.d.ts +2 -0
- package/dist/src/framework/scheduler.js +32 -12
- package/dist/src/framework/timer-manager.d.ts +1 -0
- package/dist/src/framework/timer-manager.js +7 -1
- package/dist/src/framework/trigger-engine.d.ts +7 -1
- package/dist/src/framework/trigger-engine.js +143 -45
- package/dist/src/index.d.ts +1 -1
- package/dist/src/index.js +93 -24
- package/dist/src/lib.d.ts +3 -1
- package/dist/src/lib.js +2 -1
- package/dist/src/services.d.ts +178 -0
- package/dist/src/services.js +106 -0
- package/dist/src/testing.d.ts +5 -1
- package/dist/src/testing.js +42 -7
- package/dist/src/types/actions.d.ts +4 -2
- package/dist/src/types/automation.d.ts +8 -1
- package/dist/src/types/automation.js +35 -0
- package/dist/src/types/triggers.d.ts +29 -2
- package/dist/src/types/triggers.js +26 -1
- package/package.json +10 -4
- package/dist/src/framework/observability.d.ts +0 -25
- package/dist/src/framework/observability.js +0 -34
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
|
```
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
export interface HAServicePayload {
|
|
3
|
+
domain: string;
|
|
4
|
+
services: Record<string, HAServiceDef>;
|
|
5
|
+
}
|
|
6
|
+
export interface HAServiceDef {
|
|
7
|
+
fields: Record<string, HAServiceField>;
|
|
8
|
+
target?: unknown;
|
|
9
|
+
}
|
|
10
|
+
export interface HAServiceField {
|
|
11
|
+
required?: boolean;
|
|
12
|
+
selector?: Record<string, unknown>;
|
|
13
|
+
advanced?: boolean;
|
|
14
|
+
}
|
|
15
|
+
export declare function inferFieldType(field: HAServiceField): string;
|
|
16
|
+
export declare function generateFileContent(services: HAServicePayload[], domains?: string[]): string;
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { writeFile, mkdir } from 'node:fs/promises';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
// ---------- Type inference ----------
|
|
5
|
+
export function inferFieldType(field) {
|
|
6
|
+
const selector = field.selector;
|
|
7
|
+
if (!selector)
|
|
8
|
+
return 'unknown';
|
|
9
|
+
const key = Object.keys(selector)[0];
|
|
10
|
+
if (!key)
|
|
11
|
+
return 'unknown';
|
|
12
|
+
switch (key) {
|
|
13
|
+
case 'number':
|
|
14
|
+
case 'color_temp':
|
|
15
|
+
return 'number';
|
|
16
|
+
case 'boolean':
|
|
17
|
+
return 'boolean';
|
|
18
|
+
case 'color_rgb':
|
|
19
|
+
return '[number, number, number]';
|
|
20
|
+
case 'duration':
|
|
21
|
+
return '{ hours?: number; minutes?: number; seconds?: number }';
|
|
22
|
+
case 'object':
|
|
23
|
+
return 'Record<string, unknown>';
|
|
24
|
+
default:
|
|
25
|
+
return 'string';
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
// ---------- Service builder generation ----------
|
|
29
|
+
function generateServiceBuilder(domain, service, def) {
|
|
30
|
+
const hasTarget = !!def.target;
|
|
31
|
+
const fieldEntries = Object.entries(def.fields ?? {});
|
|
32
|
+
const hasFields = fieldEntries.length > 0;
|
|
33
|
+
const params = [];
|
|
34
|
+
if (hasTarget)
|
|
35
|
+
params.push('target: { entity_id: string }');
|
|
36
|
+
let anyRequired = false;
|
|
37
|
+
if (hasFields) {
|
|
38
|
+
anyRequired = fieldEntries.some(([, f]) => f.required);
|
|
39
|
+
const fieldDefs = fieldEntries
|
|
40
|
+
.map(([name, field]) => `${name}${field.required ? '' : '?'}: ${inferFieldType(field)}`)
|
|
41
|
+
.join('; ');
|
|
42
|
+
params.push(`data${anyRequired ? '' : '?'}: { ${fieldDefs} }`);
|
|
43
|
+
}
|
|
44
|
+
const bodyParts = [
|
|
45
|
+
`type: 'ha.call_service'`,
|
|
46
|
+
`domain: '${domain}'`,
|
|
47
|
+
`service: '${service}'`,
|
|
48
|
+
];
|
|
49
|
+
if (hasTarget)
|
|
50
|
+
bodyParts.push('target');
|
|
51
|
+
if (hasFields) {
|
|
52
|
+
bodyParts.push(`data: data as Record<string, unknown>${anyRequired ? '' : ' | undefined'}`);
|
|
53
|
+
}
|
|
54
|
+
return ` ${service}: (${params.join(', ')}): Action => ({ ${bodyParts.join(', ')} })`;
|
|
55
|
+
}
|
|
56
|
+
// ---------- File generation ----------
|
|
57
|
+
export function generateFileContent(services, domains) {
|
|
58
|
+
const filtered = domains
|
|
59
|
+
? services.filter(({ domain }) => domains.includes(domain))
|
|
60
|
+
: services;
|
|
61
|
+
const domainBlocks = filtered.map(({ domain, services: svcMap }) => {
|
|
62
|
+
const builders = Object.entries(svcMap)
|
|
63
|
+
.map(([service, def]) => generateServiceBuilder(domain, service, def))
|
|
64
|
+
.join(',\n');
|
|
65
|
+
return ` ${domain}: {\n${builders},\n }`;
|
|
66
|
+
});
|
|
67
|
+
const domainsNote = domains ? `\n// domains: ${domains.join(', ')}` : '';
|
|
68
|
+
const blockStr = domainBlocks.length > 0 ? `\n${domainBlocks.join(',\n')},\n` : '';
|
|
69
|
+
return `// generated — do not edit — run: npm run generate:ha-services${domainsNote}
|
|
70
|
+
import type { Action } from '@ajclarkson/homerun';
|
|
71
|
+
|
|
72
|
+
export const Services = {${blockStr}};
|
|
73
|
+
`;
|
|
74
|
+
}
|
|
75
|
+
// ---------- CLI ----------
|
|
76
|
+
async function main() {
|
|
77
|
+
const url = process.env.HA_URL;
|
|
78
|
+
const token = process.env.HA_TOKEN;
|
|
79
|
+
if (!url || !token) {
|
|
80
|
+
console.error('Error: HA_URL and HA_TOKEN must be set');
|
|
81
|
+
process.exit(1);
|
|
82
|
+
}
|
|
83
|
+
const domainsArg = process.argv.find((a) => a.startsWith('--domains='));
|
|
84
|
+
const domains = domainsArg ? domainsArg.slice('--domains='.length).split(',') : undefined;
|
|
85
|
+
const res = await fetch(`${url}/api/services`, {
|
|
86
|
+
headers: { Authorization: `Bearer ${token}` },
|
|
87
|
+
});
|
|
88
|
+
if (!res.ok) {
|
|
89
|
+
console.error(`Error: HA API returned ${res.status} ${res.statusText}`);
|
|
90
|
+
process.exit(1);
|
|
91
|
+
}
|
|
92
|
+
const allServices = (await res.json());
|
|
93
|
+
const content = generateFileContent(allServices, domains);
|
|
94
|
+
const outPath = path.join(process.cwd(), 'types', 'ha-services.ts');
|
|
95
|
+
await mkdir(path.dirname(outPath), { recursive: true });
|
|
96
|
+
await writeFile(outPath, content, 'utf8');
|
|
97
|
+
const filtered = domains
|
|
98
|
+
? allServices.filter(({ domain }) => domains.includes(domain))
|
|
99
|
+
: allServices;
|
|
100
|
+
const serviceCount = filtered.reduce((n, { services: s }) => n + Object.keys(s).length, 0);
|
|
101
|
+
const domainLabel = domains ? `${domains.length} selected` : `all ${allServices.length}`;
|
|
102
|
+
console.log(`Written ${serviceCount} services across ${domainLabel} domains to ${outPath}`);
|
|
103
|
+
}
|
|
104
|
+
if (process.argv[1]?.endsWith('generate-ha-services.ts') ||
|
|
105
|
+
process.argv[1]?.endsWith('generate-ha-services.js') ||
|
|
106
|
+
process.argv[1]?.endsWith('homerun-generate-ha-services')) {
|
|
107
|
+
main().catch((err) => {
|
|
108
|
+
console.error('Fatal:', err);
|
|
109
|
+
process.exit(1);
|
|
110
|
+
});
|
|
111
|
+
}
|
|
@@ -8,18 +8,14 @@ export function inferStateType(entity, allObservedStates) {
|
|
|
8
8
|
case 'binary_sensor':
|
|
9
9
|
case 'input_boolean':
|
|
10
10
|
case 'switch':
|
|
11
|
-
return "'on' | 'off'";
|
|
11
|
+
return "'on' | 'off' | 'unavailable' | 'unknown'";
|
|
12
12
|
case 'input_select': {
|
|
13
13
|
const options = entity.attributes.options;
|
|
14
14
|
if (Array.isArray(options) && options.length > 0) {
|
|
15
|
-
return options.map((o) => `'${o}'`).join(' | ');
|
|
15
|
+
return options.map((o) => `'${o}'`).join(' | ') + " | 'unavailable' | 'unknown'";
|
|
16
16
|
}
|
|
17
17
|
return 'string';
|
|
18
18
|
}
|
|
19
|
-
case 'person': {
|
|
20
|
-
const states = allObservedStates ?? [entity.state];
|
|
21
|
-
return [...new Set(states)].map((s) => `'${s}'`).join(' | ');
|
|
22
|
-
}
|
|
23
19
|
default:
|
|
24
20
|
return 'string';
|
|
25
21
|
}
|
|
@@ -37,18 +33,16 @@ export function generateFileContent(states) {
|
|
|
37
33
|
const entries = states
|
|
38
34
|
.map((s) => {
|
|
39
35
|
const stateType = inferStateType(s, personStates.get(s.entity_id));
|
|
40
|
-
return `
|
|
36
|
+
return ` '${s.entity_id}': { state: ${stateType} };`;
|
|
41
37
|
})
|
|
42
38
|
.join('\n');
|
|
43
39
|
return `// generated — do not edit — run: npm run generate:ha-types
|
|
44
|
-
|
|
40
|
+
declare global {
|
|
41
|
+
interface HAEntities {
|
|
45
42
|
${entries}
|
|
43
|
+
}
|
|
46
44
|
}
|
|
47
|
-
|
|
48
|
-
export type HAState = {
|
|
49
|
-
<E extends keyof HAEntities>(entity: E): HAEntities[E]['state'];
|
|
50
|
-
<E extends string>(entity: E): string | undefined;
|
|
51
|
-
};
|
|
45
|
+
export {};
|
|
52
46
|
`;
|
|
53
47
|
}
|
|
54
48
|
// ---------- CLI ----------
|
|
@@ -2,26 +2,38 @@ import type { MqttClient } from 'mqtt';
|
|
|
2
2
|
import type { Action } from '../types/actions.js';
|
|
3
3
|
import type { HAClient } from './ha-client.js';
|
|
4
4
|
import type { TimerManager } from './timer-manager.js';
|
|
5
|
-
import type {
|
|
5
|
+
import type { EventPublisher } from './event-publisher.js';
|
|
6
|
+
import type { MetricsBackend } from './metrics.js';
|
|
6
7
|
export interface ExecutionContext {
|
|
7
8
|
correlationId: string;
|
|
8
9
|
automationId: string;
|
|
9
10
|
location: string;
|
|
10
11
|
subsystem: string;
|
|
12
|
+
rootCorrelationId?: string;
|
|
13
|
+
parentCorrelationId?: string;
|
|
14
|
+
parentAutomationId?: string;
|
|
11
15
|
}
|
|
12
16
|
interface Deps {
|
|
13
17
|
haClient: HAClient;
|
|
14
18
|
mqttClient: MqttClient;
|
|
15
19
|
timerManager: TimerManager;
|
|
16
|
-
|
|
20
|
+
eventPublisher: EventPublisher;
|
|
17
21
|
dryRun: boolean;
|
|
22
|
+
metrics?: MetricsBackend;
|
|
23
|
+
commandAck?: {
|
|
24
|
+
enabled: boolean;
|
|
25
|
+
timeoutMs: number;
|
|
26
|
+
};
|
|
18
27
|
}
|
|
19
28
|
export declare class ActionRuntime {
|
|
20
29
|
private readonly deps;
|
|
21
30
|
constructor(deps: Deps);
|
|
31
|
+
private handleAckTimeout;
|
|
22
32
|
execute(actions: Action[], ctx: ExecutionContext): Promise<void>;
|
|
23
33
|
private runAction;
|
|
24
34
|
private dispatch;
|
|
25
|
-
private
|
|
35
|
+
private baseFields;
|
|
36
|
+
private makeStartedEvent;
|
|
37
|
+
private makeResultEvent;
|
|
26
38
|
}
|
|
27
39
|
export {};
|
|
@@ -1,7 +1,90 @@
|
|
|
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
|
+
}
|
|
1
63
|
export class ActionRuntime {
|
|
2
64
|
deps;
|
|
3
65
|
constructor(deps) {
|
|
4
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
|
+
});
|
|
5
88
|
}
|
|
6
89
|
async execute(actions, ctx) {
|
|
7
90
|
for (const action of actions) {
|
|
@@ -9,24 +92,64 @@ export class ActionRuntime {
|
|
|
9
92
|
}
|
|
10
93
|
}
|
|
11
94
|
async runAction(action, ctx) {
|
|
12
|
-
|
|
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();
|
|
13
99
|
try {
|
|
14
100
|
if (!this.deps.dryRun) {
|
|
15
|
-
await this.dispatch(action);
|
|
101
|
+
await this.dispatch(action, ctx);
|
|
16
102
|
}
|
|
17
|
-
|
|
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'));
|
|
18
107
|
}
|
|
19
108
|
catch (err) {
|
|
20
|
-
const
|
|
21
|
-
this.deps.
|
|
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));
|
|
22
118
|
}
|
|
23
119
|
}
|
|
24
|
-
async dispatch(action) {
|
|
120
|
+
async dispatch(action, ctx) {
|
|
25
121
|
switch (action.type) {
|
|
26
|
-
case 'ha.call_service':
|
|
27
|
-
|
|
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
|
+
});
|
|
28
143
|
break;
|
|
144
|
+
}
|
|
29
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
|
+
}
|
|
30
153
|
await this.deps.mqttClient.publishAsync(action.topic, action.payload, { retain: action.retain ?? false });
|
|
31
154
|
break;
|
|
32
155
|
case 'timer.start':
|
|
@@ -41,18 +164,30 @@ export class ActionRuntime {
|
|
|
41
164
|
}
|
|
42
165
|
}
|
|
43
166
|
}
|
|
44
|
-
|
|
167
|
+
baseFields(ctx) {
|
|
45
168
|
return {
|
|
46
|
-
schema: 'home.events.
|
|
169
|
+
schema: 'home.events.v2',
|
|
47
170
|
correlation_id: ctx.correlationId,
|
|
171
|
+
root_correlation_id: ctx.rootCorrelationId ?? ctx.correlationId,
|
|
48
172
|
automation_id: ctx.automationId,
|
|
49
173
|
location: ctx.location,
|
|
50
174
|
subsystem: ctx.subsystem,
|
|
51
|
-
type,
|
|
52
|
-
actions: [action],
|
|
53
175
|
timestamp: new Date().toISOString(),
|
|
54
176
|
...(this.deps.dryRun ? { dry_run: true } : {}),
|
|
55
|
-
...
|
|
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 }),
|
|
56
191
|
};
|
|
57
192
|
}
|
|
58
193
|
}
|
|
@@ -1,14 +1,20 @@
|
|
|
1
1
|
import type { AutomationRegistry } from './registry.js';
|
|
2
|
-
import type {
|
|
2
|
+
import type { EventPublisher } from './event-publisher.js';
|
|
3
3
|
import type { Automation } from '../types/automation.js';
|
|
4
4
|
import type { TriggerEvent } from '../types/triggers.js';
|
|
5
|
+
export interface MetricsProvider {
|
|
6
|
+
getMetrics(): Promise<string>;
|
|
7
|
+
contentType: string;
|
|
8
|
+
}
|
|
5
9
|
export interface ApiServerDeps {
|
|
6
10
|
registry: AutomationRegistry;
|
|
7
11
|
onTrigger: (automation: Automation<unknown>, event: TriggerEvent) => void;
|
|
8
12
|
onReload: () => Promise<void>;
|
|
9
13
|
isReady: () => boolean;
|
|
10
14
|
entityCount: () => number;
|
|
11
|
-
|
|
15
|
+
eventPublisher: EventPublisher;
|
|
16
|
+
dryRun?: boolean;
|
|
17
|
+
metrics?: MetricsProvider;
|
|
12
18
|
}
|
|
13
19
|
export declare class ApiServer {
|
|
14
20
|
private readonly deps;
|
|
@@ -24,5 +30,6 @@ export declare class ApiServer {
|
|
|
24
30
|
private postReload;
|
|
25
31
|
private getHealthLive;
|
|
26
32
|
private getHealthReady;
|
|
33
|
+
private getMetrics;
|
|
27
34
|
private getEvents;
|
|
28
35
|
}
|
|
@@ -41,6 +41,8 @@ export class ApiServer {
|
|
|
41
41
|
return this.getHealthReady(res);
|
|
42
42
|
if (method === 'GET' && url === '/events')
|
|
43
43
|
return this.getEvents(req, res);
|
|
44
|
+
if (method === 'GET' && url === '/metrics')
|
|
45
|
+
return this.getMetrics(res);
|
|
44
46
|
const triggerMatch = method === 'POST' && url.match(/^\/automations\/(.+)\/trigger$/);
|
|
45
47
|
if (triggerMatch)
|
|
46
48
|
return this.postTrigger(triggerMatch[1], res);
|
|
@@ -51,7 +53,8 @@ export class ApiServer {
|
|
|
51
53
|
id: a.id,
|
|
52
54
|
location: a.location,
|
|
53
55
|
subsystem: a.subsystem,
|
|
54
|
-
|
|
56
|
+
enabled: a.enabled ?? true,
|
|
57
|
+
triggers: a.triggers.map(serializeTrigger),
|
|
55
58
|
}));
|
|
56
59
|
json(res, 200, automations);
|
|
57
60
|
}
|
|
@@ -61,7 +64,8 @@ export class ApiServer {
|
|
|
61
64
|
json(res, 404, { error: `no automation with id "${id}"` });
|
|
62
65
|
return;
|
|
63
66
|
}
|
|
64
|
-
|
|
67
|
+
const correlation_id = crypto.randomUUID();
|
|
68
|
+
this.deps.onTrigger(automation, { type: 'on_start', correlation_id, root_correlation_id: correlation_id });
|
|
65
69
|
json(res, 200, { ok: true });
|
|
66
70
|
}
|
|
67
71
|
postReload(res) {
|
|
@@ -84,6 +88,22 @@ export class ApiServer {
|
|
|
84
88
|
status: 'ready',
|
|
85
89
|
entities: this.deps.entityCount(),
|
|
86
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' });
|
|
87
107
|
});
|
|
88
108
|
}
|
|
89
109
|
getEvents(req, res) {
|
|
@@ -93,12 +113,18 @@ export class ApiServer {
|
|
|
93
113
|
Connection: 'keep-alive',
|
|
94
114
|
});
|
|
95
115
|
res.flushHeaders();
|
|
96
|
-
const unsubscribe = this.deps.
|
|
116
|
+
const unsubscribe = this.deps.eventPublisher.subscribe((event) => {
|
|
97
117
|
res.write(`data: ${JSON.stringify(event)}\n\n`);
|
|
98
118
|
});
|
|
99
119
|
req.on('close', unsubscribe);
|
|
100
120
|
}
|
|
101
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
|
+
}
|
|
102
128
|
function json(res, status, body) {
|
|
103
129
|
const payload = JSON.stringify(body);
|
|
104
130
|
res.writeHead(status, { 'Content-Type': 'application/json' });
|
|
@@ -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 {};
|