@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,230 @@
1
+ /**
2
+ * Pure matching helpers for the rules engine.
3
+ *
4
+ * v0.2 scope:
5
+ * - `matchesMqttTrigger` — event + optional deviceId filter
6
+ * - `classifyMqttPayload` — heuristic that turns a raw shadow
7
+ * payload into a canonical event name
8
+ * - `evaluateConditions` — time_between (sync) + device_state
9
+ * (async, requires caller-supplied fetcher)
10
+ *
11
+ * All matching stays pure: `evaluateConditions` does not touch the
12
+ * filesystem, network, or globals. Callers inject a `fetchStatus`
13
+ * function; the engine's caller-provided fetcher dedupes per-tick so
14
+ * multiple rules querying the same device share one round trip.
15
+ */
16
+ import { isDeviceState, isTimeBetween, isAllCondition, isAnyCondition, isNotCondition, } from './types.js';
17
+ import { isWithinTuple } from './quiet-hours.js';
18
+ /**
19
+ * Mapped states from SwitchBot MQTT shadow payloads. Each entry lists
20
+ * the canonical event name plus the payload-field + value that produces
21
+ * it. Keep this table tiny in the PoC — we widen it as users ask for
22
+ * more event names.
23
+ */
24
+ const EVENT_CLASSIFIERS = [
25
+ { field: 'detectionState', value: 'DETECTED', event: 'motion.detected' },
26
+ { field: 'detectionState', value: 'NOT_DETECTED', event: 'motion.cleared' },
27
+ { field: 'openState', value: 'OPEN', event: 'contact.opened' },
28
+ { field: 'openState', value: 'CLOSE', event: 'contact.closed' },
29
+ { field: 'openState', value: 'TIMEOUT_NOT_CLOSED', event: 'contact.opened' },
30
+ ];
31
+ /** Extract `deviceMac` + a classified event from a shadow message. */
32
+ export function classifyMqttPayload(payload) {
33
+ const p = payload;
34
+ const ctx = (p?.context ?? {});
35
+ const deviceId = typeof ctx.deviceMac === 'string' ? ctx.deviceMac : undefined;
36
+ for (const c of EVENT_CLASSIFIERS) {
37
+ const raw = ctx[c.field];
38
+ if (typeof raw !== 'string')
39
+ continue;
40
+ if (c.value instanceof RegExp ? c.value.test(raw) : raw === c.value) {
41
+ return { event: c.event, deviceId };
42
+ }
43
+ }
44
+ return { event: 'device.shadow', deviceId };
45
+ }
46
+ /**
47
+ * Compare an MQTT trigger against an `EngineEvent`. We accept a trigger
48
+ * when the event name matches AND the optional `device` filter resolves
49
+ * to the event's deviceId (callers pre-resolve aliases → deviceIds so
50
+ * the matcher stays pure).
51
+ */
52
+ export function matchesMqttTrigger(trigger, event, resolvedTriggerDeviceId) {
53
+ if (event.source !== 'mqtt')
54
+ return false;
55
+ if (trigger.event !== event.event && trigger.event !== 'device.shadow')
56
+ return false;
57
+ if (resolvedTriggerDeviceId && event.deviceId && resolvedTriggerDeviceId !== event.deviceId) {
58
+ return false;
59
+ }
60
+ return true;
61
+ }
62
+ /**
63
+ * Evaluate all conditions; AND-joined at the top level. Composite nodes
64
+ * (all/any/not) are evaluated recursively. Unsupported conditions short-
65
+ * circuit to "not matched" and surface in `unsupported` so the engine
66
+ * can warn loudly rather than silently drop fires. device_state
67
+ * conditions need `ctx.fetchStatus` — without it they count as
68
+ * unsupported (e.g. lint / dry list paths).
69
+ */
70
+ export async function evaluateConditions(conditions, now, ctx = {}) {
71
+ const result = { matched: true, failures: [], unsupported: [] };
72
+ if (!conditions || conditions.length === 0)
73
+ return result;
74
+ for (const c of conditions) {
75
+ const sub = await evaluateSingle(c, now, ctx);
76
+ if (!sub.matched) {
77
+ result.matched = false;
78
+ result.failures.push(...sub.failures);
79
+ }
80
+ result.unsupported.push(...sub.unsupported);
81
+ if (!sub.matched && result.unsupported.length > 0) {
82
+ // Propagate unsupported from inner composite even if outer still matched
83
+ }
84
+ }
85
+ return result;
86
+ }
87
+ async function evaluateSingle(c, now, ctx) {
88
+ const ok = { matched: true, failures: [], unsupported: [] };
89
+ const fail = (msg) => ({ matched: false, failures: [msg], unsupported: [] });
90
+ if (isAllCondition(c)) {
91
+ const result = { matched: true, failures: [], unsupported: [] };
92
+ for (const sub of c.all) {
93
+ const r = await evaluateSingle(sub, now, ctx);
94
+ if (!r.matched) {
95
+ result.matched = false;
96
+ result.failures.push(...r.failures);
97
+ }
98
+ result.unsupported.push(...r.unsupported);
99
+ }
100
+ return result;
101
+ }
102
+ if (isAnyCondition(c)) {
103
+ const result = { matched: false, failures: [], unsupported: [] };
104
+ for (const sub of c.any) {
105
+ const r = await evaluateSingle(sub, now, ctx);
106
+ result.unsupported.push(...r.unsupported);
107
+ if (r.matched) {
108
+ result.matched = true;
109
+ result.failures = [];
110
+ return result;
111
+ }
112
+ result.failures.push(...r.failures);
113
+ }
114
+ return result;
115
+ }
116
+ if (isNotCondition(c)) {
117
+ const r = await evaluateSingle(c.not, now, ctx);
118
+ if (r.unsupported.length > 0)
119
+ return { matched: false, failures: [], unsupported: r.unsupported };
120
+ return r.matched ? fail('not: inner condition matched (negated)') : ok;
121
+ }
122
+ if (isTimeBetween(c)) {
123
+ return isWithinTuple(c.time_between, now)
124
+ ? ok
125
+ : fail(`time_between ${c.time_between[0]}-${c.time_between[1]} did not include ${now.toTimeString().slice(0, 5)}`);
126
+ }
127
+ if (isDeviceState(c)) {
128
+ if (!ctx.fetchStatus) {
129
+ return {
130
+ matched: false,
131
+ failures: [],
132
+ unsupported: [{ keyword: 'device_state', hint: 'device_state evaluation requires a live status fetcher; this call site did not provide one.' }],
133
+ };
134
+ }
135
+ const resolved = resolveDeviceRef(c.device, ctx.aliases);
136
+ if (!resolved)
137
+ return fail(`device_state: could not resolve device "${c.device}" to an id (no matching alias).`);
138
+ try {
139
+ const status = await ctx.fetchStatus(resolved);
140
+ if (!compareField(status[c.field], c.op, c.value)) {
141
+ const actual = formatValue(status[c.field]);
142
+ const expected = formatValue(c.value);
143
+ return fail(`device_state ${c.device}.${c.field} ${c.op} ${expected} (actual: ${actual})`);
144
+ }
145
+ return ok;
146
+ }
147
+ catch (err) {
148
+ return fail(`device_state ${c.device}.${c.field}: fetch failed — ${err instanceof Error ? err.message : String(err)}`);
149
+ }
150
+ }
151
+ return {
152
+ matched: false,
153
+ failures: [],
154
+ unsupported: [{ keyword: 'unknown', hint: `Unrecognised condition shape: ${JSON.stringify(c).slice(0, 120)}` }],
155
+ };
156
+ }
157
+ function resolveDeviceRef(ref, aliases) {
158
+ if (!ref)
159
+ return null;
160
+ if (aliases && ref in aliases)
161
+ return aliases[ref];
162
+ // Raw deviceId (MAC / SwitchBot id) — accept as-is.
163
+ return ref;
164
+ }
165
+ function compareField(actual, op, expected) {
166
+ // Equality operators run on the raw values so booleans, numbers, and
167
+ // strings all work naturally. Ordering operators coerce to numbers —
168
+ // JSON statuses often arrive as strings like "22.5" so coercion is
169
+ // what people mean when they write `battery >= 20`.
170
+ switch (op) {
171
+ case '==':
172
+ return looseEqual(actual, expected);
173
+ case '!=':
174
+ return !looseEqual(actual, expected);
175
+ case '<':
176
+ case '>':
177
+ case '<=':
178
+ case '>=': {
179
+ const a = toNumber(actual);
180
+ const b = toNumber(expected);
181
+ if (a === null || b === null)
182
+ return false;
183
+ if (op === '<')
184
+ return a < b;
185
+ if (op === '>')
186
+ return a > b;
187
+ if (op === '<=')
188
+ return a <= b;
189
+ return a >= b;
190
+ }
191
+ default:
192
+ return false;
193
+ }
194
+ }
195
+ function looseEqual(a, b) {
196
+ if (a === b)
197
+ return true;
198
+ if (a === undefined || b === undefined || a === null || b === null)
199
+ return false;
200
+ // Strings from shadow payloads are case-sensitive for device states
201
+ // (e.g. "on" / "off") — policy authors can match explicitly. Numbers
202
+ // coerce through `Number()` so `"22" == 22` holds.
203
+ if (typeof a === 'number' || typeof b === 'number') {
204
+ const na = toNumber(a);
205
+ const nb = toNumber(b);
206
+ return na !== null && nb !== null && na === nb;
207
+ }
208
+ return String(a) === String(b);
209
+ }
210
+ function toNumber(v) {
211
+ if (typeof v === 'number')
212
+ return Number.isFinite(v) ? v : null;
213
+ if (typeof v === 'string') {
214
+ const trimmed = v.trim();
215
+ if (!trimmed)
216
+ return null;
217
+ const n = Number(trimmed);
218
+ return Number.isFinite(n) ? n : null;
219
+ }
220
+ if (typeof v === 'boolean')
221
+ return v ? 1 : 0;
222
+ return null;
223
+ }
224
+ function formatValue(v) {
225
+ if (v === undefined)
226
+ return 'undefined';
227
+ if (typeof v === 'string')
228
+ return JSON.stringify(v);
229
+ return String(v);
230
+ }
@@ -0,0 +1,95 @@
1
+ /**
2
+ * Cross-platform supervisor glue for `switchbot rules run`.
3
+ *
4
+ * The running engine registers a pid file and a reload sentinel under
5
+ * `~/.switchbot/`; the `switchbot rules reload` subcommand reads them
6
+ * to signal the live process:
7
+ *
8
+ * - Unix (SIGHUP supported): `process.kill(pid, 'SIGHUP')`.
9
+ * - Windows (no SIGHUP): write `~/.switchbot/rules.reload`. The engine
10
+ * polls this path and consumes it, so the same `rules reload`
11
+ * command works on every platform.
12
+ *
13
+ * The files are tiny (<100 bytes) and created with 0o600; cleanup is
14
+ * best-effort on exit so a crash leaves at most a stale pid the user
15
+ * can overwrite with a fresh `rules run`.
16
+ */
17
+ import fs from 'node:fs';
18
+ import os from 'node:os';
19
+ import path from 'node:path';
20
+ const DEFAULT_DIR = path.join(os.homedir(), '.switchbot');
21
+ export function getDefaultPidFilePaths() {
22
+ return {
23
+ dir: DEFAULT_DIR,
24
+ pidFile: path.join(DEFAULT_DIR, 'rules.pid'),
25
+ reloadFile: path.join(DEFAULT_DIR, 'rules.reload'),
26
+ };
27
+ }
28
+ /** Write the current process pid. Creates parent dir with 0700 if needed. */
29
+ export function writePidFile(pidFile, pid = process.pid) {
30
+ const dir = path.dirname(pidFile);
31
+ fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
32
+ fs.writeFileSync(pidFile, `${pid}\n`, { mode: 0o600 });
33
+ }
34
+ /** Return the pid persisted in the file, or null if absent / unparseable. */
35
+ export function readPidFile(pidFile) {
36
+ try {
37
+ const raw = fs.readFileSync(pidFile, 'utf-8').trim();
38
+ const n = Number(raw);
39
+ return Number.isInteger(n) && n > 0 ? n : null;
40
+ }
41
+ catch {
42
+ return null;
43
+ }
44
+ }
45
+ /**
46
+ * Remove the pid file only when it still refers to the current process.
47
+ * A stale file from an earlier run is left alone so we don't accidentally
48
+ * clobber a new supervisor that already won the race.
49
+ */
50
+ export function clearPidFile(pidFile, pid = process.pid) {
51
+ try {
52
+ const existing = readPidFile(pidFile);
53
+ if (existing === pid)
54
+ fs.unlinkSync(pidFile);
55
+ }
56
+ catch {
57
+ // best effort
58
+ }
59
+ }
60
+ export function writeReloadSentinel(reloadFile) {
61
+ const dir = path.dirname(reloadFile);
62
+ fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
63
+ fs.writeFileSync(reloadFile, `${Date.now()}\n`, { mode: 0o600 });
64
+ }
65
+ export function consumeReloadSentinel(reloadFile) {
66
+ try {
67
+ if (!fs.existsSync(reloadFile))
68
+ return false;
69
+ fs.unlinkSync(reloadFile);
70
+ return true;
71
+ }
72
+ catch {
73
+ return false;
74
+ }
75
+ }
76
+ /** Detect whether SIGHUP is usable on the current platform. */
77
+ export function sighupSupported() {
78
+ return process.platform !== 'win32';
79
+ }
80
+ /**
81
+ * Check whether a pid is alive — used by `rules reload` to avoid
82
+ * signalling dead pids, which would otherwise leave the user wondering
83
+ * why nothing happened. Node's `process.kill(pid, 0)` throws `ESRCH`
84
+ * for dead pids and `EPERM` for pids we cannot signal (still alive).
85
+ */
86
+ export function isPidAlive(pid) {
87
+ try {
88
+ process.kill(pid, 0);
89
+ return true;
90
+ }
91
+ catch (err) {
92
+ const code = err.code;
93
+ return code === 'EPERM';
94
+ }
95
+ }
@@ -0,0 +1,45 @@
1
+ /**
2
+ * Time-window helpers shared by `time_between` conditions and (later)
3
+ * the top-level `quiet_hours` block. Both evaluate a local-clock HH:MM
4
+ * range that may cross midnight.
5
+ */
6
+ const HHMM = /^([01]\d|2[0-3]):[0-5]\d$/;
7
+ function toMinutes(hhmm) {
8
+ if (!HHMM.test(hhmm)) {
9
+ throw new Error(`Invalid HH:MM value: "${hhmm}"`);
10
+ }
11
+ const [h, m] = hhmm.split(':').map(Number);
12
+ return h * 60 + m;
13
+ }
14
+ function minutesOf(d) {
15
+ return d.getHours() * 60 + d.getMinutes();
16
+ }
17
+ /**
18
+ * `true` when `now` falls inside the window. If `start > end` the window
19
+ * is interpreted as overnight (e.g. 22:00 → 07:00 crosses midnight).
20
+ *
21
+ * Boundary semantics: start is inclusive, end is exclusive. A window of
22
+ * 09:00 → 09:00 therefore matches nothing — callers who want "always"
23
+ * should omit the condition entirely rather than fake it with equal
24
+ * times.
25
+ */
26
+ export function isWithin(window, now) {
27
+ const s = toMinutes(window.start);
28
+ const e = toMinutes(window.end);
29
+ const n = minutesOf(now);
30
+ if (s === e)
31
+ return false;
32
+ if (s < e)
33
+ return n >= s && n < e;
34
+ return n >= s || n < e;
35
+ }
36
+ /** Convenience wrapper that accepts the schema's tuple shape. */
37
+ export function isWithinTuple(range, now) {
38
+ return isWithin({ start: range[0], end: range[1] }, now);
39
+ }
40
+ /** Top-level quiet_hours block helper — same math, schema shape differs. */
41
+ export function isInQuietHours(qh, now) {
42
+ if (!qh?.start || !qh.end)
43
+ return false;
44
+ return isWithin({ start: qh.start, end: qh.end }, now);
45
+ }
@@ -0,0 +1,95 @@
1
+ import { stringify as yamlStringify } from 'yaml';
2
+ import { COMMAND_KEYWORDS } from '../lib/command-keywords.js';
3
+ const TRIGGER_KEYWORDS = [
4
+ { pattern: /\bmotion\b|\bdetect/i, trigger: 'mqtt', event: 'motion.detected' },
5
+ { pattern: /\bdoor\b|\bcontact\b|\bopen.*sensor/i, trigger: 'mqtt', event: 'contact.opened' },
6
+ { pattern: /\bbutton\b|\bpress/i, trigger: 'mqtt', event: 'button.pressed' },
7
+ { pattern: /\bwebhook\b|\bhttp\b|\bifttt\b/i, trigger: 'webhook' },
8
+ { pattern: /\bevery\b|\bdaily\b|\bmorning\b|\bnight\b|\bevening\b|\b\d{1,2}\s*[ap]m\b/i, trigger: 'cron' },
9
+ ];
10
+ function inferTrigger(intent) {
11
+ for (const t of TRIGGER_KEYWORDS) {
12
+ if (t.pattern.test(intent))
13
+ return { trigger: t.trigger, event: t.event };
14
+ }
15
+ return { trigger: 'mqtt', event: 'device.shadow' };
16
+ }
17
+ function inferSchedule(intent, warnings) {
18
+ const amMatch = /\b(\d{1,2})\s*am\b/i.exec(intent);
19
+ if (amMatch)
20
+ return `0 ${parseInt(amMatch[1], 10)} * * *`;
21
+ const pmMatch = /\b(\d{1,2})\s*pm\b/i.exec(intent);
22
+ if (pmMatch)
23
+ return `0 ${parseInt(pmMatch[1], 10) + 12} * * *`;
24
+ if (/\bevery\s*hour/i.test(intent))
25
+ return '0 * * * *';
26
+ if (/\bnight\b|\bevening\b/i.test(intent))
27
+ return '0 22 * * *';
28
+ if (/\bmorning\b/i.test(intent))
29
+ return '0 8 * * *';
30
+ warnings.push(`Could not infer cron schedule from intent "${intent}" — defaulted to "0 8 * * *". Edit the generated rule to set the correct schedule.`);
31
+ return '0 8 * * *';
32
+ }
33
+ function inferCommand(intent, warnings) {
34
+ for (const k of COMMAND_KEYWORDS) {
35
+ if (k.pattern.test(intent))
36
+ return k.command;
37
+ }
38
+ warnings.push(`Could not infer command from intent "${intent}" — defaulted to "turnOn". Edit the generated rule to set the correct command.`);
39
+ return 'turnOn';
40
+ }
41
+ export function suggestRule(opts) {
42
+ const warnings = [];
43
+ // Resolve trigger
44
+ let triggerSource = opts.trigger;
45
+ let inferredEvent;
46
+ if (!triggerSource) {
47
+ const inferred = inferTrigger(opts.intent);
48
+ triggerSource = inferred.trigger;
49
+ inferredEvent = inferred.event;
50
+ if (inferredEvent === 'device.shadow') {
51
+ warnings.push(`Could not infer trigger type from intent "${opts.intent}" — defaulted to mqtt/device.shadow. Set --trigger and --event explicitly.`);
52
+ }
53
+ }
54
+ // Build the when block
55
+ let when;
56
+ if (triggerSource === 'mqtt') {
57
+ const event = opts.event ?? inferredEvent ?? 'device.shadow';
58
+ const mqttTrigger = { source: 'mqtt', event };
59
+ if (opts.devices && opts.devices.length > 0) {
60
+ const sensorDevice = opts.devices[0];
61
+ mqttTrigger.device = sensorDevice.name ?? sensorDevice.id;
62
+ }
63
+ when = mqttTrigger;
64
+ }
65
+ else if (triggerSource === 'cron') {
66
+ const schedule = opts.schedule ?? inferSchedule(opts.intent, warnings);
67
+ const cronTrigger = { source: 'cron', schedule };
68
+ if (opts.days && opts.days.length > 0)
69
+ cronTrigger.days = opts.days;
70
+ when = cronTrigger;
71
+ }
72
+ else {
73
+ when = { source: 'webhook', path: opts.webhookPath ?? '/action' };
74
+ }
75
+ // Build then[] — one action per device (skip the sensor device for mqtt)
76
+ const command = inferCommand(opts.intent, warnings);
77
+ const actionDevices = triggerSource === 'mqtt' && opts.devices && opts.devices.length > 1
78
+ ? opts.devices.slice(1)
79
+ : (opts.devices ?? []);
80
+ const then = actionDevices.length > 0
81
+ ? actionDevices.map((d) => ({
82
+ command: `devices command <id> ${command}`,
83
+ device: d.name ?? d.id,
84
+ }))
85
+ : [{ command: `devices command <id> ${command}` }];
86
+ const rule = {
87
+ name: opts.intent,
88
+ when,
89
+ then,
90
+ dry_run: true,
91
+ ...(triggerSource === 'mqtt' ? { throttle: { max_per: '10m' } } : {}),
92
+ };
93
+ const ruleYaml = yamlStringify(rule, { lineWidth: 0 });
94
+ return { rule, ruleYaml, warnings };
95
+ }
@@ -0,0 +1,116 @@
1
+ /**
2
+ * Throttle gate — per-rule, optionally keyed by deviceId.
3
+ *
4
+ * Semantics:
5
+ * - `max_per: "10m"` → a rule may fire at most once every 10 minutes
6
+ * per (rule, deviceId) pair.
7
+ * - `dedupe_window: "5s"` → suppress fires whose key already fired
8
+ * within the window (collapses rapid sensor bursts into one action).
9
+ * - Fires that would violate the window are **suppressed** (not
10
+ * queued) and surface as `{ allowed: false, reason: 'throttled' }`.
11
+ * - When a rule has no `throttle` block, `ThrottleGate.check` returns
12
+ * `{ allowed: true }` immediately.
13
+ *
14
+ * The gate is in-memory only. Re-reads between processes (or after
15
+ * SIGHUP reload) start with a clean slate — a deliberate choice,
16
+ * because persisting throttle state would lock the engine into a
17
+ * schema that changes every time we add a trigger type.
18
+ */
19
+ const DURATION_RE = /^(\d+)([smh])$/;
20
+ export function parseMaxPerMs(expr) {
21
+ const m = DURATION_RE.exec(expr.trim());
22
+ if (!m)
23
+ throw new Error(`Invalid throttle.max_per: "${expr}"`);
24
+ const n = Number(m[1]);
25
+ const unit = m[2];
26
+ const unitMs = unit === 's' ? 1_000 : unit === 'm' ? 60_000 : 3_600_000;
27
+ return n * unitMs;
28
+ }
29
+ export class ThrottleGate {
30
+ lastFireAt = new Map();
31
+ /** Sliding-window fire-time log for count-based maxFiringsPerHour. */
32
+ fireTimes = new Map();
33
+ keyOf(ruleName, deviceId) {
34
+ return deviceId ? `${ruleName}::${deviceId}` : ruleName;
35
+ }
36
+ /**
37
+ * Does **not** record the fire. Call `record()` after the action
38
+ * actually runs so that dry-run / throttled paths don't bump the
39
+ * window.
40
+ */
41
+ check(ruleName, windowMs, now, deviceId, dedupeWindowMs) {
42
+ const key = this.keyOf(ruleName, deviceId);
43
+ const last = this.lastFireAt.get(key);
44
+ // dedupe_window check: suppress if last fire was within this (typically smaller) window
45
+ if (dedupeWindowMs !== null && dedupeWindowMs !== undefined && dedupeWindowMs > 0) {
46
+ if (last !== undefined) {
47
+ const dedupeEnd = last + dedupeWindowMs;
48
+ if (now < dedupeEnd) {
49
+ return { allowed: false, lastFiredAt: last, nextAllowedAt: dedupeEnd, dedupedBy: 'dedupe_window' };
50
+ }
51
+ }
52
+ }
53
+ // max_per / cooldown check
54
+ if (windowMs === null || windowMs <= 0)
55
+ return { allowed: true, lastFiredAt: last };
56
+ if (last === undefined)
57
+ return { allowed: true };
58
+ const earliest = last + windowMs;
59
+ if (now >= earliest)
60
+ return { allowed: true, lastFiredAt: last };
61
+ return { allowed: false, lastFiredAt: last, nextAllowedAt: earliest, dedupedBy: windowMs > 0 ? 'max_per' : undefined };
62
+ }
63
+ record(ruleName, now, deviceId) {
64
+ this.lastFireAt.set(this.keyOf(ruleName, deviceId), now);
65
+ }
66
+ /** Count-based check: has the rule fired >= maxCount times in the last windowMs? */
67
+ checkMaxFirings(ruleName, maxCount, windowMs, now, deviceId) {
68
+ const key = this.keyOf(ruleName, deviceId);
69
+ const times = (this.fireTimes.get(key) ?? []).filter((t) => now - t < windowMs);
70
+ this.fireTimes.set(key, times);
71
+ return { allowed: times.length < maxCount, count: times.length, max: maxCount };
72
+ }
73
+ /** Record a count-based fire (call alongside record()). */
74
+ recordFire(ruleName, now, deviceId) {
75
+ const key = this.keyOf(ruleName, deviceId);
76
+ const times = this.fireTimes.get(key) ?? [];
77
+ times.push(now);
78
+ this.fireTimes.set(key, times);
79
+ }
80
+ /** Drop everything — used by engine.reload when a rule is removed. */
81
+ forget(ruleName) {
82
+ const prefix = `${ruleName}::`;
83
+ for (const k of this.lastFireAt.keys()) {
84
+ if (k === ruleName || k.startsWith(prefix))
85
+ this.lastFireAt.delete(k);
86
+ }
87
+ for (const k of this.fireTimes.keys()) {
88
+ if (k === ruleName || k.startsWith(prefix))
89
+ this.fireTimes.delete(k);
90
+ }
91
+ }
92
+ /**
93
+ * Drop every window whose rule name isn't in the given set — used by
94
+ * `engine.reload` after a policy swap. Entries for names that survive
95
+ * the reload are preserved so unchanged rules don't get a free
96
+ * one-fire amnesty.
97
+ */
98
+ retainOnly(ruleNames) {
99
+ for (const k of this.lastFireAt.keys()) {
100
+ const sep = k.indexOf('::');
101
+ const ruleName = sep === -1 ? k : k.slice(0, sep);
102
+ if (!ruleNames.has(ruleName))
103
+ this.lastFireAt.delete(k);
104
+ }
105
+ for (const k of this.fireTimes.keys()) {
106
+ const sep = k.indexOf('::');
107
+ const ruleName = sep === -1 ? k : k.slice(0, sep);
108
+ if (!ruleNames.has(ruleName))
109
+ this.fireTimes.delete(k);
110
+ }
111
+ }
112
+ /** Test helper — exposes the underlying size. */
113
+ size() {
114
+ return this.lastFireAt.size;
115
+ }
116
+ }
@@ -0,0 +1,34 @@
1
+ /**
2
+ * Runtime TypeScript shapes for policy v0.2 rule objects.
3
+ *
4
+ * These are hand-mirrored from `src/policy/schema/v0.2.json` — the ajv
5
+ * validator is the source of truth for what a file may contain, this
6
+ * file is the source of truth for what the engine expects after load.
7
+ * When you edit one, edit the other in the same commit.
8
+ */
9
+ /** Guards used outside this file. */
10
+ export function isMqttTrigger(t) {
11
+ return t.source === 'mqtt';
12
+ }
13
+ export function isCronTrigger(t) {
14
+ return t.source === 'cron';
15
+ }
16
+ export function isWebhookTrigger(t) {
17
+ return t.source === 'webhook';
18
+ }
19
+ export function isTimeBetween(c) {
20
+ return Array.isArray(c.time_between);
21
+ }
22
+ export function isDeviceState(c) {
23
+ const d = c;
24
+ return typeof d.device === 'string' && typeof d.field === 'string' && typeof d.op === 'string';
25
+ }
26
+ export function isAllCondition(c) {
27
+ return Array.isArray(c.all);
28
+ }
29
+ export function isAnyCondition(c) {
30
+ return Array.isArray(c.any);
31
+ }
32
+ export function isNotCondition(c) {
33
+ return c.not !== undefined && !Array.isArray(c.not);
34
+ }