@switchbot/openapi-cli 2.6.3 → 2.7.2

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.
@@ -1,10 +1,11 @@
1
- import { printJson, isJsonMode, handleError, UsageError } from '../utils/output.js';
1
+ import { printJson, isJsonMode, handleError, UsageError, emitStreamHeader } from '../utils/output.js';
2
2
  import { fetchDeviceStatus } from '../lib/devices.js';
3
3
  import { getCachedDevice } from '../devices/cache.js';
4
4
  import { parseDurationToMs, getFields } from '../utils/flags.js';
5
5
  import { intArg, durationArg, stringArg } from '../utils/arg-parsers.js';
6
6
  import { createClient } from '../api/client.js';
7
7
  import { resolveDeviceId } from '../utils/name-resolver.js';
8
+ import { resolveFieldList, listAllCanonical } from '../schema/field-aliases.js';
8
9
  const DEFAULT_INTERVAL_MS = 30_000;
9
10
  const MIN_INTERVAL_MS = 1_000;
10
11
  function diff(prev, next, fields) {
@@ -101,7 +102,15 @@ Examples:
101
102
  maxTicks = Math.floor(n);
102
103
  }
103
104
  const forMs = options.for ? parseDurationToMs(options.for) : null;
104
- const fields = getFields() ?? null;
105
+ const rawFields = getFields() ?? null;
106
+ // Resolve aliases upfront against the static canonical registry.
107
+ // Validating here lets UsageError exit the command before any
108
+ // polling starts, and keeps mid-loop error handling free of
109
+ // "misuse" concerns. Unknown fields that are not registered as
110
+ // aliases but happen to match an API key pass through unchanged.
111
+ const fields = rawFields
112
+ ? resolveFieldList(rawFields, listAllCanonical())
113
+ : null;
105
114
  const ac = new AbortController();
106
115
  const onSig = () => ac.abort();
107
116
  process.on('SIGINT', onSig);
@@ -109,6 +118,10 @@ Examples:
109
118
  const forTimer = forMs !== null && forMs > 0
110
119
  ? setTimeout(() => ac.abort(), forMs)
111
120
  : null;
121
+ // P7: streaming JSON contract — first line under --json is the
122
+ // stream header so consumers can route by eventKind/cadence.
123
+ if (isJsonMode())
124
+ emitStreamHeader({ eventKind: 'tick', cadence: 'poll' });
112
125
  try {
113
126
  const prev = new Map();
114
127
  const client = createClient();
package/dist/config.js CHANGED
@@ -3,6 +3,7 @@ import path from 'node:path';
3
3
  import os from 'node:os';
4
4
  import { getConfigPath } from './utils/flags.js';
5
5
  import { getActiveProfile } from './lib/request-context.js';
6
+ import { emitJsonError, isJsonMode } from './utils/output.js';
6
7
  function sanitizeOptionalString(v) {
7
8
  if (typeof v !== 'string')
8
9
  return undefined;
@@ -51,20 +52,36 @@ export function loadConfig() {
51
52
  const hint = profile
52
53
  ? `No credentials configured for profile "${profile}". Run: switchbot --profile ${profile} config set-token <token> <secret>`
53
54
  : 'No credentials configured. Run: switchbot config set-token <token> <secret>';
54
- console.error(`${hint}\nOr set SWITCHBOT_TOKEN and SWITCHBOT_SECRET environment variables.`);
55
+ const msg = `${hint}\nOr set SWITCHBOT_TOKEN and SWITCHBOT_SECRET environment variables.`;
56
+ if (isJsonMode()) {
57
+ emitJsonError({ code: 1, kind: 'runtime', message: hint });
58
+ }
59
+ else {
60
+ console.error(msg);
61
+ }
55
62
  process.exit(1);
56
63
  }
57
64
  try {
58
65
  const raw = fs.readFileSync(file, 'utf-8');
59
66
  const cfg = JSON.parse(raw);
60
67
  if (!cfg.token || !cfg.secret) {
61
- console.error('Invalid config format. Please re-run: switchbot config set-token');
68
+ if (isJsonMode()) {
69
+ emitJsonError({ code: 1, kind: 'runtime', message: 'Invalid config format. Please re-run: switchbot config set-token' });
70
+ }
71
+ else {
72
+ console.error('Invalid config format. Please re-run: switchbot config set-token');
73
+ }
62
74
  process.exit(1);
63
75
  }
64
76
  return cfg;
65
77
  }
66
78
  catch {
67
- console.error('Failed to read config file. Please re-run: switchbot config set-token');
79
+ if (isJsonMode()) {
80
+ emitJsonError({ code: 1, kind: 'runtime', message: 'Failed to read config file. Please re-run: switchbot config set-token' });
81
+ }
82
+ else {
83
+ console.error('Failed to read config file. Please re-run: switchbot config set-token');
84
+ }
68
85
  process.exit(1);
69
86
  }
70
87
  }
@@ -156,36 +173,63 @@ export function readProfileMeta(profile) {
156
173
  }
157
174
  }
