@switchbot/openapi-cli 2.7.2 → 3.1.0
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 +481 -103
- package/dist/api/client.js +23 -1
- package/dist/commands/agent-bootstrap.js +47 -2
- package/dist/commands/auth.js +354 -0
- package/dist/commands/batch.js +20 -4
- package/dist/commands/capabilities.js +155 -65
- package/dist/commands/config.js +109 -0
- package/dist/commands/daemon.js +367 -0
- package/dist/commands/devices.js +62 -11
- package/dist/commands/doctor.js +417 -8
- package/dist/commands/events.js +3 -3
- package/dist/commands/explain.js +1 -2
- package/dist/commands/health.js +113 -0
- package/dist/commands/install.js +246 -0
- package/dist/commands/mcp.js +888 -7
- package/dist/commands/plan.js +379 -103
- package/dist/commands/policy.js +586 -0
- package/dist/commands/rules.js +875 -0
- package/dist/commands/scenes.js +140 -0
- package/dist/commands/schema.js +0 -2
- package/dist/commands/status-sync.js +131 -0
- package/dist/commands/uninstall.js +237 -0
- package/dist/commands/upgrade-check.js +88 -0
- package/dist/config.js +14 -0
- package/dist/credentials/backends/file.js +101 -0
- package/dist/credentials/backends/linux.js +129 -0
- package/dist/credentials/backends/macos.js +129 -0
- package/dist/credentials/backends/windows.js +215 -0
- package/dist/credentials/keychain.js +88 -0
- package/dist/credentials/prime.js +52 -0
- package/dist/devices/catalog.js +4 -10
- package/dist/index.js +30 -1
- package/dist/install/default-steps.js +257 -0
- package/dist/install/preflight.js +212 -0
- package/dist/install/steps.js +67 -0
- package/dist/lib/command-keywords.js +17 -0
- package/dist/lib/daemon-state.js +46 -0
- package/dist/lib/destructive-mode.js +12 -0
- package/dist/lib/devices.js +1 -1
- package/dist/lib/plan-store.js +68 -0
- package/dist/policy/add-rule.js +124 -0
- package/dist/policy/diff.js +91 -0
- package/dist/policy/examples/policy.example.yaml +99 -0
- package/dist/policy/format.js +57 -0
- package/dist/policy/load.js +61 -0
- package/dist/policy/migrate.js +67 -0
- package/dist/policy/schema/v0.2.json +331 -0
- package/dist/policy/schema.js +18 -0
- package/dist/policy/validate.js +262 -0
- package/dist/rules/action.js +205 -0
- package/dist/rules/audit-query.js +89 -0
- package/dist/rules/conflict-analyzer.js +203 -0
- package/dist/rules/cron-scheduler.js +186 -0
- package/dist/rules/destructive.js +52 -0
- package/dist/rules/engine.js +757 -0
- package/dist/rules/matcher.js +230 -0
- package/dist/rules/pid-file.js +95 -0
- package/dist/rules/quiet-hours.js +45 -0
- package/dist/rules/suggest.js +95 -0
- package/dist/rules/throttle.js +116 -0
- package/dist/rules/types.js +34 -0
- package/dist/rules/webhook-listener.js +223 -0
- package/dist/rules/webhook-token.js +90 -0
- package/dist/status-sync/manager.js +268 -0
- package/dist/utils/audit.js +12 -2
- package/dist/utils/health.js +101 -0
- package/dist/utils/output.js +72 -23
- package/dist/utils/retry.js +81 -0
- package/package.json +12 -4
|
@@ -0,0 +1,757 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Rules engine runtime — orchestrates trigger subscription, matcher
|
|
3
|
+
* pipeline, throttle gate, and action executor.
|
|
4
|
+
*
|
|
5
|
+
* v0.2 PoC scope:
|
|
6
|
+
* - Loads an `automation` block from a policy file.
|
|
7
|
+
* - Subscribes to a single MQTT client; routes every shadow message
|
|
8
|
+
* through `matchesMqttTrigger` → `evaluateConditions` → throttle →
|
|
9
|
+
* `executeRuleAction`.
|
|
10
|
+
* - Cron + webhook triggers are **recognised but not wired** — they
|
|
11
|
+
* surface in the static lint as `unsupported` so users know the
|
|
12
|
+
* feature is pending (E1/E2 fill it in without a schema change).
|
|
13
|
+
* - Exposes `start()`, `stop()`, `getStats()` for the rules run
|
|
14
|
+
* subcommand.
|
|
15
|
+
*
|
|
16
|
+
* Not responsible for: loading the policy file, validating it, talking
|
|
17
|
+
* to the SwitchBot REST API (that's `executeCommand`), or writing
|
|
18
|
+
* audit lines (that's each module's local responsibility).
|
|
19
|
+
*/
|
|
20
|
+
import { randomUUID } from 'node:crypto';
|
|
21
|
+
import { fetchDeviceStatus } from '../lib/devices.js';
|
|
22
|
+
import { isDestructiveCommand } from './destructive.js';
|
|
23
|
+
import { classifyMqttPayload, evaluateConditions, matchesMqttTrigger, } from './matcher.js';
|
|
24
|
+
import { ThrottleGate, parseMaxPerMs } from './throttle.js';
|
|
25
|
+
import { executeRuleAction, parseRuleCommand } from './action.js';
|
|
26
|
+
import { CronScheduler } from './cron-scheduler.js';
|
|
27
|
+
import { WebhookListener, DEFAULT_WEBHOOK_PORT } from './webhook-listener.js';
|
|
28
|
+
import { isCronTrigger, isMqttTrigger, isWebhookTrigger, } from './types.js';
|
|
29
|
+
import { Cron } from 'croner';
|
|
30
|
+
import { writeAudit } from '../utils/audit.js';
|
|
31
|
+
export function lintRules(automation) {
|
|
32
|
+
const rules = automation?.rules ?? [];
|
|
33
|
+
const entries = [];
|
|
34
|
+
let unsupportedCount = 0;
|
|
35
|
+
const seenNames = new Set();
|
|
36
|
+
for (const r of rules) {
|
|
37
|
+
const issues = [];
|
|
38
|
+
if (seenNames.has(r.name)) {
|
|
39
|
+
issues.push({ rule: r.name, severity: 'error', code: 'duplicate-name', message: `Duplicate rule name "${r.name}".` });
|
|
40
|
+
}
|
|
41
|
+
seenNames.add(r.name);
|
|
42
|
+
// Trigger support — cron + webhook are both wired in E1/E2. The
|
|
43
|
+
// only remaining unsupported source would be an unknown string.
|
|
44
|
+
if (r.when.source !== 'mqtt' && r.when.source !== 'cron' && r.when.source !== 'webhook') {
|
|
45
|
+
issues.push({
|
|
46
|
+
rule: r.name,
|
|
47
|
+
severity: 'warning',
|
|
48
|
+
code: 'trigger-unsupported',
|
|
49
|
+
message: `Trigger source "${r.when.source}" is not recognised by this build.`,
|
|
50
|
+
});
|
|
51
|
+
unsupportedCount++;
|
|
52
|
+
}
|
|
53
|
+
// Cron expression validity (cron trigger is now active in E1).
|
|
54
|
+
if (r.when.source === 'cron') {
|
|
55
|
+
try {
|
|
56
|
+
// eslint-disable-next-line no-new
|
|
57
|
+
new Cron(r.when.schedule, { paused: true });
|
|
58
|
+
}
|
|
59
|
+
catch (err) {
|
|
60
|
+
issues.push({
|
|
61
|
+
rule: r.name,
|
|
62
|
+
severity: 'error',
|
|
63
|
+
code: 'invalid-cron',
|
|
64
|
+
message: `cron schedule "${r.when.schedule}" is not parseable: ${err instanceof Error ? err.message : String(err)}`,
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
// Webhook path sanity — must start with "/" and carry at least one
|
|
69
|
+
// non-slash character. Keeps common typos out of production.
|
|
70
|
+
if (r.when.source === 'webhook') {
|
|
71
|
+
const p = r.when.path;
|
|
72
|
+
if (typeof p !== 'string' || !p.startsWith('/') || p.length < 2) {
|
|
73
|
+
issues.push({
|
|
74
|
+
rule: r.name,
|
|
75
|
+
severity: 'error',
|
|
76
|
+
code: 'invalid-webhook-path',
|
|
77
|
+
message: `webhook path "${String(p)}" must start with "/" and contain at least one character.`,
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
// Destructive guard
|
|
82
|
+
for (let i = 0; i < r.then.length; i++) {
|
|
83
|
+
if (isDestructiveCommand(r.then[i].command)) {
|
|
84
|
+
issues.push({
|
|
85
|
+
rule: r.name,
|
|
86
|
+
severity: 'error',
|
|
87
|
+
code: 'destructive-action',
|
|
88
|
+
message: `then[${i}] uses a destructive verb — the engine will refuse to run this rule.`,
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
// Throttle expression
|
|
93
|
+
if (r.throttle) {
|
|
94
|
+
try {
|
|
95
|
+
parseMaxPerMs(r.throttle.max_per);
|
|
96
|
+
}
|
|
97
|
+
catch {
|
|
98
|
+
issues.push({
|
|
99
|
+
rule: r.name,
|
|
100
|
+
severity: 'error',
|
|
101
|
+
code: 'invalid-throttle',
|
|
102
|
+
message: `throttle.max_per "${r.throttle.max_per}" is not a valid duration.`,
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
if (r.throttle.dedupe_window) {
|
|
106
|
+
try {
|
|
107
|
+
parseMaxPerMs(r.throttle.dedupe_window);
|
|
108
|
+
}
|
|
109
|
+
catch {
|
|
110
|
+
issues.push({
|
|
111
|
+
rule: r.name,
|
|
112
|
+
severity: 'error',
|
|
113
|
+
code: 'invalid-dedupe-window',
|
|
114
|
+
message: `throttle.dedupe_window "${r.throttle.dedupe_window}" is not a valid duration.`,
|
|
115
|
+
});
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
// cooldown field validation
|
|
120
|
+
if (r.cooldown) {
|
|
121
|
+
try {
|
|
122
|
+
parseMaxPerMs(r.cooldown);
|
|
123
|
+
}
|
|
124
|
+
catch {
|
|
125
|
+
issues.push({
|
|
126
|
+
rule: r.name,
|
|
127
|
+
severity: 'error',
|
|
128
|
+
code: 'invalid-cooldown',
|
|
129
|
+
message: `cooldown "${r.cooldown}" is not a valid duration (expected e.g. "10m", "1h").`,
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
if (r.throttle?.max_per) {
|
|
133
|
+
issues.push({
|
|
134
|
+
rule: r.name,
|
|
135
|
+
severity: 'warning',
|
|
136
|
+
code: 'cooldown-throttle-overlap',
|
|
137
|
+
message: `Both "cooldown" and "throttle.max_per" are set. "cooldown" takes precedence.`,
|
|
138
|
+
});
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
// requires_stable_for field validation
|
|
142
|
+
if (r.requires_stable_for) {
|
|
143
|
+
try {
|
|
144
|
+
parseMaxPerMs(r.requires_stable_for);
|
|
145
|
+
}
|
|
146
|
+
catch {
|
|
147
|
+
issues.push({
|
|
148
|
+
rule: r.name,
|
|
149
|
+
severity: 'error',
|
|
150
|
+
code: 'invalid-requires-stable-for',
|
|
151
|
+
message: `requires_stable_for "${r.requires_stable_for}" is not a valid duration.`,
|
|
152
|
+
});
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
// hysteresis field validation
|
|
156
|
+
if (r.hysteresis) {
|
|
157
|
+
try {
|
|
158
|
+
parseMaxPerMs(r.hysteresis);
|
|
159
|
+
}
|
|
160
|
+
catch {
|
|
161
|
+
issues.push({
|
|
162
|
+
rule: r.name,
|
|
163
|
+
severity: 'error',
|
|
164
|
+
code: 'invalid-hysteresis',
|
|
165
|
+
message: `hysteresis "${r.hysteresis}" is not a valid duration (expected e.g. "10m", "1h").`,
|
|
166
|
+
});
|
|
167
|
+
}
|
|
168
|
+
if (r.requires_stable_for) {
|
|
169
|
+
issues.push({
|
|
170
|
+
rule: r.name,
|
|
171
|
+
severity: 'warning',
|
|
172
|
+
code: 'hysteresis-requires-stable-overlap',
|
|
173
|
+
message: `Both "hysteresis" and "requires_stable_for" are set. "hysteresis" takes precedence.`,
|
|
174
|
+
});
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
// maxFiringsPerHour field validation
|
|
178
|
+
if (r.maxFiringsPerHour !== undefined) {
|
|
179
|
+
if (!Number.isInteger(r.maxFiringsPerHour) || r.maxFiringsPerHour < 1) {
|
|
180
|
+
issues.push({
|
|
181
|
+
rule: r.name,
|
|
182
|
+
severity: 'error',
|
|
183
|
+
code: 'invalid-maxFiringsPerHour',
|
|
184
|
+
message: `maxFiringsPerHour must be a positive integer (got ${r.maxFiringsPerHour}).`,
|
|
185
|
+
});
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
const enabled = r.enabled !== false;
|
|
189
|
+
const hasError = issues.some((i) => i.severity === 'error');
|
|
190
|
+
const hasUnsupported = issues.some((i) => i.code === 'trigger-unsupported');
|
|
191
|
+
const status = !enabled
|
|
192
|
+
? 'disabled'
|
|
193
|
+
: hasError
|
|
194
|
+
? 'error'
|
|
195
|
+
: hasUnsupported
|
|
196
|
+
? 'unsupported'
|
|
197
|
+
: 'ok';
|
|
198
|
+
entries.push({ name: r.name, enabled, status, issues });
|
|
199
|
+
}
|
|
200
|
+
return {
|
|
201
|
+
rules: entries,
|
|
202
|
+
valid: entries.every((e) => e.status !== 'error'),
|
|
203
|
+
unsupportedCount,
|
|
204
|
+
};
|
|
205
|
+
}
|
|
206
|
+
export class RulesEngine {
|
|
207
|
+
opts;
|
|
208
|
+
rules;
|
|
209
|
+
aliases;
|
|
210
|
+
throttle = new ThrottleGate();
|
|
211
|
+
/** hysteresis / requires_stable_for: tracks when each (rule::device) trigger was first seen. */
|
|
212
|
+
hysteresisFirstSeen = new Map();
|
|
213
|
+
unsubscribeMessage = null;
|
|
214
|
+
unsubscribeState = null;
|
|
215
|
+
cronScheduler = null;
|
|
216
|
+
webhookListener = null;
|
|
217
|
+
started = false;
|
|
218
|
+
stopped = false;
|
|
219
|
+
/**
|
|
220
|
+
* Sequential dispatch queue. Two MQTT messages arriving in the same
|
|
221
|
+
* tick would otherwise race inside the throttle check — each sees an
|
|
222
|
+
* empty lastFireAt map because neither has recorded yet. Serialising
|
|
223
|
+
* keeps the semantics of `max_per` honest.
|
|
224
|
+
*/
|
|
225
|
+
pendingChain = Promise.resolve();
|
|
226
|
+
stats = {
|
|
227
|
+
started: false,
|
|
228
|
+
rulesLoaded: 0,
|
|
229
|
+
rulesActive: 0,
|
|
230
|
+
eventsProcessed: 0,
|
|
231
|
+
fires: 0,
|
|
232
|
+
dryFires: 0,
|
|
233
|
+
throttled: 0,
|
|
234
|
+
conditionsFailed: 0,
|
|
235
|
+
};
|
|
236
|
+
constructor(opts) {
|
|
237
|
+
this.opts = opts;
|
|
238
|
+
this.rules = (opts.automation?.rules ?? []).filter((r) => r.enabled !== false);
|
|
239
|
+
this.aliases = opts.aliases ?? {};
|
|
240
|
+
this.stats.rulesLoaded = opts.automation?.rules?.length ?? 0;
|
|
241
|
+
this.stats.rulesActive = this.rules.length;
|
|
242
|
+
}
|
|
243
|
+
getStats() {
|
|
244
|
+
return { ...this.stats, started: this.started && !this.stopped };
|
|
245
|
+
}
|
|
246
|
+
getRules() {
|
|
247
|
+
return this.rules;
|
|
248
|
+
}
|
|
249
|
+
/**
|
|
250
|
+
* Subscribes to MQTT and begins the pipeline. Throws if the policy
|
|
251
|
+
* block is missing `enabled: true` or if lint finds errors (e.g.
|
|
252
|
+
* destructive command in a rule action).
|
|
253
|
+
*/
|
|
254
|
+
async start() {
|
|
255
|
+
if (this.opts.automation?.enabled !== true) {
|
|
256
|
+
throw new Error('automation.enabled is not true — engine start refused.');
|
|
257
|
+
}
|
|
258
|
+
const lint = lintRules(this.opts.automation);
|
|
259
|
+
if (!lint.valid) {
|
|
260
|
+
const errors = lint.rules.flatMap((r) => r.issues.filter((i) => i.severity === 'error'));
|
|
261
|
+
throw new Error(`Rule lint failed: ${errors.map((e) => `${e.rule}:${e.code}`).join(', ')}`);
|
|
262
|
+
}
|
|
263
|
+
if (this.rules.some((r) => isMqttTrigger(r.when))) {
|
|
264
|
+
const topic = this.opts.mqttCredential.topics.status;
|
|
265
|
+
this.opts.mqttClient.subscribe(topic);
|
|
266
|
+
this.unsubscribeMessage = this.opts.mqttClient.onMessage((_topic, payload) => {
|
|
267
|
+
this.enqueue(() => this.onMqttMessage(payload));
|
|
268
|
+
});
|
|
269
|
+
}
|
|
270
|
+
// Cron triggers. We start the scheduler only when at least one cron
|
|
271
|
+
// rule is active — no need to stand up timers otherwise.
|
|
272
|
+
const cronRules = this.rules.filter((r) => isCronTrigger(r.when));
|
|
273
|
+
if (cronRules.length > 0) {
|
|
274
|
+
this.cronScheduler = new CronScheduler({
|
|
275
|
+
dispatch: (rule, event) => this.enqueue(() => this.onCronFire(rule, event)),
|
|
276
|
+
});
|
|
277
|
+
for (const r of cronRules)
|
|
278
|
+
this.cronScheduler.register(r);
|
|
279
|
+
this.cronScheduler.start();
|
|
280
|
+
}
|
|
281
|
+
// Webhook triggers. Only bind the HTTP port when at least one rule
|
|
282
|
+
// needs it — standing up the listener unconditionally would force
|
|
283
|
+
// every user into an open port they didn't ask for.
|
|
284
|
+
const webhookRules = this.rules.filter((r) => isWebhookTrigger(r.when));
|
|
285
|
+
if (webhookRules.length > 0) {
|
|
286
|
+
if (!this.opts.webhookToken) {
|
|
287
|
+
throw new Error('webhook rules require a bearer token — pass RulesEngineOptions.webhookToken.');
|
|
288
|
+
}
|
|
289
|
+
this.webhookListener = new WebhookListener({
|
|
290
|
+
rules: webhookRules,
|
|
291
|
+
bearerToken: this.opts.webhookToken,
|
|
292
|
+
host: this.opts.webhookHost,
|
|
293
|
+
port: this.opts.webhookPort ?? DEFAULT_WEBHOOK_PORT,
|
|
294
|
+
dispatch: (rule, event) => this.enqueue(() => this.onWebhookFire(rule, event)),
|
|
295
|
+
});
|
|
296
|
+
await this.webhookListener.start();
|
|
297
|
+
}
|
|
298
|
+
this.unsubscribeState = this.opts.mqttClient.onStateChange((state) => {
|
|
299
|
+
if (state === 'failed' && !this.stopped) {
|
|
300
|
+
// Propagate to caller via stats; the rules run command decides
|
|
301
|
+
// whether to exit. No internal restart — we rely on supervisors.
|
|
302
|
+
this.started = false;
|
|
303
|
+
}
|
|
304
|
+
});
|
|
305
|
+
this.started = true;
|
|
306
|
+
this.stats.started = true;
|
|
307
|
+
}
|
|
308
|
+
async stop() {
|
|
309
|
+
if (this.stopped)
|
|
310
|
+
return;
|
|
311
|
+
this.stopped = true;
|
|
312
|
+
this.started = false;
|
|
313
|
+
this.unsubscribeMessage?.();
|
|
314
|
+
this.unsubscribeState?.();
|
|
315
|
+
this.unsubscribeMessage = null;
|
|
316
|
+
this.unsubscribeState = null;
|
|
317
|
+
if (this.cronScheduler) {
|
|
318
|
+
this.cronScheduler.stop();
|
|
319
|
+
this.cronScheduler = null;
|
|
320
|
+
}
|
|
321
|
+
if (this.webhookListener) {
|
|
322
|
+
await this.webhookListener.stop();
|
|
323
|
+
this.webhookListener = null;
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
/**
|
|
327
|
+
* Hot-reload the running engine with a fresh automation block and
|
|
328
|
+
* alias map — typically triggered by SIGHUP or by the `rules reload`
|
|
329
|
+
* subcommand writing the reload sentinel file.
|
|
330
|
+
*
|
|
331
|
+
* Semantics:
|
|
332
|
+
* - Rejects (and keeps the old ruleset) when the new automation is
|
|
333
|
+
* disabled or fails lint. The engine never silently degrades.
|
|
334
|
+
* - Diffs cron registrations by `rule.name` + `schedule`: unchanged
|
|
335
|
+
* entries keep their armed timer, changed/removed entries are
|
|
336
|
+
* unregistered, new entries are registered and armed.
|
|
337
|
+
* - Hands the fresh webhook rule list to the live listener (keeps
|
|
338
|
+
* the bound port / open connections). If the reload removes every
|
|
339
|
+
* webhook rule the listener is torn down; if it adds the first
|
|
340
|
+
* webhook rule we refuse — spinning up a new listener mid-run
|
|
341
|
+
* would silently change the security surface.
|
|
342
|
+
* - `ThrottleGate` state is retained for surviving rule names and
|
|
343
|
+
* dropped for removed ones. A rule that was throttled before the
|
|
344
|
+
* reload stays throttled after it (same name = same window), but
|
|
345
|
+
* a renamed rule resets.
|
|
346
|
+
*/
|
|
347
|
+
async reload(nextAutomation, nextAliases = {}) {
|
|
348
|
+
if (!this.started || this.stopped) {
|
|
349
|
+
return { changed: false, errors: ['engine not running'], warnings: [] };
|
|
350
|
+
}
|
|
351
|
+
if (nextAutomation?.enabled !== true) {
|
|
352
|
+
return {
|
|
353
|
+
changed: false,
|
|
354
|
+
errors: ['automation.enabled is not true in the new policy — refusing to reload'],
|
|
355
|
+
warnings: [],
|
|
356
|
+
};
|
|
357
|
+
}
|
|
358
|
+
const lint = lintRules(nextAutomation);
|
|
359
|
+
if (!lint.valid) {
|
|
360
|
+
const errs = lint.rules.flatMap((r) => r.issues.filter((i) => i.severity === 'error').map((i) => `${i.rule}:${i.code}`));
|
|
361
|
+
return { changed: false, errors: errs, warnings: [] };
|
|
362
|
+
}
|
|
363
|
+
const warnings = [];
|
|
364
|
+
const nextActive = (nextAutomation.rules ?? []).filter((r) => r.enabled !== false);
|
|
365
|
+
const nextByName = new Map(nextActive.map((r) => [r.name, r]));
|
|
366
|
+
const oldByName = new Map(this.rules.map((r) => [r.name, r]));
|
|
367
|
+
// Cron diff
|
|
368
|
+
if (this.cronScheduler) {
|
|
369
|
+
for (const [name, oldRule] of oldByName) {
|
|
370
|
+
if (!isCronTrigger(oldRule.when))
|
|
371
|
+
continue;
|
|
372
|
+
const next = nextByName.get(name);
|
|
373
|
+
const same = next &&
|
|
374
|
+
isCronTrigger(next.when) &&
|
|
375
|
+
next.when.schedule === oldRule.when.schedule;
|
|
376
|
+
if (!same)
|
|
377
|
+
this.cronScheduler.unregister(name);
|
|
378
|
+
}
|
|
379
|
+
for (const [name, newRule] of nextByName) {
|
|
380
|
+
if (!isCronTrigger(newRule.when))
|
|
381
|
+
continue;
|
|
382
|
+
if (this.cronScheduler.hasRegistered(name))
|
|
383
|
+
continue;
|
|
384
|
+
this.cronScheduler.register(newRule);
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
else {
|
|
388
|
+
// No scheduler yet but now we have cron rules — stand one up.
|
|
389
|
+
const cronRules = nextActive.filter((r) => isCronTrigger(r.when));
|
|
390
|
+
if (cronRules.length > 0) {
|
|
391
|
+
this.cronScheduler = new CronScheduler({
|
|
392
|
+
dispatch: (rule, event) => this.enqueue(() => this.onCronFire(rule, event)),
|
|
393
|
+
});
|
|
394
|
+
for (const r of cronRules)
|
|
395
|
+
this.cronScheduler.register(r);
|
|
396
|
+
this.cronScheduler.start();
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
// Webhook diff — keep the listener alive if possible.
|
|
400
|
+
const newWebhookRules = nextActive.filter((r) => isWebhookTrigger(r.when));
|
|
401
|
+
if (this.webhookListener) {
|
|
402
|
+
if (newWebhookRules.length === 0) {
|
|
403
|
+
await this.webhookListener.stop();
|
|
404
|
+
this.webhookListener = null;
|
|
405
|
+
}
|
|
406
|
+
else {
|
|
407
|
+
this.webhookListener.updateRules(newWebhookRules);
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
else if (newWebhookRules.length > 0) {
|
|
411
|
+
warnings.push('webhook rules added via reload — full restart required for the listener to bind. Skipping activation.');
|
|
412
|
+
}
|
|
413
|
+
// Swap ruleset + aliases atomically relative to the next event.
|
|
414
|
+
this.rules = nextActive;
|
|
415
|
+
this.aliases = nextAliases;
|
|
416
|
+
this.stats.rulesLoaded = nextAutomation.rules?.length ?? 0;
|
|
417
|
+
this.stats.rulesActive = nextActive.length;
|
|
418
|
+
this.throttle.retainOnly(new Set(nextByName.keys()));
|
|
419
|
+
return { changed: true, errors: [], warnings };
|
|
420
|
+
}
|
|
421
|
+
/**
|
|
422
|
+
* Expose the MQTT pipeline for direct invocation from tests — feeds a
|
|
423
|
+
* synthetic payload through the same matcher/throttle/action chain.
|
|
424
|
+
*/
|
|
425
|
+
async ingestMqttForTest(payload) {
|
|
426
|
+
await this.enqueue(() => this.onMqttMessage(payload, { preParsed: true }));
|
|
427
|
+
}
|
|
428
|
+
/**
|
|
429
|
+
* Dispatch a pre-built EngineEvent through all matching MQTT rules.
|
|
430
|
+
* Used by tests that need full control over the event timestamp (e.g.
|
|
431
|
+
* hysteresis tests that advance time manually).
|
|
432
|
+
*/
|
|
433
|
+
async ingestEventForTest(event) {
|
|
434
|
+
for (const rule of this.rules) {
|
|
435
|
+
if (!isMqttTrigger(rule.when))
|
|
436
|
+
continue;
|
|
437
|
+
const resolvedFilter = rule.when.device
|
|
438
|
+
? this.aliases[rule.when.device] ?? rule.when.device
|
|
439
|
+
: undefined;
|
|
440
|
+
if (!matchesMqttTrigger(rule.when, event, resolvedFilter))
|
|
441
|
+
continue;
|
|
442
|
+
this.stats.eventsProcessed++;
|
|
443
|
+
await this.enqueue(() => this.dispatchRule(rule, event));
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
/**
|
|
447
|
+
* Fire a cron rule directly without needing the scheduler/timers.
|
|
448
|
+
* Used by tests that want to exercise the dispatch pipeline without
|
|
449
|
+
* depending on fake timers or croner's internals.
|
|
450
|
+
*/
|
|
451
|
+
async ingestCronForTest(rule, when = new Date()) {
|
|
452
|
+
if (!isCronTrigger(rule.when)) {
|
|
453
|
+
throw new Error(`ingestCronForTest: rule "${rule.name}" is not a cron trigger`);
|
|
454
|
+
}
|
|
455
|
+
const event = {
|
|
456
|
+
source: 'cron',
|
|
457
|
+
event: rule.when.schedule,
|
|
458
|
+
t: when,
|
|
459
|
+
payload: { schedule: rule.when.schedule },
|
|
460
|
+
};
|
|
461
|
+
await this.enqueue(() => this.onCronFire(rule, event));
|
|
462
|
+
}
|
|
463
|
+
/**
|
|
464
|
+
* Fire a webhook rule directly without standing up the HTTP listener.
|
|
465
|
+
*/
|
|
466
|
+
async ingestWebhookForTest(rule, body = '', when = new Date()) {
|
|
467
|
+
if (!isWebhookTrigger(rule.when)) {
|
|
468
|
+
throw new Error(`ingestWebhookForTest: rule "${rule.name}" is not a webhook trigger`);
|
|
469
|
+
}
|
|
470
|
+
const event = {
|
|
471
|
+
source: 'webhook',
|
|
472
|
+
event: rule.when.path,
|
|
473
|
+
t: when,
|
|
474
|
+
payload: { path: rule.when.path, body },
|
|
475
|
+
};
|
|
476
|
+
await this.enqueue(() => this.onWebhookFire(rule, event));
|
|
477
|
+
}
|
|
478
|
+
/** Returns the bound webhook port when the listener is active. */
|
|
479
|
+
getWebhookPort() {
|
|
480
|
+
return this.webhookListener?.getPort() ?? null;
|
|
481
|
+
}
|
|
482
|
+
/** Read-only peek at cron schedule state — for `rules list` extras. */
|
|
483
|
+
getCronSchedule(ruleName) {
|
|
484
|
+
return this.cronScheduler?.getScheduledFor(ruleName) ?? null;
|
|
485
|
+
}
|
|
486
|
+
/** Test helper — resolves after all queued dispatches complete. */
|
|
487
|
+
async drainForTest() {
|
|
488
|
+
await this.pendingChain;
|
|
489
|
+
}
|
|
490
|
+
/**
|
|
491
|
+
* Append a task to the dispatch queue; callers get back a promise that
|
|
492
|
+
* resolves when their task finishes (errors are swallowed — we never
|
|
493
|
+
* want the queue itself to die because one rule threw). Returning a
|
|
494
|
+
* promise lets awaited callsites (ingestMqttForTest) observe completion.
|
|
495
|
+
*/
|
|
496
|
+
enqueue(task) {
|
|
497
|
+
const next = this.pendingChain.then(() => task().catch(() => undefined));
|
|
498
|
+
this.pendingChain = next;
|
|
499
|
+
return next;
|
|
500
|
+
}
|
|
501
|
+
async onMqttMessage(payload, opts = {}) {
|
|
502
|
+
if (this.stopped || !this.started)
|
|
503
|
+
return;
|
|
504
|
+
let parsed;
|
|
505
|
+
if (opts.preParsed) {
|
|
506
|
+
parsed = payload;
|
|
507
|
+
}
|
|
508
|
+
else {
|
|
509
|
+
try {
|
|
510
|
+
parsed = JSON.parse(payload.toString('utf-8'));
|
|
511
|
+
}
|
|
512
|
+
catch {
|
|
513
|
+
return;
|
|
514
|
+
}
|
|
515
|
+
}
|
|
516
|
+
this.stats.eventsProcessed++;
|
|
517
|
+
const classified = classifyMqttPayload(parsed);
|
|
518
|
+
const now = new Date();
|
|
519
|
+
const event = {
|
|
520
|
+
source: 'mqtt',
|
|
521
|
+
event: classified.event,
|
|
522
|
+
deviceId: classified.deviceId,
|
|
523
|
+
t: now,
|
|
524
|
+
payload: parsed,
|
|
525
|
+
};
|
|
526
|
+
for (const rule of this.rules) {
|
|
527
|
+
if (!isMqttTrigger(rule.when))
|
|
528
|
+
continue;
|
|
529
|
+
const resolvedFilter = rule.when.device
|
|
530
|
+
? this.aliases[rule.when.device] ?? rule.when.device
|
|
531
|
+
: undefined;
|
|
532
|
+
if (!matchesMqttTrigger(rule.when, event, resolvedFilter))
|
|
533
|
+
continue;
|
|
534
|
+
await this.dispatchRule(rule, event);
|
|
535
|
+
if (this.opts.maxFirings !== undefined && this.stats.eventsProcessed >= 0 && this.firesTotal() >= this.opts.maxFirings) {
|
|
536
|
+
await this.stop();
|
|
537
|
+
return;
|
|
538
|
+
}
|
|
539
|
+
}
|
|
540
|
+
}
|
|
541
|
+
async onCronFire(rule, event) {
|
|
542
|
+
if (this.stopped || !this.started)
|
|
543
|
+
return;
|
|
544
|
+
this.stats.eventsProcessed++;
|
|
545
|
+
await this.dispatchRule(rule, event);
|
|
546
|
+
if (this.opts.maxFirings !== undefined && this.firesTotal() >= this.opts.maxFirings) {
|
|
547
|
+
await this.stop();
|
|
548
|
+
}
|
|
549
|
+
}
|
|
550
|
+
async onWebhookFire(rule, event) {
|
|
551
|
+
if (this.stopped || !this.started)
|
|
552
|
+
return;
|
|
553
|
+
this.stats.eventsProcessed++;
|
|
554
|
+
await this.dispatchRule(rule, event);
|
|
555
|
+
if (this.opts.maxFirings !== undefined && this.firesTotal() >= this.opts.maxFirings) {
|
|
556
|
+
await this.stop();
|
|
557
|
+
}
|
|
558
|
+
}
|
|
559
|
+
firesTotal() {
|
|
560
|
+
return this.stats.fires + this.stats.dryFires;
|
|
561
|
+
}
|
|
562
|
+
async dispatchRule(rule, event) {
|
|
563
|
+
const fireId = randomUUID();
|
|
564
|
+
// Per-tick status cache: one pipeline run through dispatchRule, one
|
|
565
|
+
// cache. Multiple device_state conditions on the same deviceId share
|
|
566
|
+
// a single round trip; subsequent pipeline runs see fresh status.
|
|
567
|
+
const statusCache = new Map();
|
|
568
|
+
const baseFetcher = this.opts.statusFetcher ??
|
|
569
|
+
((id) => fetchDeviceStatus(id, this.opts.httpClient));
|
|
570
|
+
const fetchStatus = (deviceId) => {
|
|
571
|
+
const existing = statusCache.get(deviceId);
|
|
572
|
+
if (existing)
|
|
573
|
+
return existing;
|
|
574
|
+
const p = baseFetcher(deviceId);
|
|
575
|
+
statusCache.set(deviceId, p);
|
|
576
|
+
return p;
|
|
577
|
+
};
|
|
578
|
+
const cond = await evaluateConditions(rule.conditions, event.t, {
|
|
579
|
+
aliases: this.aliases,
|
|
580
|
+
fetchStatus,
|
|
581
|
+
});
|
|
582
|
+
if (!cond.matched) {
|
|
583
|
+
// If conditions are not met, the trigger is no longer "continuously stable" —
|
|
584
|
+
// reset the hysteresis clock so intermittent satisfaction never accumulates.
|
|
585
|
+
const hasHysteresis = rule.hysteresis ?? rule.requires_stable_for;
|
|
586
|
+
if (hasHysteresis) {
|
|
587
|
+
const hysteresisKey = `${rule.name}::${event.deviceId ?? ''}`;
|
|
588
|
+
this.hysteresisFirstSeen.delete(hysteresisKey);
|
|
589
|
+
}
|
|
590
|
+
if (cond.unsupported.length > 0) {
|
|
591
|
+
writeAudit({
|
|
592
|
+
t: event.t.toISOString(),
|
|
593
|
+
kind: 'rule-fire',
|
|
594
|
+
deviceId: event.deviceId ?? 'unknown',
|
|
595
|
+
command: rule.then[0]?.command ?? '',
|
|
596
|
+
parameter: null,
|
|
597
|
+
commandType: 'command',
|
|
598
|
+
dryRun: true,
|
|
599
|
+
result: 'error',
|
|
600
|
+
error: `condition-unsupported:${cond.unsupported.map((u) => u.keyword).join(',')}`,
|
|
601
|
+
rule: {
|
|
602
|
+
name: rule.name,
|
|
603
|
+
triggerSource: rule.when.source,
|
|
604
|
+
matchedDevice: event.deviceId,
|
|
605
|
+
fireId,
|
|
606
|
+
reason: cond.unsupported.map((u) => u.hint).join(' | '),
|
|
607
|
+
},
|
|
608
|
+
});
|
|
609
|
+
this.opts.onFire?.({ ruleName: rule.name, fireId, status: 'unsupported', deviceId: event.deviceId, reason: cond.unsupported.map((u) => u.keyword).join(',') });
|
|
610
|
+
return;
|
|
611
|
+
}
|
|
612
|
+
this.stats.conditionsFailed++;
|
|
613
|
+
this.opts.onFire?.({ ruleName: rule.name, fireId, status: 'conditions-failed', deviceId: event.deviceId, reason: cond.failures.join('; ') });
|
|
614
|
+
return;
|
|
615
|
+
}
|
|
616
|
+
// cooldown takes precedence over throttle.max_per when both are set.
|
|
617
|
+
const effectiveMaxPerMs = rule.cooldown
|
|
618
|
+
? parseMaxPerMs(rule.cooldown)
|
|
619
|
+
: rule.throttle ? parseMaxPerMs(rule.throttle.max_per) : null;
|
|
620
|
+
const dedupeWindowMs = rule.throttle?.dedupe_window ? parseMaxPerMs(rule.throttle.dedupe_window) : null;
|
|
621
|
+
const throttleKey = event.deviceId;
|
|
622
|
+
const check = this.throttle.check(rule.name, effectiveMaxPerMs, event.t.getTime(), throttleKey, dedupeWindowMs);
|
|
623
|
+
if (!check.allowed) {
|
|
624
|
+
this.stats.throttled++;
|
|
625
|
+
writeAudit({
|
|
626
|
+
t: event.t.toISOString(),
|
|
627
|
+
kind: 'rule-throttled',
|
|
628
|
+
deviceId: event.deviceId ?? 'unknown',
|
|
629
|
+
command: rule.then[0]?.command ?? '',
|
|
630
|
+
parameter: null,
|
|
631
|
+
commandType: 'command',
|
|
632
|
+
dryRun: true,
|
|
633
|
+
result: 'ok',
|
|
634
|
+
rule: {
|
|
635
|
+
name: rule.name,
|
|
636
|
+
triggerSource: rule.when.source,
|
|
637
|
+
matchedDevice: event.deviceId,
|
|
638
|
+
fireId,
|
|
639
|
+
reason: check.nextAllowedAt
|
|
640
|
+
? `${check.dedupedBy ?? 'throttled'} — next allowed at ${new Date(check.nextAllowedAt).toISOString()}`
|
|
641
|
+
: check.dedupedBy ?? 'throttled',
|
|
642
|
+
},
|
|
643
|
+
});
|
|
644
|
+
this.opts.onFire?.({ ruleName: rule.name, fireId, status: 'throttled', deviceId: event.deviceId });
|
|
645
|
+
return;
|
|
646
|
+
}
|
|
647
|
+
// hysteresis / requires_stable_for: require the trigger to have been continuously
|
|
648
|
+
// observed for the specified duration before firing.
|
|
649
|
+
const hysteresisMs = rule.hysteresis
|
|
650
|
+
? parseMaxPerMs(rule.hysteresis)
|
|
651
|
+
: rule.requires_stable_for ? parseMaxPerMs(rule.requires_stable_for) : null;
|
|
652
|
+
if (hysteresisMs !== null) {
|
|
653
|
+
const hysteresisKey = `${rule.name}::${event.deviceId ?? ''}`;
|
|
654
|
+
const firstSeen = this.hysteresisFirstSeen.get(hysteresisKey);
|
|
655
|
+
const now = event.t.getTime();
|
|
656
|
+
if (firstSeen === undefined) {
|
|
657
|
+
this.hysteresisFirstSeen.set(hysteresisKey, now);
|
|
658
|
+
writeAudit({
|
|
659
|
+
t: event.t.toISOString(), kind: 'rule-throttled',
|
|
660
|
+
deviceId: event.deviceId ?? 'unknown', command: rule.then[0]?.command ?? '',
|
|
661
|
+
parameter: null, commandType: 'command', dryRun: true, result: 'ok',
|
|
662
|
+
rule: { name: rule.name, triggerSource: rule.when.source, matchedDevice: event.deviceId, fireId, reason: `hysteresis — first observation, waiting ${hysteresisMs}ms for stability` },
|
|
663
|
+
});
|
|
664
|
+
this.opts.onFire?.({ ruleName: rule.name, fireId, status: 'throttled', deviceId: event.deviceId, reason: 'hysteresis-first-seen' });
|
|
665
|
+
return;
|
|
666
|
+
}
|
|
667
|
+
if (now - firstSeen < hysteresisMs) {
|
|
668
|
+
writeAudit({
|
|
669
|
+
t: event.t.toISOString(), kind: 'rule-throttled',
|
|
670
|
+
deviceId: event.deviceId ?? 'unknown', command: rule.then[0]?.command ?? '',
|
|
671
|
+
parameter: null, commandType: 'command', dryRun: true, result: 'ok',
|
|
672
|
+
rule: { name: rule.name, triggerSource: rule.when.source, matchedDevice: event.deviceId, fireId, reason: `hysteresis — stable for ${now - firstSeen}ms of required ${hysteresisMs}ms` },
|
|
673
|
+
});
|
|
674
|
+
this.opts.onFire?.({ ruleName: rule.name, fireId, status: 'throttled', deviceId: event.deviceId, reason: 'hysteresis-not-stable' });
|
|
675
|
+
return;
|
|
676
|
+
}
|
|
677
|
+
// Stable long enough — clear so the next trigger starts fresh.
|
|
678
|
+
this.hysteresisFirstSeen.delete(hysteresisKey);
|
|
679
|
+
}
|
|
680
|
+
// maxFiringsPerHour: count-based rate cap over a rolling 1-hour window.
|
|
681
|
+
if (rule.maxFiringsPerHour !== undefined) {
|
|
682
|
+
const countCheck = this.throttle.checkMaxFirings(rule.name, rule.maxFiringsPerHour, 3_600_000, event.t.getTime(), event.deviceId);
|
|
683
|
+
if (!countCheck.allowed) {
|
|
684
|
+
this.stats.throttled++;
|
|
685
|
+
writeAudit({
|
|
686
|
+
t: event.t.toISOString(), kind: 'rule-throttled',
|
|
687
|
+
deviceId: event.deviceId ?? 'unknown', command: rule.then[0]?.command ?? '',
|
|
688
|
+
parameter: null, commandType: 'command', dryRun: true, result: 'ok',
|
|
689
|
+
rule: { name: rule.name, triggerSource: rule.when.source, matchedDevice: event.deviceId, fireId, reason: `maxFiringsPerHour — ${countCheck.count}/${countCheck.max} in last hour` },
|
|
690
|
+
});
|
|
691
|
+
this.opts.onFire?.({ ruleName: rule.name, fireId, status: 'throttled', deviceId: event.deviceId, reason: 'maxFiringsPerHour' });
|
|
692
|
+
return;
|
|
693
|
+
}
|
|
694
|
+
}
|
|
695
|
+
// suppressIfAlreadyDesired: skip if device's live state already matches the command outcome.
|
|
696
|
+
if (rule.suppressIfAlreadyDesired) {
|
|
697
|
+
const firstAction = rule.then[0];
|
|
698
|
+
const parsed = firstAction ? parseRuleCommand(firstAction.command) : null;
|
|
699
|
+
const verb = parsed?.verb ?? firstAction?.command;
|
|
700
|
+
if ((verb === 'turnOn' || verb === 'turnOff') && (firstAction?.device || event.deviceId)) {
|
|
701
|
+
const targetId = firstAction?.device ? (this.aliases[firstAction.device] ?? firstAction.device) : event.deviceId;
|
|
702
|
+
try {
|
|
703
|
+
const deviceStatus = await fetchStatus(targetId);
|
|
704
|
+
const powerState = deviceStatus['powerState'];
|
|
705
|
+
if ((verb === 'turnOn' && powerState === 'on') || (verb === 'turnOff' && powerState === 'off')) {
|
|
706
|
+
writeAudit({
|
|
707
|
+
t: event.t.toISOString(), kind: 'rule-throttled',
|
|
708
|
+
deviceId: targetId, command: verb ?? '',
|
|
709
|
+
parameter: null, commandType: 'command', dryRun: true, result: 'ok',
|
|
710
|
+
rule: { name: rule.name, triggerSource: rule.when.source, matchedDevice: event.deviceId, fireId, reason: `suppressIfAlreadyDesired — powerState already "${powerState}"` },
|
|
711
|
+
});
|
|
712
|
+
this.opts.onFire?.({ ruleName: rule.name, fireId, status: 'throttled', deviceId: event.deviceId, reason: 'already-desired' });
|
|
713
|
+
return;
|
|
714
|
+
}
|
|
715
|
+
}
|
|
716
|
+
catch {
|
|
717
|
+
// best-effort — if status fetch fails, proceed with the action
|
|
718
|
+
}
|
|
719
|
+
}
|
|
720
|
+
}
|
|
721
|
+
let fired = false;
|
|
722
|
+
let allDry = true;
|
|
723
|
+
for (const action of rule.then) {
|
|
724
|
+
const result = await executeRuleAction(action, {
|
|
725
|
+
rule,
|
|
726
|
+
fireId,
|
|
727
|
+
aliases: this.aliases,
|
|
728
|
+
httpClient: this.opts.httpClient,
|
|
729
|
+
globalDryRun: this.opts.globalDryRun,
|
|
730
|
+
skipApiCall: this.opts.skipApiCall,
|
|
731
|
+
});
|
|
732
|
+
if (result.blocked) {
|
|
733
|
+
this.opts.onFire?.({ ruleName: rule.name, fireId, status: 'blocked', deviceId: result.deviceId, reason: result.error });
|
|
734
|
+
if ((action.on_error ?? 'continue') === 'stop')
|
|
735
|
+
break;
|
|
736
|
+
continue;
|
|
737
|
+
}
|
|
738
|
+
if (!result.dryRun)
|
|
739
|
+
allDry = false;
|
|
740
|
+
if (result.ok)
|
|
741
|
+
fired = true;
|
|
742
|
+
if (!result.ok && (action.on_error ?? 'continue') === 'stop')
|
|
743
|
+
break;
|
|
744
|
+
}
|
|
745
|
+
if (fired) {
|
|
746
|
+
if (allDry)
|
|
747
|
+
this.stats.dryFires++;
|
|
748
|
+
else
|
|
749
|
+
this.stats.fires++;
|
|
750
|
+
this.throttle.record(rule.name, event.t.getTime(), throttleKey);
|
|
751
|
+
if (rule.maxFiringsPerHour !== undefined) {
|
|
752
|
+
this.throttle.recordFire(rule.name, event.t.getTime(), throttleKey);
|
|
753
|
+
}
|
|
754
|
+
this.opts.onFire?.({ ruleName: rule.name, fireId, status: allDry ? 'dry' : 'fired', deviceId: event.deviceId });
|
|
755
|
+
}
|
|
756
|
+
}
|
|
757
|
+
}
|