@ajclarkson/homerun 0.0.1-edge.01eba24
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-types.d.ts +8 -0
- package/dist/scripts/generate-ha-types.js +81 -0
- package/dist/src/framework/action-runtime.d.ts +27 -0
- package/dist/src/framework/action-runtime.js +58 -0
- package/dist/src/framework/api-server.d.ts +29 -0
- package/dist/src/framework/api-server.js +107 -0
- package/dist/src/framework/ha-client.d.ts +47 -0
- package/dist/src/framework/ha-client.js +125 -0
- package/dist/src/framework/hot-reload.d.ts +9 -0
- package/dist/src/framework/hot-reload.js +87 -0
- package/dist/src/framework/observability.d.ts +34 -0
- package/dist/src/framework/observability.js +47 -0
- package/dist/src/framework/pipeline.d.ts +12 -0
- package/dist/src/framework/pipeline.js +56 -0
- package/dist/src/framework/registry.d.ts +10 -0
- package/dist/src/framework/registry.js +21 -0
- package/dist/src/framework/scheduler.d.ts +11 -0
- package/dist/src/framework/scheduler.js +35 -0
- package/dist/src/framework/timer-manager.d.ts +8 -0
- package/dist/src/framework/timer-manager.js +22 -0
- package/dist/src/framework/trigger-engine.d.ts +22 -0
- package/dist/src/framework/trigger-engine.js +223 -0
- package/dist/src/index.d.ts +1 -0
- package/dist/src/index.js +98 -0
- package/dist/src/lib.d.ts +4 -0
- package/dist/src/lib.js +1 -0
- package/dist/src/testing.d.ts +16 -0
- package/dist/src/testing.js +25 -0
- package/dist/src/types/actions.d.ts +21 -0
- package/dist/src/types/actions.js +1 -0
- package/dist/src/types/automation.d.ts +25 -0
- package/dist/src/types/automation.js +9 -0
- package/dist/src/types/triggers.d.ts +50 -0
- package/dist/src/types/triggers.js +1 -0
- package/package.json +55 -0
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
const DOUBLE_PRESS_WINDOW_MS = 400;
|
|
2
|
+
class ButtonGestureHandler {
|
|
3
|
+
entityId;
|
|
4
|
+
dispatch;
|
|
5
|
+
supportsDoublePress;
|
|
6
|
+
gestureState = 'idle';
|
|
7
|
+
resolveTimer = null;
|
|
8
|
+
constructor(entityId, dispatch, supportsDoublePress) {
|
|
9
|
+
this.entityId = entityId;
|
|
10
|
+
this.dispatch = dispatch;
|
|
11
|
+
this.supportsDoublePress = supportsDoublePress;
|
|
12
|
+
}
|
|
13
|
+
handle(actionState, correlationId) {
|
|
14
|
+
const parsed = parseButtonAction(actionState);
|
|
15
|
+
if (!parsed)
|
|
16
|
+
return;
|
|
17
|
+
const { button, pressType } = parsed;
|
|
18
|
+
if (this.gestureState === 'idle') {
|
|
19
|
+
if (pressType === 'short') {
|
|
20
|
+
if (!this.supportsDoublePress) {
|
|
21
|
+
this.dispatch({ type: 'button', entity_id: this.entityId, gesture: 'single_press', button, correlation_id: correlationId });
|
|
22
|
+
}
|
|
23
|
+
else {
|
|
24
|
+
this.gestureState = 'resolving';
|
|
25
|
+
this.resolveTimer = setTimeout(() => {
|
|
26
|
+
this.resolveTimer = null;
|
|
27
|
+
this.gestureState = 'idle';
|
|
28
|
+
this.dispatch({ type: 'button', entity_id: this.entityId, gesture: 'single_press', button, correlation_id: correlationId });
|
|
29
|
+
}, DOUBLE_PRESS_WINDOW_MS);
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
else {
|
|
33
|
+
this.dispatch({ type: 'button', entity_id: this.entityId, gesture: 'hold', button, correlation_id: correlationId });
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
else {
|
|
37
|
+
// resolving — waiting for double press
|
|
38
|
+
clearTimeout(this.resolveTimer);
|
|
39
|
+
this.resolveTimer = null;
|
|
40
|
+
this.gestureState = 'idle';
|
|
41
|
+
if (pressType === 'short') {
|
|
42
|
+
this.dispatch({ type: 'button', entity_id: this.entityId, gesture: 'double_press', button, correlation_id: correlationId });
|
|
43
|
+
}
|
|
44
|
+
else {
|
|
45
|
+
this.dispatch({ type: 'button', entity_id: this.entityId, gesture: 'hold', button, correlation_id: correlationId });
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
// Classifies a Z2M action state string.
|
|
51
|
+
// Numeric button prefix ("1_short_release") is separated out and returned as `button`.
|
|
52
|
+
// Recognises common Z2M short/hold conventions; returns null for unrecognised values.
|
|
53
|
+
export function parseButtonAction(state) {
|
|
54
|
+
const s = state.toLowerCase();
|
|
55
|
+
const prefixed = s.match(/^(\d+)[_-](.+)$/);
|
|
56
|
+
const button = prefixed?.[1];
|
|
57
|
+
const action = prefixed ? prefixed[2] : s;
|
|
58
|
+
if (/long|hold/.test(action))
|
|
59
|
+
return { button, pressType: 'hold' };
|
|
60
|
+
if (/short|click|single/.test(action) || action === 'press' || action === 'on' || action === 'toggle') {
|
|
61
|
+
return { button, pressType: 'short' };
|
|
62
|
+
}
|
|
63
|
+
return null;
|
|
64
|
+
}
|
|
65
|
+
// ---------- Trigger Engine ----------
|
|
66
|
+
export class TriggerEngine {
|
|
67
|
+
registry;
|
|
68
|
+
haClient;
|
|
69
|
+
onMatch;
|
|
70
|
+
mqttClient;
|
|
71
|
+
buttonHandlers = new Map();
|
|
72
|
+
durationTimers = new Map();
|
|
73
|
+
constructor(registry, haClient, onMatch, mqttClient) {
|
|
74
|
+
this.registry = registry;
|
|
75
|
+
this.haClient = haClient;
|
|
76
|
+
this.onMatch = onMatch;
|
|
77
|
+
this.mqttClient = mqttClient;
|
|
78
|
+
this.rebuildButtonHandlers();
|
|
79
|
+
registry.onChange(() => this.rebuildButtonHandlers());
|
|
80
|
+
}
|
|
81
|
+
rebuildButtonHandlers() {
|
|
82
|
+
this.buttonHandlers.clear();
|
|
83
|
+
const entityGestures = new Map();
|
|
84
|
+
for (const automation of this.registry.getAll()) {
|
|
85
|
+
for (const trigger of automation.triggers) {
|
|
86
|
+
if (trigger.type === 'button') {
|
|
87
|
+
if (!entityGestures.has(trigger.entity)) {
|
|
88
|
+
entityGestures.set(trigger.entity, new Set());
|
|
89
|
+
}
|
|
90
|
+
entityGestures.get(trigger.entity).add(trigger.gesture);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
for (const [entity, gestures] of entityGestures) {
|
|
95
|
+
this.buttonHandlers.set(entity, new ButtonGestureHandler(entity, (e) => this.dispatch(e), gestures.has('double_press')));
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
start() {
|
|
99
|
+
this.haClient.ready
|
|
100
|
+
.then(() => {
|
|
101
|
+
this.haClient.on('state_changed', (event) => {
|
|
102
|
+
const handler = this.buttonHandlers.get(event.entity_id);
|
|
103
|
+
if (handler) {
|
|
104
|
+
handler.handle(event.new_state.state, event.correlation_id);
|
|
105
|
+
}
|
|
106
|
+
else {
|
|
107
|
+
this.dispatch({
|
|
108
|
+
type: 'state_changed',
|
|
109
|
+
entity_id: event.entity_id,
|
|
110
|
+
old_state: event.old_state,
|
|
111
|
+
new_state: event.new_state,
|
|
112
|
+
correlation_id: event.correlation_id,
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
});
|
|
116
|
+
})
|
|
117
|
+
.catch((err) => {
|
|
118
|
+
console.error('[trigger-engine] failed to start:', err);
|
|
119
|
+
});
|
|
120
|
+
if (this.mqttClient) {
|
|
121
|
+
const topics = new Set();
|
|
122
|
+
for (const automation of this.registry.getAll()) {
|
|
123
|
+
for (const trigger of automation.triggers) {
|
|
124
|
+
if (trigger.type === 'mqtt_in')
|
|
125
|
+
topics.add(trigger.topic);
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
for (const topic of topics) {
|
|
129
|
+
this.mqttClient.subscribe(topic);
|
|
130
|
+
}
|
|
131
|
+
this.mqttClient.subscribe('homerun/trigger/+');
|
|
132
|
+
this.mqttClient.on('message', (topic, payload) => {
|
|
133
|
+
if (topic.startsWith('homerun/trigger/')) {
|
|
134
|
+
const automationId = topic.slice('homerun/trigger/'.length);
|
|
135
|
+
const automation = this.registry.getById(automationId);
|
|
136
|
+
if (!automation) {
|
|
137
|
+
console.warn(`[trigger-engine] manual trigger: no automation with id "${automationId}"`);
|
|
138
|
+
return;
|
|
139
|
+
}
|
|
140
|
+
this.onMatch(automation, { type: 'on_start', correlation_id: crypto.randomUUID() });
|
|
141
|
+
return;
|
|
142
|
+
}
|
|
143
|
+
this.dispatch({
|
|
144
|
+
type: 'mqtt_in',
|
|
145
|
+
topic,
|
|
146
|
+
payload: payload.toString(),
|
|
147
|
+
correlation_id: `mqtt-${Date.now()}`,
|
|
148
|
+
});
|
|
149
|
+
});
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
// Entry point for Timer Manager loopback and resolved button gestures.
|
|
153
|
+
dispatch(event) {
|
|
154
|
+
this.matchAndFire(event);
|
|
155
|
+
}
|
|
156
|
+
// ---------- Private ----------
|
|
157
|
+
matchAndFire(event) {
|
|
158
|
+
for (const automation of this.registry.getAll()) {
|
|
159
|
+
for (const trigger of automation.triggers) {
|
|
160
|
+
if (matchesTrigger(trigger, event)) {
|
|
161
|
+
if (trigger.type === 'state_changed' && trigger.duration && event.type === 'state_changed') {
|
|
162
|
+
const key = `${automation.id}:${event.entity_id}`;
|
|
163
|
+
const existing = this.durationTimers.get(key);
|
|
164
|
+
if (existing !== undefined)
|
|
165
|
+
clearTimeout(existing);
|
|
166
|
+
const timer = setTimeout(() => {
|
|
167
|
+
this.durationTimers.delete(key);
|
|
168
|
+
if (this.haClient.state(event.entity_id)?.state === event.new_state.state) {
|
|
169
|
+
this.onMatch(automation, event);
|
|
170
|
+
}
|
|
171
|
+
}, trigger.duration);
|
|
172
|
+
this.durationTimers.set(key, timer);
|
|
173
|
+
}
|
|
174
|
+
else {
|
|
175
|
+
this.onMatch(automation, event);
|
|
176
|
+
}
|
|
177
|
+
break; // don't fire the same automation twice for one event
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
// ---------- Trigger matching ----------
|
|
184
|
+
function matchesTrigger(trigger, event) {
|
|
185
|
+
if (trigger.type !== event.type)
|
|
186
|
+
return false;
|
|
187
|
+
switch (trigger.type) {
|
|
188
|
+
case 'state_changed': {
|
|
189
|
+
if (event.type !== 'state_changed')
|
|
190
|
+
return false;
|
|
191
|
+
return typeof trigger.entity === 'string'
|
|
192
|
+
? trigger.entity === event.entity_id
|
|
193
|
+
: trigger.entity.test(event.entity_id);
|
|
194
|
+
}
|
|
195
|
+
case 'timer_expired': {
|
|
196
|
+
if (event.type !== 'timer_expired')
|
|
197
|
+
return false;
|
|
198
|
+
return trigger.timerKey === event.timerKey;
|
|
199
|
+
}
|
|
200
|
+
case 'button': {
|
|
201
|
+
if (event.type !== 'button')
|
|
202
|
+
return false;
|
|
203
|
+
return (trigger.entity === event.entity_id &&
|
|
204
|
+
trigger.gesture === event.gesture &&
|
|
205
|
+
(trigger.button === undefined || trigger.button === event.button));
|
|
206
|
+
}
|
|
207
|
+
case 'schedule': {
|
|
208
|
+
if (event.type !== 'schedule')
|
|
209
|
+
return false;
|
|
210
|
+
return trigger.cron === event.cron;
|
|
211
|
+
}
|
|
212
|
+
case 'on_start':
|
|
213
|
+
return event.type === 'on_start';
|
|
214
|
+
case 'mqtt_in':
|
|
215
|
+
if (event.type !== 'mqtt_in')
|
|
216
|
+
return false;
|
|
217
|
+
return trigger.topic === event.topic;
|
|
218
|
+
default: {
|
|
219
|
+
const _exhaustive = trigger;
|
|
220
|
+
return false;
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
import 'dotenv/config';
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import 'dotenv/config';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { connect } from 'mqtt';
|
|
4
|
+
import { HAClient } from './framework/ha-client.js';
|
|
5
|
+
import { AutomationRegistry } from './framework/registry.js';
|
|
6
|
+
import { Observability } from './framework/observability.js';
|
|
7
|
+
import { TimerManager } from './framework/timer-manager.js';
|
|
8
|
+
import { ActionRuntime } from './framework/action-runtime.js';
|
|
9
|
+
import { TriggerEngine } from './framework/trigger-engine.js';
|
|
10
|
+
import { Scheduler } from './framework/scheduler.js';
|
|
11
|
+
import { rescanAutomations, startHotReload } from './framework/hot-reload.js';
|
|
12
|
+
import { runPipeline } from './framework/pipeline.js';
|
|
13
|
+
import { ApiServer } from './framework/api-server.js';
|
|
14
|
+
process.on('uncaughtException', (err) => {
|
|
15
|
+
console.error('[homerun] uncaughtException:', err);
|
|
16
|
+
});
|
|
17
|
+
process.on('unhandledRejection', (reason) => {
|
|
18
|
+
console.error('[homerun] unhandledRejection:', reason);
|
|
19
|
+
});
|
|
20
|
+
const dryRun = process.env.DRY_RUN === 'true';
|
|
21
|
+
const lwtTopic = dryRun ? 'homerun/dev/status' : 'homerun/status';
|
|
22
|
+
const lwtPayload = JSON.stringify({ status: 'offline', timestamp: new Date().toISOString() });
|
|
23
|
+
// 1. Connect MQTT before anything else (Observability and ActionRuntime need it).
|
|
24
|
+
const mqtt = connect(process.env.MQTT_URL, {
|
|
25
|
+
will: { topic: lwtTopic, payload: lwtPayload, qos: 1, retain: true },
|
|
26
|
+
});
|
|
27
|
+
await new Promise((resolve, reject) => {
|
|
28
|
+
mqtt.once('connect', () => resolve());
|
|
29
|
+
mqtt.once('error', reject);
|
|
30
|
+
});
|
|
31
|
+
// 2. Instantiate components in dependency order.
|
|
32
|
+
// TimerManager holds a closure over `engine` — the late binding is intentional;
|
|
33
|
+
// `engine` is assigned before any timer can fire.
|
|
34
|
+
const haClient = new HAClient();
|
|
35
|
+
const registry = new AutomationRegistry();
|
|
36
|
+
const observability = new Observability(mqtt);
|
|
37
|
+
let engine;
|
|
38
|
+
const timerManager = new TimerManager((e) => engine.dispatch(e));
|
|
39
|
+
const actionRuntime = new ActionRuntime({
|
|
40
|
+
haClient,
|
|
41
|
+
mqttClient: mqtt,
|
|
42
|
+
timerManager,
|
|
43
|
+
observability,
|
|
44
|
+
dryRun,
|
|
45
|
+
});
|
|
46
|
+
// 3. Initial automation load — must complete before the engine and scheduler start.
|
|
47
|
+
const automationsDir = path.resolve(process.env.AUTOMATIONS_DIR);
|
|
48
|
+
await rescanAutomations(automationsDir, registry);
|
|
49
|
+
console.log(`[homerun] loaded ${registry.getAll().length} automation(s)`);
|
|
50
|
+
// 4. Wire up the engine and scheduler.
|
|
51
|
+
engine = new TriggerEngine(registry, haClient, (automation, event) => {
|
|
52
|
+
runPipeline(automation, event, haClient, { observability, actionRuntime, dryRun }).catch((err) => {
|
|
53
|
+
console.error('[homerun] pipeline error:', err);
|
|
54
|
+
});
|
|
55
|
+
}, mqtt);
|
|
56
|
+
const scheduler = new Scheduler(registry.getAll(), (e) => engine.dispatch(e), haClient.ready);
|
|
57
|
+
engine.start();
|
|
58
|
+
scheduler.start();
|
|
59
|
+
// 5. Start hot-reload watcher (dev) and SIGUSR1 rescan (git-sync sidecar in K8s).
|
|
60
|
+
startHotReload(automationsDir, registry);
|
|
61
|
+
async function reload() {
|
|
62
|
+
await rescanAutomations(automationsDir, registry);
|
|
63
|
+
const count = registry.getAll().length;
|
|
64
|
+
console.log(`[homerun] rescan complete — ${count} automation(s) registered`);
|
|
65
|
+
observability.publishLifecycle('rescan_complete', count, dryRun);
|
|
66
|
+
}
|
|
67
|
+
process.on('SIGUSR1', () => {
|
|
68
|
+
console.log('[homerun] SIGUSR1 received — rescanning automations');
|
|
69
|
+
reload().catch((err) => {
|
|
70
|
+
console.error('[homerun] rescan failed:', err);
|
|
71
|
+
});
|
|
72
|
+
});
|
|
73
|
+
// 6. Start the HTTP API server.
|
|
74
|
+
let haReady = false;
|
|
75
|
+
const apiServer = new ApiServer({
|
|
76
|
+
registry,
|
|
77
|
+
onTrigger: (automation, event) => {
|
|
78
|
+
runPipeline(automation, event, haClient, { observability, actionRuntime, dryRun }).catch((err) => {
|
|
79
|
+
console.error('[homerun] pipeline error (http trigger):', err);
|
|
80
|
+
});
|
|
81
|
+
},
|
|
82
|
+
onReload: reload,
|
|
83
|
+
isReady: () => haReady,
|
|
84
|
+
entityCount: () => haClient.entityCount,
|
|
85
|
+
observability,
|
|
86
|
+
dryRun,
|
|
87
|
+
});
|
|
88
|
+
await apiServer.start(Number(process.env.API_PORT ?? 7070));
|
|
89
|
+
// 7. Connect to HA last — state_changed events start flowing once ready resolves.
|
|
90
|
+
haClient.on('reconnected', () => {
|
|
91
|
+
console.log(`[homerun] reconnected — ${haClient.entityCount} entities refreshed`);
|
|
92
|
+
observability.publishLifecycle('ha_reconnected', registry.getAll().length, dryRun);
|
|
93
|
+
});
|
|
94
|
+
await haClient.connect(process.env.HA_URL, process.env.HA_TOKEN);
|
|
95
|
+
await haClient.ready;
|
|
96
|
+
haReady = true;
|
|
97
|
+
console.log(`[homerun] ready — ${haClient.entityCount} entities cached`);
|
|
98
|
+
observability.publishLifecycle('server_started', registry.getAll().length, dryRun);
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
export { defineAutomation, abort } from './types/automation.js';
|
|
2
|
+
export type { Automation, Decision, Abort, HAState, HAContext } from './types/automation.js';
|
|
3
|
+
export type { Trigger, TriggerEvent } from './types/triggers.js';
|
|
4
|
+
export type { Action } from './types/actions.js';
|
package/dist/src/lib.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { defineAutomation, abort } from './types/automation.js';
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import type { Automation, Decision, Abort } from './types/automation.js';
|
|
2
|
+
import type { TriggerEvent } from './types/triggers.js';
|
|
3
|
+
import type { HAContext } from './framework/ha-client.js';
|
|
4
|
+
type TestStateEntry = {
|
|
5
|
+
state: string;
|
|
6
|
+
attributes?: Record<string, unknown>;
|
|
7
|
+
last_changed?: string;
|
|
8
|
+
last_updated?: string;
|
|
9
|
+
};
|
|
10
|
+
interface TestOptions {
|
|
11
|
+
event: TriggerEvent;
|
|
12
|
+
state?: Record<string, TestStateEntry>;
|
|
13
|
+
ha?: Partial<HAContext>;
|
|
14
|
+
}
|
|
15
|
+
export declare function testAutomation<C>(automation: Automation<C>, options: TestOptions): Decision | Abort;
|
|
16
|
+
export {};
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { isAbort } from './types/automation.js';
|
|
2
|
+
export function testAutomation(automation, options) {
|
|
3
|
+
const { event, state = {}, ha = {} } = options;
|
|
4
|
+
const stateFunc = (entityId) => {
|
|
5
|
+
const entry = state[entityId];
|
|
6
|
+
if (!entry)
|
|
7
|
+
return undefined;
|
|
8
|
+
return {
|
|
9
|
+
entity_id: entityId,
|
|
10
|
+
state: entry.state,
|
|
11
|
+
attributes: entry.attributes ?? {},
|
|
12
|
+
last_changed: entry.last_changed ?? '',
|
|
13
|
+
last_updated: entry.last_updated ?? '',
|
|
14
|
+
};
|
|
15
|
+
};
|
|
16
|
+
const haContext = {
|
|
17
|
+
entitiesByLabel: ha.entitiesByLabel ?? (() => []),
|
|
18
|
+
labelsFor: ha.labelsFor ?? (() => []),
|
|
19
|
+
entitiesByArea: ha.entitiesByArea ?? (() => []),
|
|
20
|
+
};
|
|
21
|
+
const ctx = automation.context(stateFunc, haContext, event);
|
|
22
|
+
if (isAbort(ctx))
|
|
23
|
+
return ctx;
|
|
24
|
+
return automation.reduce(ctx);
|
|
25
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
export type Action = {
|
|
2
|
+
type: 'ha.call_service';
|
|
3
|
+
domain: string;
|
|
4
|
+
service: string;
|
|
5
|
+
target?: {
|
|
6
|
+
entity_id: string;
|
|
7
|
+
};
|
|
8
|
+
data?: Record<string, unknown>;
|
|
9
|
+
} | {
|
|
10
|
+
type: 'mqtt.publish';
|
|
11
|
+
topic: string;
|
|
12
|
+
payload: string;
|
|
13
|
+
retain?: boolean;
|
|
14
|
+
} | {
|
|
15
|
+
type: 'timer.start';
|
|
16
|
+
timerKey: string;
|
|
17
|
+
delayMs: number;
|
|
18
|
+
} | {
|
|
19
|
+
type: 'timer.cancel';
|
|
20
|
+
timerKey: string;
|
|
21
|
+
};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import type { HAState, HAContext } from '../framework/ha-client.js';
|
|
2
|
+
import type { Trigger, TriggerEvent } from './triggers.js';
|
|
3
|
+
import type { Action } from './actions.js';
|
|
4
|
+
export type { HAState, HAContext };
|
|
5
|
+
export interface Decision {
|
|
6
|
+
decision: string;
|
|
7
|
+
reason?: string;
|
|
8
|
+
actions: Action[];
|
|
9
|
+
inputs?: Record<string, unknown>;
|
|
10
|
+
}
|
|
11
|
+
export type Abort = {
|
|
12
|
+
abort: true;
|
|
13
|
+
reason: string;
|
|
14
|
+
};
|
|
15
|
+
export declare const abort: (reason: string) => Abort;
|
|
16
|
+
export declare function isAbort(value: unknown): value is Abort;
|
|
17
|
+
export interface Automation<C> {
|
|
18
|
+
id: string;
|
|
19
|
+
location: string;
|
|
20
|
+
subsystem: string;
|
|
21
|
+
triggers: Trigger[];
|
|
22
|
+
context: (state: HAState, ha: HAContext, event: TriggerEvent) => C | Abort;
|
|
23
|
+
reduce: (ctx: C) => Decision;
|
|
24
|
+
}
|
|
25
|
+
export declare function defineAutomation<C>(automation: Automation<C>): Automation<C>;
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
export const abort = (reason) => ({ abort: true, reason });
|
|
2
|
+
export function isAbort(value) {
|
|
3
|
+
return typeof value === 'object' && value !== null && value.abort === true;
|
|
4
|
+
}
|
|
5
|
+
// Identity function — provides type inference on C so the reduce argument
|
|
6
|
+
// is typed correctly without the user annotating the context shape explicitly.
|
|
7
|
+
export function defineAutomation(automation) {
|
|
8
|
+
return automation;
|
|
9
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import type { EntityState } from '../framework/ha-client.js';
|
|
2
|
+
export type Trigger = {
|
|
3
|
+
type: 'state_changed';
|
|
4
|
+
entity: string | RegExp;
|
|
5
|
+
duration?: number;
|
|
6
|
+
} | {
|
|
7
|
+
type: 'schedule';
|
|
8
|
+
cron: string;
|
|
9
|
+
} | {
|
|
10
|
+
type: 'on_start';
|
|
11
|
+
} | {
|
|
12
|
+
type: 'timer_expired';
|
|
13
|
+
timerKey: string;
|
|
14
|
+
} | {
|
|
15
|
+
type: 'button';
|
|
16
|
+
entity: string;
|
|
17
|
+
gesture: 'single_press' | 'double_press' | 'hold';
|
|
18
|
+
button?: string;
|
|
19
|
+
} | {
|
|
20
|
+
type: 'mqtt_in';
|
|
21
|
+
topic: string;
|
|
22
|
+
};
|
|
23
|
+
type TriggerEventBase = {
|
|
24
|
+
correlation_id: string;
|
|
25
|
+
parent_correlation_id?: string;
|
|
26
|
+
};
|
|
27
|
+
export type TriggerEvent = TriggerEventBase & ({
|
|
28
|
+
type: 'state_changed';
|
|
29
|
+
entity_id: string;
|
|
30
|
+
old_state: EntityState | undefined;
|
|
31
|
+
new_state: EntityState;
|
|
32
|
+
} | {
|
|
33
|
+
type: 'schedule';
|
|
34
|
+
cron: string;
|
|
35
|
+
} | {
|
|
36
|
+
type: 'on_start';
|
|
37
|
+
} | {
|
|
38
|
+
type: 'timer_expired';
|
|
39
|
+
timerKey: string;
|
|
40
|
+
} | {
|
|
41
|
+
type: 'button';
|
|
42
|
+
entity_id: string;
|
|
43
|
+
gesture: 'single_press' | 'double_press' | 'hold';
|
|
44
|
+
button?: string;
|
|
45
|
+
} | {
|
|
46
|
+
type: 'mqtt_in';
|
|
47
|
+
topic: string;
|
|
48
|
+
payload: string;
|
|
49
|
+
});
|
|
50
|
+
export {};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
package/package.json
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@ajclarkson/homerun",
|
|
3
|
+
"type": "module",
|
|
4
|
+
"version": "0.0.1-edge.01eba24",
|
|
5
|
+
"description": "TypeScript automation framework for Home Assistant",
|
|
6
|
+
"files": [
|
|
7
|
+
"dist"
|
|
8
|
+
],
|
|
9
|
+
"main": "./dist/src/lib.js",
|
|
10
|
+
"types": "./dist/src/lib.d.ts",
|
|
11
|
+
"exports": {
|
|
12
|
+
".": {
|
|
13
|
+
"types": "./dist/src/lib.d.ts",
|
|
14
|
+
"default": "./dist/src/lib.js"
|
|
15
|
+
},
|
|
16
|
+
"./testing": {
|
|
17
|
+
"types": "./dist/src/testing.d.ts",
|
|
18
|
+
"default": "./dist/src/testing.js"
|
|
19
|
+
}
|
|
20
|
+
},
|
|
21
|
+
"scripts": {
|
|
22
|
+
"dev": "tsx watch src/index.ts",
|
|
23
|
+
"build": "tsc",
|
|
24
|
+
"test": "vitest",
|
|
25
|
+
"test:run": "vitest run",
|
|
26
|
+
"generate:ha-types": "tsx scripts/generate-ha-types.ts",
|
|
27
|
+
"generate:ha-types:compiled": "homerun-generate-ha-types"
|
|
28
|
+
},
|
|
29
|
+
"bin": {
|
|
30
|
+
"homerun": "./dist/src/index.js",
|
|
31
|
+
"homerun-generate-ha-types": "./dist/scripts/generate-ha-types.js"
|
|
32
|
+
},
|
|
33
|
+
"repository": {
|
|
34
|
+
"type": "git",
|
|
35
|
+
"url": "https://github.com/ajclarkson/homerun"
|
|
36
|
+
},
|
|
37
|
+
"keywords": [],
|
|
38
|
+
"author": "Adam Clarkson",
|
|
39
|
+
"license": "MIT",
|
|
40
|
+
"dependencies": {
|
|
41
|
+
"chokidar": "^5.0.0",
|
|
42
|
+
"dotenv": "^17.4.2",
|
|
43
|
+
"esbuild": "^0.28.1",
|
|
44
|
+
"home-assistant-js-websocket": "^9.6.0",
|
|
45
|
+
"mqtt": "^5.15.1",
|
|
46
|
+
"node-cron": "^4.4.1",
|
|
47
|
+
"zod": "^4.4.3"
|
|
48
|
+
},
|
|
49
|
+
"devDependencies": {
|
|
50
|
+
"@types/node": "^26.0.0",
|
|
51
|
+
"tsx": "^4.22.4",
|
|
52
|
+
"typescript": "^6.0.3",
|
|
53
|
+
"vitest": "^4.1.9"
|
|
54
|
+
}
|
|
55
|
+
}
|