@ajclarkson/homerun 0.0.1-edge.db0717b → 0.0.1-edge.e40faa4

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.
Files changed (34) hide show
  1. package/dist/scripts/generate-ha-services.d.ts +16 -0
  2. package/dist/scripts/generate-ha-services.js +111 -0
  3. package/dist/src/framework/action-runtime.d.ts +4 -2
  4. package/dist/src/framework/action-runtime.js +12 -3
  5. package/dist/src/framework/api-server.d.ts +8 -2
  6. package/dist/src/framework/api-server.js +26 -2
  7. package/dist/src/framework/config.d.ts +27 -0
  8. package/dist/src/framework/config.js +80 -0
  9. package/dist/src/framework/{observability.d.ts → event-publisher.d.ts} +2 -2
  10. package/dist/src/framework/{observability.js → event-publisher.js} +2 -2
  11. package/dist/src/framework/ha-client.d.ts +1 -0
  12. package/dist/src/framework/ha-client.js +4 -0
  13. package/dist/src/framework/hot-reload.js +2 -1
  14. package/dist/src/framework/metrics-prom.d.ts +18 -0
  15. package/dist/src/framework/metrics-prom.js +93 -0
  16. package/dist/src/framework/metrics.d.ts +10 -0
  17. package/dist/src/framework/metrics.js +5 -0
  18. package/dist/src/framework/pipeline.d.ts +4 -2
  19. package/dist/src/framework/pipeline.js +13 -4
  20. package/dist/src/framework/timer-manager.d.ts +1 -0
  21. package/dist/src/framework/timer-manager.js +5 -0
  22. package/dist/src/framework/trigger-engine.d.ts +4 -1
  23. package/dist/src/framework/trigger-engine.js +93 -29
  24. package/dist/src/index.d.ts +1 -1
  25. package/dist/src/index.js +81 -25
  26. package/dist/src/lib.d.ts +2 -0
  27. package/dist/src/lib.js +1 -0
  28. package/dist/src/services.d.ts +178 -0
  29. package/dist/src/services.js +106 -0
  30. package/dist/src/testing.d.ts +2 -0
  31. package/dist/src/testing.js +2 -2
  32. package/dist/src/types/automation.d.ts +1 -0
  33. package/dist/src/types/triggers.d.ts +2 -1
  34. package/package.json +9 -3
@@ -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
+ }
@@ -2,7 +2,8 @@ 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 { Observability } from './observability.js';
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;
@@ -13,8 +14,9 @@ interface Deps {
13
14
  haClient: HAClient;
14
15
  mqttClient: MqttClient;
15
16
  timerManager: TimerManager;
16
- observability: Observability;
17
+ eventPublisher: EventPublisher;
17
18
  dryRun: boolean;
19
+ metrics?: MetricsBackend;
18
20
  }
19
21
  export declare class ActionRuntime {
20
22
  private readonly deps;
@@ -9,16 +9,25 @@ export class ActionRuntime {
9
9
  }
10
10
  }
11
11
  async runAction(action, ctx) {
12
- this.deps.observability.publishActionEvent(this.makeEvent(ctx, 'action_started', action));
12
+ const labels = { location: ctx.location, action_type: action.type };
13
+ this.deps.eventPublisher.publishActionEvent(this.makeEvent(ctx, 'action_started', action));
14
+ this.deps.metrics?.incrementCounter('homerun_actions_dispatched_total', labels);
15
+ const start = performance.now();
13
16
  try {
14
17
  if (!this.deps.dryRun) {
15
18
  await this.dispatch(action);
16
19
  }
17
- this.deps.observability.publishActionEvent(this.makeEvent(ctx, 'action_result', action, { reason: 'ok' }));
20
+ const duration = (performance.now() - start) / 1000;
21
+ this.deps.metrics?.observeHistogram('homerun_action_duration_seconds', duration, labels);
22
+ this.deps.metrics?.incrementCounter('homerun_actions_succeeded_total', labels);
23
+ this.deps.eventPublisher.publishActionEvent(this.makeEvent(ctx, 'action_result', action, { reason: 'ok' }));
18
24
  }
19
25
  catch (err) {
26
+ const duration = (performance.now() - start) / 1000;
27
+ this.deps.metrics?.observeHistogram('homerun_action_duration_seconds', duration, labels);
28
+ this.deps.metrics?.incrementCounter('homerun_actions_failed_total', labels);
20
29
  const reason = err instanceof Error ? err.message : String(err);
21
- this.deps.observability.publishActionEvent(this.makeEvent(ctx, 'action_result', action, { reason }));
30
+ this.deps.eventPublisher.publishActionEvent(this.makeEvent(ctx, 'action_result', action, { reason }));
22
31
  }
23
32
  }
