@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.
@@ -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
- printJson(body);
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
- renderRows(wideHeaders, rows, fmt, userFields ?? defaultFields);
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
- console.log(`\nTotal: ${deviceList.length} physical device(s), ${infraredRemoteList.length} IR remote device(s)`);
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 allHeaders = Object.keys(body);
171
- const allRows = [Object.values(body)];
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('<cmd>', 'Command name, e.g. turnOn, turnOff, setColor, setBrightness, setAll, startClean')
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, cmd, parameter, options) => {
239
- const deviceId = resolveDeviceId(deviceIdArg, options.name);
240
- const validation = validateCommand(deviceId, cmd, parameter, options.type);
241
- if (!validation.ok) {
242
- const err = validation.error;
243
- if (isJsonMode()) {
244
- const obj = { code: 2, kind: 'usage', message: err.message };
245
- if (err.hint)
246
- obj.hint = err.hint;
247
- obj.context = { validationKind: err.kind };
248
- console.error(JSON.stringify({ error: obj }));
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
- console.error(`Error: ${err.message}`);
252
- if (err.hint)
253
- console.error(err.hint);
254
- if (err.kind === 'unknown-command') {
255
- const cached = getCachedDevice(deviceId);
256
- if (cached) {
257
- console.error(`Run 'switchbot devices commands ${JSON.stringify(cached.type)}' for parameter formats and descriptions.`);
258
- 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.)`);
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
- process.exit(2);
263
- }
264
- const cachedForGuard = getCachedDevice(deviceId);
265
- if (!options.yes &&
266
- !isDryRun() &&
267
- isDestructiveCommand(cachedForGuard?.type, cmd, options.type)) {
268
- const typeLabel = cachedForGuard?.type ?? 'unknown';
269
- const reason = getDestructiveReason(cachedForGuard?.type, cmd, options.type);
270
- if (isJsonMode()) {
271
- console.error(JSON.stringify({
272
- error: {
273
- code: 2,
274
- kind: 'guard',
275
- message: `"${cmd}" on ${typeLabel} is destructive and requires --yes.`,
276
- hint: reason
277
- ? `Re-run with --yes to confirm. Reason: ${reason}`
278
- : 'Re-run with --yes to confirm, or --dry-run to preview without sending.',
279
- context: { command: cmd, deviceType: typeLabel, deviceId, ...(reason ? { destructiveReason: reason } : {}) },
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
- else {
284
- console.error(`Refusing to run destructive command "${cmd}" on ${typeLabel} without --yes.`);
285
- if (reason)
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
- console.log(`✓ Command sent: ${cmd}`);
315
- if (isIr)
316
- console.log(' Note: IR command sent — no device confirmation (fire-and-forget).');
317
- if (body && typeof body === 'object' && Object.keys(body).length > 0) {
318
- printKeyValue(body);
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
- return [label, c.parameter, c.description];
681
+ const base = [label, c.parameter, c.description];
682
+ return hasExamples ? [...base, (c.exampleParams ?? []).join(' | ') || ''] : base;
546
683
  });
547
- printTable(['command', 'parameter', 'description'], rows);
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.');
@@ -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,
@@ -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('Subscribe to local webhook events forwarded by SwitchBot');
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
  }