@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,91 @@
1
+ export const MAX_POLICY_DIFF_CHANGES = 200;
2
+ function isPlainObject(v) {
3
+ return !!v && typeof v === 'object' && !Array.isArray(v);
4
+ }
5
+ function collectPolicyDiff(left, right, at, out, limit) {
6
+ if (out.length >= limit)
7
+ return;
8
+ if (Array.isArray(left) && Array.isArray(right)) {
9
+ const maxLen = Math.max(left.length, right.length);
10
+ for (let i = 0; i < maxLen; i++) {
11
+ if (out.length >= limit)
12
+ return;
13
+ const path = `${at}[${i}]`;
14
+ if (i >= left.length) {
15
+ out.push({ path, kind: 'added', after: right[i] });
16
+ }
17
+ else if (i >= right.length) {
18
+ out.push({ path, kind: 'removed', before: left[i] });
19
+ }
20
+ else {
21
+ collectPolicyDiff(left[i], right[i], path, out, limit);
22
+ }
23
+ }
24
+ return;
25
+ }
26
+ if (isPlainObject(left) && isPlainObject(right)) {
27
+ const keys = new Set([...Object.keys(left), ...Object.keys(right)]);
28
+ for (const key of [...keys].sort()) {
29
+ if (out.length >= limit)
30
+ return;
31
+ const path = at === '$' ? `$.${key}` : `${at}.${key}`;
32
+ const leftHas = Object.prototype.hasOwnProperty.call(left, key);
33
+ const rightHas = Object.prototype.hasOwnProperty.call(right, key);
34
+ if (!leftHas && rightHas) {
35
+ out.push({ path, kind: 'added', after: right[key] });
36
+ }
37
+ else if (leftHas && !rightHas) {
38
+ out.push({ path, kind: 'removed', before: left[key] });
39
+ }
40
+ else {
41
+ collectPolicyDiff(left[key], right[key], path, out, limit);
42
+ }
43
+ }
44
+ return;
45
+ }
46
+ if (!Object.is(left, right)) {
47
+ out.push({ path: at, kind: 'changed', before: left, after: right });
48
+ }
49
+ }
50
+ function buildLineDiff(before, after) {
51
+ const beforeLines = before.split('\n');
52
+ const afterLines = after.split('\n');
53
+ const lines = ['--- before', '+++ after'];
54
+ let i = 0;
55
+ let j = 0;
56
+ while (i < beforeLines.length || j < afterLines.length) {
57
+ const b = beforeLines[i];
58
+ const a = afterLines[j];
59
+ if (i < beforeLines.length && j < afterLines.length && b === a) {
60
+ lines.push(` ${b}`);
61
+ i++;
62
+ j++;
63
+ }
64
+ else if (j < afterLines.length && (i >= beforeLines.length || b !== a)) {
65
+ lines.push(`+${a}`);
66
+ j++;
67
+ }
68
+ else {
69
+ lines.push(`-${b}`);
70
+ i++;
71
+ }
72
+ }
73
+ return lines.join('\n');
74
+ }
75
+ export function diffPolicyValues(leftDoc, rightDoc, leftSource, rightSource, maxChanges = MAX_POLICY_DIFF_CHANGES) {
76
+ const changes = [];
77
+ collectPolicyDiff(leftDoc, rightDoc, '$', changes, maxChanges);
78
+ const equal = changes.length === 0;
79
+ return {
80
+ equal,
81
+ changeCount: changes.length,
82
+ truncated: changes.length >= maxChanges,
83
+ stats: {
84
+ added: changes.filter((c) => c.kind === 'added').length,
85
+ removed: changes.filter((c) => c.kind === 'removed').length,
86
+ changed: changes.filter((c) => c.kind === 'changed').length,
87
+ },
88
+ changes,
89
+ diff: buildLineDiff(leftSource, rightSource),
90
+ };
91
+ }
@@ -0,0 +1,99 @@
1
+ # ============================================================================
2
+ # SwitchBot policy — example
3
+ # ============================================================================
4
+ # Copy this file to your user config directory and edit it:
5
+ #
6
+ # mkdir -p ~/.switchbot
7
+ # cp policy.example.yaml ~/.switchbot/policy.yaml
8
+ #
9
+ # Every section is OPTIONAL. If a field isn't set, the CLI/agent layer falls back to
10
+ # a safe default (documented next to each field).
11
+ #
12
+ # Agents read this file before every session. They never write to it
13
+ # without showing you the diff and asking first.
14
+ # ============================================================================
15
+
16
+ # Schema version. Do not remove this line — the skill uses it to detect
17
+ # breaking changes and migrate your file when a newer schema ships.
18
+ version: "0.2"
19
+
20
+ # ----------------------------------------------------------------------------
21
+ # aliases — friendly names the agent can resolve to real devices
22
+ # ----------------------------------------------------------------------------
23
+ # The #1 reason to have a policy file. Without aliases, the agent has to
24
+ # guess which device you mean when you say "the bedroom light", and it can
25
+ # guess wrong if two devices have similar names.
26
+ #
27
+ # Get each deviceId from:
28
+ # switchbot devices list --format=tsv
29
+ #
30
+ # The format is: "what the user says": "<deviceId>"
31
+ # Quote the key if it contains spaces or non-ASCII characters.
32
+ aliases:
33
+ # "living room light": "01-202407090924-26354212"
34
+ # "bedroom AC": "02-202502111234-85411230"
35
+ # "front door lock": "03-202501201700-99887766"
36
+ # "kitchen plug": "04-202503081500-55443322"
37
+
38
+ # ----------------------------------------------------------------------------
39
+ # confirmations — which actions require explicit user approval
40
+ # ----------------------------------------------------------------------------
41
+ # The skill already refuses destructive actions (locks, deletions) by
42
+ # default. Use this section to adjust the defaults for your account.
43
+ #
44
+ # always_confirm: extra actions that need confirmation even though they
45
+ # wouldn't by default (e.g. you never want the agent to
46
+ # turn on the AC without asking).
47
+ # never_confirm: actions that normally confirm but you trust (NEVER add
48
+ # destructive actions here — the skill will reject that).
49
+ confirmations:
50
+ always_confirm:
51
+ # - "setTargetTemperature"
52
+ # - "setThermostatMode"
53
+
54
+ never_confirm:
55
+ # - "turnOn"
56
+ # - "turnOff"
57
+
58
+ # ----------------------------------------------------------------------------
59
+ # quiet_hours — during these hours, every mutation requires confirmation
60
+ # ----------------------------------------------------------------------------
61
+ # Times are 24-hour, local system time. If omitted, no quiet hours apply.
62
+ quiet_hours:
63
+ # start: "22:00"
64
+ # end: "08:00"
65
+
66
+ # ----------------------------------------------------------------------------
67
+ # audit — where to log every action the agent takes
68
+ # ----------------------------------------------------------------------------
69
+ # The skill ALWAYS logs mutations and destructive actions. This section
70
+ # controls where the log goes and how long it's kept.
71
+ audit:
72
+ # Path for the audit log. "~" is expanded. JSON Lines format.
73
+ log_path: "~/.switchbot/audit.log"
74
+
75
+ # How long to keep log lines. "never" disables rotation. Accepts units:
76
+ # d (days), w (weeks), m (months). Default: "90d".
77
+ retention: "90d"
78
+
79
+ # ----------------------------------------------------------------------------
80
+ # automation — Phase 4 (rule engine). Leave `enabled: false` for now.
81
+ # ----------------------------------------------------------------------------
82
+ # The rule engine ships in Phase 4. This section is reserved so the schema
83
+ # validates today; if you set `enabled: true` before Phase 4 lands, the
84
+ # skill will warn you and ignore it.
85
+ automation:
86
+ enabled: false
87
+ # rules: []
88
+
89
+ # ----------------------------------------------------------------------------
90
+ # cli — optional CLI-level overrides
91
+ # ----------------------------------------------------------------------------
92
+ cli:
93
+ # Which profile to use if you have multiple SwitchBot accounts. The CLI
94
+ # supports `switchbot --profile <name>`. Default: "default".
95
+ profile: "default"
96
+
97
+ # Device cache TTL. The skill refreshes the cache when it's older than
98
+ # this. Defaults to the CLI's own default (typically 5 minutes).
99
+ # cache_ttl: "5m"
@@ -0,0 +1,57 @@
1
+ import chalk, { Chalk } from 'chalk';
2
+ const noColorChalk = new Chalk({ level: 0 });
3
+ function colorize(enabled) {
4
+ return enabled ? chalk : noColorChalk;
5
+ }
6
+ function snippet(source, line, col, length, c) {
7
+ const lines = source.split(/\r?\n/);
8
+ if (line < 1 || line > lines.length)
9
+ return '';
10
+ const lineText = lines[line - 1];
11
+ const gutter = ` ${line} | `;
12
+ const pad = ' '.repeat(gutter.length);
13
+ const caretStart = Math.max(0, col - 1);
14
+ const caretLen = Math.max(1, length);
15
+ const caret = `${' '.repeat(caretStart)}${c.red('^'.repeat(caretLen))}`;
16
+ return `${c.dim(gutter)}${lineText}\n${c.dim(pad)}${caret}`;
17
+ }
18
+ function estimateTokenLength(source, line, col) {
19
+ const lines = source.split(/\r?\n/);
20
+ if (line < 1 || line > lines.length)
21
+ return 1;
22
+ const lineText = lines[line - 1];
23
+ const start = Math.max(0, col - 1);
24
+ if (start >= lineText.length)
25
+ return 1;
26
+ const rest = lineText.slice(start);
27
+ const quoted = rest.match(/^(['"]).*?\1/);
28
+ if (quoted)
29
+ return quoted[0].length;
30
+ const token = rest.match(/^[^\s,\[\]{}]+/);
31
+ return token ? token[0].length : 1;
32
+ }
33
+ function formatError(err, policyPath, source, opts) {
34
+ const c = colorize(opts.color ?? true);
35
+ const loc = err.line !== undefined && err.col !== undefined ? `${err.line}:${err.col}` : '(unknown)';
36
+ const header = `${c.cyan(policyPath)}:${c.yellow(loc)}`;
37
+ const body = [`${c.red.bold('error')}: ${err.message}`];
38
+ if (err.line !== undefined && err.col !== undefined && !opts.noSnippet) {
39
+ const len = estimateTokenLength(source, err.line, err.col);
40
+ const snip = snippet(source, err.line, err.col, len, c);
41
+ if (snip)
42
+ body.unshift(snip);
43
+ }
44
+ if (err.hint)
45
+ body.push(`${c.green.bold('hint')}: ${err.hint}`);
46
+ return [header, ...body].join('\n');
47
+ }
48
+ export function formatValidationResult(result, source, opts = {}) {
49
+ const c = colorize(opts.color ?? true);
50
+ if (result.valid) {
51
+ return `${c.green.bold('✓')} ${result.policyPath} is valid (schema v${result.schemaVersion})`;
52
+ }
53
+ const blocks = result.errors.map((e) => formatError(e, result.policyPath, source, opts));
54
+ const count = result.errors.length;
55
+ const footer = `${c.red.bold(`✗ ${count} ${count === 1 ? 'error' : 'errors'}`)} in ${result.policyPath} (schema v${result.schemaVersion})`;
56
+ return [...blocks, '', footer].join('\n\n').replace(/\n{3,}/g, '\n\n');
57
+ }
@@ -0,0 +1,61 @@
1
+ import { readFileSync } from 'node:fs';
2
+ import { homedir } from 'node:os';
3
+ import { join, resolve } from 'node:path';
4
+ import { parseDocument, LineCounter } from 'yaml';
5
+ export const DEFAULT_POLICY_PATH = join(homedir(), '.config', 'openclaw', 'switchbot', 'policy.yaml');
6
+ export function resolvePolicyPath(options = {}) {
7
+ const { flag, env = process.env } = options;
8
+ if (flag && flag.trim().length > 0)
9
+ return resolve(flag);
10
+ const fromEnv = env.SWITCHBOT_POLICY_PATH;
11
+ if (fromEnv && fromEnv.trim().length > 0)
12
+ return resolve(fromEnv);
13
+ return DEFAULT_POLICY_PATH;
14
+ }
15
+ export class PolicyFileNotFoundError extends Error {
16
+ policyPath;
17
+ constructor(policyPath) {
18
+ super(`policy file not found: ${policyPath}`);
19
+ this.policyPath = policyPath;
20
+ this.name = 'PolicyFileNotFoundError';
21
+ }
22
+ }
23
+ export class PolicyYamlParseError extends Error {
24
+ policyPath;
25
+ yamlErrors;
26
+ constructor(message, policyPath, yamlErrors) {
27
+ super(message);
28
+ this.policyPath = policyPath;
29
+ this.yamlErrors = yamlErrors;
30
+ this.name = 'PolicyYamlParseError';
31
+ }
32
+ }
33
+ export function loadPolicyFile(policyPath) {
34
+ let source;
35
+ try {
36
+ source = readFileSync(policyPath, 'utf-8');
37
+ }
38
+ catch (err) {
39
+ const e = err;
40
+ if (e.code === 'ENOENT')
41
+ throw new PolicyFileNotFoundError(policyPath);
42
+ throw err;
43
+ }
44
+ const lineCounter = new LineCounter();
45
+ const doc = parseDocument(source, { lineCounter, keepSourceTokens: true });
46
+ if (doc.errors.length > 0) {
47
+ const yamlErrors = doc.errors.map((e) => {
48
+ const pos = e.pos?.[0];
49
+ const loc = pos !== undefined ? lineCounter.linePos(pos) : undefined;
50
+ return { line: loc?.line, col: loc?.col, message: e.message };
51
+ });
52
+ throw new PolicyYamlParseError(doc.errors[0].message, policyPath, yamlErrors);
53
+ }
54
+ return {
55
+ path: policyPath,
56
+ source,
57
+ doc,
58
+ lineCounter,
59
+ data: doc.toJS({ maxAliasCount: 100 }),
60
+ };
61
+ }
@@ -0,0 +1,67 @@
1
+ import { isMap, isScalar, parseDocument, LineCounter } from 'yaml';
2
+ import { validateLoadedPolicy } from './validate.js';
3
+ export class PolicyMigrationError extends Error {
4
+ code;
5
+ constructor(message, code) {
6
+ super(message);
7
+ this.code = code;
8
+ this.name = 'PolicyMigrationError';
9
+ }
10
+ }
11
+ const MIGRATION_CHAIN = [];
12
+ function bumpVersionScalar(doc, target) {
13
+ if (!isMap(doc.contents)) {
14
+ throw new PolicyMigrationError('policy root must be a YAML mapping (got null or an array)', 'invalid-shape');
15
+ }
16
+ const pair = doc.contents.items.find((p) => isScalar(p.key) && p.key.value === 'version');
17
+ if (!pair || !isScalar(pair.value)) {
18
+ throw new PolicyMigrationError('policy has no `version` scalar to migrate; add `version: "0.2"` (or `"0.1"`) and retry', 'no-version-field');
19
+ }
20
+ pair.value.value = target;
21
+ }
22
+ function findPlan(from, to) {
23
+ const chain = [];
24
+ let cur = from;
25
+ while (cur !== to) {
26
+ const step = MIGRATION_CHAIN.find((p) => p.fromVersion === cur);
27
+ if (!step) {
28
+ throw new PolicyMigrationError(`no migration path from v${from} to v${to} (missing step at v${cur})`, 'no-path');
29
+ }
30
+ chain.push(step);
31
+ cur = step.toVersion;
32
+ }
33
+ return chain;
34
+ }
35
+ export function planMigration(loaded, from, to) {
36
+ if (from === to) {
37
+ const precheck = validateLoadedPolicy(loaded);
38
+ return { changed: false, fromVersion: from, toVersion: to, nextSource: loaded.source, precheck };
39
+ }
40
+ const plan = findPlan(from, to);
41
+ // Round-trip through source instead of Document.clone(): keeps comments +
42
+ // anchors intact, works across yaml library versions, and leaves the
43
+ // caller's `loaded.doc` untouched.
44
+ const nextLineCounter = new LineCounter();
45
+ const clone = parseDocument(loaded.source, {
46
+ lineCounter: nextLineCounter,
47
+ keepSourceTokens: true,
48
+ });
49
+ for (const step of plan)
50
+ step.migrate(clone);
51
+ const nextSource = String(clone);
52
+ // Re-parse after serialization so `doc` and `source` stay in sync for the
53
+ // validator's line/col mapping.
54
+ const reLineCounter = new LineCounter();
55
+ const reDoc = parseDocument(nextSource, {
56
+ lineCounter: reLineCounter,
57
+ keepSourceTokens: true,
58
+ });
59
+ const precheck = validateLoadedPolicy({
60
+ path: loaded.path,
61
+ source: nextSource,
62
+ doc: reDoc,
63
+ lineCounter: reLineCounter,
64
+ data: reDoc.toJS({ maxAliasCount: 100 }),
65
+ });
66
+ return { changed: true, fromVersion: from, toVersion: to, nextSource, precheck };
67
+ }
@@ -0,0 +1,302 @@
1
+ {
2
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
3
+ "$id": "https://schemas.openclaw.ai/switchbot/v0.2/policy.json",
4
+ "title": "OpenClaw SwitchBot policy v0.2",
5
+ "description": "Tightens the `automation.rules[]` shape that v0.1 left as a loose `array of object`. Validator reads this when the policy file's top-level `version` field is \"0.2\". See docs/design/phase4-rules-schema.md for the field-level rationale.",
6
+ "type": "object",
7
+ "additionalProperties": false,
8
+ "required": ["version"],
9
+ "properties": {
10
+ "version": {
11
+ "type": "string",
12
+ "const": "0.2",
13
+ "description": "Policy schema version. Will migrate 0.1 -> 0.2 in place via `switchbot policy migrate`."
14
+ },
15
+
16
+ "aliases": {
17
+ "type": ["object", "null"],
18
+ "description": "Unchanged from v0.1.",
19
+ "additionalProperties": {
20
+ "type": "string",
21
+ "pattern": "^[A-Z0-9]{2,}-[A-Z0-9-]+$"
22
+ }
23
+ },
24
+
25
+ "confirmations": {
26
+ "type": ["object", "null"],
27
+ "additionalProperties": false,
28
+ "description": "Unchanged from v0.1.",
29
+ "properties": {
30
+ "always_confirm": {
31
+ "type": ["array", "null"],
32
+ "uniqueItems": true,
33
+ "items": { "type": "string", "minLength": 1 }
34
+ },
35
+ "never_confirm": {
36
+ "type": ["array", "null"],
37
+ "uniqueItems": true,
38
+ "items": {
39
+ "type": "string",
40
+ "minLength": 1,
41
+ "not": {
42
+ "enum": ["lock", "unlock", "deleteWebhook", "deleteScene", "factoryReset"]
43
+ }
44
+ }
45
+ }
46
+ }
47
+ },
48
+
49
+ "quiet_hours": {
50
+ "type": ["object", "null"],
51
+ "additionalProperties": false,
52
+ "description": "Unchanged from v0.1.",
53
+ "properties": {
54
+ "start": { "type": "string", "pattern": "^([01]\\d|2[0-3]):[0-5]\\d$" },
55
+ "end": { "type": "string", "pattern": "^([01]\\d|2[0-3]):[0-5]\\d$" }
56
+ },
57
+ "dependentRequired": { "start": ["end"], "end": ["start"] }
58
+ },
59
+
60
+ "audit": {
61
+ "type": ["object", "null"],
62
+ "additionalProperties": false,
63
+ "properties": {
64
+ "log_path": { "type": "string", "minLength": 1 },
65
+ "retention": { "type": "string", "pattern": "^(never|\\d+[dwm])$" }
66
+ }
67
+ },
68
+
69
+ "automation": {
70
+ "type": ["object", "null"],
71
+ "description": "In v0.2, `rules[]` gets a real shape. `enabled: false` still fully disables the engine regardless of rules defined.",
72
+ "additionalProperties": false,
73
+ "properties": {
74
+ "enabled": {
75
+ "type": "boolean",
76
+ "default": false
77
+ },
78
+ "rules": {
79
+ "type": ["array", "null"],
80
+ "items": { "$ref": "#/$defs/rule" }
81
+ }
82
+ }
83
+ },
84
+
85
+ "cli": {
86
+ "type": ["object", "null"],
87
+ "additionalProperties": false,
88
+ "description": "Unchanged from v0.1.",
89
+ "properties": {
90
+ "profile": { "type": "string", "minLength": 1, "default": "default" },
91
+ "cache_ttl": { "type": "string", "pattern": "^\\d+[smh]$" }
92
+ }
93
+ }
94
+ },
95
+
96
+ "$defs": {
97
+ "rule": {
98
+ "type": "object",
99
+ "additionalProperties": false,
100
+ "required": ["name", "when", "then"],
101
+ "properties": {
102
+ "name": {
103
+ "type": "string",
104
+ "minLength": 1,
105
+ "description": "Human label used in audit log and dry-run output. Unique per policy file."
106
+ },
107
+ "enabled": {
108
+ "type": "boolean",
109
+ "default": true,
110
+ "description": "Lets you disable a single rule without deleting it."
111
+ },
112
+ "when": { "$ref": "#/$defs/trigger" },
113
+ "conditions": {
114
+ "type": ["array", "null"],
115
+ "description": "Optional AND-joined gates evaluated after the trigger matches. All must pass for the rule to fire.",
116
+ "items": { "$ref": "#/$defs/condition" }
117
+ },
118
+ "then": {
119
+ "type": "array",
120
+ "minItems": 1,
121
+ "description": "One or more actions executed in order. If any action fails, the remainder still runs (policy log records each result).",
122
+ "items": { "$ref": "#/$defs/action" }
123
+ },
124
+ "throttle": {
125
+ "type": ["object", "null"],
126
+ "additionalProperties": false,
127
+ "description": "Optional rate limit. Applied per-rule, keyed by the trigger's deviceId when present.",
128
+ "properties": {
129
+ "max_per": {
130
+ "type": "string",
131
+ "pattern": "^\\d+[smh]$",
132
+ "description": "Minimum spacing between fires, e.g. \"10m\". Later triggers inside the window are suppressed and audited."
133
+ }
134
+ },
135
+ "required": ["max_per"]
136
+ },
137
+ "dry_run": {
138
+ "type": "boolean",
139
+ "default": true,
140
+ "description": "When true, actions write to the audit log (kind=dry-run) but do NOT hit the SwitchBot API."
141
+ }
142
+ }
143
+ },
144
+
145
+ "trigger": {
146
+ "type": "object",
147
+ "oneOf": [
148
+ { "$ref": "#/$defs/triggerMqtt" },
149
+ { "$ref": "#/$defs/triggerCron" },
150
+ { "$ref": "#/$defs/triggerWebhook" }
151
+ ]
152
+ },
153
+
154
+ "triggerMqtt": {
155
+ "type": "object",
156
+ "additionalProperties": false,
157
+ "required": ["source", "event"],
158
+ "properties": {
159
+ "source": { "const": "mqtt" },
160
+ "event": {
161
+ "type": "string",
162
+ "description": "Event type from `switchbot events mqtt-tail --json`, e.g. `motion.detected`, `contact.opened`, `button.pressed`, `device.shadow`."
163
+ },
164
+ "device": {
165
+ "type": "string",
166
+ "description": "Optional filter by deviceId or alias. Matches the trigger's `deviceId` payload field."
167
+ }
168
+ }
169
+ },
170
+
171
+ "triggerCron": {
172
+ "type": "object",
173
+ "additionalProperties": false,
174
+ "required": ["source", "schedule"],
175
+ "properties": {
176
+ "source": { "const": "cron" },
177
+ "schedule": {
178
+ "type": "string",
179
+ "description": "Standard 5-field cron (minute hour dom month dow). Interpreted in local system timezone."
180
+ },
181
+ "days": {
182
+ "type": "array",
183
+ "description": "Optional weekday filter applied after the cron expression fires. Values are full-name or 3-letter day abbreviations (case-insensitive): mon/monday … sun/sunday. When omitted, all days pass.",
184
+ "uniqueItems": true,
185
+ "minItems": 1,
186
+ "items": {
187
+ "type": "string",
188
+ "enum": ["mon", "tue", "wed", "thu", "fri", "sat", "sun",
189
+ "monday", "tuesday", "wednesday", "thursday", "friday", "saturday", "sunday"]
190
+ }
191
+ }
192
+ }
193
+ },
194
+
195
+ "triggerWebhook": {
196
+ "type": "object",
197
+ "additionalProperties": false,
198
+ "required": ["source", "path"],
199
+ "properties": {
200
+ "source": { "const": "webhook" },
201
+ "path": {
202
+ "type": "string",
203
+ "pattern": "^/[a-z0-9/_-]+$",
204
+ "description": "Local HTTP path the rule engine listens on. Auth + transport are configured elsewhere (Phase 3)."
205
+ }
206
+ }
207
+ },
208
+
209
+ "condition": {
210
+ "description": "Predicate evaluated after the trigger matches. Leaf shapes: time_between, device_state. Composites: all (AND), any (OR), not (negation). `additionalProperties: false` lives on each `oneOf` branch so keys are validated per-shape.",
211
+ "oneOf": [
212
+ {
213
+ "type": "object",
214
+ "additionalProperties": false,
215
+ "required": ["time_between"],
216
+ "properties": {
217
+ "time_between": {
218
+ "type": "array",
219
+ "items": { "type": "string", "pattern": "^([01]\\d|2[0-3]):[0-5]\\d$" },
220
+ "minItems": 2,
221
+ "maxItems": 2,
222
+ "description": "Two HH:MM strings: [start, end]. End-before-start means overnight window."
223
+ }
224
+ }
225
+ },
226
+ {
227
+ "type": "object",
228
+ "additionalProperties": false,
229
+ "required": ["device", "field", "op", "value"],
230
+ "properties": {
231
+ "device": { "type": "string", "description": "deviceId or alias" },
232
+ "field": { "type": "string", "description": "status field name, e.g. `online`, `power`, `brightness`" },
233
+ "op": { "enum": ["==", "!=", "<", ">", "<=", ">="] },
234
+ "value": { "description": "Literal to compare against. Booleans, strings, numbers." }
235
+ }
236
+ },
237
+ {
238
+ "type": "object",
239
+ "additionalProperties": false,
240
+ "required": ["all"],
241
+ "properties": {
242
+ "all": {
243
+ "type": "array",
244
+ "minItems": 1,
245
+ "items": { "$ref": "#/$defs/condition" },
246
+ "description": "All sub-conditions must be true (logical AND)."
247
+ }
248
+ }
249
+ },
250
+ {
251
+ "type": "object",
252
+ "additionalProperties": false,
253
+ "required": ["any"],
254
+ "properties": {
255
+ "any": {
256
+ "type": "array",
257
+ "minItems": 1,
258
+ "items": { "$ref": "#/$defs/condition" },
259
+ "description": "At least one sub-condition must be true (logical OR)."
260
+ }
261
+ }
262
+ },
263
+ {
264
+ "type": "object",
265
+ "additionalProperties": false,
266
+ "required": ["not"],
267
+ "properties": {
268
+ "not": {
269
+ "$ref": "#/$defs/condition",
270
+ "description": "Negates the sub-condition."
271
+ }
272
+ }
273
+ }
274
+ ]
275
+ },
276
+
277
+ "action": {
278
+ "type": "object",
279
+ "additionalProperties": false,
280
+ "required": ["command"],
281
+ "properties": {
282
+ "command": {
283
+ "type": "string",
284
+ "description": "A CLI invocation fragment, e.g. `devices command <id> turnOn`. The engine prepends `switchbot` and appends `--audit-log`."
285
+ },
286
+ "device": {
287
+ "type": "string",
288
+ "description": "deviceId or alias resolved before building the command. Substituted into the `<id>` slot."
289
+ },
290
+ "args": {
291
+ "type": ["object", "null"],
292
+ "description": "Extra key/value pairs rendered as `--key value` flags."
293
+ },
294
+ "on_error": {
295
+ "enum": ["continue", "stop"],
296
+ "default": "continue",
297
+ "description": "If this action fails, should the rule keep executing its remaining `then[]` entries?"
298
+ }
299
+ }
300
+ }
301
+ }
302
+ }