24
33
  async dispatch(action) {
@@ -1,15 +1,20 @@
1
1
  import type { AutomationRegistry } from './registry.js';
2
- import type { Observability } from './observability.js';
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
- observability: Observability;
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
- triggerTypes: a.triggers.map((t) => t.type),
56
+ enabled: a.enabled ?? true,
57
+ triggers: a.triggers.map(serializeTrigger),
55
58
  }));
56
59
  json(res, 200, automations);
57
60
  }
@@ -87,6 +90,21 @@ export class ApiServer {
87
90
  ...(this.deps.dryRun && { dry_run: true }),
88
91
  });
89
92
  }
93
+ getMetrics(res) {
94
+ if (!this.deps.metrics) {
95
+ json(res, 404, { error: 'metrics not enabled' });
96
+ return;
97
+ }
98
+ this.deps.metrics.getMetrics()
99
+ .then((output) => {
100
+ res.writeHead(200, { 'Content-Type': this.deps.metrics.contentType });
101
+ res.end(output);
102
+ })
103
+ .catch((err) => {
104
+ console.error('[ApiServer] metrics scrape failed:', err);
105
+ json(res, 500, { error: 'metrics scrape failed' });
106
+ });
107
+ }
90
108
  getEvents(req, res) {
91
109
  res.writeHead(200, {
92
110
  'Content-Type': 'text/event-stream',
@@ -94,12 +112,18 @@ export class ApiServer {
94
112
  Connection: 'keep-alive',
95
113
  });
96
114
  res.flushHeaders();
97
- const unsubscribe = this.deps.observability.subscribe((event) => {
115
+ const unsubscribe = this.deps.eventPublisher.subscribe((event) => {
98
116
  res.write(`data: ${JSON.stringify(event)}\n\n`);
99
117
  });
100
118
  req.on('close', unsubscribe);
101
119
  }
102
120
  }
121
+ function serializeTrigger(trigger) {
122
+ if (trigger.type === 'state_changed' && trigger.entity instanceof RegExp) {
123
+ return { ...trigger, entity: trigger.entity.toString() };
124
+ }
125
+ return trigger;
126
+ }
103
127
  function json(res, status, body) {
104
128
  const payload = JSON.stringify(body);
105
129
  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
+ }
@@ -14,7 +14,7 @@ export interface ObsEvent {
14
14
  dry_run?: boolean;
15
15
  timestamp: string;
16
16
  }
17
- export type LifecycleEventType = 'server_started' | 'rescan_complete' | 'ha_reconnected';
17
+ export type LifecycleEventType = 'server_started' | 'server_stopping' | 'rescan_complete' | 'ha_reconnected';
18
18
  export interface LifecycleEvent {
19
19
  schema: 'home.lifecycle.v1';
20
20
  type: LifecycleEventType;
@@ -22,7 +22,7 @@ export interface LifecycleEvent {
22
22
  timestamp: string;
23
23
  dry_run?: boolean;
24
24
  }
25
- export declare class Observability {
25
+ export declare class EventPublisher {
26
26
  private readonly mqtt;
27
27
  private readonly listeners;
28
28
  constructor(mqtt: MqttClient);
@@ -1,4 +1,4 @@
1
- export class Observability {
1
+ export class EventPublisher {
2
2
  mqtt;
3
3
  listeners = [];
4
4
  constructor(mqtt) {
@@ -41,7 +41,7 @@ export class Observability {
41
41
  }
42
42
  publish(topic, payload, retain) {
43
43
  this.mqtt.publishAsync(topic, payload, { retain }).catch((err) => {
44
- console.error(`[Observability] MQTT publish failed on ${topic}:`, err);
44
+ console.error(`[EventPublisher] MQTT publish failed on ${topic}:`, err);
45
45
  });
46
46
  }
47
47
  }
@@ -40,6 +40,7 @@ export declare class HAClient extends EventEmitter {
40
40
  readonly context: HAContext;
41
41
  get entityCount(): number;
42
42
  callService(domain: string, service: string, target?: HassServiceTarget, data?: Record<string, unknown>): Promise<void>;
43
+ disconnect(): void;
43
44
  connect(url: string, token: string): Promise<void>;
44
45
  private repopulateCache;
45
46
  private diffAndUpdate;
@@ -28,6 +28,10 @@ export class HAClient extends EventEmitter {
28
28
  throw new Error('HAClient not connected');
29
29
  await callService(this.connection, domain, service, data, target);
30
30
  }
31
+ disconnect() {
32
+ this.connection?.close();
33
+ this.connection = null;
34
+ }
31
35
  async connect(url, token) {
32
36
  const auth = createLongLivedTokenAuth(url, token);
33
37
  this.connection = await createConnection({ auth });
@@ -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).includes('types');
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 {
@@ -0,0 +1,18 @@
1
+ import { Registry } from 'prom-client';
2
+ import type { MetricsBackend } from './metrics.js';
3
+ export declare class PromMetricsBackend implements MetricsBackend {
4
+ readonly registry: Registry<"text/plain; version=0.0.4; charset=utf-8">;
5
+ private readonly haEventsTotal;
6
+ private readonly pipelineRunsTotal;
7
+ private readonly actionsDispatchedTotal;
8
+ private readonly actionsSucceededTotal;
9
+ private readonly actionsFailedTotal;
10
+ private readonly actionDurationSeconds;
11
+ private readonly automationsLoaded;
12
+ constructor(collectDefaults?: boolean);
13
+ incrementCounter(name: string, labels?: Record<string, string>): void;
14
+ observeHistogram(name: string, value: number, labels?: Record<string, string>): void;
15
+ setGauge(name: string, value: number): void;
16
+ getMetrics(): Promise<string>;
17
+ get contentType(): string;
18
+ }
@@ -0,0 +1,93 @@
1
+ import { Registry, Counter, Gauge, Histogram, collectDefaultMetrics } from 'prom-client';
2
+ export class PromMetricsBackend {
3
+ registry = new Registry();
4
+ haEventsTotal;
5
+ pipelineRunsTotal;
6
+ actionsDispatchedTotal;
7
+ actionsSucceededTotal;
8
+ actionsFailedTotal;
9
+ actionDurationSeconds;
10
+ automationsLoaded;
11
+ constructor(collectDefaults = false) {
12
+ if (collectDefaults) {
13
+ collectDefaultMetrics({ register: this.registry });
14
+ }
15
+ this.haEventsTotal = new Counter({
16
+ name: 'homerun_ha_events_received_total',
17
+ help: 'Total HA and MQTT events received by homerun',
18
+ labelNames: ['event_type'],
19
+ registers: [this.registry],
20
+ });
21
+ this.pipelineRunsTotal = new Counter({
22
+ name: 'homerun_pipeline_runs_total',
23
+ help: 'Total automation pipeline runs',
24
+ labelNames: ['location', 'trigger_type'],
25
+ registers: [this.registry],
26
+ });
27
+ this.actionsDispatchedTotal = new Counter({
28
+ name: 'homerun_actions_dispatched_total',
29
+ help: 'Total actions dispatched by the action runtime',
30
+ labelNames: ['location', 'action_type'],
31
+ registers: [this.registry],
32
+ });
33
+ this.actionsSucceededTotal = new Counter({
34
+ name: 'homerun_actions_succeeded_total',
35
+ help: 'Total actions that completed successfully',
36
+ labelNames: ['location', 'action_type'],
37
+ registers: [this.registry],
38
+ });
39
+ this.actionsFailedTotal = new Counter({
40
+ name: 'homerun_actions_failed_total',
41
+ help: 'Total actions that failed with an error',
42
+ labelNames: ['location', 'action_type'],
43
+ registers: [this.registry],
44
+ });
45
+ this.actionDurationSeconds = new Histogram({
46
+ name: 'homerun_action_duration_seconds',
47
+ help: 'Duration of action execution in seconds',
48
+ labelNames: ['location', 'action_type'],
49
+ buckets: [0.01, 0.05, 0.1, 0.5, 1, 2, 5],
50
+ registers: [this.registry],
51
+ });
52
+ this.automationsLoaded = new Gauge({
53
+ name: 'homerun_automations_loaded',
54
+ help: 'Number of automations currently loaded',
55
+ registers: [this.registry],
56
+ });
57
+ }
58
+ incrementCounter(name, labels = {}) {
59
+ switch (name) {
60
+ case 'homerun_ha_events_received_total':
61
+ this.haEventsTotal.inc(labels);
62
+ break;
63
+ case 'homerun_pipeline_runs_total':
64
+ this.pipelineRunsTotal.inc(labels);
65
+ break;
66
+ case 'homerun_actions_dispatched_total':
67
+ this.actionsDispatchedTotal.inc(labels);
68
+ break;
69
+ case 'homerun_actions_succeeded_total':
70
+ this.actionsSucceededTotal.inc(labels);
71
+ break;
72
+ case 'homerun_actions_failed_total':
73
+ this.actionsFailedTotal.inc(labels);
74
+ break;
75
+ }
76
+ }
77
+ observeHistogram(name, value, labels = {}) {
78
+ if (name === 'homerun_action_duration_seconds') {
79
+ this.actionDurationSeconds.observe(labels, value);
80
+ }
81
+ }
82
+ setGauge(name, value) {
83
+ if (name === 'homerun_automations_loaded') {
84
+ this.automationsLoaded.set(value);
85
+ }
86
+ }
87
+ async getMetrics() {
88
+ return this.registry.metrics();
89
+ }
90
+ get contentType() {
91
+ return this.registry.contentType;
92
+ }
93
+ }
@@ -0,0 +1,10 @@
1
+ export interface MetricsBackend {
2
+ incrementCounter(name: string, labels?: Record<string, string>): void;
3
+ observeHistogram(name: string, value: number, labels?: Record<string, string>): void;
4
+ setGauge(name: string, value: number, labels?: Record<string, string>): void;
5
+ }
6
+ export declare class NoopMetricsBackend implements MetricsBackend {
7
+ incrementCounter(): void;
8
+ observeHistogram(): void;
9
+ setGauge(): void;
10
+ }
@@ -0,0 +1,5 @@
1
+ export class NoopMetricsBackend {
2
+ incrementCounter() { }
3
+ observeHistogram() { }
4
+ setGauge() { }
5
+ }
@@ -1,12 +1,14 @@
1
1
  import type { Automation } from '../types/automation.js';
2
2
  import type { TriggerEvent } from '../types/triggers.js';
3
3
  import type { HAClient } from './ha-client.js';
4
- import type { Observability } from './observability.js';
4
+ import type { EventPublisher } from './event-publisher.js';
5
5
  import type { ActionRuntime } from './action-runtime.js';
6
+ import type { MetricsBackend } from './metrics.js';
6
7
  interface Deps {
7
- observability: Observability;
8
+ eventPublisher: EventPublisher;
8
9
  actionRuntime: ActionRuntime;
9
10
  dryRun?: boolean;
11
+ metrics?: MetricsBackend;
10
12
  }
11
13
  export declare function runPipeline(automation: Automation<unknown>, event: TriggerEvent, haClient: HAClient, deps: Deps): Promise<void>;
12
14
  export {};
@@ -1,5 +1,9 @@
1
1
  import { isAbort } from '../types/automation.js';
2
2
  export async function runPipeline(automation, event, haClient, deps) {
3
+ deps.metrics?.incrementCounter('homerun_pipeline_runs_total', {
4
+ location: automation.location,
5
+ trigger_type: event.type,
6
+ });
3
7
  const correlationId = event.correlation_id;
4
8
  const timestamp = new Date().toISOString();
5
9
  const base = {
@@ -11,17 +15,22 @@ export async function runPipeline(automation, event, haClient, deps) {
11
15
  timestamp,
12
16
  ...(deps.dryRun ? { dry_run: true } : {}),
13
17
  };
18
+ // Step 1: Enabled check
19
+ if (automation.enabled === false) {
20
+ deps.eventPublisher.publishDecision({ ...base, event_type: 'abort', reason: 'disabled' });
21
+ return;
22
+ }
14
23
  // Step 2: Context
15
24
  let ctx;
16
25
  try {
17
26
  ctx = automation.context(haClient.state, haClient.context, event);
18
27
  }
19
28
  catch {
20
- deps.observability.publishDecision({ ...base, event_type: 'abort', reason: 'unhandled_error' });
29
+ deps.eventPublisher.publishDecision({ ...base, event_type: 'abort', reason: 'unhandled_error' });
21
30
  return;
22
31
  }
23
32
  if (isAbort(ctx)) {
24
- deps.observability.publishDecision({ ...base, event_type: 'abort', reason: ctx.reason });
33
+ deps.eventPublisher.publishDecision({ ...base, event_type: 'abort', reason: ctx.reason });
25
34
  return;
26
35
  }
27
36
  // Step 3: Reduce
@@ -30,7 +39,7 @@ export async function runPipeline(automation, event, haClient, deps) {
30
39
  result = automation.reduce(ctx);
31
40
  }
32
41
  catch {
33
- deps.observability.publishDecision({ ...base, event_type: 'abort', reason: 'unhandled_error' });
42
+ deps.eventPublisher.publishDecision({ ...base, event_type: 'abort', reason: 'unhandled_error' });
34
43
  return;
35
44
  }
36
45
  // Step 4: Validate — safe defaults
@@ -45,7 +54,7 @@ export async function runPipeline(automation, event, haClient, deps) {
45
54
  };
46
55
  // Step 5: Fanout
47
56
  await Promise.all([
48
- Promise.resolve(deps.observability.publishDecision(decision)),
57
+ Promise.resolve(deps.eventPublisher.publishDecision(decision)),
49
58
  deps.actionRuntime.execute(actions, {
50
59
  correlationId,
51
60
  automationId: automation.id,
@@ -5,4 +5,5 @@ export declare class TimerManager {
5
5
  constructor(dispatch: (event: TriggerEvent) => void);
6
6
  start(timerKey: string, delayMs: number): void;
7
7
  cancel(timerKey: string): void;
8
+ cancelAll(): void;
8
9
  }