158
175
  export function showConfig() {
176
+ const summary = getConfigSummary();
177
+ if (summary.source === 'env') {
178
+ console.log('Credential source: environment variables');
179
+ console.log(`token : ${summary.token ?? ''}`);
180
+ console.log(`secret: ${summary.secret ?? ''}`);
181
+ return;
182
+ }
183
+ if (summary.source === 'none') {
184
+ console.log('No credentials configured');
185
+ return;
186
+ }
187
+ if (summary.source === 'invalid') {
188
+ console.error('Failed to read config file');
189
+ return;
190
+ }
191
+ console.log(`Credential source: ${summary.path}`);
192
+ if (summary.label)
193
+ console.log(`label : ${summary.label}`);
194
+ if (summary.description)
195
+ console.log(`desc : ${summary.description}`);
196
+ console.log(`token : ${summary.token ?? ''}`);
197
+ console.log(`secret: ${summary.secret ?? ''}`);
198
+ if (summary.dailyCap)
199
+ console.log(`limits: dailyCap=${summary.dailyCap}`);
200
+ if (summary.defaultFlags?.length)
201
+ console.log(`defaults: ${summary.defaultFlags.join(' ')}`);
202
+ }
203
+ export function getConfigSummary() {
159
204
  const envToken = process.env.SWITCHBOT_TOKEN;
160
205
  const envSecret = process.env.SWITCHBOT_SECRET;
161
206
  if (envToken && envSecret) {
162
- console.log('Credential source: environment variables');
163
- console.log(`token : ${maskCredential(envToken)}`);
164
- console.log(`secret: ${maskSecret(envSecret)}`);
165
- return;
207
+ return {
208
+ source: 'env',
209
+ token: maskCredential(envToken),
210
+ secret: maskSecret(envSecret),
211
+ };
166
212
  }
167
213
  const file = configFilePath();
168
214
  if (!fs.existsSync(file)) {
169
- console.log('No credentials configured');
170
- return;
215
+ return { source: 'none' };
171
216
  }
172
217
  try {
173
218
  const raw = fs.readFileSync(file, 'utf-8');
174
219
  const cfg = JSON.parse(raw);
175
- console.log(`Credential source: ${file}`);
176
- if (cfg.label)
177
- console.log(`label : ${cfg.label}`);
178
- if (cfg.description)
179
- console.log(`desc : ${cfg.description}`);
180
- console.log(`token : ${maskCredential(cfg.token)}`);
181
- console.log(`secret: ${maskSecret(cfg.secret)}`);
182
- if (cfg.limits?.dailyCap)
183
- console.log(`limits: dailyCap=${cfg.limits.dailyCap}`);
184
- if (cfg.defaults?.flags?.length)
185
- console.log(`defaults: ${cfg.defaults.flags.join(' ')}`);
220
+ return {
221
+ source: 'file',
222
+ path: file,
223
+ label: cfg.label,
224
+ description: cfg.description,
225
+ token: maskCredential(cfg.token),
226
+ secret: maskSecret(cfg.secret),
227
+ dailyCap: cfg.limits?.dailyCap,
228
+ defaultFlags: cfg.defaults?.flags,
229
+ };
186
230
  }
187
231
  catch {
188
- console.error('Failed to read config file');
232
+ return { source: 'invalid', path: file };
189
233
  }
190
234
  }
