@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.
Files changed (69) hide show
  1. package/README.md +481 -103
  2. package/dist/api/client.js +23 -1
  3. package/dist/commands/agent-bootstrap.js +47 -2
  4. package/dist/commands/auth.js +354 -0
  5. package/dist/commands/batch.js +20 -4
  6. package/dist/commands/capabilities.js +155 -65
  7. package/dist/commands/config.js +109 -0
  8. package/dist/commands/daemon.js +367 -0
  9. package/dist/commands/devices.js +62 -11
  10. package/dist/commands/doctor.js +417 -8
  11. package/dist/commands/events.js +3 -3
  12. package/dist/commands/explain.js +1 -2
  13. package/dist/commands/health.js +113 -0
  14. package/dist/commands/install.js +246 -0
  15. package/dist/commands/mcp.js +888 -7
  16. package/dist/commands/plan.js +379 -103
  17. package/dist/commands/policy.js +586 -0
  18. package/dist/commands/rules.js +875 -0
  19. package/dist/commands/scenes.js +140 -0
  20. package/dist/commands/schema.js +0 -2
  21. package/dist/commands/status-sync.js +131 -0
  22. package/dist/commands/uninstall.js +237 -0
  23. package/dist/commands/upgrade-check.js +88 -0
  24. package/dist/config.js +14 -0
  25. package/dist/credentials/backends/file.js +101 -0
  26. package/dist/credentials/backends/linux.js +129 -0
  27. package/dist/credentials/backends/macos.js +129 -0
  28. package/dist/credentials/backends/windows.js +215 -0
  29. package/dist/credentials/keychain.js +88 -0
  30. package/dist/credentials/prime.js +52 -0
  31. package/dist/devices/catalog.js +4 -10
  32. package/dist/index.js +30 -1
  33. package/dist/install/default-steps.js +257 -0
  34. package/dist/install/preflight.js +212 -0
  35. package/dist/install/steps.js +67 -0
  36. package/dist/lib/command-keywords.js +17 -0
  37. package/dist/lib/daemon-state.js +46 -0
  38. package/dist/lib/destructive-mode.js +12 -0
  39. package/dist/lib/devices.js +1 -1
  40. package/dist/lib/plan-store.js +68 -0
  41. package/dist/policy/add-rule.js +124 -0
  42. package/dist/policy/diff.js +91 -0
  43. package/dist/policy/examples/policy.example.yaml +99 -0
  44. package/dist/policy/format.js +57 -0
  45. package/dist/policy/load.js +61 -0
  46. package/dist/policy/migrate.js +67 -0
  47. package/dist/policy/schema/v0.2.json +331 -0
  48. package/dist/policy/schema.js +18 -0
  49. package/dist/policy/validate.js +262 -0
  50. package/dist/rules/action.js +205 -0
  51. package/dist/rules/audit-query.js +89 -0
  52. package/dist/rules/conflict-analyzer.js +203 -0
  53. package/dist/rules/cron-scheduler.js +186 -0
  54. package/dist/rules/destructive.js +52 -0
  55. package/dist/rules/engine.js +757 -0
  56. package/dist/rules/matcher.js +230 -0
  57. package/dist/rules/pid-file.js +95 -0
  58. package/dist/rules/quiet-hours.js +45 -0
  59. package/dist/rules/suggest.js +95 -0
  60. package/dist/rules/throttle.js +116 -0
  61. package/dist/rules/types.js +34 -0
  62. package/dist/rules/webhook-listener.js +223 -0
  63. package/dist/rules/webhook-token.js +90 -0
  64. package/dist/status-sync/manager.js +268 -0
  65. package/dist/utils/audit.js +12 -2
  66. package/dist/utils/health.js +101 -0
  67. package/dist/utils/output.js +72 -23
  68. package/dist/utils/retry.js +81 -0
  69. 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
+ }