@switchbot/openapi-cli 2.4.0 → 2.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,10 +1,10 @@
1
1
  import { enumArg, stringArg } from '../utils/arg-parsers.js';
2
- import { printTable, printKeyValue, printJson, isJsonMode, handleError, UsageError } from '../utils/output.js';
2
+ import { printTable, printKeyValue, printJson, isJsonMode, handleError, UsageError, emitJsonError } from '../utils/output.js';
3
3
  import { resolveFormat, resolveFields, renderRows } from '../utils/format.js';
4
4
  import { findCatalogEntry, getEffectiveCatalog } from '../devices/catalog.js';
5
5
  import { getCachedDevice } from '../devices/cache.js';
6
6
  import { loadDeviceMeta } from '../devices/device-meta.js';
7
- import { resolveDeviceId } from '../utils/name-resolver.js';
7
+ import { resolveDeviceId, ALL_STRATEGIES } from '../utils/name-resolver.js';
8
8
  import { fetchDeviceList, fetchDeviceStatus, executeCommand, describeDevice, validateCommand, isDestructiveCommand, getDestructiveReason, buildHubLocationMap, DeviceNotFoundError, } from '../lib/devices.js';
9
9
  import { validateParameter } from '../devices/param-validator.js';
10
10
  import { registerBatchCommand } from './batch.js';
@@ -13,6 +13,7 @@ import { registerExplainCommand } from './explain.js';
13
13
  import { registerExpandCommand } from './expand.js';
14
14
  import { registerDevicesMetaCommand } from './device-meta.js';
15
15
  import { isDryRun } from '../utils/flags.js';
