@ajclarkson/homerun 0.0.1-edge.db0717b → 0.0.1-edge.db46fae
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/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 +10 -3
- package/dist/src/framework/action-runtime.js +56 -12
- package/dist/src/framework/api-server.d.ts +8 -2
- package/dist/src/framework/api-server.js +28 -3
- package/dist/src/framework/config.d.ts +27 -0
- package/dist/src/framework/config.js +80 -0
- package/dist/src/framework/event-publisher.d.ts +58 -0
- package/dist/src/framework/event-publisher.js +75 -0
- package/dist/src/framework/ha-client.d.ts +23 -2
- package/dist/src/framework/ha-client.js +49 -3
- 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 +30 -6
- 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 +84 -26
- package/dist/src/lib.d.ts +2 -0
- package/dist/src/lib.js +1 -0
- package/dist/src/services.d.ts +178 -0
- package/dist/src/services.js +106 -0
- package/dist/src/testing.d.ts +4 -1
- package/dist/src/testing.js +15 -3
- package/dist/src/types/actions.d.ts +1 -0
- package/dist/src/types/automation.d.ts +2 -1
- 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 -34
- package/dist/src/framework/observability.js +0 -47
|
@@ -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,19 +2,24 @@ 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;
|
|
18
23
|
}
|
|
19
24
|
export declare class ActionRuntime {
|
|
20
25
|
private readonly deps;
|
|
@@ -22,6 +27,8 @@ export declare class ActionRuntime {
|
|
|
22
27
|
execute(actions: Action[], ctx: ExecutionContext): Promise<void>;
|
|
23
28
|
private runAction;
|
|
24
29
|
private dispatch;
|
|
25
|
-
private
|
|
30
|
+
private baseFields;
|
|
31
|
+
private makeStartedEvent;
|
|
32
|
+
private makeResultEvent;
|
|
26
33
|
}
|
|
27
34
|
export {};
|
|
@@ -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) {
|
|
@@ -9,24 +17,48 @@ export class ActionRuntime {
|
|
|
9
17
|
}
|
|
10
18
|
}
|
|
11
19
|
async runAction(action, ctx) {
|
|
12
|
-
|
|
20
|
+
const labels = { location: ctx.location, action_type: action.type };
|
|
21
|
+
this.deps.eventPublisher.publishActionEvent(this.makeStartedEvent(ctx, action));
|
|
22
|
+
this.deps.metrics?.incrementCounter('homerun_actions_dispatched_total', labels);
|
|
23
|
+
const start = performance.now();
|
|
13
24
|
try {
|
|
14
25
|
if (!this.deps.dryRun) {
|
|
15
|
-
await this.dispatch(action);
|
|
26
|
+
await this.dispatch(action, ctx);
|
|
16
27
|
}
|
|
17
|
-
|
|
28
|
+
const duration = (performance.now() - start) / 1000;
|
|
29
|
+
this.deps.metrics?.observeHistogram('homerun_action_duration_seconds', duration, labels);
|
|
30
|
+
this.deps.metrics?.incrementCounter('homerun_actions_succeeded_total', labels);
|
|
31
|
+
this.deps.eventPublisher.publishActionEvent(this.makeResultEvent(ctx, action, 'ok'));
|
|
18
32
|
}
|
|
19
33
|
catch (err) {
|
|
20
|
-
const
|
|
21
|
-
this.deps.
|
|
34
|
+
const duration = (performance.now() - start) / 1000;
|
|
35
|
+
this.deps.metrics?.observeHistogram('homerun_action_duration_seconds', duration, labels);
|
|
36
|
+
this.deps.metrics?.incrementCounter('homerun_actions_failed_total', labels);
|
|
37
|
+
const error = err instanceof Error
|
|
38
|
+
? err.message
|
|
39
|
+
: typeof err === 'object' && err !== null
|
|
40
|
+
? safeStringify(err)
|
|
41
|
+
: String(err);
|
|
42
|
+
this.deps.eventPublisher.publishActionEvent(this.makeResultEvent(ctx, action, 'error', error));
|
|
22
43
|
}
|
|
23
44
|
}
|
|
24
|
-
async dispatch(action) {
|
|
45
|
+
async dispatch(action, ctx) {
|
|
25
46
|
switch (action.type) {
|
|
26
47
|
case 'ha.call_service':
|
|
27
|
-
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
|
+
});
|
|
28
53
|
break;
|
|
29
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
|
+
}
|
|
30
62
|
await this.deps.mqttClient.publishAsync(action.topic, action.payload, { retain: action.retain ?? false });
|
|
31
63
|
break;
|
|
32
64
|
case 'timer.start':
|
|
@@ -41,18 +73,30 @@ export class ActionRuntime {
|
|
|
41
73
|
}
|
|
42
74
|
}
|
|
43
75
|
}
|
|
44
|
-
|
|
76
|
+
baseFields(ctx) {
|
|
45
77
|
return {
|
|
46
|
-
schema: 'home.events.
|
|
78
|
+
schema: 'home.events.v2',
|
|
47
79
|
correlation_id: ctx.correlationId,
|
|
80
|
+
root_correlation_id: ctx.rootCorrelationId ?? ctx.correlationId,
|
|
48
81
|
automation_id: ctx.automationId,
|
|
49
82
|
location: ctx.location,
|
|
50
83
|
subsystem: ctx.subsystem,
|
|
51
|
-
event_type,
|
|
52
|
-
actions: [action],
|
|
53
84
|
timestamp: new Date().toISOString(),
|
|
54
85
|
...(this.deps.dryRun ? { dry_run: true } : {}),
|
|
55
|
-
...
|
|
86
|
+
...(ctx.parentCorrelationId && { parent_correlation_id: ctx.parentCorrelationId }),
|
|
87
|
+
...(ctx.parentAutomationId && { parent_automation_id: ctx.parentAutomationId }),
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
makeStartedEvent(ctx, action) {
|
|
91
|
+
return { ...this.baseFields(ctx), event_type: 'action_started', action };
|
|
92
|
+
}
|
|
93
|
+
makeResultEvent(ctx, action, status, error) {
|
|
94
|
+
return {
|
|
95
|
+
...this.baseFields(ctx),
|
|
96
|
+
event_type: 'action_result',
|
|
97
|
+
action,
|
|
98
|
+
status,
|
|
99
|
+
...(error !== undefined && { error }),
|
|
56
100
|
};
|
|
57
101
|
}
|
|
58
102
|
}
|
|
@@ -1,15 +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;
|
|
12
16
|
dryRun?: boolean;
|
|
17
|
+
metrics?: MetricsProvider;
|
|
13
18
|
}
|
|
14
19
|
export declare class ApiServer {
|
|
15
20
|
private readonly deps;
|
|
@@ -25,5 +30,6 @@ export declare class ApiServer {
|
|
|
25
30
|
private postReload;
|
|
26
31
|
private getHealthLive;
|
|
27
32
|
private getHealthReady;
|
|
33
|
+
private getMetrics;
|
|
28
34
|
private getEvents;
|
|
29
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) {
|
|
@@ -87,6 +91,21 @@ export class ApiServer {
|
|
|
87
91
|
...(this.deps.dryRun && { dry_run: true }),
|
|
88
92
|
});
|
|
89
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
|
+
}
|
|
90
109
|
getEvents(req, res) {
|
|
91
110
|
res.writeHead(200, {
|
|
92
111
|
'Content-Type': 'text/event-stream',
|
|
@@ -94,12 +113,18 @@ export class ApiServer {
|
|
|
94
113
|
Connection: 'keep-alive',
|
|
95
114
|
});
|
|
96
115
|
res.flushHeaders();
|
|
97
|
-
const unsubscribe = this.deps.
|
|
116
|
+
const unsubscribe = this.deps.eventPublisher.subscribe((event) => {
|
|
98
117
|
res.write(`data: ${JSON.stringify(event)}\n\n`);
|
|
99
118
|
});
|
|
100
119
|
req.on('close', unsubscribe);
|
|
101
120
|
}
|
|
102
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
|
+
}
|
|
103
128
|
function json(res, status, body) {
|
|
104
129
|
const payload = JSON.stringify(body);
|
|
105
130
|
res.writeHead(status, { 'Content-Type': 'application/json' });
|
|
@@ -0,0 +1,27 @@
|
|
|
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
|
+
}, z.core.$strip>;
|
|
24
|
+
export type HomerunConfig = z.infer<typeof ConfigSchema>;
|
|
25
|
+
export declare function parseConfig(configContent: string, secretsContent?: string): HomerunConfig;
|
|
26
|
+
export declare function loadConfig(configPath?: string): Promise<HomerunConfig>;
|
|
27
|
+
export {};
|
|
@@ -0,0 +1,80 @@
|
|
|
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
|
+
});
|
|
13
|
+
const SCHEMA_WITH_SECRET = CORE_SCHEMA.withTags(secretTag);
|
|
14
|
+
const ConfigSchema = z.object({
|
|
15
|
+
homeassistant: z.object({
|
|
16
|
+
url: z.string().min(1, 'homeassistant.url is required'),
|
|
17
|
+
token: z.string().min(1, 'homeassistant.token is required'),
|
|
18
|
+
}),
|
|
19
|
+
mqtt: z.object({
|
|
20
|
+
url: z.string().min(1, 'mqtt.url is required'),
|
|
21
|
+
}),
|
|
22
|
+
automations: z.object({
|
|
23
|
+
dir: z.string().min(1, 'automations.dir is required'),
|
|
24
|
+
}),
|
|
25
|
+
server: z.object({
|
|
26
|
+
port: z.coerce.number().int().min(1).max(65535).default(7070),
|
|
27
|
+
shutdown_timeout_ms: z.coerce.number().int().min(0).default(10_000),
|
|
28
|
+
}).default({ port: 7070, shutdown_timeout_ms: 10_000 }),
|
|
29
|
+
options: z.object({
|
|
30
|
+
dry_run: z.boolean().default(false),
|
|
31
|
+
}).default({ dry_run: false }),
|
|
32
|
+
metrics: z.object({
|
|
33
|
+
enabled: z.boolean().default(false),
|
|
34
|
+
}).default({ enabled: false }),
|
|
35
|
+
});
|
|
36
|
+
function resolveSecrets(value, secrets) {
|
|
37
|
+
if (isSecretRef(value)) {
|
|
38
|
+
const key = value.__secret;
|
|
39
|
+
if (!(key in secrets)) {
|
|
40
|
+
throw new Error(`!secret '${key}' not found in secrets.yaml`);
|
|
41
|
+
}
|
|
42
|
+
return secrets[key];
|
|
43
|
+
}
|
|
44
|
+
if (Array.isArray(value)) {
|
|
45
|
+
return value.map((item) => resolveSecrets(item, secrets));
|
|
46
|
+
}
|
|
47
|
+
if (typeof value === 'object' && value !== null) {
|
|
48
|
+
return Object.fromEntries(Object.entries(value).map(([k, v]) => [k, resolveSecrets(v, secrets)]));
|
|
49
|
+
}
|
|
50
|
+
return value;
|
|
51
|
+
}
|
|
52
|
+
export function parseConfig(configContent, secretsContent) {
|
|
53
|
+
const raw = (load(configContent, { schema: SCHEMA_WITH_SECRET }) ?? {});
|
|
54
|
+
const secrets = secretsContent
|
|
55
|
+
? (load(secretsContent) ?? {})
|
|
56
|
+
: {};
|
|
57
|
+
const resolved = resolveSecrets(raw, secrets);
|
|
58
|
+
const result = ConfigSchema.safeParse(resolved);
|
|
59
|
+
if (!result.success) {
|
|
60
|
+
const errors = result.error.issues
|
|
61
|
+
.map((i) => ` ${i.path.join('.') || '(root)'}: ${i.message}`)
|
|
62
|
+
.join('\n');
|
|
63
|
+
throw new Error(`[homerun] Invalid configuration:\n${errors}`);
|
|
64
|
+
}
|
|
65
|
+
return result.data;
|
|
66
|
+
}
|
|
67
|
+
export async function loadConfig(configPath) {
|
|
68
|
+
const absPath = path.resolve(configPath ?? process.env.HOMERUN_CONFIG ?? './configuration.yaml');
|
|
69
|
+
if (!existsSync(absPath)) {
|
|
70
|
+
throw new Error(`[homerun] Config file not found: ${absPath}\n` +
|
|
71
|
+
'Create configuration.yaml or set HOMERUN_CONFIG to point to your config file.\n' +
|
|
72
|
+
'See configuration.yaml.example for reference.');
|
|
73
|
+
}
|
|
74
|
+
const configContent = await readFile(absPath, 'utf8');
|
|
75
|
+
const secretsPath = path.join(path.dirname(absPath), 'secrets.yaml');
|
|
76
|
+
const secretsContent = existsSync(secretsPath)
|
|
77
|
+
? await readFile(secretsPath, 'utf8')
|
|
78
|
+
: undefined;
|
|
79
|
+
return parseConfig(configContent, secretsContent);
|
|
80
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
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';
|
|
28
|
+
reason?: string;
|
|
29
|
+
} | {
|
|
30
|
+
event_type: 'action_started';
|
|
31
|
+
action: Action;
|
|
32
|
+
} | {
|
|
33
|
+
event_type: 'action_result';
|
|
34
|
+
action: Action;
|
|
35
|
+
status: 'ok' | 'error';
|
|
36
|
+
error?: string;
|
|
37
|
+
});
|
|
38
|
+
export type LifecycleEventType = 'server_started' | 'server_stopping' | 'rescan_complete' | 'ha_reconnected';
|
|
39
|
+
export interface LifecycleEvent {
|
|
40
|
+
schema: 'home.lifecycle.v1';
|
|
41
|
+
type: LifecycleEventType;
|
|
42
|
+
automation_count: number;
|
|
43
|
+
timestamp: string;
|
|
44
|
+
dry_run?: boolean;
|
|
45
|
+
}
|
|
46
|
+
export declare class EventPublisher {
|
|
47
|
+
private readonly mqtt;
|
|
48
|
+
private readonly listeners;
|
|
49
|
+
constructor(mqtt: MqttClient);
|
|
50
|
+
subscribe(listener: (event: ObsEvent) => void): () => void;
|
|
51
|
+
publishDecision(event: ObsEvent): void;
|
|
52
|
+
publishActionEvent(event: ObsEvent): void;
|
|
53
|
+
publishLifecycle(type: LifecycleEventType, automationCount: number, dryRun?: boolean): void;
|
|
54
|
+
private publish;
|
|
55
|
+
private safeSerialize;
|
|
56
|
+
private notifyListeners;
|
|
57
|
+
}
|
|
58
|
+
export {};
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
export class EventPublisher {
|
|
2
|
+
mqtt;
|
|
3
|
+
listeners = [];
|
|
4
|
+
constructor(mqtt) {
|
|
5
|
+
this.mqtt = mqtt;
|
|
6
|
+
}
|
|
7
|
+
subscribe(listener) {
|
|
8
|
+
this.listeners.push(listener);
|
|
9
|
+
return () => {
|
|
10
|
+
const idx = this.listeners.indexOf(listener);
|
|
11
|
+
if (idx !== -1)
|
|
12
|
+
this.listeners.splice(idx, 1);
|
|
13
|
+
};
|
|
14
|
+
}
|
|
15
|
+
publishDecision(event) {
|
|
16
|
+
const payload = this.safeSerialize(event);
|
|
17
|
+
if (payload !== undefined) {
|
|
18
|
+
const ns = event.dry_run ? 'homerun/dev' : 'homerun';
|
|
19
|
+
this.publish(`${ns}/events`, payload, false);
|
|
20
|
+
this.publish(`${ns}/${event.location}/${event.subsystem}/decision`, payload, true);
|
|
21
|
+
}
|
|
22
|
+
this.notifyListeners(event);
|
|
23
|
+
}
|
|
24
|
+
publishActionEvent(event) {
|
|
25
|
+
const payload = this.safeSerialize(event);
|
|
26
|
+
if (payload !== undefined) {
|
|
27
|
+
const ns = event.dry_run ? 'homerun/dev' : 'homerun';
|
|
28
|
+
this.publish(`${ns}/events`, payload, false);
|
|
29
|
+
}
|
|
30
|
+
this.notifyListeners(event);
|
|
31
|
+
}
|
|
32
|
+
publishLifecycle(type, automationCount, dryRun = false) {
|
|
33
|
+
const event = {
|
|
34
|
+
schema: 'home.lifecycle.v1',
|
|
35
|
+
type,
|
|
36
|
+
automation_count: automationCount,
|
|
37
|
+
timestamp: new Date().toISOString(),
|
|
38
|
+
...(dryRun && { dry_run: true }),
|
|
39
|
+
};
|
|
40
|
+
const ns = dryRun ? 'homerun/dev' : 'homerun';
|
|
41
|
+
const payload = JSON.stringify(event);
|
|
42
|
+
this.publish(`${ns}/lifecycle`, payload, false);
|
|
43
|
+
this.publish(`${ns}/status`, JSON.stringify({ status: 'online', automation_count: automationCount, timestamp: event.timestamp }), true);
|
|
44
|
+
}
|
|
45
|
+
publish(topic, payload, retain) {
|
|
46
|
+
this.mqtt.publishAsync(topic, payload, { retain }).catch((err) => {
|
|
47
|
+
console.error(`[EventPublisher] MQTT publish failed on ${topic}:`, err);
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
// Observability must never affect automation behaviour — a decision/action event that fails
|
|
51
|
+
// to serialize (e.g. conditions defaulting to a context object with a circular reference) must
|
|
52
|
+
// not throw, since runPipeline awaits this alongside actionRuntime.execute() in the same
|
|
53
|
+
// Promise.all; a synchronous throw here would silently prevent real actions from ever running.
|
|
54
|
+
safeSerialize(event) {
|
|
55
|
+
try {
|
|
56
|
+
return JSON.stringify(event);
|
|
57
|
+
}
|
|
58
|
+
catch (err) {
|
|
59
|
+
console.error(`[EventPublisher] failed to serialize ${event.event_type} event for ${event.automation_id} — MQTT publish skipped:`, err);
|
|
60
|
+
return undefined;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
// Isolates listeners from each other and from the publisher — the same reliability principle
|
|
64
|
+
// applied to subscribers (e.g. the /events SSE endpoint) as to serialization above.
|
|
65
|
+
notifyListeners(event) {
|
|
66
|
+
for (const l of this.listeners) {
|
|
67
|
+
try {
|
|
68
|
+
l(event);
|
|
69
|
+
}
|
|
70
|
+
catch (err) {
|
|
71
|
+
console.error('[EventPublisher] listener threw, continuing:', err);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
}
|