@switchbot/openapi-cli 1.1.0 → 1.3.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 (105) hide show
  1. package/README.md +174 -18
  2. package/dist/api/client.d.ts +7 -1
  3. package/dist/api/client.js +44 -8
  4. package/dist/api/client.js.map +1 -1
  5. package/dist/commands/batch.d.ts +2 -0
  6. package/dist/commands/batch.js +252 -0
  7. package/dist/commands/batch.js.map +1 -0
  8. package/dist/commands/cache.d.ts +2 -0
  9. package/dist/commands/cache.js +108 -0
  10. package/dist/commands/cache.js.map +1 -0
  11. package/dist/commands/capabilities.d.ts +2 -0
  12. package/dist/commands/capabilities.js +91 -0
  13. package/dist/commands/capabilities.js.map +1 -0
  14. package/dist/commands/catalog.d.ts +2 -0
  15. package/dist/commands/catalog.js +291 -0
  16. package/dist/commands/catalog.js.map +1 -0
  17. package/dist/commands/config.js +123 -10
  18. package/dist/commands/config.js.map +1 -1
  19. package/dist/commands/device-meta.d.ts +2 -0
  20. package/dist/commands/device-meta.js +142 -0
  21. package/dist/commands/device-meta.js.map +1 -0
  22. package/dist/commands/devices.js +272 -152
  23. package/dist/commands/devices.js.map +1 -1
  24. package/dist/commands/doctor.d.ts +2 -0
  25. package/dist/commands/doctor.js +147 -0
  26. package/dist/commands/doctor.js.map +1 -0
  27. package/dist/commands/events.d.ts +15 -0
  28. package/dist/commands/events.js +188 -0
  29. package/dist/commands/events.js.map +1 -0
  30. package/dist/commands/expand.d.ts +2 -0
  31. package/dist/commands/expand.js +192 -0
  32. package/dist/commands/expand.js.map +1 -0
  33. package/dist/commands/explain.d.ts +2 -0
  34. package/dist/commands/explain.js +137 -0
  35. package/dist/commands/explain.js.map +1 -0
  36. package/dist/commands/history.d.ts +2 -0
  37. package/dist/commands/history.js +104 -0
  38. package/dist/commands/history.js.map +1 -0
  39. package/dist/commands/mcp.d.ts +4 -0
  40. package/dist/commands/mcp.js +386 -0
  41. package/dist/commands/mcp.js.map +1 -0
  42. package/dist/commands/plan.d.ts +38 -0
  43. package/dist/commands/plan.js +356 -0
  44. package/dist/commands/plan.js.map +1 -0
  45. package/dist/commands/quota.d.ts +2 -0
  46. package/dist/commands/quota.js +77 -0
  47. package/dist/commands/quota.js.map +1 -0
  48. package/dist/commands/scenes.js +19 -13
  49. package/dist/commands/scenes.js.map +1 -1
  50. package/dist/commands/schema.d.ts +2 -0
  51. package/dist/commands/schema.js +77 -0
  52. package/dist/commands/schema.js.map +1 -0
  53. package/dist/commands/watch.d.ts +2 -0
  54. package/dist/commands/watch.js +161 -0
  55. package/dist/commands/watch.js.map +1 -0
  56. package/dist/commands/webhook.js +37 -22
  57. package/dist/commands/webhook.js.map +1 -1
  58. package/dist/config.d.ts +11 -0
  59. package/dist/config.js +32 -6
  60. package/dist/config.js.map +1 -1
  61. package/dist/devices/cache.d.ts +50 -0
  62. package/dist/devices/cache.js +152 -1
  63. package/dist/devices/cache.js.map +1 -1
  64. package/dist/devices/catalog.d.ts +49 -0
  65. package/dist/devices/catalog.js +362 -92
  66. package/dist/devices/catalog.js.map +1 -1
  67. package/dist/devices/device-meta.d.ts +15 -0
  68. package/dist/devices/device-meta.js +52 -0
  69. package/dist/devices/device-meta.js.map +1 -0
  70. package/dist/index.js +31 -1
  71. package/dist/index.js.map +1 -1
  72. package/dist/lib/devices.d.ts +144 -0
  73. package/dist/lib/devices.js +329 -0
  74. package/dist/lib/devices.js.map +1 -0
  75. package/dist/lib/scenes.d.ts +7 -0
  76. package/dist/lib/scenes.js +11 -0
  77. package/dist/lib/scenes.js.map +1 -0
  78. package/dist/utils/audit.d.ts +13 -0
  79. package/dist/utils/audit.js +43 -0
  80. package/dist/utils/audit.js.map +1 -0
  81. package/dist/utils/filter.d.ts +45 -0
  82. package/dist/utils/filter.js +96 -0
  83. package/dist/utils/filter.js.map +1 -0
  84. package/dist/utils/flags.d.ts +42 -0
  85. package/dist/utils/flags.js +108 -0
  86. package/dist/utils/flags.js.map +1 -1
  87. package/dist/utils/format.d.ts +9 -0
  88. package/dist/utils/format.js +109 -0
  89. package/dist/utils/format.js.map +1 -0
  90. package/dist/utils/name-resolver.d.ts +17 -0
  91. package/dist/utils/name-resolver.js +78 -0
  92. package/dist/utils/name-resolver.js.map +1 -0
  93. package/dist/utils/output.d.ts +18 -0
  94. package/dist/utils/output.js +66 -6
  95. package/dist/utils/output.js.map +1 -1
  96. package/dist/utils/quota.d.ts +48 -0
  97. package/dist/utils/quota.js +144 -0
  98. package/dist/utils/quota.js.map +1 -0
  99. package/dist/utils/retry.d.ts +23 -0
  100. package/dist/utils/retry.js +60 -0
  101. package/dist/utils/retry.js.map +1 -0
  102. package/dist/utils/string.d.ts +2 -0
  103. package/dist/utils/string.js +23 -0
  104. package/dist/utils/string.js.map +1 -0
  105. package/package.json +4 -1