16
+ import { DryRunSignal } from '../api/client.js';
16
17
  export function registerDevicesCommand(program) {
17
18
  const COMMAND_TYPES = ['command', 'customize'];
18
19
  const devices = program
@@ -73,10 +74,12 @@ Examples:
73
74
  $ switchbot devices list --filter type="Air Conditioner"
74
75
  $ switchbot devices list --filter category=ir
75
76
  $ switchbot devices list --filter name=living,category=physical
77
+ $ switchbot devices list --filter 'name~living' # explicit substring
78
+ $ switchbot devices list --filter 'type=/Air.*/' # regex (case-insensitive)
76
79
  `)
77
80
  .option('--wide', 'Show all columns (controlType, family, roomID, room, hub, cloud)')
78
81
  .option('--show-hidden', 'Include devices hidden via "devices meta set --hide"')
79
- .option('--filter <expr>', 'Filter devices: "type=X", "name=X", "category=physical|ir", "room=X" (comma-separated key=value pairs)', stringArg('--filter'))
82
+ .option('--filter <expr>', 'Filter devices: comma-separated clauses. Each clause is "key=value" (substring; exact for category), "key~value" (explicit substring), or "key=/regex/" (case-insensitive regex). Supported keys: type, name, category, room.', stringArg('--filter'))
80
83
  .action(async (options) => {
81
84
  try {
82
85
  const body = await fetchDeviceList();
@@ -84,36 +87,77 @@ Examples:
84
87
  const fmt = resolveFormat();
85
88
  const deviceMeta = loadDeviceMeta();
86
89
  const hubLocation = buildHubLocationMap(deviceList);
87
- let listFilter = null;
90
+ const SUPPORTED_KEYS = ['type', 'name', 'category', 'room'];
91
+ let listClauses = null;
88
92
  if (options.filter) {
89
- listFilter = {};
93
+ listClauses = [];
90
94
  for (const pair of options.filter.split(',')) {
91
- const eq = pair.indexOf('=');
92
- if (eq === -1)
93
- throw new UsageError(`Invalid --filter pair "${pair.trim()}". Expected key=value.`);
94
- const k = pair.slice(0, eq).trim();
95
- const v = pair.slice(eq + 1).trim();
96
- if (!['type', 'name', 'category', 'room'].includes(k)) {
97
- throw new UsageError(`Unknown --filter key "${k}". Supported: type, name, category, room.`);
95
+ const trimmed = pair.trim();
96
+ if (!trimmed)
97
+ continue;
98
+ const regexMatch = /^([^=~]+)=\/(.*)\/$/.exec(trimmed);
99
+ const tildeIdx = trimmed.indexOf('~');
100
+ const eqIdx = trimmed.indexOf('=');
101
+ let key;
102
+ let op;
103
+ let raw;
104
+ let regex;
105
+ if (regexMatch) {
106
+ key = regexMatch[1].trim();
107
+ op = 'regex';
108
+ raw = regexMatch[2];
109
+ try {
110
+ regex = new RegExp(raw, 'i');
111
+ }
112
+ catch (err) {
113
+ throw new UsageError(`Invalid regex in --filter "${trimmed}": ${err.message}`);
114
+ }
115
+ }
116
+ else if (tildeIdx !== -1 && (eqIdx === -1 || tildeIdx < eqIdx)) {
117
+ key = trimmed.slice(0, tildeIdx).trim();
118
+ op = 'sub';
119
+ raw = trimmed.slice(tildeIdx + 1).trim().toLowerCase();
120
+ }
121
+ else if (eqIdx !== -1) {
122
+ key = trimmed.slice(0, eqIdx).trim();
123
+ op = 'eq';
124
+ raw = trimmed.slice(eqIdx + 1).trim().toLowerCase();
125
+ }
126
+ else {
127
+ throw new UsageError(`Invalid --filter pair "${trimmed}". Expected key=value, key~value, or key=/regex/.`);
128
+ }
129
+ if (!SUPPORTED_KEYS.includes(key)) {
130
+ throw new UsageError(`Unknown --filter key "${key}". Supported: ${SUPPORTED_KEYS.join(', ')}.`);
98
131
  }
99
- listFilter[k] = v.toLowerCase();
132
+ listClauses.push({ key: key, op, raw, regex });
100
133
  }
101
134
  }
102
135
  const matchesFilter = (entry) => {
103
- if (!listFilter)
136
+ if (!listClauses || listClauses.length === 0)
104
137
  return true;
105
- if (listFilter.type && !entry.type.toLowerCase().includes(listFilter.type))
106
- return false;
107
- if (listFilter.name && !entry.name.toLowerCase().includes(listFilter.name))
108
- return false;
109
- if (listFilter.category && entry.category !== listFilter.category)
110
- return false;
111
- if (listFilter.room && !entry.room.toLowerCase().includes(listFilter.room))
112
- return false;
138
+ for (const c of listClauses) {
139
+ const fieldVal = entry[c.key] ?? '';
140
+ const lower = fieldVal.toLowerCase();
141
+ let ok;
142
+ if (c.op === 'regex') {
143
+ ok = c.regex.test(fieldVal);
144
+ }
145
+ else if (c.op === 'sub') {
146
+ ok = lower.includes(c.raw);
147
+ }
148
+ else if (c.key === 'category') {
149
+ ok = lower === c.raw;
150
+ }
151
+ else {
152
+ ok = lower.includes(c.raw);
153
+ }
154
+ if (!ok)
155
+ return false;
156
+ }
113
157
  return true;
114
158
  };
115
159
  if (fmt === 'json' && process.argv.includes('--json')) {
116
- if (listFilter) {
160
+ if (listClauses) {
117
161
  const filteredDeviceList = deviceList.filter((d) => matchesFilter({ type: d.deviceType || '', name: d.deviceName, category: 'physical', room: d.roomName || '' }));
118
162
  const filteredIrList = infraredRemoteList.filter((d) => {
119
163
  const inherited = hubLocation.get(d.hubDeviceId);
@@ -171,19 +215,19 @@ Examples:
171
215
  ]);
172
216
  }
173
217
  if (rows.length === 0 && fmt === 'table') {
174
- console.log(listFilter ? 'No devices matched the filter.' : 'No devices found');
218
+ console.log(listClauses ? 'No devices matched the filter.' : 'No devices found');
175
219
  return;
176
220
  }
177
221
  const defaultFields = options.wide ? undefined : narrowHeaders;
178
222
  // Accept API field names and short aliases alongside canonical column names
179
223
  const DEVICE_LIST_ALIASES = {
180
- name: 'deviceName', deviceType: 'type', type: 'type',
224
+ id: 'deviceId', name: 'deviceName', deviceType: 'type', type: 'type',
181
225
  roomName: 'room', familyName: 'family',
182
226
  hubDeviceId: 'hub', enableCloudService: 'cloud',
183
227
  };
184
228
  renderRows(wideHeaders, rows, fmt, userFields ?? defaultFields, DEVICE_LIST_ALIASES);
185
229
  if (fmt === 'table') {
186
- const totalLabel = listFilter
230
+ const totalLabel = listClauses
187
231
  ? `${rows.length} match(es) (${deviceList.length} physical + ${infraredRemoteList.length} IR before filter)`
188
232
  : `${deviceList.length} physical device(s), ${infraredRemoteList.length} IR remote device(s)`;
189
233
  console.log(`\nTotal: ${totalLabel}`);
@@ -200,7 +244,7 @@ Examples:
200
244
  .description('Query the real-time status of a specific device')
201
245
  .argument('[deviceId]', 'Device ID from "devices list" (or use --name or --ids)')
202
246
  .option('--name <query>', 'Resolve device by fuzzy name instead of deviceId', stringArg('--name'))
203
- .option('--name-strategy <s>', 'Name match strategy: exact|prefix|substring|fuzzy|first|require-unique (default: fuzzy)', stringArg('--name-strategy'))
247
+ .option('--name-strategy <s>', `Name match strategy: ${ALL_STRATEGIES.join('|')} (default: fuzzy)`, stringArg('--name-strategy'))
204
248
  .option('--name-type <type>', 'Narrow --name by device type (e.g. "Bot", "Color Bulb")', stringArg('--name-type'))
205
249
  .option('--name-category <cat>', 'Narrow --name by category: physical|ir', enumArg('--name-category', ['physical', 'ir']))
206
250
  .option('--name-room <room>', 'Narrow --name by room name (substring match)', stringArg('--name-room'))
@@ -299,15 +343,16 @@ Examples:
299
343
  .description('Send a control command to a device')
300
344
  .argument('[deviceId]', 'Target device ID (or use --name)')
301
345
  .argument('[cmd]', 'Command name, e.g. turnOn, turnOff, setColor, setBrightness, setAll, startClean')
302
- .argument('[parameter]', 'Command parameter. Omit for commands like turnOn/turnOff (defaults to "default"). Format depends on the command (see below).')
346
+ .argument('[parameter]', 'Command parameter. Omit for commands like turnOn/turnOff (defaults to "default"). Format depends on the command (see below). Negative numbers like -1 are accepted as-is (use `--` before them only if Commander mis-parses in your shell).')
347
+ .allowUnknownOption()
303
348
  .option('--name <query>', 'Resolve device by fuzzy name instead of deviceId', stringArg('--name'))
304
- .option('--name-strategy <s>', 'Name match strategy: exact|prefix|substring|fuzzy|first|require-unique (default for command: require-unique)', stringArg('--name-strategy'))
349
+ .option('--name-strategy <s>', `Name match strategy: ${ALL_STRATEGIES.join('|')} (default for command: require-unique)`, stringArg('--name-strategy'))
305
350
  .option('--name-type <type>', 'Narrow --name by device type (e.g. "Bot", "Color Bulb")', stringArg('--name-type'))
306
351
  .option('--name-category <cat>', 'Narrow --name by category: physical|ir', enumArg('--name-category', ['physical', 'ir']))
307
352
  .option('--name-room <room>', 'Narrow --name by room name (substring match)', stringArg('--name-room'))
308
353
  .option('--type <commandType>', 'Command type: "command" for built-in commands (default), "customize" for user-defined IR buttons', enumArg('--type', COMMAND_TYPES), 'command')
309
354
  .option('--yes', 'Confirm a destructive command (Smart Lock unlock, Garage open, …). --dry-run is always allowed without --yes.')
310
- .option('--idempotency-key <key>', 'Idempotency key for deduplication (60s window; same key replays cached result)', stringArg('--idempotency-key'))
355
+ .option('--idempotency-key <key>', 'Client-supplied key to dedupe retries. process-local 60s window; cache is per Node process (MCP session, batch run, plan run). Independent CLI invocations do not share cache.', stringArg('--idempotency-key'))
311
356
  .addHelpText('after', `
312
357
  ────────────────────────────────────────────────────────────────────────
313
358
  For the full list of commands a specific device supports — and their
@@ -354,6 +399,10 @@ Examples:
354
399
  $ switchbot devices command <lockId> unlock --yes
355
400
  `)
356
401
  .action(async (deviceIdArg, cmdArg, parameter, options) => {
402
+ // Declared outside try so the DryRunSignal catch branch can reference them.
403
+ let _deviceId;
404
+ let _cmd;
405
+ let _parsedParam;
357
406
  try {
358
407
  // BUG-FIX: When --name is provided, Commander fills positionals left-to-right
359
408
  // starting at [deviceId]. Shift them back to their semantic slots.
@@ -387,6 +436,7 @@ Examples:
387
436
  category: options.nameCategory,
388
437
  room: options.nameRoom,
389
438
  });
439
+ _deviceId = deviceId;
390
440
  if (!getCachedDevice(deviceId)) {
391
441
  console.error(`Note: device ${deviceId} is not in the local cache — run 'switchbot devices list' first to enable command validation.`);
392
442
  }
@@ -398,7 +448,7 @@ Examples:
398
448
  if (err.hint)
399
449
  obj.hint = err.hint;
400
450
  obj.context = { validationKind: err.kind };
401
- console.error(JSON.stringify({ error: obj }));
451
+ emitJsonError(obj);
402
452
  }
403
453
  else {
404
454
  console.error(`Error: ${err.message}`);
@@ -428,14 +478,12 @@ Examples:
428
478
  const paramCheck = validateParameter(cachedForParam.type, cmd, parameter);
429
479
  if (!paramCheck.ok) {
430
480
  if (isJsonMode()) {
431
- console.error(JSON.stringify({
432
- error: {
433
- code: 2,
434
- kind: 'usage',
435
- message: paramCheck.error,
436
- context: { command: cmd, deviceType: cachedForParam.type, deviceId },
437
- },
438
- }));
481
+ emitJsonError({
482
+ code: 2,
483
+ kind: 'usage',
484
+ message: paramCheck.error,
485
+ context: { command: cmd, deviceType: cachedForParam.type, deviceId },
486
+ });
439
487
  }
440
488
  else {
441
489
  console.error(`Error: ${paramCheck.error}`);
@@ -452,17 +500,15 @@ Examples:
452
500
  const typeLabel = cachedForGuard?.type ?? 'unknown';
453
501
  const reason = getDestructiveReason(cachedForGuard?.type, cmd, options.type);
454
502
  if (isJsonMode()) {
455
- console.error(JSON.stringify({
456
- error: {
457
- code: 2,
458
- kind: 'guard',
459
- message: `"${cmd}" on ${typeLabel} is destructive and requires --yes.`,
460
- hint: reason
461
- ? `Re-run with --yes to confirm. Reason: ${reason}`
462
- : 'Re-run with --yes to confirm, or --dry-run to preview without sending.',
463
- context: { command: cmd, deviceType: typeLabel, deviceId, ...(reason ? { destructiveReason: reason } : {}) },
464
- },
465
- }));
503
+ emitJsonError({
504
+ code: 2,
505
+ kind: 'guard',
506
+ message: `"${cmd}" on ${typeLabel} is destructive and requires --yes.`,
507
+ hint: reason
508
+ ? `Re-run with --yes to confirm. Reason: ${reason}`
509
+ : 'Re-run with --yes to confirm, or --dry-run to preview without sending.',
510
+ context: { command: cmd, deviceType: typeLabel, deviceId, ...(reason ? { destructiveReason: reason } : {}) },
511
+ });
466
512
  }
467
513
  else {
468
514
  console.error(`Refusing to run destructive command "${cmd}" on ${typeLabel} without --yes.`);
@@ -486,6 +532,9 @@ Examples:
486
532
  // keep as string
487
533
  }
488
534
  }
535
+ // Capture for DryRunSignal catch branch (which runs after executeCommand throws).
536
+ _cmd = cmd;
537
+ _parsedParam = parsedParam;
489
538
  const body = await executeCommand(deviceId, cmd, parsedParam, options.type, undefined, { idempotencyKey: options.idempotencyKey });
490
539
  const isIr = getCachedDevice(deviceId)?.category === 'ir';
491
540
  const verification = isIr
@@ -523,6 +572,17 @@ Examples:
523
572
  // Error('__exit__')) so they aren't double-handled and the exit code is preserved.
524
573
  if (error instanceof Error && error.message === '__exit__')
525
574
  throw error;
575
+ if (error instanceof DryRunSignal) {
576
+ const commandType = (options.type ?? 'command');
577
+ const wouldSend = { deviceId: _deviceId, command: _cmd, parameter: _parsedParam, commandType };
578
+ if (isJsonMode()) {
579
+ printJson({ dryRun: true, wouldSend });
580
+ }
581
+ else {
582
+ console.log(`[dry-run] Would POST devices/${_deviceId}/commands with ${JSON.stringify({ command: _cmd, parameter: _parsedParam, commandType })}`);
583
+ }
584
+ return;
585
+ }
526
586
  handleError(error);
527
587
  }
528
588
  });
@@ -610,7 +670,7 @@ Examples:
610
670
  .description('Describe a device by ID: metadata + supported commands + status fields (1 API call)')
611
671
  .argument('[deviceId]', 'Target device ID (or use --name)')
612
672
  .option('--name <query>', 'Resolve device by fuzzy name instead of deviceId', stringArg('--name'))
613
- .option('--name-strategy <s>', 'Name match strategy: exact|prefix|substring|fuzzy|first|require-unique (default: fuzzy)', stringArg('--name-strategy'))
673
+ .option('--name-strategy <s>', `Name match strategy: ${ALL_STRATEGIES.join('|')} (default: fuzzy)`, stringArg('--name-strategy'))
614
674
  .option('--name-type <type>', 'Narrow --name by device type', stringArg('--name-type'))
615
675
  .option('--name-category <cat>', 'Narrow --name by category: physical|ir', enumArg('--name-category', ['physical', 'ir']))
616
676
  .option('--name-room <room>', 'Narrow --name by room name (substring match)', stringArg('--name-room'))
@@ -716,8 +776,20 @@ Examples:
716
776
  }
717
777
  catch (error) {
718
778
  if (error instanceof DeviceNotFoundError) {
719
- console.error(error.message);
720
- console.error(`Try 'switchbot devices list' to see the full list.`);
779
+ const message = `${error.message} Try 'switchbot devices list' to see the full list.`;
780
+ if (isJsonMode()) {
781
+ emitJsonError({
782
+ code: 1,
783
+ kind: 'runtime',
784
+ message,
785
+ errorClass: 'runtime',
786
+ transient: false,
787
+ });
788
+ }
789
+ else {
790
+ console.error(error.message);
791
+ console.error(`Try 'switchbot devices list' to see the full list.`);
792
+ }
721
793
  process.exit(1);
722
794
  }
723
795
  handleError(error);
@@ -1,7 +1,9 @@
1
1
  import http from 'node:http';
2
2
  import crypto from 'node:crypto';
3
3
  import { printJson, isJsonMode, handleError, UsageError } from '../utils/output.js';
4
- import { intArg, stringArg } from '../utils/arg-parsers.js';
4
+ import { intArg, stringArg, durationArg } from '../utils/arg-parsers.js';
5
+ import { parseDurationToMs } from '../utils/flags.js';
6
+ import { parseFilterExpr, matchClause, FilterSyntaxError } from '../utils/filter.js';
5
7
  import { SwitchBotMqttClient } from '../mqtt/client.js';
6
8
  import { fetchMqttCredential } from '../mqtt/credential.js';
7
9
  import { tryLoadConfig } from '../config.js';
@@ -28,45 +30,42 @@ function extractEventId(parsed) {
28
30
  return ctx.eventId;
29
31
  return null;
30
32
  }
31
- function matchFilter(body, filter) {
32
- if (!filter)
33
+ function matchFilter(body, clauses) {
34
+ if (!clauses || clauses.length === 0)
33
35
  return true;
34
36
  if (!body || typeof body !== 'object')
35
37
  return false;
36
38
  const b = body;
37
39
  const ctx = (b.context ?? b);
38
- if (filter.deviceId && ctx.deviceMac !== filter.deviceId && ctx.deviceId !== filter.deviceId) {
39
- return false;
40
- }
41
- if (filter.type && ctx.deviceType !== filter.type) {
42
- return false;
40
+ for (const c of clauses) {
41
+ let candidate;
42
+ if (c.key === 'deviceId') {
43
+ const mac = ctx.deviceMac;
44
+ const id = ctx.deviceId;
45
+ candidate = String(typeof mac === 'string' && mac ? mac : typeof id === 'string' ? id : '');
46
+ }
47
+ else {
48
+ const t = ctx.deviceType;
49
+ candidate = typeof t === 'string' ? t : '';
50
+ }
51
+ if (!matchClause(candidate, c))
52
+ return false;
43
53
  }
44
54
  return true;
45
55
  }
56
+ const EVENT_FILTER_KEYS = ['deviceId', 'type'];
46
57
  function parseFilter(flag) {
47
58
  if (!flag)
48
59
  return null;
49
- const allowed = new Set(['deviceId', 'type']);
50
- const out = {};
51
- for (const pair of flag.split(',')) {
52
- const eq = pair.indexOf('=');
53
- if (eq === -1 || eq === 0) {
54
- throw new UsageError(`Invalid --filter pair "${pair.trim()}". Expected "key=value". Supported keys: deviceId, type.`);
55
- }
56
- const k = pair.slice(0, eq).trim();
57
- const v = pair.slice(eq + 1).trim();
58
- if (!v) {
59
- throw new UsageError(`Empty value for --filter key "${k}". Expected "key=value". Supported keys: deviceId, type.`);
60
- }
61
- if (!allowed.has(k)) {
62
- throw new UsageError(`Unknown --filter key "${k}". Supported keys: deviceId, type.`);
60
+ try {
61
+ return parseFilterExpr(flag, EVENT_FILTER_KEYS);
62
+ }
63
+ catch (e) {
64
+ if (e instanceof FilterSyntaxError) {
65
+ throw new UsageError(e.message);
63
66
  }
64
- if (k === 'deviceId')
65
- out.deviceId = v;
66
- else if (k === 'type')
67
- out.type = v;
67
+ throw e;
68
68
  }
69
- return out;
70
69
  }
71
70
  export function startReceiver(port, pathMatch, filter, onEvent) {
72
71
  const server = http.createServer((req, res) => {
@@ -133,8 +132,9 @@ export function registerEventsCommand(program) {
133
132
  .description('Run a local HTTP receiver and print incoming webhook events as JSONL')
134
133
  .option('--port <n>', `Local port to listen on (default ${DEFAULT_PORT})`, intArg('--port', { min: 1, max: 65535 }), String(DEFAULT_PORT))
135
134
  .option('--path <p>', `HTTP path to match (default "${DEFAULT_PATH}"; use "*" for all paths)`, stringArg('--path'), DEFAULT_PATH)
136
- .option('--filter <expr>', 'Filter events, e.g. "deviceId=ABC123" or "type=Bot" (comma-separated)', stringArg('--filter'))
135
+ .option('--filter <expr>', 'Filter events by deviceId / type. Grammar: "key=value" (substring), "key~value" (substring), "key=/regex/" (regex). Comma-separated clauses are AND-ed.', stringArg('--filter'))
137
136
  .option('--max <n>', 'Stop after N matching events (default: run until Ctrl-C)', intArg('--max', { min: 1 }))
137
+ .option('--for <dur>', 'Stop after elapsed time (e.g. "5m", "30s"). Combines with --max: first limit wins.', durationArg('--for'))
138
138
  .addHelpText('after', `
139
139
  SwitchBot posts events to a single webhook URL configured via:
140
140
  $ switchbot webhook setup https://<your-public-host>/<path>
@@ -147,14 +147,20 @@ Output (JSONL, one event per line):
147
147
  { "t": "<ISO>", "remote": "<ip:port>", "path": "/",
148
148
  "body": <parsed JSON or raw string>, "matched": true }
149
149
 
150
- Filter grammar: comma-separated "key=value" pairs. Supported keys:
151
- deviceId=<id> match by context.deviceMac / context.deviceId
152
- type=<type> match by context.deviceType (e.g. "Bot", "WoMeter")
150
+ Filter grammar: comma-separated clauses (AND-ed). Each clause is one of
151
+ key=value — case-insensitive substring
152
+ key~value — explicit case-insensitive substring
153
+ key=/regex/ — case-insensitive regex
154
+
155
+ Supported keys:
156
+ deviceId match by context.deviceMac / context.deviceId
157
+ type match by context.deviceType (e.g. "Bot", "WoMeter")
153
158
 
154
159
  Examples:
155
160
  $ switchbot events tail --port 3000
156
161
  $ switchbot events tail --port 3000 --filter deviceId=ABC123
157
- $ switchbot events tail --filter 'type=WoMeter' --max 5 --json
162
+ $ switchbot events tail --filter 'type~Meter' --max 5 --json
163
+ $ switchbot events tail --filter 'type=/Bot|Meter/'
158
164
  `)
159
165
  .action(async (options) => {
160
166
  try {
@@ -166,9 +172,13 @@ Examples:
166
172
  if (maxMatched !== null && (!Number.isFinite(maxMatched) || maxMatched < 1)) {
167
173
  throw new UsageError(`Invalid --max "${options.max}". Must be a positive integer.`);
168
174
  }
175
+ const forMs = options.for ? parseDurationToMs(options.for) : null;
169
176
  const filter = parseFilter(options.filter);
170
177
  let matchedCount = 0;
171
178
  const ac = new AbortController();
179
+ const forTimer = forMs !== null && forMs > 0
180
+ ? setTimeout(() => ac.abort(), forMs)
181
+ : null;
172
182
  await new Promise((resolve, reject) => {
173
183
  let server = null;
174
184
  try {
@@ -197,6 +207,8 @@ Examples:
197
207
  if (!isJsonMode())
198
208
  console.error(startMsg);
199
209
  const cleanup = () => {
210
+ if (forTimer)
211
+ clearTimeout(forTimer);
200
212
  server?.close();
201
213
  resolve();
202
214
  };
@@ -214,6 +226,7 @@ Examples:
214
226
  .description('Subscribe to SwitchBot MQTT shadow events and stream them as JSONL')
215
227
  .option('--topic <pattern>', 'MQTT topic filter (default: SwitchBot shadow topic from credential)', stringArg('--topic'))
216
228
  .option('--max <n>', 'Stop after N events (default: run until Ctrl-C)', intArg('--max', { min: 1 }))
229
+ .option('--for <dur>', 'Stop after elapsed time (e.g. "5m", "30s"). Combines with --max: first limit wins.', durationArg('--for'))
217
230
  .option('--sink <type>', 'Output sink: stdout (default), file, webhook, openclaw, telegram, homeassistant (repeatable)', (val, prev) => [...prev, val], [])
218
231
  .option('--sink-file <path>', 'File path for file sink', stringArg('--sink-file'))
219
232
  .option('--webhook-url <url>', 'Webhook URL for webhook sink', stringArg('--webhook-url'))
@@ -235,6 +248,7 @@ Output (JSONL, one event per line):
235
248
  { "t": "<ISO>", "eventId": "<uuid>", "topic": "<mqtt-topic>", "payload": <parsed JSON or raw string> }
236
249
 
237
250
  Control records (interleaved, no "payload" field — use type-prefix to filter):
251
+ { "type": "__session_start", "at": "<ISO>", "eventId": "<uuid>", "state": "connecting" } before credential fetch (JSON mode only)
238
252
  { "type": "__connect", "at": "<ISO>", "eventId": "<uuid>" } first successful connect
239
253
  { "type": "__reconnect", "at": "<ISO>", "eventId": "<uuid>" } connect after a disconnect
240
254
  { "type": "__disconnect", "at": "<ISO>", "eventId": "<uuid>" } reconnecting or failed
@@ -272,6 +286,7 @@ Examples:
272
286
  if (maxEvents !== null && (!Number.isInteger(maxEvents) || maxEvents < 1)) {
273
287
  throw new UsageError(`Invalid --max "${options.max}". Must be a positive integer.`);
274
288
  }
289
+ const forMs = options.for ? parseDurationToMs(options.for) : null;
275
290
  const loaded = tryLoadConfig();
276
291
  if (!loaded) {
277
292
  throw new UsageError('No credentials found. Run \'switchbot config set-token\' or set SWITCHBOT_TOKEN and SWITCHBOT_SECRET.');
@@ -331,10 +346,24 @@ Examples:
331
346
  if (!isJsonMode()) {
332
347
  console.error('Fetching MQTT credentials from SwitchBot service…');
333
348
  }
349
+ // Emit a __session_start envelope immediately (before any credential
350
+ // fetch) so JSON consumers can distinguish "connecting" from "never
351
+ // connected" even when mqtt-tail exits before the broker connects.
352
+ if (isJsonMode()) {
353
+ printJson({
354
+ type: '__session_start',
355
+ at: new Date().toISOString(),
356
+ eventId: crypto.randomUUID(),
357
+ state: 'connecting',
358
+ });
359
+ }
334
360
  const credential = await fetchMqttCredential(loaded.token, loaded.secret);
335
361
  const topic = options.topic ?? credential.topics.status;
336
362
  let eventCount = 0;
337
363
  const ac = new AbortController();
364
+ const forTimer = forMs !== null && forMs > 0
365
+ ? setTimeout(() => ac.abort(), forMs)
366
+ : null;
338
367
  const client = new SwitchBotMqttClient(credential, () => fetchMqttCredential(loaded.token, loaded.secret));
339
368
  const unsub = client.onMessage((msgTopic, payload) => {
340
369
  let parsed;
@@ -385,6 +414,13 @@ Examples:
385
414
  else {
386
415
  console.log(JSON.stringify(ctl));
387
416
  }
417
+ // Persist to __control.jsonl — best-effort, never blocks the stream.
418
+ try {
419
+ deviceHistoryStore.recordControl(ctl);
420
+ }
421
+ catch {
422
+ // swallow
423
+ }
388
424
  };
389
425
  const unsubState = client.onStateChange((state) => {
390
426
  if (!isJsonMode()) {
@@ -413,6 +449,8 @@ Examples:
413
449
  }
414
450
  await new Promise((resolve) => {
415
451
  const cleanup = () => {
452
+ if (forTimer)
453
+ clearTimeout(forTimer);
416
454
  process.removeListener('SIGINT', cleanup);
417
455
  process.removeListener('SIGTERM', cleanup);
418
456
  unsub();
@@ -1,5 +1,5 @@
1
1
  import { intArg, stringArg } from '../utils/arg-parsers.js';
2
- import { handleError, isJsonMode, printJson, UsageError } from '../utils/output.js';
2
+ import { handleError, isJsonMode, printJson, UsageError, emitJsonError } from '../utils/output.js';
3
3
  import { getCachedDevice } from '../devices/cache.js';
4
4
  import { executeCommand, isDestructiveCommand, getDestructiveReason } from '../lib/devices.js';
5
5
  import { isDryRun } from '../utils/flags.js';
@@ -93,10 +93,12 @@ Examples:
93
93
  if (!options.yes && !isDryRun() && isDestructiveCommand(deviceType, command, 'command')) {
94
94
  const reason = getDestructiveReason(deviceType, command, 'command');
95
95
  if (isJsonMode()) {
96
- console.error(JSON.stringify({ error: { code: 2, kind: 'guard',
97
- message: `"${command}" on ${deviceType || 'device'} is destructive and requires --yes.`,
98
- hint: reason ? `Re-run with --yes. Reason: ${reason}` : 'Re-run with --yes to confirm.',
99
- } }));
96
+ emitJsonError({
97
+ code: 2,
98
+ kind: 'guard',
99
+ message: `"${command}" on ${deviceType || 'device'} is destructive and requires --yes.`,
100
+ hint: reason ? `Re-run with --yes. Reason: ${reason}` : 'Re-run with --yes to confirm.',
101
+ });
100
102
  }
101
103
  else {
102
104
  console.error(`Refusing to run destructive command "${command}" without --yes.`);