@switchbot/openapi-cli 2.7.2 → 3.0.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 (55) hide show
  1. package/README.md +383 -101
  2. package/dist/commands/agent-bootstrap.js +47 -2
  3. package/dist/commands/auth.js +354 -0
  4. package/dist/commands/config.js +30 -0
  5. package/dist/commands/devices.js +0 -1
  6. package/dist/commands/doctor.js +184 -7
  7. package/dist/commands/events.js +3 -3
  8. package/dist/commands/explain.js +1 -2
  9. package/dist/commands/install.js +246 -0
  10. package/dist/commands/mcp.js +796 -3
  11. package/dist/commands/plan.js +110 -14
  12. package/dist/commands/policy.js +469 -0
  13. package/dist/commands/rules.js +657 -0
  14. package/dist/commands/schema.js +0 -2
  15. package/dist/commands/status-sync.js +131 -0
  16. package/dist/commands/uninstall.js +237 -0
  17. package/dist/config.js +14 -0
  18. package/dist/credentials/backends/file.js +101 -0
  19. package/dist/credentials/backends/linux.js +129 -0
  20. package/dist/credentials/backends/macos.js +129 -0
  21. package/dist/credentials/backends/windows.js +215 -0
  22. package/dist/credentials/keychain.js +88 -0
  23. package/dist/credentials/prime.js +52 -0
  24. package/dist/devices/catalog.js +4 -10
  25. package/dist/index.js +23 -1
  26. package/dist/install/default-steps.js +257 -0
  27. package/dist/install/preflight.js +212 -0
  28. package/dist/install/steps.js +67 -0
  29. package/dist/lib/command-keywords.js +17 -0
  30. package/dist/lib/devices.js +0 -1
  31. package/dist/policy/add-rule.js +124 -0
  32. package/dist/policy/diff.js +91 -0
  33. package/dist/policy/examples/policy.example.yaml +99 -0
  34. package/dist/policy/format.js +57 -0
  35. package/dist/policy/load.js +61 -0
  36. package/dist/policy/migrate.js +67 -0
  37. package/dist/policy/schema/v0.2.json +302 -0
  38. package/dist/policy/schema.js +18 -0
  39. package/dist/policy/validate.js +262 -0
  40. package/dist/rules/action.js +205 -0
  41. package/dist/rules/audit-query.js +89 -0
  42. package/dist/rules/cron-scheduler.js +186 -0
  43. package/dist/rules/destructive.js +52 -0
  44. package/dist/rules/engine.js +567 -0
  45. package/dist/rules/matcher.js +230 -0
  46. package/dist/rules/pid-file.js +95 -0
  47. package/dist/rules/quiet-hours.js +45 -0
  48. package/dist/rules/suggest.js +95 -0
  49. package/dist/rules/throttle.js +78 -0
  50. package/dist/rules/types.js +34 -0
  51. package/dist/rules/webhook-listener.js +223 -0
  52. package/dist/rules/webhook-token.js +90 -0
  53. package/dist/status-sync/manager.js +268 -0
  54. package/dist/utils/audit.js +12 -2
  55. package/package.json +12 -4