@@ -1,7 +1,16 @@
1
- import { createClient } from '../api/client.js';
2
- import { printTable, printKeyValue, printJson, isJsonMode, handleError } from '../utils/output.js';
3
- import { DEVICE_CATALOG, findCatalogEntry } from '../devices/catalog.js';
4
- import { getCachedDevice, updateCacheFromDeviceList } from '../devices/cache.js';
1
+ import { printTable, printKeyValue, printJson, isJsonMode, handleError, UsageError } from '../utils/output.js';
2
+ import { resolveFormat, resolveFields, renderRows } from '../utils/format.js';
3
+ import { findCatalogEntry, getEffectiveCatalog } from '../devices/catalog.js';
4
+ import { getCachedDevice } from '../devices/cache.js';
5
+ import { loadDeviceMeta } from '../devices/device-meta.js';
6
+ import { resolveDeviceId } from '../utils/name-resolver.js';
7
+ import { fetchDeviceList, fetchDeviceStatus, executeCommand, describeDevice, validateCommand, isDestructiveCommand, getDestructiveReason, buildHubLocationMap, DeviceNotFoundError, } from '../lib/devices.js';
8
+ import { registerBatchCommand } from './batch.js';
9
+ import { registerWatchCommand } from './watch.js';
10
+ import { registerExplainCommand } from './explain.js';
11
+ import { registerExpandCommand } from './expand.js';
12
+ import { registerDevicesMetaCommand } from './device-meta.js';
13
+ import { isDryRun } from '../utils/flags.js';
5
14
  export function registerDevicesCommand(program) {
6
15
  const devices = program
7
16
  .command('devices')
@@ -31,9 +40,12 @@ Run any subcommand with --help for its own flags and examples.
31
40
  .command('list')
32
41
  .description('List all physical devices and IR remote devices on the account')
33
42
  .addHelpText('after', `
34
- Output columns: deviceId, deviceName, type, controlType, family, roomID, room, hub, cloud
43
+ Default columns: deviceId, deviceName, type, category
44
+ Pass --wide for the full operator view: + controlType, family, roomID, room, hub, cloud
45
+ --fields accepts any subset of all column names (exit 2 on unknown names).
35
46
 
36
- type - physical deviceType (e.g. "Bot", "Curtain") or "[IR] <remoteType>"
47
+ type - physical deviceType (e.g. "Bot", "Curtain") or IR remoteType (e.g. "TV")
48
+ category - "physical" or "ir"
37
49
  controlType - functional classification from the API (e.g. "Bot", "Switch",
38
50
  "TV") — may differ from 'type' and groups devices by behavior
39
51
  family - home/family name (IR remotes inherit this from their bound Hub)
@@ -50,55 +62,74 @@ the table; --json returns the raw API body unchanged.)
50
62
 
51
63
  Examples:
52
64
  $ switchbot devices list
65
+ $ switchbot devices list --wide
66
+ $ switchbot devices list --format tsv --fields deviceId,deviceName,type,category
53
67
  $ switchbot devices list --json | jq '.deviceList[] | select(.familyName == "家里")'
54
68
  $ switchbot devices list --json | jq '[.deviceList[], .infraredRemoteList[]] | group_by(.familyName)'
55
69
  `)
56
- .action(async () => {
70
+ .option('--wide', 'Show all columns (controlType, family, roomID, room, hub, cloud)')
71
+ .option('--show-hidden', 'Include devices hidden via "devices meta set --hide"')
72
+ .action(async (options) => {
57
73
  try {
58
- const client = createClient();
59
- const res = await client.get('/v1.1/devices');
60
- const { deviceList, infraredRemoteList } = res.data.body;
61
- updateCacheFromDeviceList(res.data.body);
62
- if (isJsonMode()) {
63
- printJson(res.data.body);
74
+ const body = await fetchDeviceList();
75
+ const { deviceList, infraredRemoteList } = body;
76
+ const fmt = resolveFormat();
77
+ const deviceMeta = loadDeviceMeta();
78
+ if (fmt === 'json' && process.argv.includes('--json')) {
79
+ printJson(body);
64
80
  return;
65
81
  }
66
82
  const hubLocation = buildHubLocationMap(deviceList);
83
+ const narrowHeaders = ['deviceId', 'deviceName', 'type', 'category'];
84
+ const wideHeaders = ['deviceId', 'deviceName', 'type', 'category', 'controlType', 'family', 'roomID', 'room', 'hub', 'cloud', 'alias'];
85
+ const userFields = resolveFields();
86
+ const headers = userFields ? wideHeaders : (options.wide ? wideHeaders : narrowHeaders);
67
87
  const rows = [];
68
88
  for (const d of deviceList) {
89
+ if (!options.showHidden && deviceMeta.devices[d.deviceId]?.hidden)
90
+ continue;
69
91
  rows.push([
70
92
  d.deviceId,
71
93
  d.deviceName,
72
94
  d.deviceType || '—',
95
+ 'physical',
73
96
  d.controlType || '—',
74
97
  d.familyName || '—',
75
98
  d.roomID || '—',
76
99
  d.roomName || '—',
77
100
  !d.hubDeviceId || d.hubDeviceId === '000000000000' ? '—' : d.hubDeviceId,
78
101
  d.enableCloudService,
102
+ deviceMeta.devices[d.deviceId]?.alias ?? '—',
79
103
  ]);
80
104
  }
81
105
  for (const d of infraredRemoteList) {
106
+ if (!options.showHidden && deviceMeta.devices[d.deviceId]?.hidden)
107
+ continue;
82
108
  const inherited = hubLocation.get(d.hubDeviceId);
83
109
  rows.push([
84
110
  d.deviceId,
85
111
  d.deviceName,
86
- `[IR] ${d.remoteType}`,
112
+ d.remoteType,
113
+ 'ir',
87
114
  d.controlType || '—',
88
115
  inherited?.family || '—',
89
116
  inherited?.roomID || '—',
90
117
  inherited?.room || '—',
91
118
  d.hubDeviceId,
92
119
  null,
120
+ deviceMeta.devices[d.deviceId]?.alias ?? '—',
93
121
  ]);
94
122
  }
95
- if (rows.length === 0) {
123
+ if (rows.length === 0 && fmt === 'table') {
96
124
  console.log('No devices found');
97
125
  return;
98
126
  }
99
- printTable(['deviceId', 'deviceName', 'type', 'controlType', 'family', 'roomID', 'room', 'hub', 'cloud'], rows);
100
- console.log(`\nTotal: ${deviceList.length} physical device(s), ${infraredRemoteList.length} IR remote device(s)`);
101
- console.log(`Tip: 'switchbot devices describe <deviceId>' shows a device's supported commands.`);
127
+ const defaultFields = options.wide ? undefined : narrowHeaders;
128
+ renderRows(wideHeaders, rows, fmt, userFields ?? defaultFields);
129
+ if (fmt === 'table') {
130
+ console.log(`\nTotal: ${deviceList.length} physical device(s), ${infraredRemoteList.length} IR remote device(s)`);
131
+ console.log(`Tip: 'switchbot devices describe <deviceId>' shows a device's supported commands.`);
132
+ }
102
133
  }
103
134
  catch (error) {
104
135
  handleError(error);
@@ -108,35 +139,41 @@ Examples:
108
139
  devices
109
140
  .command('status')
110
141
  .description('Query the real-time status of a specific device')
111
- .argument('<deviceId>', 'Device ID from "devices list" (physical devices only; IR remotes have no status)')
142
+ .argument('[deviceId]', 'Device ID from "devices list" (or use --name)')
143
+ .option('--name <query>', 'Resolve device by fuzzy name instead of deviceId')
112
144
  .addHelpText('after', `
113
- Returned fields vary by device type e.g. Bot returns power/battery, Meter
114
- returns temperature/humidity/battery, Curtain returns slidePosition/moving,
115
- Color Bulb returns brightness/color/colorTemperature, etc.
145
+ Status fields vary by device type. To discover them without a live call:
116
146
 
117
- To see exactly which status fields a given type returns BEFORE calling the
118
- API, use the offline catalog:
147
+ switchbot devices commands <type> (prints the "Status fields" section)
119
148
 
120
- switchbot devices commands <type> (prints the "Status fields" section)
121
-
122
- IR remote devices cannot be queried — the SwitchBot API returns no status
123
- channel for them. Use 'devices list' to confirm a deviceId is a physical
124
- device (not in the 'infraredRemoteList').
149
+ For --fields: run the command once with --format yaml (no --fields) to see
150
+ all field names returned by your specific device, then narrow with --fields.
125
151
 
126
152
  Examples:
127
153
  $ switchbot devices status ABC123DEF456
154
+ $ switchbot devices status --name "客厅空调"
128
155
  $ switchbot devices status ABC123DEF456 --json
156
+ $ switchbot devices status ABC123DEF456 --format yaml
157
+ $ switchbot devices status ABC123DEF456 --format tsv --fields power,battery
129
158
  $ switchbot devices status ABC123DEF456 --json | jq '.battery'
130
159
  `)
131
- .action(async (deviceId) => {
160
+ .action(async (deviceIdArg, options) => {
132
161
  try {
133
- const client = createClient();
134
- const res = await client.get(`/v1.1/devices/${deviceId}/status`);
135
- if (isJsonMode()) {
136
- printJson(res.data.body);
162
+ const deviceId = resolveDeviceId(deviceIdArg, options.name);
163
+ const body = await fetchDeviceStatus(deviceId);
164
+ const fmt = resolveFormat();
165
+ if (fmt === 'json' && process.argv.includes('--json')) {
166
+ printJson(body);
137
167
  return;
138
168
  }
139
- printKeyValue(res.data.body);
169
+ if (fmt !== 'table') {
170
+ const allHeaders = Object.keys(body);
171
+ const allRows = [Object.values(body)];
172
+ const fields = resolveFields();
173
+ renderRows(allHeaders, allRows, fmt, fields);
174
+ return;
175
+ }
176
+ printKeyValue(body);
140
177
  }
141
178
  catch (error) {
142
179
  handleError(error);
@@ -146,10 +183,12 @@ Examples:
146
183
  devices
147
184
  .command('command')
148
185
  .description('Send a control command to a device')
149
- .argument('<deviceId>', 'Target device ID from "devices list"')
186
+ .argument('[deviceId]', 'Target device ID (or use --name)')
150
187
  .argument('<cmd>', 'Command name, e.g. turnOn, turnOff, setColor, setBrightness, setAll, startClean')
151
188
  .argument('[parameter]', 'Command parameter. Omit for commands like turnOn/turnOff (defaults to "default"). Format depends on the command (see below).')
189
+ .option('--name <query>', 'Resolve device by fuzzy name instead of deviceId')
152
190
  .option('--type <commandType>', 'Command type: "command" for built-in commands (default), "customize" for user-defined IR buttons', 'command')
191
+ .option('--yes', 'Confirm a destructive command (Smart Lock unlock, Garage open, …). --dry-run is always allowed without --yes.')
153
192
  .addHelpText('after', `
154
193
  ────────────────────────────────────────────────────────────────────────
155
194
  For the full list of commands a specific device supports — and their
@@ -182,17 +221,73 @@ Common errors:
182
221
  161 device offline (BLE devices need a Hub bridge)
183
222
  171 hub offline
184
223
 
224
+ Safety:
225
+ Destructive commands (Smart Lock unlock, Garage Door Opener turnOn/turnOff,
226
+ Keypad createKey/deleteKey, …) are blocked by default. Pass --yes to confirm,
227
+ or --dry-run to preview without sending.
228
+
185
229
  Examples:
186
230
  $ switchbot devices command ABC123 turnOn
187
231
  $ switchbot devices command ABC123 setColor "255:0:0"
188
232
  $ switchbot devices command ABC123 setAll "26,1,3,on"
189
233
  $ switchbot devices command ABC123 startClean '{"action":"sweep","param":{"fanLevel":2,"times":1}}'
190
234
  $ switchbot devices command ABC123 "MyButton" --type customize
235
+ $ switchbot devices command <lockId> unlock --yes
191
236
  `)
192
- .action(async (deviceId, cmd, parameter, options) => {
193
- validateCommandAgainstCache(deviceId, cmd, parameter, options.type);
237
+ .action(async (deviceIdArg, cmd, parameter, options) => {
238
+ const deviceId = resolveDeviceId(deviceIdArg, options.name);
239
+ const validation = validateCommand(deviceId, cmd, parameter, options.type);
240
+ if (!validation.ok) {
241
+ const err = validation.error;
242
+ if (isJsonMode()) {
243
+ const obj = { code: 2, kind: 'usage', message: err.message };
244
+ if (err.hint)
245
+ obj.hint = err.hint;
246
+ obj.context = { validationKind: err.kind };
247
+ console.error(JSON.stringify({ error: obj }));
248
+ }
249
+ else {
250
+ console.error(`Error: ${err.message}`);
251
+ if (err.hint)
252
+ console.error(err.hint);
253
+ if (err.kind === 'unknown-command') {
254
+ const cached = getCachedDevice(deviceId);
255
+ if (cached) {
256
+ console.error(`Run 'switchbot devices commands ${JSON.stringify(cached.type)}' for parameter formats and descriptions.`);
257
+ console.error(`(If the catalog is out of date, run 'switchbot devices list' to refresh the local cache, or pass --type customize for custom IR buttons.)`);
258
+ }
259
+ }
260
+ }
261
+ process.exit(2);
262
+ }
263
+ const cachedForGuard = getCachedDevice(deviceId);
264
+ if (!options.yes &&
265
+ !isDryRun() &&
266
+ isDestructiveCommand(cachedForGuard?.type, cmd, options.type)) {
267
+ const typeLabel = cachedForGuard?.type ?? 'unknown';
268
+ const reason = getDestructiveReason(cachedForGuard?.type, cmd, options.type);
269
+ if (isJsonMode()) {
270
+ console.error(JSON.stringify({
271
+ error: {
272
+ code: 2,
273
+ kind: 'guard',
274
+ message: `"${cmd}" on ${typeLabel} is destructive and requires --yes.`,
275
+ hint: reason
276
+ ? `Re-run with --yes to confirm. Reason: ${reason}`
277
+ : 'Re-run with --yes to confirm, or --dry-run to preview without sending.',
278
+ context: { command: cmd, deviceType: typeLabel, deviceId, ...(reason ? { destructiveReason: reason } : {}) },
279
+ },
280
+ }));
281
+ }
282
+ else {
283
+ console.error(`Refusing to run destructive command "${cmd}" on ${typeLabel} without --yes.`);
284
+ if (reason)
285
+ console.error(`Reason: ${reason}`);
286
+ console.error(`Re-run with --yes to confirm, or --dry-run to preview without sending.`);
287
+ }
288
+ process.exit(2);
289
+ }
194
290
  try {
195
- const client = createClient();
196
291
  // parameter may be a JSON object string (e.g. S10 startClean) or a plain string
197
292
  let parsedParam = parameter ?? 'default';
198
293
  if (parameter) {
@@ -203,19 +298,23 @@ Examples:
203
298
  // keep as string
204
299
  }
205
300
  }
206
- const body = {
207
- command: cmd,
208
- parameter: parsedParam,
209
- commandType: options.type,
210
- };
211
- const res = await client.post(`/v1.1/devices/${deviceId}/commands`, body);
301
+ const body = await executeCommand(deviceId, cmd, parsedParam, options.type);
302
+ const isIr = getCachedDevice(deviceId)?.category === 'ir';
212
303
  if (isJsonMode()) {
213
- printJson(res.data.body);
304
+ const result = { ok: true, command: cmd, deviceId };
305
+ if (isIr)
306
+ result.subKind = 'ir-no-feedback';
307
+ if (body && typeof body === 'object' && Object.keys(body).length > 0) {
308
+ Object.assign(result, body);
309
+ }
310
+ printJson(result);
214
311
  return;
215
312
  }
216
313
  console.log(`✓ Command sent: ${cmd}`);
217
- if (res.data.body && typeof res.data.body === 'object' && Object.keys(res.data.body).length > 0) {
218
- printKeyValue(res.data.body);
314
+ if (isIr)
315
+ console.log(' Note: IR command sent — no device confirmation (fire-and-forget).');
316
+ if (body && typeof body === 'object' && Object.keys(body).length > 0) {
317
+ printKeyValue(body);
219
318
  }
220
319
  }
221
320
  catch (error) {
@@ -235,18 +334,28 @@ Examples:
235
334
  $ switchbot devices types --json
236
335
  `)
237
336
  .action(() => {
238
- if (isJsonMode()) {
239
- printJson(DEVICE_CATALOG);
240
- return;
337
+ try {
338
+ const catalog = getEffectiveCatalog();
339
+ const fmt = resolveFormat();
340
+ if (fmt === 'json') {
341
+ printJson(catalog);
342
+ return;
343
+ }
344
+ const headers = ['type', 'category', 'commands', 'aliases'];
345
+ const rows = catalog.map((e) => [
346
+ e.type,
347
+ e.category,
348
+ String(e.commands.length),
349
+ (e.aliases ?? []).join(', ') || '—',
350
+ ]);
351
+ renderRows(headers, rows, fmt, resolveFields());
352
+ if (fmt === 'table') {
353
+ console.log(`\nTotal: ${catalog.length} device type(s)`);
354
+ }
355
+ }
356
+ catch (error) {
357
+ handleError(error);
241
358
  }
242
- const rows = DEVICE_CATALOG.map((e) => [
243
- e.type,
244
- e.category,
245
- String(e.commands.length),
246
- (e.aliases ?? []).join(', ') || '—',
247
- ]);
248
- printTable(['type', 'category', 'commands', 'aliases'], rows);
249
- console.log(`\nTotal: ${DEVICE_CATALOG.length} device type(s)`);
250
359
  });
251
360
  // switchbot devices commands <type>
252
361
  devices
@@ -270,65 +379,78 @@ Examples:
270
379
  `)
271
380
  .action((typeParts) => {
272
381
  const type = typeParts.join(' ');
273
- const match = findCatalogEntry(type);
274
- if (!match) {
275
- console.error(`No device type matches "${type}".`);
276
- console.error(`Try 'switchbot devices types' to see the full list.`);
277
- process.exit(2);
278
- }
279
- if (Array.isArray(match)) {
280
- console.error(`"${type}" matches multiple types. Be more specific:`);
281
- for (const m of match)
282
- console.error(` • ${m.type}`);
283
- process.exit(2);
382
+ try {
383
+ const match = findCatalogEntry(type);
384
+ if (!match) {
385
+ throw new UsageError(`No device type matches "${type}". Try 'switchbot devices types' to see the full list.`);
386
+ }
387
+ if (Array.isArray(match)) {
388
+ const types = match.map((m) => m.type).join(', ');
389
+ throw new UsageError(`"${type}" matches multiple types: ${types}. Be more specific.`);
390
+ }
391
+ if (isJsonMode()) {
392
+ printJson(match);
393
+ return;
394
+ }
395
+ renderCatalogEntry(match);
284
396
  }
285
- if (isJsonMode()) {
286
- printJson(match);
287
- return;
397
+ catch (error) {
398
+ handleError(error);
288
399
  }
289
- renderCatalogEntry(match);
290
400
  });
291
401
  // switchbot devices describe <deviceId>
292
402
  devices
293
403
  .command('describe')
294
404
  .description('Describe a device by ID: metadata + supported commands + status fields (1 API call)')
295
- .argument('<deviceId>', 'Target device ID from "devices list"')
405
+ .argument('[deviceId]', 'Target device ID (or use --name)')
406
+ .option('--name <query>', 'Resolve device by fuzzy name instead of deviceId')
407
+ .option('--live', 'Also fetch live status values and merge them into capabilities (costs 1 extra API call)')
296
408
  .addHelpText('after', `
297
- Makes a single GET /v1.1/devices call to look up the device's type, then
298
- prints its metadata alongside the matching catalog entry (supported
299
- commands + parameter formats + status field names).
409
+ Makes a GET /v1.1/devices call to look up the device's type, then prints its
410
+ metadata alongside the matching catalog entry (supported commands + parameter
411
+ formats + status field names). With --live, makes a second call to fetch the
412
+ current status values and merges them into the output.
300
413
 
301
- Does NOT fetch live status values. Use 'switchbot devices status <id>' for that.
414
+ JSON output shape (--json):
415
+ {
416
+ device: <raw API fields>,
417
+ controlType: <string|null>,
418
+ catalog: <catalog entry, or null>,
419
+ capabilities: {
420
+ role: <functional role>,
421
+ readOnly: <boolean>,
422
+ commands: [{command, parameter, description, idempotent?, destructive?, exampleParams?}],
423
+ statusFields: [<name>],
424
+ liveStatus: <status payload when --live was passed>
425
+ },
426
+ source: "catalog" | "live" | "catalog+live" | "none",
427
+ suggestedActions: [{command, parameter?, description}]
428
+ }
302
429
 
303
430
  Examples:
304
431
  $ switchbot devices describe ABC123DEF456
432
+ $ switchbot devices describe ABC123DEF456 --live
305
433
  $ switchbot devices describe ABC123DEF456 --json
434
+ $ switchbot devices describe <lockId> --json | jq '.capabilities.commands[] | select(.destructive)'
306
435
  `)
307
- .action(async (deviceId) => {
436
+ .action(async (deviceIdArg, options) => {
308
437
  try {
309
- const client = createClient();
310
- const res = await client.get('/v1.1/devices');
311
- const { deviceList, infraredRemoteList } = res.data.body;
312
- updateCacheFromDeviceList(res.data.body);
313
- const physical = deviceList.find((d) => d.deviceId === deviceId);
314
- const ir = infraredRemoteList.find((d) => d.deviceId === deviceId);
315
- if (!physical && !ir) {
316
- console.error(`No device with id "${deviceId}" found on this account.`);
317
- console.error(`Try 'switchbot devices list' to see the full list.`);
318
- process.exit(1);
319
- }
320
- const typeName = physical ? (physical.deviceType ?? '') : ir.remoteType;
321
- const match = typeName ? findCatalogEntry(typeName) : null;
322
- const catalogEntry = !match || Array.isArray(match) ? null : match;
438
+ const deviceId = resolveDeviceId(deviceIdArg, options.name);
439
+ const result = await describeDevice(deviceId, options);
440
+ const { device, isPhysical, typeName, controlType, catalog, capabilities, source, suggestedActions: picks } = result;
323
441
  if (isJsonMode()) {
324
442
  printJson({
325
- device: physical ?? ir,
326
- controlType: (physical?.controlType ?? ir?.controlType) ?? null,
327
- catalog: catalogEntry,
443
+ device,
444
+ controlType,
445
+ catalog,
446
+ capabilities,
447
+ source,
448
+ suggestedActions: picks,
328
449
  });
329
450
  return;
330
451
  }
331
- if (physical) {
452
+ if (isPhysical) {
453
+ const physical = device;
332
454
  printKeyValue({
333
455
  deviceId: physical.deviceId,
334
456
  deviceName: physical.deviceName,
@@ -341,8 +463,9 @@ Examples:
341
463
  cloudService: physical.enableCloudService,
342
464
  });
343
465
  }
344
- else if (ir) {
345
- const inherited = buildHubLocationMap(deviceList).get(ir.hubDeviceId);
466
+ else {
467
+ const ir = device;
468
+ const inherited = result.inheritedLocation;
346
469
  printKeyValue({
347
470
  deviceId: ir.deviceId,
348
471
  deviceName: ir.deviceName,
@@ -354,66 +477,55 @@ Examples:
354
477
  hub: ir.hubDeviceId || '—',
355
478
  });
356
479
  }
480
+ const liveStatus = capabilities && 'liveStatus' in capabilities ? capabilities.liveStatus : undefined;
357
481
  console.log('');
358
- if (!catalogEntry) {
482
+ if (!catalog) {
359
483
  console.log(`(Type "${typeName}" is not in the built-in catalog — no command reference available.)`);
360
- console.log(`Send custom IR buttons with: switchbot devices command ${deviceId} "<buttonName>" --type customize`);
484
+ if (isPhysical) {
485
+ console.log(`Try 'switchbot devices status ${deviceId}' to see what this device reports.`);
486
+ }
487
+ else {
488
+ console.log(`Send custom IR buttons with: switchbot devices command ${deviceId} "<buttonName>" --type customize`);
489
+ }
490
+ if (liveStatus) {
491
+ console.log('\nLive status:');
492
+ printKeyValue(liveStatus);
493
+ }
361
494
  return;
362
495
  }
363
- renderCatalogEntry(catalogEntry);
496
+ renderCatalogEntry(catalog);
497
+ if (liveStatus) {
498
+ console.log('\nLive status:');
499
+ printKeyValue(liveStatus);
500
+ }
364
501
  }
365
502
  catch (error) {
503
+ if (error instanceof DeviceNotFoundError) {
504
+ console.error(error.message);
505
+ console.error(`Try 'switchbot devices list' to see the full list.`);
506
+ process.exit(1);
507
+ }
366
508
  handleError(error);
367
509
  }
368
510
  });
369
- }
370
- function buildHubLocationMap(deviceList) {
371
- const map = new Map();
372
- for (const d of deviceList) {
373
- if (!d.deviceId)
374
- continue;
375
- map.set(d.deviceId, {
376
- family: d.familyName ?? undefined,
377
- room: d.roomName ?? undefined,
378
- roomID: d.roomID ?? undefined,
379
- });
380
- }
381
- return map;
382
- }
383
- function validateCommandAgainstCache(deviceId, cmd, parameter, commandType) {
384
- // Custom IR buttons have arbitrary names — skip validation.
385
- if (commandType === 'customize')
386
- return;
387
- const cached = getCachedDevice(deviceId);
388
- if (!cached)
389
- return;
390
- const match = findCatalogEntry(cached.type);
391
- if (!match || Array.isArray(match))
392
- return;
393
- const entry = match;
394
- const builtinCommands = entry.commands.filter((c) => c.commandType !== 'customize');
395
- if (builtinCommands.length === 0)
396
- return;
397
- const spec = builtinCommands.find((c) => c.command === cmd);
398
- if (!spec) {
399
- const unique = [...new Set(builtinCommands.map((c) => c.command))];
400
- console.error(`Error: "${cmd}" is not a supported command for ${cached.name} (${cached.type}).`);
401
- console.error(`Supported commands: ${unique.join(', ')}`);
402
- console.error(`Run 'switchbot devices commands ${JSON.stringify(cached.type)}' for parameter formats and descriptions.`);
403
- console.error(`(If the catalog is out of date, run 'switchbot devices list' to refresh the local cache, or pass --type customize for custom IR buttons.)`);
404
- process.exit(2);
405
- }
406
- const noParamExpected = spec.parameter === '—';
407
- const userProvidedParam = parameter !== undefined && parameter !== 'default';
408
- if (noParamExpected && userProvidedParam) {
409
- console.error(`Error: "${cmd}" takes no parameter, but one was provided: "${parameter}".`);
410
- console.error(`Try: switchbot devices command ${deviceId} ${cmd}`);
411
- process.exit(2);
412
- }
511
+ // switchbot devices batch <command> ...
512
+ registerBatchCommand(devices);
513
+ // switchbot devices watch <id...>
514
+ registerWatchCommand(devices);
515
+ // switchbot devices explain <id>
516
+ registerExplainCommand(devices);
517
+ // switchbot devices expand <id> <cmd> [semantic flags]
518
+ registerExpandCommand(devices);
519
+ // switchbot devices meta set/get/list/clear
520
+ registerDevicesMetaCommand(devices);
413
521
  }
414
522
  function renderCatalogEntry(entry) {
415
523
  console.log(`Type: ${entry.type}`);
416
524
  console.log(`Category: ${entry.category === 'ir' ? 'IR remote' : 'Physical device'}`);
525
+ if (entry.role)
526
+ console.log(`Role: ${entry.role}`);
527
+ if (entry.readOnly)
528
+ console.log(`ReadOnly: yes (status-only device, no control commands)`);
417
529
  if (entry.aliases && entry.aliases.length > 0) {
418
530
  console.log(`Aliases: ${entry.aliases.join(', ')}`);
419
531
  }
@@ -422,12 +534,20 @@ function renderCatalogEntry(entry) {
422
534
  }
423
535
  else {
424
536
  console.log('\nCommands:');
425
- const rows = entry.commands.map((c) => [
426
- c.commandType === 'customize' ? `${c.command} [customize]` : c.command,
427
- c.parameter,
428
- c.description,
429
- ]);
537
+ const rows = entry.commands.map((c) => {
538
+ const flags = [];
539
+ if (c.commandType === 'customize')
540
+ flags.push('customize');
541
+ if (c.destructive)
542
+ flags.push('!destructive');
543
+ const label = flags.length > 0 ? `${c.command} [${flags.join(', ')}]` : c.command;
544
+ return [label, c.parameter, c.description];
545
+ });
430
546
  printTable(['command', 'parameter', 'description'], rows);
547
+ const hasDestructive = entry.commands.some((c) => c.destructive);
548
+ if (hasDestructive) {
549
+ console.log('\n[!destructive] commands have hard-to-reverse real-world effects — confirm before issuing.');
550
+ }
431
551
  }
432
552
  if (entry.statusFields && entry.statusFields.length > 0) {
433
553
  console.log('\nStatus fields (from "devices status"):');