191
235
  function maskCredential(token) {
@@ -7,14 +7,99 @@
7
7
  * - CommandSpec.idempotent: repeat-safe — calling it N times ends in the
8
8
  * same state as calling it once (turnOn, setBrightness 50). Agents can
9
9
  * retry these freely. Counter-examples: toggle, press, volumeAdd.
10
- * - CommandSpec.destructive: causes a real-world effect that is hard or
11
- * unsafe to reverse (unlock, garage open, deleteKey). UIs and agents
12
- * should require explicit confirmation before issuing these.
10
+ * - CommandSpec.safetyTier: explicit action safety classification. See
11
+ * SafetyTier for the 5-tier enum. Built-in entries set this on the
12
+ * destructive tier; other tiers are derived (see deriveSafetyTier).
13
+ * - CommandSpec.destructive (deprecated, v3.0 removal): legacy boolean
14
+ * that maps to safetyTier === 'destructive'. Still accepted in
15
+ * ~/.switchbot/catalog.json overlays and derived into safetyTier.
13
16
  * - DeviceCatalogEntry.role: functional grouping for filter/search
14
17
  * ("all lighting", "all security"). Does not affect API behavior.
15
18
  * - DeviceCatalogEntry.readOnly: the device has no control commands; it
16
19
  * can only be queried via 'devices status'.
17
20
  */
21
+ /**
22
+ * Catalog shape version. Bump when any of the exported interfaces
23
+ * (CommandSpec / DeviceCatalogEntry / SafetyTier) gain/lose/rename a
24
+ * load-bearing field. The agent-bootstrap payload's schemaVersion must
25
+ * stay pinned to this value; `doctor` fails the `catalog-schema` check
26
+ * when they drift.
27
+ */
28
+ export const CATALOG_SCHEMA_VERSION = '1.0';
29
+ /**
30
+ * Human-readable descriptions for common status fields. Populated from
31
+ * the SwitchBot API v1.1 docs. Used by deriveStatusQueries() so every
32
+ * query has a meaningful description even when the entry itself only
33
+ * declares the field name.
34
+ */
35
+ const STATUS_FIELD_DESCRIPTIONS = {
36
+ power: 'Power state (on/off)',
37
+ battery: 'Battery percentage (0-100)',
38
+ version: 'Firmware version string',
39
+ temperature: 'Ambient temperature (°C)',
40
+ humidity: 'Ambient humidity (% RH)',
41
+ CO2: 'CO2 concentration (ppm)',
42
+ brightness: 'Current brightness (0-100)',
43
+ color: 'Current RGB color (r:g:b)',
44
+ colorTemperature: 'Color temperature in Kelvin',
45
+ mode: 'Operating mode',
46
+ deviceMode: 'Hardware mode (Bot-specific)',
47
+ lockState: 'Lock state (locked/unlocked)',
48
+ doorState: 'Door contact state (open/closed)',
49
+ calibrate: 'Calibration status',
50
+ moving: 'Motion in progress (boolean)',
51
+ slidePosition: 'Slide position (0-100)',
52
+ group: 'Multi-device group membership',
53
+ direction: 'Tilt direction',
54
+ voltage: 'Line voltage',
55
+ electricCurrent: 'Instantaneous current draw',
56
+ electricityOfDay: 'kWh consumed today',
57
+ usedElectricity: 'Cumulative kWh',
58
+ useTime: 'Total runtime (seconds)',
59
+ weight: 'Load / weight reading',
60
+ switchStatus: 'Relay state (integer encoded)',
61
+ switch1Status: 'Channel 1 relay state',
62
+ switch2Status: 'Channel 2 relay state',
63
+ workingStatus: 'Device working status (vacuum/purifier)',
64
+ onlineStatus: 'Online / offline (string)',
65
+ online: 'Online / offline (boolean or int)',
66
+ taskType: 'Current task identifier',
67
+ nightStatus: 'Night-mode status',
68
+ oscillation: 'Horizontal oscillation on/off',
69
+ verticalOscillation: 'Vertical oscillation on/off',
70
+ chargingStatus: 'Charging (boolean)',
71
+ fanSpeed: 'Current fan speed level',
72
+ nebulizationEfficiency: 'Humidifier mist level',
73
+ childLock: 'Child-lock engaged',
74
+ sound: 'Beep / audio feedback enabled',
75
+ lackWater: 'Water tank low (boolean)',
76
+ filterElement: 'Filter life remaining',
77
+ auto: 'Auto mode enabled',
78
+ targetTemperature: 'Thermostat target temperature',
79
+ moveDetected: 'Motion detected (boolean)',
80
+ openState: 'Contact sensor open/closed',
81
+ status: 'Device-specific status word',
82
+ lightLevel: 'Ambient light level',
83
+ };
84
+ /**
85
+ * P11: derive the read-only query list for an entry. If the entry has
86
+ * explicit `statusQueries`, return them as-is; otherwise synthesize one
87
+ * ReadOnlyQuerySpec per `statusFields` entry, all keyed to the `status`
88
+ * endpoint. IR-category entries have no status channel so return [].
89
+ */
90
+ export function deriveStatusQueries(entry) {
91
+ if (entry.statusQueries && entry.statusQueries.length > 0)
92
+ return entry.statusQueries;
93
+ if (entry.category === 'ir')
94
+ return [];
95
+ const fields = entry.statusFields ?? [];
96
+ return fields.map((f) => ({
97
+ field: f,
98
+ description: STATUS_FIELD_DESCRIPTIONS[f] ?? `${f} (see API docs)`,
99
+ endpoint: 'status',
100
+ safetyTier: 'read',
101
+ }));
102
+ }
18
103
  // ---- Command fragments (reused across entries) -------------------------
19
104
  const onOff = [
20
105
  { command: 'turnOn', parameter: '—', description: 'Power on', idempotent: true },
@@ -64,7 +149,7 @@ export const DEVICE_CATALOG = [
64
149
  aliases: ['Smart Lock Pro'],
65
150
  commands: [
66
151
  { command: 'lock', parameter: '—', description: 'Lock the door', idempotent: true },
67
- { command: 'unlock', parameter: '—', description: 'Unlock the door', idempotent: true, destructive: true, destructiveReason: 'Physically unlocks the door — anyone nearby can open it.' },
152
+ { command: 'unlock', parameter: '—', description: 'Unlock the door', idempotent: true, safetyTier: 'destructive', safetyReason: 'Physically unlocks the door — anyone nearby can open it.' },
68
153
  { command: 'deadbolt', parameter: '—', description: 'Pro only: engage deadbolt', idempotent: true },
69
154
  ],
70
155
  statusFields: ['battery', 'version', 'lockState', 'doorState', 'calibrate'],
@@ -76,7 +161,7 @@ export const DEVICE_CATALOG = [
76
161
  role: 'security',
77
162
  commands: [
78
163
  { command: 'lock', parameter: '—', description: 'Lock the door', idempotent: true },
79
- { command: 'unlock', parameter: '—', description: 'Unlock the door', idempotent: true, destructive: true, destructiveReason: 'Physically unlocks the door — anyone nearby can open it.' },
164
+ { command: 'unlock', parameter: '—', description: 'Unlock the door', idempotent: true, safetyTier: 'destructive', safetyReason: 'Physically unlocks the door — anyone nearby can open it.' },
80
165
  ],
81
166
  statusFields: ['battery', 'version', 'lockState', 'doorState', 'calibrate'],
82
167
  },
@@ -87,7 +172,7 @@ export const DEVICE_CATALOG = [
87
172
  role: 'security',
88
173
  commands: [
89
174
  { command: 'lock', parameter: '—', description: 'Lock the door', idempotent: true },
90
- { command: 'unlock', parameter: '—', description: 'Unlock the door', idempotent: true, destructive: true, destructiveReason: 'Physically unlocks the door — anyone nearby can open it.' },
175
+ { command: 'unlock', parameter: '—', description: 'Unlock the door', idempotent: true, safetyTier: 'destructive', safetyReason: 'Physically unlocks the door — anyone nearby can open it.' },
91
176
  { command: 'deadbolt', parameter: '—', description: 'Engage deadbolt', idempotent: true },
92
177
  ],
93
178
  statusFields: ['battery', 'version', 'lockState', 'doorState', 'calibrate'],
@@ -219,7 +304,7 @@ export const DEVICE_CATALOG = [
219
304
  category: 'physical',
220
305
  description: 'Entry-level robot vacuum with start/stop/dock and four suction power levels.',
221
306
  role: 'cleaning',
222
- aliases: ['Robot Vacuum Cleaner S1 Plus', 'K10+'],
307
+ aliases: ['Robot Vacuum', 'Robot Vacuum Cleaner S1 Plus', 'K10+'],
223
308
  commands: [
224
309
  { command: 'start', parameter: '—', description: 'Start cleaning', idempotent: true },
225
310
  { command: 'stop', parameter: '—', description: 'Stop cleaning', idempotent: true },
@@ -306,8 +391,8 @@ export const DEVICE_CATALOG = [
306
391
  description: 'Cloud-connected garage door controller; turnOn opens and turnOff closes the door.',
307
392
  role: 'security',
308
393
  commands: [
309
- { command: 'turnOn', parameter: '—', description: 'Open the garage door', idempotent: true, destructive: true, destructiveReason: 'Opens the garage door — anyone nearby can enter the space.' },
310
- { command: 'turnOff', parameter: '—', description: 'Close the garage door', idempotent: true, destructive: true, destructiveReason: 'Closes the garage door — verify no person or obstacle is in the way.' },
394
+ { command: 'turnOn', parameter: '—', description: 'Open the garage door', idempotent: true, safetyTier: 'destructive', safetyReason: 'Opens the garage door — anyone nearby can enter the space.' },
395
+ { command: 'turnOff', parameter: '—', description: 'Close the garage door', idempotent: true, safetyTier: 'destructive', safetyReason: 'Closes the garage door — verify no person or obstacle is in the way.' },
311
396
  ],
312
397
  statusFields: ['switchStatus', 'version', 'online'],
313
398
  },
@@ -329,8 +414,8 @@ export const DEVICE_CATALOG = [
329
414
  role: 'security',
330
415
  aliases: ['Keypad Touch'],
331
416
  commands: [
332
- { command: 'createKey', parameter: '\'{"name":"...","type":"permanent|timeLimit|disposable|urgent","password":"6-12 digits","startTime":<s>,"endTime":<s>}\'', description: 'Create a passcode (async; result via webhook)', idempotent: false, destructive: true, destructiveReason: 'Provisions a new access credential — anyone with this passcode can unlock the door.' },
333
- { command: 'deleteKey', parameter: '\'{"id":<passcode_id>}\'', description: 'Delete a passcode (async; result via webhook)', idempotent: true, destructive: true, destructiveReason: 'Permanently removes a passcode — the holder immediately loses door access.' },
417
+ { command: 'createKey', parameter: '\'{"name":"...","type":"permanent|timeLimit|disposable|urgent","password":"6-12 digits","startTime":<s>,"endTime":<s>}\'', description: 'Create a passcode (async; result via webhook)', idempotent: false, safetyTier: 'destructive', safetyReason: 'Provisions a new access credential — anyone with this passcode can unlock the door.' },
418
+ { command: 'deleteKey', parameter: '\'{"id":<passcode_id>}\'', description: 'Delete a passcode (async; result via webhook)', idempotent: true, safetyTier: 'destructive', safetyReason: 'Permanently removes a passcode — the holder immediately loses door access.' },
334
419
  ],
335
420
  statusFields: ['version'],
336
421
  },
@@ -538,13 +623,41 @@ export function findCatalogEntry(query) {
538
623
  return matches[0];
539
624
  return matches;
540
625
  }
626
+ /**
627
+ * Derive the safety tier for a catalog command, honouring an explicit
628
+ * `safetyTier` when present and falling back to heuristic inference.
629
+ *
630
+ * The inference order is:
631
+ * 1. Explicit `spec.safetyTier`.
632
+ * 2. Legacy `spec.destructive: true` → `'destructive'` (overlay compat).
633
+ * 3. IR context (customize command OR entry.category === 'ir')
634
+ * → `'ir-fire-forget'`.
635
+ * 4. Default → `'mutation'`.
636
+ */
637
+ export function deriveSafetyTier(spec, entry) {
638
+ if (spec.safetyTier)
639
+ return spec.safetyTier;
640
+ if (spec.destructive)
641
+ return 'destructive';
642
+ if (spec.commandType === 'customize')
643
+ return 'ir-fire-forget';
644
+ if (entry?.category === 'ir')
645
+ return 'ir-fire-forget';
646
+ return 'mutation';
647
+ }
648
+ /** Read the safety reason for a command, with fallback to the legacy field. */
649
+ export function getCommandSafetyReason(spec) {
650
+ return spec.safetyReason ?? spec.destructiveReason ?? null;
651
+ }
541
652
  /**
542
653
  * Pick up to 3 non-destructive, idempotent commands an agent can safely invoke
543
654
  * to explore or exercise a device. Used by `devices describe --json` to hint
544
655
  * at concrete next steps.
545
656
  */
546
657
  export function suggestedActions(entry) {
547
- const safe = entry.commands.filter((c) => c.idempotent === true && !c.destructive && c.commandType !== 'customize');
658
+ const safe = entry.commands.filter((c) => c.idempotent === true &&
659
+ deriveSafetyTier(c, entry) !== 'destructive' &&
660
+ c.commandType !== 'customize');
548
661
  const picks = [];
549
662
  const seen = new Set();
550
663
  for (const c of safe) {
@@ -0,0 +1,270 @@
1
+ /**
2
+ * Declarative metadata for non-device resources exposed by the SwitchBot API:
3
+ * scenes, webhooks, and keypad credentials ("keys").
4
+ *
5
+ * Consumed by `capabilities --json` and `schema export` so AI agents can
6
+ * discover these surfaces the same way they discover device commands.
7
+ *
8
+ * Scope:
9
+ * - Descriptive metadata only (no runtime execution — CLI/MCP handlers stay
10
+ * source-of-truth for behavior).
11
+ * - Webhook event list is derived from the device catalog and is advisory —
12
+ * not every SwitchBot device actually pushes every listed event; refer to
13
+ * the SwitchBot webhook docs for authoritative shapes.
14
+ */
15
+ const COMMON_WEBHOOK_FIELDS = [
16
+ { name: 'deviceType', type: 'string', description: 'SwitchBot device type string', example: 'WoMeter' },
17
+ { name: 'deviceMac', type: 'string', description: 'Bluetooth MAC address (uppercase, colon-separated)', example: 'AA:BB:CC:11:22:33' },
18
+ { name: 'timeOfSample', type: 'timestamp', description: 'Millisecond Unix timestamp when the sample was taken', example: 1700000000000 },
19
+ ];
20
+ export const RESOURCE_CATALOG = {
21
+ scenes: {
22
+ description: 'Manual scenes (IFTTT-style rules) authored in the SwitchBot app. Execution is fire-and-forget from the cloud — side-effects happen on the user\'s devices.',
23
+ operations: [
24
+ {
25
+ verb: 'list',
26
+ method: 'GET',
27
+ endpoint: '/v1.1/scenes',
28
+ params: [],
29
+ safetyTier: 'read',
30
+ },
31
+ {
32
+ verb: 'execute',
33
+ method: 'POST',
34
+ endpoint: '/v1.1/scenes/{sceneId}/execute',
35
+ params: [{ name: 'sceneId', required: true, type: 'string' }],
36
+ safetyTier: 'mutation',
37
+ },
38
+ {
39
+ verb: 'describe',
40
+ method: 'GET',
41
+ endpoint: '/v1.1/scenes/{sceneId}',
42
+ params: [{ name: 'sceneId', required: true, type: 'string' }],
43
+ safetyTier: 'read',
44
+ },
45
+ ],
46
+ },
47
+ webhooks: {
48
+ endpoints: [
49
+ {
50
+ verb: 'setup',
51
+ method: 'POST',
52
+ path: '/v1.1/webhook/setupWebhook',
53
+ safetyTier: 'mutation',
54
+ requiredParams: ['url'],
55
+ },
56
+ {
57
+ verb: 'query',
58
+ method: 'POST',
59
+ path: '/v1.1/webhook/queryWebhook',
60
+ safetyTier: 'read',
61
+ requiredParams: ['action'],
62
+ },
63
+ {
64
+ verb: 'update',
65
+ method: 'POST',
66
+ path: '/v1.1/webhook/updateWebhook',
67
+ safetyTier: 'mutation',
68
+ requiredParams: ['url', 'enable'],
69
+ },
70
+ {
71
+ verb: 'delete',
72
+ method: 'POST',
73
+ path: '/v1.1/webhook/deleteWebhook',
74
+ safetyTier: 'destructive',
75
+ requiredParams: ['url'],
76
+ },
77
+ ],
78
+ events: [
79
+ {
80
+ eventType: 'WoMeter',
81
+ devicePattern: 'Meter / Meter Plus / Indoor-Outdoor Meter',
82
+ fields: [
83
+ ...COMMON_WEBHOOK_FIELDS,
84
+ { name: 'temperature', type: 'number', description: 'Ambient temperature in Celsius', example: 22.5 },
85
+ { name: 'humidity', type: 'number', description: 'Relative humidity (%)', example: 45 },
86
+ { name: 'battery', type: 'number', description: 'Battery remaining (%)', example: 88 },
87
+ ],
88
+ },
89
+ {
90
+ eventType: 'WoCO2Sensor',
91
+ devicePattern: 'CO2 Monitor',
92
+ fields: [
93
+ ...COMMON_WEBHOOK_FIELDS,
94
+ { name: 'CO2', type: 'number', description: 'CO2 concentration in ppm', example: 520 },
95
+ { name: 'temperature', type: 'number', description: 'Ambient temperature in Celsius' },
96
+ { name: 'humidity', type: 'number', description: 'Relative humidity (%)' },
97
+ ],
98
+ },
99
+ {
100
+ eventType: 'WoPresence',
101
+ devicePattern: 'Motion Sensor / Video Doorbell motion',
102
+ fields: [
103
+ ...COMMON_WEBHOOK_FIELDS,
104
+ { name: 'detectionState', type: 'string', description: 'Detection result word', example: 'DETECTED' },
105
+ ],
106
+ },
107
+ {
108
+ eventType: 'WoContact',
109
+ devicePattern: 'Contact Sensor',
110
+ fields: [
111
+ ...COMMON_WEBHOOK_FIELDS,
112
+ { name: 'openState', type: 'string', description: 'Door/window state', example: 'open' },
113
+ { name: 'moveDetected', type: 'boolean', description: 'Motion detected during this sample' },
114
+ ],
115
+ },
116
+ {
117
+ eventType: 'WoLock',
118
+ devicePattern: 'Smart Lock / Smart Lock Lite / Smart Lock Pro',
119
+ fields: [
120
+ ...COMMON_WEBHOOK_FIELDS,
121
+ { name: 'lockState', type: 'string', description: 'Lock state: locked, unlocked, jammed', example: 'locked' },
122
+ { name: 'battery', type: 'number', description: 'Battery remaining (%)' },
123
+ ],
124
+ },
125
+ {
126
+ eventType: 'WoPlug',
127
+ devicePattern: 'Plug Mini / Plug / Relay Switch',
128
+ fields: [
129
+ ...COMMON_WEBHOOK_FIELDS,
130
+ { name: 'power', type: 'string', description: 'Power state (on/off)', example: 'on' },
131
+ { name: 'voltage', type: 'number', description: 'Instantaneous voltage (V)' },
132
+ { name: 'electricCurrent', type: 'number', description: 'Instantaneous current (A)' },
133
+ ],
134
+ },
135
+ {
136
+ eventType: 'WoBot',
137
+ devicePattern: 'Bot',
138
+ fields: [
139
+ ...COMMON_WEBHOOK_FIELDS,
140
+ { name: 'power', type: 'string', description: 'Power state (on/off)' },
141
+ { name: 'battery', type: 'number', description: 'Battery remaining (%)' },
142
+ ],
143
+ },
144
+ {
145
+ eventType: 'WoCurtain',
146
+ devicePattern: 'Curtain / Blind Tilt / Roller Shade',
147
+ fields: [
148
+ ...COMMON_WEBHOOK_FIELDS,
149
+ { name: 'slidePosition', type: 'number', description: 'Current slide position (0–100)' },
150
+ { name: 'calibrate', type: 'boolean', description: 'True if device is calibrated' },
151
+ ],
152
+ },
153
+ {
154
+ eventType: 'WoDoorbell',
155
+ devicePattern: 'Video Doorbell button press',
156
+ fields: [
157
+ ...COMMON_WEBHOOK_FIELDS,
158
+ { name: 'buttonName', type: 'string', description: 'Identifier of the pressed button' },
159
+ { name: 'pressedAt', type: 'timestamp', description: 'Press timestamp in milliseconds' },
160
+ ],
161
+ },
162
+ {
163
+ eventType: 'WoKeypad',
164
+ devicePattern: 'Keypad scan / createKey result / deleteKey result',
165
+ fields: [
166
+ ...COMMON_WEBHOOK_FIELDS,
167
+ { name: 'eventType', type: 'string', description: 'Sub-event (createKey / deleteKey / invalidCode)' },
168
+ { name: 'commandId', type: 'string', description: 'Correlation id returned by the original command' },
169
+ { name: 'result', type: 'string', description: 'Outcome (success / failed / timeout)' },
170
+ ],
171
+ },
172
+ {
173
+ eventType: 'WoColorBulb',
174
+ devicePattern: 'Color Bulb',
175
+ fields: [
176
+ ...COMMON_WEBHOOK_FIELDS,
177
+ { name: 'power', type: 'string', description: 'Power state (on/off)' },
178
+ { name: 'brightness', type: 'number', description: 'Brightness (0–100)' },
179
+ { name: 'color', type: 'string', description: 'RGB triplet "r:g:b"' },
180
+ { name: 'colorTemperature', type: 'number', description: 'Color temperature in Kelvin' },
181
+ ],
182
+ },
183
+ {
184
+ eventType: 'WoStrip',
185
+ devicePattern: 'Strip Light',
186
+ fields: [
187
+ ...COMMON_WEBHOOK_FIELDS,
188
+ { name: 'power', type: 'string', description: 'Power state (on/off)' },
189
+ { name: 'brightness', type: 'number', description: 'Brightness (0–100)' },
190
+ { name: 'color', type: 'string', description: 'RGB triplet "r:g:b"' },
191
+ ],
192
+ },
193
+ {
194
+ eventType: 'WoSweeper',
195
+ devicePattern: 'Robot Vacuum',
196
+ fields: [
197
+ ...COMMON_WEBHOOK_FIELDS,
198
+ { name: 'workingStatus', type: 'string', description: 'Cleaning state' },
199
+ { name: 'battery', type: 'number', description: 'Battery remaining (%)' },
200
+ { name: 'taskType', type: 'string', description: 'Current task (standby / clean / charge)' },
201
+ ],
202
+ },
203
+ {
204
+ eventType: 'WoWaterLeakDetect',
205
+ devicePattern: 'Water Leak Detector',
206
+ fields: [
207
+ ...COMMON_WEBHOOK_FIELDS,
208
+ { name: 'waterLeakDetect', type: 'number', description: 'Leak flag (0 = dry, 1 = leak detected)' },
209
+ { name: 'battery', type: 'number', description: 'Battery remaining (%)' },
210
+ ],
211
+ },
212
+ {
213
+ eventType: 'WoHub',
214
+ devicePattern: 'Hub 2 / Hub 3 (ambient sensors)',
215
+ fields: [
216
+ ...COMMON_WEBHOOK_FIELDS,
217
+ { name: 'temperature', type: 'number', description: 'Ambient temperature in Celsius' },
218
+ { name: 'humidity', type: 'number', description: 'Relative humidity (%)' },
219
+ { name: 'lightLevel', type: 'number', description: 'Illuminance level' },
220
+ ],
221
+ },
222
+ ],
223
+ constraints: {
224
+ maxUrlLength: 2048,
225
+ maxWebhooksPerAccount: 1,
226
+ },
227
+ },
228
+ keys: [
229
+ {
230
+ keyType: 'permanent',
231
+ description: 'Passcode that never expires — valid until manually deleted.',
232
+ requiredParams: ['name', 'password'],
233
+ optionalParams: [],
234
+ supportedDevices: ['Keypad', 'Keypad Touch'],
235
+ safetyTier: 'destructive',
236
+ },
237
+ {
238
+ keyType: 'timeLimit',
239
+ description: 'Passcode valid only between startTime and endTime (Unix seconds).',
240
+ requiredParams: ['name', 'password', 'startTime', 'endTime'],
241
+ optionalParams: [],
242
+ supportedDevices: ['Keypad', 'Keypad Touch'],
243
+ safetyTier: 'destructive',
244
+ },
245
+ {
246
+ keyType: 'disposable',
247
+ description: 'Passcode that can be used once and then auto-expires.',
248
+ requiredParams: ['name', 'password'],
249
+ optionalParams: ['startTime', 'endTime'],
250
+ supportedDevices: ['Keypad', 'Keypad Touch'],
251
+ safetyTier: 'destructive',
252
+ },
253
+ {
254
+ keyType: 'urgent',
255
+ description: 'Emergency passcode (typically tied to panic / audit workflow).',
256
+ requiredParams: ['name', 'password'],
257
+ optionalParams: [],
258
+ supportedDevices: ['Keypad', 'Keypad Touch'],
259
+ safetyTier: 'destructive',
260
+ },
261
+ ],
262
+ };
263
+ /** Convenience: return the list of known webhook event types. */
264
+ export function listWebhookEventTypes() {
265
+ return RESOURCE_CATALOG.webhooks.events.map((e) => e.eventType);
266
+ }
267
+ /** Convenience: return the list of supported keypad key types. */
268
+ export function listKeyTypes() {
269
+ return RESOURCE_CATALOG.keys.map((k) => k.keyType);
270
+ }