@@ -0,0 +1,567 @@
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 } 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
+ }
106
+ const enabled = r.enabled !== false;
107
+ const hasError = issues.some((i) => i.severity === 'error');
108
+ const hasUnsupported = issues.some((i) => i.code === 'trigger-unsupported');
109
+ const status = !enabled
110
+ ? 'disabled'
111
+ : hasError
112
+ ? 'error'
113
+ : hasUnsupported
114
+ ? 'unsupported'
115
+ : 'ok';
116
+ entries.push({ name: r.name, enabled, status, issues });
117
+ }
118
+ return {
119
+ rules: entries,
120
+ valid: entries.every((e) => e.status !== 'error'),
121
+ unsupportedCount,
122
+ };
123
+ }
124
+ export class RulesEngine {
125
+ opts;
126
+ rules;
127
+ aliases;
128
+ throttle = new ThrottleGate();
129
+ unsubscribeMessage = null;
130
+ unsubscribeState = null;
131
+ cronScheduler = null;
132
+ webhookListener = null;
133
+ started = false;
134
+ stopped = false;
135
+ /**
136
+ * Sequential dispatch queue. Two MQTT messages arriving in the same
137
+ * tick would otherwise race inside the throttle check — each sees an
138
+ * empty lastFireAt map because neither has recorded yet. Serialising
139
+ * keeps the semantics of `max_per` honest.
140
+ */
141
+ pendingChain = Promise.resolve();
142
+ stats = {
143
+ started: false,
144
+ rulesLoaded: 0,
145
+ rulesActive: 0,
146
+ eventsProcessed: 0,
147
+ fires: 0,
148
+ dryFires: 0,
149
+ throttled: 0,
150
+ conditionsFailed: 0,
151
+ };
152
+ constructor(opts) {
153
+ this.opts = opts;
154
+ this.rules = (opts.automation?.rules ?? []).filter((r) => r.enabled !== false);
155
+ this.aliases = opts.aliases ?? {};
156
+ this.stats.rulesLoaded = opts.automation?.rules?.length ?? 0;
157
+ this.stats.rulesActive = this.rules.length;
158
+ }
159
+ getStats() {
160
+ return { ...this.stats, started: this.started && !this.stopped };
161
+ }
162
+ getRules() {
163
+ return this.rules;
164
+ }
165
+ /**
166
+ * Subscribes to MQTT and begins the pipeline. Throws if the policy
167
+ * block is missing `enabled: true` or if lint finds errors (e.g.
168
+ * destructive command in a rule action).
169
+ */
170
+ async start() {
171
+ if (this.opts.automation?.enabled !== true) {
172
+ throw new Error('automation.enabled is not true — engine start refused.');
173
+ }
174
+ const lint = lintRules(this.opts.automation);
175
+ if (!lint.valid) {
176
+ const errors = lint.rules.flatMap((r) => r.issues.filter((i) => i.severity === 'error'));
177
+ throw new Error(`Rule lint failed: ${errors.map((e) => `${e.rule}:${e.code}`).join(', ')}`);
178
+ }
179
+ if (this.rules.some((r) => isMqttTrigger(r.when))) {
180
+ const topic = this.opts.mqttCredential.topics.status;
181
+ this.opts.mqttClient.subscribe(topic);
182
+ this.unsubscribeMessage = this.opts.mqttClient.onMessage((_topic, payload) => {
183
+ this.enqueue(() => this.onMqttMessage(payload));
184
+ });
185
+ }
186
+ // Cron triggers. We start the scheduler only when at least one cron
187
+ // rule is active — no need to stand up timers otherwise.
188
+ const cronRules = this.rules.filter((r) => isCronTrigger(r.when));
189
+ if (cronRules.length > 0) {
190
+ this.cronScheduler = new CronScheduler({
191
+ dispatch: (rule, event) => this.enqueue(() => this.onCronFire(rule, event)),
192
+ });
193
+ for (const r of cronRules)
194
+ this.cronScheduler.register(r);
195
+ this.cronScheduler.start();
196
+ }
197
+ // Webhook triggers. Only bind the HTTP port when at least one rule
198
+ // needs it — standing up the listener unconditionally would force
199
+ // every user into an open port they didn't ask for.
200
+ const webhookRules = this.rules.filter((r) => isWebhookTrigger(r.when));
201
+ if (webhookRules.length > 0) {
202
+ if (!this.opts.webhookToken) {
203
+ throw new Error('webhook rules require a bearer token — pass RulesEngineOptions.webhookToken.');
204
+ }
205
+ this.webhookListener = new WebhookListener({
206
+ rules: webhookRules,
207
+ bearerToken: this.opts.webhookToken,
208
+ host: this.opts.webhookHost,
209
+ port: this.opts.webhookPort ?? DEFAULT_WEBHOOK_PORT,
210
+ dispatch: (rule, event) => this.enqueue(() => this.onWebhookFire(rule, event)),
211
+ });
212
+ await this.webhookListener.start();
213
+ }
214
+ this.unsubscribeState = this.opts.mqttClient.onStateChange((state) => {
215
+ if (state === 'failed' && !this.stopped) {
216
+ // Propagate to caller via stats; the rules run command decides
217
+ // whether to exit. No internal restart — we rely on supervisors.
218
+ this.started = false;
219
+ }
220
+ });
221
+ this.started = true;
222
+ this.stats.started = true;
223
+ }
224
+ async stop() {
225
+ if (this.stopped)
226
+ return;
227
+ this.stopped = true;
228
+ this.started = false;
229
+ this.unsubscribeMessage?.();
230
+ this.unsubscribeState?.();
231
+ this.unsubscribeMessage = null;
232
+ this.unsubscribeState = null;
233
+ if (this.cronScheduler) {
234
+ this.cronScheduler.stop();
235
+ this.cronScheduler = null;
236
+ }
237
+ if (this.webhookListener) {
238
+ await this.webhookListener.stop();
239
+ this.webhookListener = null;
240
+ }
241
+ }
242
+ /**
243
+ * Hot-reload the running engine with a fresh automation block and
244
+ * alias map — typically triggered by SIGHUP or by the `rules reload`
245
+ * subcommand writing the reload sentinel file.
246
+ *
247
+ * Semantics:
248
+ * - Rejects (and keeps the old ruleset) when the new automation is
249
+ * disabled or fails lint. The engine never silently degrades.
250
+ * - Diffs cron registrations by `rule.name` + `schedule`: unchanged
251
+ * entries keep their armed timer, changed/removed entries are
252
+ * unregistered, new entries are registered and armed.
253
+ * - Hands the fresh webhook rule list to the live listener (keeps
254
+ * the bound port / open connections). If the reload removes every
255
+ * webhook rule the listener is torn down; if it adds the first
256
+ * webhook rule we refuse — spinning up a new listener mid-run
257
+ * would silently change the security surface.
258
+ * - `ThrottleGate` state is retained for surviving rule names and
259
+ * dropped for removed ones. A rule that was throttled before the
260
+ * reload stays throttled after it (same name = same window), but
261
+ * a renamed rule resets.
262
+ */
263
+ async reload(nextAutomation, nextAliases = {}) {
264
+ if (!this.started || this.stopped) {
265
+ return { changed: false, errors: ['engine not running'], warnings: [] };
266
+ }
267
+ if (nextAutomation?.enabled !== true) {
268
+ return {
269
+ changed: false,
270
+ errors: ['automation.enabled is not true in the new policy — refusing to reload'],
271
+ warnings: [],
272
+ };
273
+ }
274
+ const lint = lintRules(nextAutomation);
275
+ if (!lint.valid) {
276
+ const errs = lint.rules.flatMap((r) => r.issues.filter((i) => i.severity === 'error').map((i) => `${i.rule}:${i.code}`));
277
+ return { changed: false, errors: errs, warnings: [] };
278
+ }
279
+ const warnings = [];
280
+ const nextActive = (nextAutomation.rules ?? []).filter((r) => r.enabled !== false);
281
+ const nextByName = new Map(nextActive.map((r) => [r.name, r]));
282
+ const oldByName = new Map(this.rules.map((r) => [r.name, r]));
283
+ // Cron diff
284
+ if (this.cronScheduler) {
285
+ for (const [name, oldRule] of oldByName) {
286
+ if (!isCronTrigger(oldRule.when))
287
+ continue;
288
+ const next = nextByName.get(name);
289
+ const same = next &&
290
+ isCronTrigger(next.when) &&
291
+ next.when.schedule === oldRule.when.schedule;
292
+ if (!same)
293
+ this.cronScheduler.unregister(name);
294
+ }
295
+ for (const [name, newRule] of nextByName) {
296
+ if (!isCronTrigger(newRule.when))
297
+ continue;
298
+ if (this.cronScheduler.hasRegistered(name))
299
+ continue;
300
+ this.cronScheduler.register(newRule);
301
+ }
302
+ }
303
+ else {
304
+ // No scheduler yet but now we have cron rules — stand one up.
305
+ const cronRules = nextActive.filter((r) => isCronTrigger(r.when));
306
+ if (cronRules.length > 0) {
307
+ this.cronScheduler = new CronScheduler({
308
+ dispatch: (rule, event) => this.enqueue(() => this.onCronFire(rule, event)),
309
+ });
310
+ for (const r of cronRules)
311
+ this.cronScheduler.register(r);
312
+ this.cronScheduler.start();
313
+ }
314
+ }
315
+ // Webhook diff — keep the listener alive if possible.
316
+ const newWebhookRules = nextActive.filter((r) => isWebhookTrigger(r.when));
317
+ if (this.webhookListener) {
318
+ if (newWebhookRules.length === 0) {
319
+ await this.webhookListener.stop();
320
+ this.webhookListener = null;
321
+ }
322
+ else {
323
+ this.webhookListener.updateRules(newWebhookRules);
324
+ }
325
+ }
326
+ else if (newWebhookRules.length > 0) {
327
+ warnings.push('webhook rules added via reload — full restart required for the listener to bind. Skipping activation.');
328
+ }
329
+ // Swap ruleset + aliases atomically relative to the next event.
330
+ this.rules = nextActive;
331
+ this.aliases = nextAliases;
332
+ this.stats.rulesLoaded = nextAutomation.rules?.length ?? 0;
333
+ this.stats.rulesActive = nextActive.length;
334
+ this.throttle.retainOnly(new Set(nextByName.keys()));
335
+ return { changed: true, errors: [], warnings };
336
+ }
337
+ /**
338
+ * Expose the MQTT pipeline for direct invocation from tests — feeds a
339
+ * synthetic payload through the same matcher/throttle/action chain.
340
+ */
341
+ async ingestMqttForTest(payload) {
342
+ await this.enqueue(() => this.onMqttMessage(payload, { preParsed: true }));
343
+ }
344
+ /**
345
+ * Fire a cron rule directly without needing the scheduler/timers.
346
+ * Used by tests that want to exercise the dispatch pipeline without
347
+ * depending on fake timers or croner's internals.
348
+ */
349
+ async ingestCronForTest(rule, when = new Date()) {
350
+ if (!isCronTrigger(rule.when)) {
351
+ throw new Error(`ingestCronForTest: rule "${rule.name}" is not a cron trigger`);
352
+ }
353
+ const event = {
354
+ source: 'cron',
355
+ event: rule.when.schedule,
356
+ t: when,
357
+ payload: { schedule: rule.when.schedule },
358
+ };
359
+ await this.enqueue(() => this.onCronFire(rule, event));
360
+ }
361
+ /**
362
+ * Fire a webhook rule directly without standing up the HTTP listener.
363
+ */
364
+ async ingestWebhookForTest(rule, body = '', when = new Date()) {
365
+ if (!isWebhookTrigger(rule.when)) {
366
+ throw new Error(`ingestWebhookForTest: rule "${rule.name}" is not a webhook trigger`);
367
+ }
368
+ const event = {
369
+ source: 'webhook',
370
+ event: rule.when.path,
371
+ t: when,
372
+ payload: { path: rule.when.path, body },
373
+ };
374
+ await this.enqueue(() => this.onWebhookFire(rule, event));
375
+ }
376
+ /** Returns the bound webhook port when the listener is active. */
377
+ getWebhookPort() {
378
+ return this.webhookListener?.getPort() ?? null;
379
+ }
380
+ /** Read-only peek at cron schedule state — for `rules list` extras. */
381
+ getCronSchedule(ruleName) {
382
+ return this.cronScheduler?.getScheduledFor(ruleName) ?? null;
383
+ }
384
+ /** Test helper — resolves after all queued dispatches complete. */
385
+ async drainForTest() {
386
+ await this.pendingChain;
387
+ }
388
+ /**
389
+ * Append a task to the dispatch queue; callers get back a promise that
390
+ * resolves when their task finishes (errors are swallowed — we never
391
+ * want the queue itself to die because one rule threw). Returning a
392
+ * promise lets awaited callsites (ingestMqttForTest) observe completion.
393
+ */
394
+ enqueue(task) {
395
+ const next = this.pendingChain.then(() => task().catch(() => undefined));
396
+ this.pendingChain = next;
397
+ return next;
398
+ }
399
+ async onMqttMessage(payload, opts = {}) {
400
+ if (this.stopped || !this.started)
401
+ return;
402
+ let parsed;
403
+ if (opts.preParsed) {
404
+ parsed = payload;
405
+ }
406
+ else {
407
+ try {
408
+ parsed = JSON.parse(payload.toString('utf-8'));
409
+ }
410
+ catch {
411
+ return;
412
+ }
413
+ }
414
+ this.stats.eventsProcessed++;
415
+ const classified = classifyMqttPayload(parsed);
416
+ const now = new Date();
417
+ const event = {
418
+ source: 'mqtt',
419
+ event: classified.event,
420
+ deviceId: classified.deviceId,
421
+ t: now,
422
+ payload: parsed,
423
+ };
424
+ for (const rule of this.rules) {
425
+ if (!isMqttTrigger(rule.when))
426
+ continue;
427
+ const resolvedFilter = rule.when.device
428
+ ? this.aliases[rule.when.device] ?? rule.when.device
429
+ : undefined;
430
+ if (!matchesMqttTrigger(rule.when, event, resolvedFilter))
431
+ continue;
432
+ await this.dispatchRule(rule, event);
433
+ if (this.opts.maxFirings !== undefined && this.stats.eventsProcessed >= 0 && this.firesTotal() >= this.opts.maxFirings) {
434
+ await this.stop();
435
+ return;
436
+ }
437
+ }
438
+ }
439
+ async onCronFire(rule, event) {
440
+ if (this.stopped || !this.started)
441
+ return;
442
+ this.stats.eventsProcessed++;
443
+ await this.dispatchRule(rule, event);
444
+ if (this.opts.maxFirings !== undefined && this.firesTotal() >= this.opts.maxFirings) {
445
+ await this.stop();
446
+ }
447
+ }
448
+ async onWebhookFire(rule, event) {
449
+ if (this.stopped || !this.started)
450
+ return;
451
+ this.stats.eventsProcessed++;
452
+ await this.dispatchRule(rule, event);
453
+ if (this.opts.maxFirings !== undefined && this.firesTotal() >= this.opts.maxFirings) {
454
+ await this.stop();
455
+ }
456
+ }
457
+ firesTotal() {
458
+ return this.stats.fires + this.stats.dryFires;
459
+ }
460
+ async dispatchRule(rule, event) {
461
+ const fireId = randomUUID();
462
+ // Per-tick status cache: one pipeline run through dispatchRule, one
463
+ // cache. Multiple device_state conditions on the same deviceId share
464
+ // a single round trip; subsequent pipeline runs see fresh status.
465
+ const statusCache = new Map();
466
+ const baseFetcher = this.opts.statusFetcher ??
467
+ ((id) => fetchDeviceStatus(id, this.opts.httpClient));
468
+ const fetchStatus = (deviceId) => {
469
+ const existing = statusCache.get(deviceId);
470
+ if (existing)
471
+ return existing;
472
+ const p = baseFetcher(deviceId);
473
+ statusCache.set(deviceId, p);
474
+ return p;
475
+ };
476
+ const cond = await evaluateConditions(rule.conditions, event.t, {
477
+ aliases: this.aliases,
478
+ fetchStatus,
479
+ });
480
+ if (!cond.matched) {
481
+ if (cond.unsupported.length > 0) {
482
+ writeAudit({
483
+ t: event.t.toISOString(),
484
+ kind: 'rule-fire',
485
+ deviceId: event.deviceId ?? 'unknown',
486
+ command: rule.then[0]?.command ?? '',
487
+ parameter: null,
488
+ commandType: 'command',
489
+ dryRun: true,
490
+ result: 'error',
491
+ error: `condition-unsupported:${cond.unsupported.map((u) => u.keyword).join(',')}`,
492
+ rule: {
493
+ name: rule.name,
494
+ triggerSource: rule.when.source,
495
+ matchedDevice: event.deviceId,
496
+ fireId,
497
+ reason: cond.unsupported.map((u) => u.hint).join(' | '),
498
+ },
499
+ });
500
+ this.opts.onFire?.({ ruleName: rule.name, fireId, status: 'unsupported', deviceId: event.deviceId, reason: cond.unsupported.map((u) => u.keyword).join(',') });
501
+ return;
502
+ }
503
+ this.stats.conditionsFailed++;
504
+ this.opts.onFire?.({ ruleName: rule.name, fireId, status: 'conditions-failed', deviceId: event.deviceId, reason: cond.failures.join('; ') });
505
+ return;
506
+ }
507
+ const windowMs = rule.throttle ? parseMaxPerMs(rule.throttle.max_per) : null;
508
+ const throttleKey = event.deviceId;
509
+ const check = this.throttle.check(rule.name, windowMs, event.t.getTime(), throttleKey);
510
+ if (!check.allowed) {
511
+ this.stats.throttled++;
512
+ writeAudit({
513
+ t: event.t.toISOString(),
514
+ kind: 'rule-throttled',
515
+ deviceId: event.deviceId ?? 'unknown',
516
+ command: rule.then[0]?.command ?? '',
517
+ parameter: null,
518
+ commandType: 'command',
519
+ dryRun: true,
520
+ result: 'ok',
521
+ rule: {
522
+ name: rule.name,
523
+ triggerSource: rule.when.source,
524
+ matchedDevice: event.deviceId,
525
+ fireId,
526
+ reason: check.nextAllowedAt
527
+ ? `throttled — next allowed at ${new Date(check.nextAllowedAt).toISOString()}`
528
+ : 'throttled',
529
+ },
530
+ });
531
+ this.opts.onFire?.({ ruleName: rule.name, fireId, status: 'throttled', deviceId: event.deviceId });
532
+ return;
533
+ }
534
+ let fired = false;
535
+ let allDry = true;
536
+ for (const action of rule.then) {
537
+ const result = await executeRuleAction(action, {
538
+ rule,
539
+ fireId,
540
+ aliases: this.aliases,
541
+ httpClient: this.opts.httpClient,
542
+ globalDryRun: this.opts.globalDryRun,
543
+ skipApiCall: this.opts.skipApiCall,
544
+ });
545
+ if (result.blocked) {
546
+ this.opts.onFire?.({ ruleName: rule.name, fireId, status: 'blocked', deviceId: result.deviceId, reason: result.error });
547
+ if ((action.on_error ?? 'continue') === 'stop')
548
+ break;
549
+ continue;
550
+ }
551
+ if (!result.dryRun)
552
+ allDry = false;
553
+ if (result.ok)
554
+ fired = true;
555
+ if (!result.ok && (action.on_error ?? 'continue') === 'stop')
556
+ break;
557
+ }
558
+ if (fired) {
559
+ if (allDry)
560
+ this.stats.dryFires++;
561
+ else
562
+ this.stats.fires++;
563
+ this.throttle.record(rule.name, event.t.getTime(), throttleKey);
564
+ this.opts.onFire?.({ ruleName: rule.name, fireId, status: allDry ? 'dry' : 'fired', deviceId: event.deviceId });
565
+ }
566
+ }
567
+ }