@switchbot/openapi-cli 2.0.1 → 2.2.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.
- package/README.md +265 -39
- package/dist/commands/capabilities.js +37 -20
- package/dist/commands/devices.js +206 -66
- package/dist/commands/doctor.js +33 -0
- package/dist/commands/events.js +188 -1
- package/dist/commands/expand.js +20 -3
- package/dist/commands/mcp.js +59 -12
- package/dist/commands/plan.js +10 -2
- package/dist/commands/scenes.js +1 -1
- package/dist/commands/watch.js +13 -2
- package/dist/config.js +23 -0
- package/dist/index.js +5 -4
- package/dist/lib/devices.js +16 -1
- package/dist/mcp/device-history.js +66 -0
- package/dist/mcp/events-subscription.js +15 -12
- package/dist/mqtt/client.js +46 -50
- package/dist/mqtt/credential.js +29 -11
- package/dist/sinks/dispatcher.js +12 -0
- package/dist/sinks/file.js +19 -0
- package/dist/sinks/format.js +56 -0
- package/dist/sinks/homeassistant.js +44 -0
- package/dist/sinks/openclaw.js +33 -0
- package/dist/sinks/stdout.js +5 -0
- package/dist/sinks/telegram.js +28 -0
- package/dist/sinks/types.js +1 -0
- package/dist/sinks/webhook.js +22 -0
- package/dist/utils/flags.js +13 -12
- package/dist/utils/format.js +6 -5
- package/package.json +2 -2
package/dist/commands/devices.js
CHANGED
|
@@ -66,20 +66,62 @@ Examples:
|
|
|
66
66
|
$ switchbot devices list --format tsv --fields deviceId,deviceName,type,category
|
|
67
67
|
$ switchbot devices list --json | jq '.deviceList[] | select(.familyName == "家里")'
|
|
68
68
|
$ switchbot devices list --json | jq '[.deviceList[], .infraredRemoteList[]] | group_by(.familyName)'
|
|
69
|
+
$ switchbot devices list --filter type="Air Conditioner"
|
|
70
|
+
$ switchbot devices list --filter category=ir
|
|
71
|
+
$ switchbot devices list --filter name=living,category=physical
|
|
69
72
|
`)
|
|
70
73
|
.option('--wide', 'Show all columns (controlType, family, roomID, room, hub, cloud)')
|
|
71
74
|
.option('--show-hidden', 'Include devices hidden via "devices meta set --hide"')
|
|
75
|
+
.option('--filter <expr>', 'Filter devices: "type=X", "name=X", "category=physical|ir", "room=X" (comma-separated key=value pairs)')
|
|
72
76
|
.action(async (options) => {
|
|
73
77
|
try {
|
|
74
78
|
const body = await fetchDeviceList();
|
|
75
79
|
const { deviceList, infraredRemoteList } = body;
|
|
76
80
|
const fmt = resolveFormat();
|
|
77
81
|
const deviceMeta = loadDeviceMeta();
|
|
82
|
+
const hubLocation = buildHubLocationMap(deviceList);
|
|
83
|
+
let listFilter = null;
|
|
84
|
+
if (options.filter) {
|
|
85
|
+
listFilter = {};
|
|
86
|
+
for (const pair of options.filter.split(',')) {
|
|
87
|
+
const eq = pair.indexOf('=');
|
|
88
|
+
if (eq === -1)
|
|
89
|
+
throw new UsageError(`Invalid --filter pair "${pair.trim()}". Expected key=value.`);
|
|
90
|
+
const k = pair.slice(0, eq).trim();
|
|
91
|
+
const v = pair.slice(eq + 1).trim();
|
|
92
|
+
if (!['type', 'name', 'category', 'room'].includes(k)) {
|
|
93
|
+
throw new UsageError(`Unknown --filter key "${k}". Supported: type, name, category, room.`);
|
|
94
|
+
}
|
|
95
|
+
listFilter[k] = v.toLowerCase();
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
const matchesFilter = (entry) => {
|
|
99
|
+
if (!listFilter)
|
|
100
|
+
return true;
|
|
101
|
+
if (listFilter.type && !entry.type.toLowerCase().includes(listFilter.type))
|
|
102
|
+
return false;
|
|
103
|
+
if (listFilter.name && !entry.name.toLowerCase().includes(listFilter.name))
|
|
104
|
+
return false;
|
|
105
|
+
if (listFilter.category && entry.category !== listFilter.category)
|
|
106
|
+
return false;
|
|
107
|
+
if (listFilter.room && !entry.room.toLowerCase().includes(listFilter.room))
|
|
108
|
+
return false;
|
|
109
|
+
return true;
|
|
110
|
+
};
|
|
78
111
|
if (fmt === 'json' && process.argv.includes('--json')) {
|
|
79
|
-
|
|
112
|
+
if (listFilter) {
|
|
113
|
+
const filteredDeviceList = deviceList.filter((d) => matchesFilter({ type: d.deviceType || '', name: d.deviceName, category: 'physical', room: d.roomName || '' }));
|
|
114
|
+
const filteredIrList = infraredRemoteList.filter((d) => {
|
|
115
|
+
const inherited = hubLocation.get(d.hubDeviceId);
|
|
116
|
+
return matchesFilter({ type: d.remoteType, name: d.deviceName, category: 'ir', room: inherited?.room || '' });
|
|
117
|
+
});
|
|
118
|
+
printJson({ ok: true, deviceList: filteredDeviceList, infraredRemoteList: filteredIrList });
|
|
119
|
+
}
|
|
120
|
+
else {
|
|
121
|
+
printJson({ ok: true, ...body });
|
|
122
|
+
}
|
|
80
123
|
return;
|
|
81
124
|
}
|
|
82
|
-
const hubLocation = buildHubLocationMap(deviceList);
|
|
83
125
|
const narrowHeaders = ['deviceId', 'deviceName', 'type', 'category'];
|
|
84
126
|
const wideHeaders = ['deviceId', 'deviceName', 'type', 'category', 'controlType', 'family', 'roomID', 'room', 'hub', 'cloud', 'alias'];
|
|
85
127
|
const userFields = resolveFields();
|
|
@@ -88,6 +130,8 @@ Examples:
|
|
|
88
130
|
for (const d of deviceList) {
|
|
89
131
|
if (!options.showHidden && deviceMeta.devices[d.deviceId]?.hidden)
|
|
90
132
|
continue;
|
|
133
|
+
if (!matchesFilter({ type: d.deviceType || '', name: d.deviceName, category: 'physical', room: d.roomName || '' }))
|
|
134
|
+
continue;
|
|
91
135
|
rows.push([
|
|
92
136
|
d.deviceId,
|
|
93
137
|
d.deviceName,
|
|
@@ -106,6 +150,8 @@ Examples:
|
|
|
106
150
|
if (!options.showHidden && deviceMeta.devices[d.deviceId]?.hidden)
|
|
107
151
|
continue;
|
|
108
152
|
const inherited = hubLocation.get(d.hubDeviceId);
|
|
153
|
+
if (!matchesFilter({ type: d.remoteType, name: d.deviceName, category: 'ir', room: inherited?.room || '' }))
|
|
154
|
+
continue;
|
|
109
155
|
rows.push([
|
|
110
156
|
d.deviceId,
|
|
111
157
|
d.deviceName,
|
|
@@ -121,13 +167,22 @@ Examples:
|
|
|
121
167
|
]);
|
|
122
168
|
}
|
|
123
169
|
if (rows.length === 0 && fmt === 'table') {
|
|
124
|
-
console.log('No devices found');
|
|
170
|
+
console.log(listFilter ? 'No devices matched the filter.' : 'No devices found');
|
|
125
171
|
return;
|
|
126
172
|
}
|
|
127
173
|
const defaultFields = options.wide ? undefined : narrowHeaders;
|
|
128
|
-
|
|
174
|
+
// Accept API field names and short aliases alongside canonical column names
|
|
175
|
+
const DEVICE_LIST_ALIASES = {
|
|
176
|
+
name: 'deviceName', deviceType: 'type', type: 'type',
|
|
177
|
+
roomName: 'room', familyName: 'family',
|
|
178
|
+
hubDeviceId: 'hub', enableCloudService: 'cloud',
|
|
179
|
+
};
|
|
180
|
+
renderRows(wideHeaders, rows, fmt, userFields ?? defaultFields, DEVICE_LIST_ALIASES);
|
|
129
181
|
if (fmt === 'table') {
|
|
130
|
-
|
|
182
|
+
const totalLabel = listFilter
|
|
183
|
+
? `${rows.length} match(es) (${deviceList.length} physical + ${infraredRemoteList.length} IR before filter)`
|
|
184
|
+
: `${deviceList.length} physical device(s), ${infraredRemoteList.length} IR remote device(s)`;
|
|
185
|
+
console.log(`\nTotal: ${totalLabel}`);
|
|
131
186
|
console.log(`Tip: 'switchbot devices describe <deviceId>' shows a device's supported commands.`);
|
|
132
187
|
}
|
|
133
188
|
}
|
|
@@ -139,8 +194,9 @@ Examples:
|
|
|
139
194
|
devices
|
|
140
195
|
.command('status')
|
|
141
196
|
.description('Query the real-time status of a specific device')
|
|
142
|
-
.argument('[deviceId]', 'Device ID from "devices list" (or use --name)')
|
|
197
|
+
.argument('[deviceId]', 'Device ID from "devices list" (or use --name or --ids)')
|
|
143
198
|
.option('--name <query>', 'Resolve device by fuzzy name instead of deviceId')
|
|
199
|
+
.option('--ids <list>', 'Comma-separated device IDs for batch status (incompatible with --name)')
|
|
144
200
|
.addHelpText('after', `
|
|
145
201
|
Status fields vary by device type. To discover them without a live call:
|
|
146
202
|
|
|
@@ -155,25 +211,70 @@ Examples:
|
|
|
155
211
|
$ switchbot devices status ABC123DEF456 --json
|
|
156
212
|
$ switchbot devices status ABC123DEF456 --format yaml
|
|
157
213
|
$ switchbot devices status ABC123DEF456 --format tsv --fields power,battery
|
|
158
|
-
$ switchbot devices status ABC123DEF456 --json | jq '.battery'
|
|
214
|
+
$ switchbot devices status ABC123DEF456 --json | jq '.data.battery'
|
|
215
|
+
$ switchbot devices status --ids ABC123,DEF456,GHI789
|
|
216
|
+
$ switchbot devices status --ids ABC123,DEF456 --fields power,battery
|
|
159
217
|
`)
|
|
160
218
|
.action(async (deviceIdArg, options) => {
|
|
161
219
|
try {
|
|
220
|
+
// Batch mode: --ids id1,id2,id3
|
|
221
|
+
if (options.ids) {
|
|
222
|
+
if (options.name)
|
|
223
|
+
throw new UsageError('--ids and --name cannot be used together.');
|
|
224
|
+
const ids = options.ids.split(',').map((s) => s.trim()).filter(Boolean);
|
|
225
|
+
if (ids.length === 0)
|
|
226
|
+
throw new UsageError('--ids requires at least one device ID.');
|
|
227
|
+
const results = await Promise.allSettled(ids.map((id) => fetchDeviceStatus(id)));
|
|
228
|
+
const fetchedAt = new Date().toISOString();
|
|
229
|
+
const batch = results.map((r, i) => r.status === 'fulfilled'
|
|
230
|
+
? { deviceId: ids[i], ok: true, _fetchedAt: fetchedAt, ...r.value }
|
|
231
|
+
: { deviceId: ids[i], ok: false, error: r.reason?.message ?? String(r.reason) });
|
|
232
|
+
const batchFmt = resolveFormat();
|
|
233
|
+
if (isJsonMode() || batchFmt === 'json') {
|
|
234
|
+
printJson(batch);
|
|
235
|
+
}
|
|
236
|
+
else if (batchFmt === 'jsonl') {
|
|
237
|
+
for (const entry of batch) {
|
|
238
|
+
console.log(JSON.stringify(entry));
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
else {
|
|
242
|
+
const fields = resolveFields();
|
|
243
|
+
for (const entry of batch) {
|
|
244
|
+
const { deviceId, ok, error, _fetchedAt: ts, ...status } = entry;
|
|
245
|
+
console.log(`\n─── ${String(deviceId)} ───`);
|
|
246
|
+
if (!ok) {
|
|
247
|
+
console.error(` error: ${String(error)}`);
|
|
248
|
+
}
|
|
249
|
+
else {
|
|
250
|
+
const displayStatus = fields
|
|
251
|
+
? Object.fromEntries(fields.map((f) => [f, status[f] ?? null]))
|
|
252
|
+
: status;
|
|
253
|
+
printKeyValue(displayStatus);
|
|
254
|
+
console.error(` fetched at ${String(ts)}`);
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
return;
|
|
259
|
+
}
|
|
162
260
|
const deviceId = resolveDeviceId(deviceIdArg, options.name);
|
|
163
261
|
const body = await fetchDeviceStatus(deviceId);
|
|
262
|
+
const fetchedAt = new Date().toISOString();
|
|
164
263
|
const fmt = resolveFormat();
|
|
165
264
|
if (fmt === 'json' && process.argv.includes('--json')) {
|
|
166
|
-
printJson(body);
|
|
265
|
+
printJson({ ...body, _fetchedAt: fetchedAt });
|
|
167
266
|
return;
|
|
168
267
|
}
|
|
169
268
|
if (fmt !== 'table') {
|
|
170
|
-
const
|
|
171
|
-
const
|
|
269
|
+
const statusWithTs = { ...body, _fetchedAt: fetchedAt };
|
|
270
|
+
const allHeaders = Object.keys(statusWithTs);
|
|
271
|
+
const allRows = [Object.values(statusWithTs)];
|
|
172
272
|
const fields = resolveFields();
|
|
173
273
|
renderRows(allHeaders, allRows, fmt, fields);
|
|
174
274
|
return;
|
|
175
275
|
}
|
|
176
276
|
printKeyValue(body);
|
|
277
|
+
console.error(`\nfetched at ${fetchedAt}`);
|
|
177
278
|
}
|
|
178
279
|
catch (error) {
|
|
179
280
|
handleError(error);
|
|
@@ -184,7 +285,7 @@ Examples:
|
|
|
184
285
|
.command('command')
|
|
185
286
|
.description('Send a control command to a device')
|
|
186
287
|
.argument('[deviceId]', 'Target device ID (or use --name)')
|
|
187
|
-
.argument('
|
|
288
|
+
.argument('[cmd]', 'Command name, e.g. turnOn, turnOff, setColor, setBrightness, setAll, startClean')
|
|
188
289
|
.argument('[parameter]', 'Command parameter. Omit for commands like turnOn/turnOff (defaults to "default"). Format depends on the command (see below).')
|
|
189
290
|
.option('--name <query>', 'Resolve device by fuzzy name instead of deviceId')
|
|
190
291
|
.option('--type <commandType>', 'Command type: "command" for built-in commands (default), "customize" for user-defined IR buttons', 'command')
|
|
@@ -235,60 +336,86 @@ Examples:
|
|
|
235
336
|
$ switchbot devices command ABC123 "MyButton" --type customize
|
|
236
337
|
$ switchbot devices command <lockId> unlock --yes
|
|
237
338
|
`)
|
|
238
|
-
.action(async (deviceIdArg,
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
if (
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
339
|
+
.action(async (deviceIdArg, cmdArg, parameter, options) => {
|
|
340
|
+
try {
|
|
341
|
+
// BUG-FIX: When --name is provided, Commander misassigns the first positional
|
|
342
|
+
// to [deviceId] instead of [cmd]. Detect and shift positionals accordingly.
|
|
343
|
+
let cmd;
|
|
344
|
+
let effectiveDeviceIdArg;
|
|
345
|
+
if (options.name) {
|
|
346
|
+
if (deviceIdArg && cmdArg) {
|
|
347
|
+
throw new UsageError('Provide either a deviceId argument or --name, not both.');
|
|
348
|
+
}
|
|
349
|
+
if (!deviceIdArg && !cmdArg) {
|
|
350
|
+
throw new UsageError('Command name is required (e.g. turnOn, turnOff, setAll).');
|
|
351
|
+
}
|
|
352
|
+
// --name "x" turnOn → deviceIdArg="turnOn", cmdArg=undefined → shift
|
|
353
|
+
cmd = (deviceIdArg ?? cmdArg);
|
|
354
|
+
effectiveDeviceIdArg = undefined;
|
|
249
355
|
}
|
|
250
356
|
else {
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
357
|
+
if (!cmdArg) {
|
|
358
|
+
throw new UsageError('Command name is required (e.g. turnOn, turnOff, setAll).');
|
|
359
|
+
}
|
|
360
|
+
cmd = cmdArg;
|
|
361
|
+
effectiveDeviceIdArg = deviceIdArg;
|
|
362
|
+
}
|
|
363
|
+
const deviceId = resolveDeviceId(effectiveDeviceIdArg, options.name);
|
|
364
|
+
const validation = validateCommand(deviceId, cmd, parameter, options.type);
|
|
365
|
+
if (!validation.ok) {
|
|
366
|
+
const err = validation.error;
|
|
367
|
+
if (isJsonMode()) {
|
|
368
|
+
const obj = { code: 2, kind: 'usage', message: err.message };
|
|
369
|
+
if (err.hint)
|
|
370
|
+
obj.hint = err.hint;
|
|
371
|
+
obj.context = { validationKind: err.kind };
|
|
372
|
+
console.error(JSON.stringify({ error: obj }));
|
|
373
|
+
}
|
|
374
|
+
else {
|
|
375
|
+
console.error(`Error: ${err.message}`);
|
|
376
|
+
if (err.hint)
|
|
377
|
+
console.error(err.hint);
|
|
378
|
+
if (err.kind === 'unknown-command') {
|
|
379
|
+
const cached = getCachedDevice(deviceId);
|
|
380
|
+
if (cached) {
|
|
381
|
+
console.error(`Run 'switchbot devices commands ${JSON.stringify(cached.type)}' for parameter formats and descriptions.`);
|
|
382
|
+
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.)`);
|
|
383
|
+
}
|
|
259
384
|
}
|
|
260
385
|
}
|
|
386
|
+
process.exit(2);
|
|
261
387
|
}
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
388
|
+
const cachedForGuard = getCachedDevice(deviceId);
|
|
389
|
+
if (!options.yes &&
|
|
390
|
+
!isDryRun() &&
|
|
391
|
+
isDestructiveCommand(cachedForGuard?.type, cmd, options.type)) {
|
|
392
|
+
const typeLabel = cachedForGuard?.type ?? 'unknown';
|
|
393
|
+
const reason = getDestructiveReason(cachedForGuard?.type, cmd, options.type);
|
|
394
|
+
if (isJsonMode()) {
|
|
395
|
+
console.error(JSON.stringify({
|
|
396
|
+
error: {
|
|
397
|
+
code: 2,
|
|
398
|
+
kind: 'guard',
|
|
399
|
+
message: `"${cmd}" on ${typeLabel} is destructive and requires --yes.`,
|
|
400
|
+
hint: reason
|
|
401
|
+
? `Re-run with --yes to confirm. Reason: ${reason}`
|
|
402
|
+
: 'Re-run with --yes to confirm, or --dry-run to preview without sending.',
|
|
403
|
+
context: { command: cmd, deviceType: typeLabel, deviceId, ...(reason ? { destructiveReason: reason } : {}) },
|
|
404
|
+
},
|
|
405
|
+
}));
|
|
406
|
+
}
|
|
407
|
+
else {
|
|
408
|
+
console.error(`Refusing to run destructive command "${cmd}" on ${typeLabel} without --yes.`);
|
|
409
|
+
if (reason)
|
|
410
|
+
console.error(`Reason: ${reason}`);
|
|
411
|
+
console.error(`Re-run with --yes to confirm, or --dry-run to preview without sending.`);
|
|
412
|
+
}
|
|
413
|
+
process.exit(2);
|
|
282
414
|
}
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
console.error(`Reason: ${reason}`);
|
|
287
|
-
console.error(`Re-run with --yes to confirm, or --dry-run to preview without sending.`);
|
|
415
|
+
// Warn when --yes is given but the command is not destructive (no-op flag)
|
|
416
|
+
if (options.yes && !isDestructiveCommand(cachedForGuard?.type, cmd, options.type) && !isDryRun()) {
|
|
417
|
+
console.error(`Note: --yes has no effect; "${cmd}" is not a destructive command.`);
|
|
288
418
|
}
|
|
289
|
-
process.exit(2);
|
|
290
|
-
}
|
|
291
|
-
try {
|
|
292
419
|
// parameter may be a JSON object string (e.g. S10 startClean) or a plain string
|
|
293
420
|
let parsedParam = parameter ?? 'default';
|
|
294
421
|
if (parameter) {
|
|
@@ -311,14 +438,21 @@ Examples:
|
|
|
311
438
|
printJson(result);
|
|
312
439
|
return;
|
|
313
440
|
}
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
441
|
+
if (isIr) {
|
|
442
|
+
console.log(`→ IR signal sent: ${cmd} (no feedback — fire-and-forget)`);
|
|
443
|
+
}
|
|
444
|
+
else {
|
|
445
|
+
console.log(`✓ Command sent: ${cmd}`);
|
|
446
|
+
if (body && typeof body === 'object' && Object.keys(body).length > 0) {
|
|
447
|
+
printKeyValue(body);
|
|
448
|
+
}
|
|
319
449
|
}
|
|
320
450
|
}
|
|
321
451
|
catch (error) {
|
|
452
|
+
// Re-throw mock process.exit signals (Vitest intercepts process.exit as thrown
|
|
453
|
+
// Error('__exit__')) so they aren't double-handled and the exit code is preserved.
|
|
454
|
+
if (error instanceof Error && error.message === '__exit__')
|
|
455
|
+
throw error;
|
|
322
456
|
handleError(error);
|
|
323
457
|
}
|
|
324
458
|
});
|
|
@@ -342,9 +476,10 @@ Examples:
|
|
|
342
476
|
printJson(catalog);
|
|
343
477
|
return;
|
|
344
478
|
}
|
|
345
|
-
const headers = ['type', 'category', 'commands', 'aliases'];
|
|
479
|
+
const headers = ['type', 'role', 'category', 'commands', 'aliases'];
|
|
346
480
|
const rows = catalog.map((e) => [
|
|
347
481
|
e.type,
|
|
482
|
+
e.role ?? '—',
|
|
348
483
|
e.category,
|
|
349
484
|
String(e.commands.length),
|
|
350
485
|
(e.aliases ?? []).join(', ') || '—',
|
|
@@ -535,6 +670,7 @@ function renderCatalogEntry(entry) {
|
|
|
535
670
|
}
|
|
536
671
|
else {
|
|
537
672
|
console.log('\nCommands:');
|
|
673
|
+
const hasExamples = entry.commands.some((c) => c.exampleParams && c.exampleParams.length > 0);
|
|
538
674
|
const rows = entry.commands.map((c) => {
|
|
539
675
|
const flags = [];
|
|
540
676
|
if (c.commandType === 'customize')
|
|
@@ -542,9 +678,13 @@ function renderCatalogEntry(entry) {
|
|
|
542
678
|
if (c.destructive)
|
|
543
679
|
flags.push('!destructive');
|
|
544
680
|
const label = flags.length > 0 ? `${c.command} [${flags.join(', ')}]` : c.command;
|
|
545
|
-
|
|
681
|
+
const base = [label, c.parameter, c.description];
|
|
682
|
+
return hasExamples ? [...base, (c.exampleParams ?? []).join(' | ') || ''] : base;
|
|
546
683
|
});
|
|
547
|
-
|
|
684
|
+
const tableHeaders = hasExamples
|
|
685
|
+
? ['command', 'parameter', 'description', 'example']
|
|
686
|
+
: ['command', 'parameter', 'description'];
|
|
687
|
+
printTable(tableHeaders, rows);
|
|
548
688
|
const hasDestructive = entry.commands.some((c) => c.destructive);
|
|
549
689
|
if (hasDestructive) {
|
|
550
690
|
console.log('\n[!destructive] commands have hard-to-reverse real-world effects — confirm before issuing.');
|
package/dist/commands/doctor.js
CHANGED
|
@@ -101,6 +101,38 @@ function checkNodeVersion() {
|
|
|
101
101
|
}
|
|
102
102
|
return { name: 'node', status: 'ok', detail: `Node ${process.versions.node}` };
|
|
103
103
|
}
|
|
104
|
+
function checkMqtt() {
|
|
105
|
+
// MQTT credentials are auto-provisioned from the SwitchBot API using the
|
|
106
|
+
// account's token+secret — no extra env vars needed. Report availability
|
|
107
|
+
// based on whether REST credentials are configured (no network call).
|
|
108
|
+
const hasEnvCreds = Boolean(process.env.SWITCHBOT_TOKEN && process.env.SWITCHBOT_SECRET);
|
|
109
|
+
if (hasEnvCreds) {
|
|
110
|
+
return {
|
|
111
|
+
name: 'mqtt',
|
|
112
|
+
status: 'ok',
|
|
113
|
+
detail: "auto-provisioned from credentials — run 'switchbot events mqtt-tail' to test live connectivity",
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
const file = configFilePath();
|
|
117
|
+
if (fs.existsSync(file)) {
|
|
118
|
+
try {
|
|
119
|
+
const cfg = JSON.parse(fs.readFileSync(file, 'utf-8'));
|
|
120
|
+
if (cfg.token && cfg.secret) {
|
|
121
|
+
return {
|
|
122
|
+
name: 'mqtt',
|
|
123
|
+
status: 'ok',
|
|
124
|
+
detail: "auto-provisioned from credentials — run 'switchbot events mqtt-tail' to test live connectivity",
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
catch { /* fall through */ }
|
|
129
|
+
}
|
|
130
|
+
return {
|
|
131
|
+
name: 'mqtt',
|
|
132
|
+
status: 'warn',
|
|
133
|
+
detail: "unavailable — configure credentials first (see credentials check above)",
|
|
134
|
+
};
|
|
135
|
+
}
|
|
104
136
|
export function registerDoctorCommand(program) {
|
|
105
137
|
program
|
|
106
138
|
.command('doctor')
|
|
@@ -122,6 +154,7 @@ Examples:
|
|
|
122
154
|
checkCache(),
|
|
123
155
|
checkQuotaFile(),
|
|
124
156
|
checkClockSkew(),
|
|
157
|
+
checkMqtt(),
|
|
125
158
|
];
|
|
126
159
|
const summary = {
|
|
127
160
|
ok: checks.filter((c) => c.status === 'ok').length,
|
package/dist/commands/events.js
CHANGED
|
@@ -1,5 +1,17 @@
|
|
|
1
1
|
import http from 'node:http';
|
|
2
2
|
import { printJson, isJsonMode, handleError, UsageError } from '../utils/output.js';
|
|
3
|
+
import { SwitchBotMqttClient } from '../mqtt/client.js';
|
|
4
|
+
import { fetchMqttCredential } from '../mqtt/credential.js';
|
|
5
|
+
import { tryLoadConfig } from '../config.js';
|
|
6
|
+
import { SinkDispatcher } from '../sinks/dispatcher.js';
|
|
7
|
+
import { StdoutSink } from '../sinks/stdout.js';
|
|
8
|
+
import { FileSink } from '../sinks/file.js';
|
|
9
|
+
import { WebhookSink } from '../sinks/webhook.js';
|
|
10
|
+
import { OpenClawSink } from '../sinks/openclaw.js';
|
|
11
|
+
import { TelegramSink } from '../sinks/telegram.js';
|
|
12
|
+
import { HomeAssistantSink } from '../sinks/homeassistant.js';
|
|
13
|
+
import { parseSinkEvent } from '../sinks/format.js';
|
|
14
|
+
import { deviceHistoryStore } from '../mcp/device-history.js';
|
|
3
15
|
const DEFAULT_PORT = 3000;
|
|
4
16
|
const DEFAULT_PATH = '/';
|
|
5
17
|
const MAX_BODY_BYTES = 1_000_000;
|
|
@@ -102,7 +114,7 @@ export function startReceiver(port, pathMatch, filter, onEvent) {
|
|
|
102
114
|
export function registerEventsCommand(program) {
|
|
103
115
|
const events = program
|
|
104
116
|
.command('events')
|
|
105
|
-
.description('
|
|
117
|
+
.description('Receive SwitchBot device events — webhook receiver (tail) or MQTT stream (mqtt-tail)');
|
|
106
118
|
events
|
|
107
119
|
.command('tail')
|
|
108
120
|
.description('Run a local HTTP receiver and print incoming webhook events as JSONL')
|
|
@@ -184,4 +196,179 @@ Examples:
|
|
|
184
196
|
handleError(error);
|
|
185
197
|
}
|
|
186
198
|
});
|
|
199
|
+
events
|
|
200
|
+
.command('mqtt-tail')
|
|
201
|
+
.description('Subscribe to SwitchBot MQTT shadow events and stream them as JSONL')
|
|
202
|
+
.option('--topic <pattern>', 'MQTT topic filter (default: SwitchBot shadow topic from credential)')
|
|
203
|
+
.option('--max <n>', 'Stop after N events (default: run until Ctrl-C)')
|
|
204
|
+
.option('--sink <type>', 'Output sink: stdout (default), file, webhook, openclaw, telegram, homeassistant (repeatable)', (val, prev) => [...prev, val], [])
|
|
205
|
+
.option('--sink-file <path>', 'File path for file sink')
|
|
206
|
+
.option('--webhook-url <url>', 'Webhook URL for webhook sink')
|
|
207
|
+
.option('--openclaw-url <url>', 'OpenClaw gateway URL (default: http://localhost:18789)')
|
|
208
|
+
.option('--openclaw-token <token>', 'Bearer token for OpenClaw (or env OPENCLAW_TOKEN)')
|
|
209
|
+
.option('--openclaw-model <id>', 'OpenClaw agent model ID to route events to')
|
|
210
|
+
.option('--telegram-token <token>', 'Telegram bot token (or env TELEGRAM_TOKEN)')
|
|
211
|
+
.option('--telegram-chat <id>', 'Telegram chat/channel ID to send messages to')
|
|
212
|
+
.option('--ha-url <url>', 'Home Assistant base URL (e.g. http://homeassistant.local:8123)')
|
|
213
|
+
.option('--ha-token <token>', 'HA long-lived access token (for REST event API)')
|
|
214
|
+
.option('--ha-webhook-id <id>', 'HA webhook ID (no auth; takes priority over --ha-token)')
|
|
215
|
+
.option('--ha-event-type <type>', 'HA event type for REST API (default: switchbot_event)')
|
|
216
|
+
.addHelpText('after', `
|
|
217
|
+
Connects to the SwitchBot MQTT service using your existing credentials
|
|
218
|
+
(SWITCHBOT_TOKEN + SWITCHBOT_SECRET or ~/.switchbot/config.json).
|
|
219
|
+
No additional MQTT configuration required.
|
|
220
|
+
|
|
221
|
+
Output (JSONL, one event per line):
|
|
222
|
+
{ "t": "<ISO>", "topic": "<mqtt-topic>", "payload": <parsed JSON or raw string> }
|
|
223
|
+
|
|
224
|
+
Sink types (--sink, repeatable):
|
|
225
|
+
stdout Print JSONL to stdout (default when no --sink given)
|
|
226
|
+
file Append JSONL to --sink-file <path>
|
|
227
|
+
webhook HTTP POST to --webhook-url <url>
|
|
228
|
+
openclaw POST to OpenClaw via --openclaw-url / --openclaw-token / --openclaw-model
|
|
229
|
+
telegram Send to Telegram via --telegram-token / --telegram-chat
|
|
230
|
+
homeassistant POST to HA via --ha-url + --ha-webhook-id (or --ha-token)
|
|
231
|
+
|
|
232
|
+
Device state is also persisted to ~/.switchbot/device-history/<deviceId>.json
|
|
233
|
+
regardless of sink configuration.
|
|
234
|
+
|
|
235
|
+
Examples:
|
|
236
|
+
$ switchbot events mqtt-tail
|
|
237
|
+
$ switchbot events mqtt-tail --max 10 --json
|
|
238
|
+
$ switchbot events mqtt-tail --sink file --sink-file ~/.switchbot/events.jsonl
|
|
239
|
+
$ switchbot events mqtt-tail --sink openclaw --openclaw-token abc --openclaw-model home-agent
|
|
240
|
+
$ switchbot events mqtt-tail --sink telegram --telegram-token <token> --telegram-chat <chatId>
|
|
241
|
+
$ switchbot events mqtt-tail --sink homeassistant --ha-url http://ha.local:8123 --ha-webhook-id switchbot
|
|
242
|
+
$ switchbot events mqtt-tail --sink stdout --sink openclaw --openclaw-token abc --openclaw-model home
|
|
243
|
+
`)
|
|
244
|
+
.action(async (options) => {
|
|
245
|
+
try {
|
|
246
|
+
const maxEvents = options.max !== undefined ? Number(options.max) : null;
|
|
247
|
+
if (maxEvents !== null && (!Number.isInteger(maxEvents) || maxEvents < 1)) {
|
|
248
|
+
throw new UsageError(`Invalid --max "${options.max}". Must be a positive integer.`);
|
|
249
|
+
}
|
|
250
|
+
const loaded = tryLoadConfig();
|
|
251
|
+
if (!loaded) {
|
|
252
|
+
throw new UsageError('No credentials found. Run \'switchbot config set-token\' or set SWITCHBOT_TOKEN and SWITCHBOT_SECRET.');
|
|
253
|
+
}
|
|
254
|
+
const sinkTypes = options.sink;
|
|
255
|
+
let dispatcher = null;
|
|
256
|
+
if (sinkTypes.length > 0) {
|
|
257
|
+
const sinks = sinkTypes.map((type) => {
|
|
258
|
+
switch (type) {
|
|
259
|
+
case 'stdout':
|
|
260
|
+
return new StdoutSink();
|
|
261
|
+
case 'file': {
|
|
262
|
+
if (!options.sinkFile)
|
|
263
|
+
throw new UsageError('--sink file requires --sink-file <path>');
|
|
264
|
+
return new FileSink(options.sinkFile);
|
|
265
|
+
}
|
|
266
|
+
case 'webhook': {
|
|
267
|
+
if (!options.webhookUrl)
|
|
268
|
+
throw new UsageError('--sink webhook requires --webhook-url <url>');
|
|
269
|
+
return new WebhookSink(options.webhookUrl);
|
|
270
|
+
}
|
|
271
|
+
case 'openclaw': {
|
|
272
|
+
const token = options.openclawToken ?? process.env.OPENCLAW_TOKEN;
|
|
273
|
+
if (!token)
|
|
274
|
+
throw new UsageError('--sink openclaw requires --openclaw-token or env OPENCLAW_TOKEN');
|
|
275
|
+
if (!options.openclawModel)
|
|
276
|
+
throw new UsageError('--sink openclaw requires --openclaw-model <id>');
|
|
277
|
+
return new OpenClawSink({ url: options.openclawUrl, token, model: options.openclawModel });
|
|
278
|
+
}
|
|
279
|
+
case 'telegram': {
|
|
280
|
+
const token = options.telegramToken ?? process.env.TELEGRAM_TOKEN;
|
|
281
|
+
if (!token)
|
|
282
|
+
throw new UsageError('--sink telegram requires --telegram-token or env TELEGRAM_TOKEN');
|
|
283
|
+
if (!options.telegramChat)
|
|
284
|
+
throw new UsageError('--sink telegram requires --telegram-chat <id>');
|
|
285
|
+
return new TelegramSink({ token, chatId: options.telegramChat });
|
|
286
|
+
}
|
|
287
|
+
case 'homeassistant': {
|
|
288
|
+
if (!options.haUrl)
|
|
289
|
+
throw new UsageError('--sink homeassistant requires --ha-url <url>');
|
|
290
|
+
if (!options.haWebhookId && !options.haToken) {
|
|
291
|
+
throw new UsageError('--sink homeassistant requires --ha-webhook-id or --ha-token');
|
|
292
|
+
}
|
|
293
|
+
return new HomeAssistantSink({
|
|
294
|
+
url: options.haUrl,
|
|
295
|
+
token: options.haToken,
|
|
296
|
+
webhookId: options.haWebhookId,
|
|
297
|
+
eventType: options.haEventType,
|
|
298
|
+
});
|
|
299
|
+
}
|
|
300
|
+
default:
|
|
301
|
+
throw new UsageError(`Unknown --sink type "${type}". Supported: stdout, file, webhook, openclaw, telegram, homeassistant`);
|
|
302
|
+
}
|
|
303
|
+
});
|
|
304
|
+
dispatcher = new SinkDispatcher(sinks);
|
|
305
|
+
}
|
|
306
|
+
if (!isJsonMode()) {
|
|
307
|
+
console.error('Fetching MQTT credentials from SwitchBot service…');
|
|
308
|
+
}
|
|
309
|
+
const credential = await fetchMqttCredential(loaded.token, loaded.secret);
|
|
310
|
+
const topic = options.topic ?? credential.topics.status;
|
|
311
|
+
let eventCount = 0;
|
|
312
|
+
const ac = new AbortController();
|
|
313
|
+
const client = new SwitchBotMqttClient(credential, () => fetchMqttCredential(loaded.token, loaded.secret));
|
|
314
|
+
const unsub = client.onMessage((msgTopic, payload) => {
|
|
315
|
+
let parsed;
|
|
316
|
+
try {
|
|
317
|
+
parsed = JSON.parse(payload.toString('utf-8'));
|
|
318
|
+
}
|
|
319
|
+
catch {
|
|
320
|
+
parsed = payload.toString('utf-8');
|
|
321
|
+
}
|
|
322
|
+
const t = new Date().toISOString();
|
|
323
|
+
if (dispatcher) {
|
|
324
|
+
const { deviceId, deviceType, text } = parseSinkEvent(parsed);
|
|
325
|
+
const sinkEvent = { t, topic: msgTopic, deviceId, deviceType, payload: parsed, text };
|
|
326
|
+
deviceHistoryStore.record(deviceId, msgTopic, deviceType, parsed, t);
|
|
327
|
+
dispatcher.dispatch(sinkEvent).catch(() => { });
|
|
328
|
+
}
|
|
329
|
+
else {
|
|
330
|
+
// Default behavior: record history + print to stdout
|
|
331
|
+
const { deviceId, deviceType } = parseSinkEvent(parsed);
|
|
332
|
+
deviceHistoryStore.record(deviceId, msgTopic, deviceType, parsed, t);
|
|
333
|
+
const record = { t, topic: msgTopic, payload: parsed };
|
|
334
|
+
if (isJsonMode()) {
|
|
335
|
+
printJson(record);
|
|
336
|
+
}
|
|
337
|
+
else {
|
|
338
|
+
console.log(JSON.stringify(record));
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
eventCount++;
|
|
342
|
+
if (maxEvents !== null && eventCount >= maxEvents) {
|
|
343
|
+
ac.abort();
|
|
344
|
+
}
|
|
345
|
+
});
|
|
346
|
+
const unsubState = client.onStateChange((state) => {
|
|
347
|
+
if (!isJsonMode()) {
|
|
348
|
+
console.error(`[${new Date().toLocaleTimeString()}] MQTT state: ${state}`);
|
|
349
|
+
}
|
|
350
|
+
});
|
|
351
|
+
await client.connect();
|
|
352
|
+
client.subscribe(topic);
|
|
353
|
+
if (!isJsonMode()) {
|
|
354
|
+
console.error(`Connected to ${credential.brokerUrl} (Ctrl-C to stop)`);
|
|
355
|
+
}
|
|
356
|
+
await new Promise((resolve) => {
|
|
357
|
+
const cleanup = () => {
|
|
358
|
+
process.removeListener('SIGINT', cleanup);
|
|
359
|
+
process.removeListener('SIGTERM', cleanup);
|
|
360
|
+
unsub();
|
|
361
|
+
unsubState();
|
|
362
|
+
dispatcher?.close().catch(() => { });
|
|
363
|
+
client.disconnect().then(resolve).catch(resolve);
|
|
364
|
+
};
|
|
365
|
+
process.once('SIGINT', cleanup);
|
|
366
|
+
process.once('SIGTERM', cleanup);
|
|
367
|
+
ac.signal.addEventListener('abort', cleanup, { once: true });
|
|
368
|
+
});
|
|
369
|
+
}
|
|
370
|
+
catch (error) {
|
|
371
|
+
handleError(error);
|
|
372
|
+
}
|
|
373
|
+
});
|
|
187
374
|
}
|