@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.
- package/README.md +45 -4
- package/dist/commands/agent-bootstrap.js +6 -0
- package/dist/commands/batch.js +65 -25
- package/dist/commands/cache.js +18 -1
- package/dist/commands/capabilities.js +8 -0
- package/dist/commands/config.js +6 -6
- package/dist/commands/device-meta.js +18 -1
- package/dist/commands/devices.js +125 -53
- package/dist/commands/events.js +70 -32
- package/dist/commands/expand.js +7 -5
- package/dist/commands/history.js +124 -24
- package/dist/commands/mcp.js +178 -43
- package/dist/commands/plan.js +12 -2
- package/dist/commands/scenes.js +52 -1
- package/dist/commands/schema.js +6 -0
- package/dist/commands/watch.js +7 -0
- package/dist/devices/cache.js +61 -26
- package/dist/devices/history-agg.js +138 -0
- package/dist/devices/history-query.js +1 -1
- package/dist/index.js +7 -0
- package/dist/mcp/device-history.js +20 -9
- package/dist/utils/arg-parsers.js +2 -1
- package/dist/utils/audit.js +1 -1
- package/dist/utils/filter.js +102 -39
- package/dist/utils/flags.js +3 -1
- package/dist/utils/format.js +11 -3
- package/dist/utils/name-resolver.js +11 -3
- package/dist/utils/output.js +37 -6
- package/dist/version.js +4 -0
- package/package.json +1 -1
package/dist/commands/devices.js
CHANGED
|
@@ -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: "
|
|
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
|
-
|
|
90
|
+
const SUPPORTED_KEYS = ['type', 'name', 'category', 'room'];
|
|
91
|
+
let listClauses = null;
|
|
88
92
|
if (options.filter) {
|
|
89
|
-
|
|
93
|
+
listClauses = [];
|
|
90
94
|
for (const pair of options.filter.split(',')) {
|
|
91
|
-
const
|
|
92
|
-
if (
|
|
93
|
-
|
|
94
|
-
const
|
|
95
|
-
const
|
|
96
|
-
|
|
97
|
-
|
|
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
|
-
|
|
132
|
+
listClauses.push({ key: key, op, raw, regex });
|
|
100
133
|
}
|
|
101
134
|
}
|
|
102
135
|
const matchesFilter = (entry) => {
|
|
103
|
-
if (!
|
|
136
|
+
if (!listClauses || listClauses.length === 0)
|
|
104
137
|
return true;
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
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 (
|
|
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(
|
|
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 =
|
|
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>',
|
|
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>',
|
|
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>', '
|
|
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
|
-
|
|
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
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
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
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
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>',
|
|
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
|
-
|
|
720
|
-
|
|
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);
|
package/dist/commands/events.js
CHANGED
|
@@ -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,
|
|
32
|
-
if (!
|
|
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
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
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
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
if (
|
|
54
|
-
throw new UsageError(
|
|
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
|
-
|
|
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
|
|
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
|
|
151
|
-
|
|
152
|
-
|
|
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
|
|
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();
|
package/dist/commands/expand.js
CHANGED
|
@@ -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
|
-
|
|
97
|
-
|
|
98
|
-
|
|
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.`);
|