@switchbot/openapi-cli 2.2.1 → 2.4.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 +2 -0
- package/dist/api/client.js +33 -1
- package/dist/commands/agent-bootstrap.js +125 -0
- package/dist/commands/batch.js +108 -14
- package/dist/commands/capabilities.js +200 -62
- package/dist/commands/config.js +132 -9
- package/dist/commands/devices.js +85 -12
- package/dist/commands/doctor.js +105 -14
- package/dist/commands/events.js +53 -4
- package/dist/commands/expand.js +1 -77
- package/dist/commands/history.js +124 -2
- package/dist/commands/mcp.js +81 -2
- package/dist/commands/quota.js +4 -2
- package/dist/commands/schema.js +95 -5
- package/dist/config.js +71 -2
- package/dist/devices/history-query.js +181 -0
- package/dist/devices/param-validator.js +263 -0
- package/dist/index.js +12 -2
- package/dist/lib/devices.js +34 -15
- package/dist/lib/idempotency.js +56 -22
- package/dist/mcp/device-history.js +75 -7
- package/dist/utils/arg-parsers.js +4 -1
- package/dist/utils/audit.js +66 -1
- package/dist/utils/flags.js +18 -0
- package/dist/utils/name-resolver.js +76 -28
- package/dist/utils/output.js +115 -19
- package/dist/utils/quota.js +14 -0
- package/dist/utils/redact.js +68 -0
- package/package.json +1 -1
package/dist/commands/devices.js
CHANGED
|
@@ -6,6 +6,7 @@ import { getCachedDevice } from '../devices/cache.js';
|
|
|
6
6
|
import { loadDeviceMeta } from '../devices/device-meta.js';
|
|
7
7
|
import { resolveDeviceId } from '../utils/name-resolver.js';
|
|
8
8
|
import { fetchDeviceList, fetchDeviceStatus, executeCommand, describeDevice, validateCommand, isDestructiveCommand, getDestructiveReason, buildHubLocationMap, DeviceNotFoundError, } from '../lib/devices.js';
|
|
9
|
+
import { validateParameter } from '../devices/param-validator.js';
|
|
9
10
|
import { registerBatchCommand } from './batch.js';
|
|
10
11
|
import { registerWatchCommand } from './watch.js';
|
|
11
12
|
import { registerExplainCommand } from './explain.js';
|
|
@@ -199,6 +200,10 @@ Examples:
|
|
|
199
200
|
.description('Query the real-time status of a specific device')
|
|
200
201
|
.argument('[deviceId]', 'Device ID from "devices list" (or use --name or --ids)')
|
|
201
202
|
.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'))
|
|
204
|
+
.option('--name-type <type>', 'Narrow --name by device type (e.g. "Bot", "Color Bulb")', stringArg('--name-type'))
|
|
205
|
+
.option('--name-category <cat>', 'Narrow --name by category: physical|ir', enumArg('--name-category', ['physical', 'ir']))
|
|
206
|
+
.option('--name-room <room>', 'Narrow --name by room name (substring match)', stringArg('--name-room'))
|
|
202
207
|
.option('--ids <list>', 'Comma-separated device IDs for batch status (incompatible with --name)', stringArg('--ids'))
|
|
203
208
|
.addHelpText('after', `
|
|
204
209
|
Status fields vary by device type. To discover them without a live call:
|
|
@@ -260,7 +265,12 @@ Examples:
|
|
|
260
265
|
}
|
|
261
266
|
return;
|
|
262
267
|
}
|
|
263
|
-
const deviceId = resolveDeviceId(deviceIdArg, options.name
|
|
268
|
+
const deviceId = resolveDeviceId(deviceIdArg, options.name, {
|
|
269
|
+
strategy: options.nameStrategy ?? 'fuzzy',
|
|
270
|
+
type: options.nameType,
|
|
271
|
+
category: options.nameCategory,
|
|
272
|
+
room: options.nameRoom,
|
|
273
|
+
});
|
|
264
274
|
const body = await fetchDeviceStatus(deviceId);
|
|
265
275
|
const fetchedAt = new Date().toISOString();
|
|
266
276
|
const fmt = resolveFormat();
|
|
@@ -291,6 +301,10 @@ Examples:
|
|
|
291
301
|
.argument('[cmd]', 'Command name, e.g. turnOn, turnOff, setColor, setBrightness, setAll, startClean')
|
|
292
302
|
.argument('[parameter]', 'Command parameter. Omit for commands like turnOn/turnOff (defaults to "default"). Format depends on the command (see below).')
|
|
293
303
|
.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'))
|
|
305
|
+
.option('--name-type <type>', 'Narrow --name by device type (e.g. "Bot", "Color Bulb")', stringArg('--name-type'))
|
|
306
|
+
.option('--name-category <cat>', 'Narrow --name by category: physical|ir', enumArg('--name-category', ['physical', 'ir']))
|
|
307
|
+
.option('--name-room <room>', 'Narrow --name by room name (substring match)', stringArg('--name-room'))
|
|
294
308
|
.option('--type <commandType>', 'Command type: "command" for built-in commands (default), "customize" for user-defined IR buttons', enumArg('--type', COMMAND_TYPES), 'command')
|
|
295
309
|
.option('--yes', 'Confirm a destructive command (Smart Lock unlock, Garage open, …). --dry-run is always allowed without --yes.')
|
|
296
310
|
.option('--idempotency-key <key>', 'Idempotency key for deduplication (60s window; same key replays cached result)', stringArg('--idempotency-key'))
|
|
@@ -341,19 +355,22 @@ Examples:
|
|
|
341
355
|
`)
|
|
342
356
|
.action(async (deviceIdArg, cmdArg, parameter, options) => {
|
|
343
357
|
try {
|
|
344
|
-
// BUG-FIX: When --name is provided, Commander
|
|
345
|
-
//
|
|
358
|
+
// BUG-FIX: When --name is provided, Commander fills positionals left-to-right
|
|
359
|
+
// starting at [deviceId]. Shift them back to their semantic slots.
|
|
346
360
|
let cmd;
|
|
347
361
|
let effectiveDeviceIdArg;
|
|
348
362
|
if (options.name) {
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
}
|
|
352
|
-
if (!deviceIdArg && !cmdArg) {
|
|
363
|
+
// `--name "x" <cmd> [parameter]` → Commander binds deviceIdArg=<cmd>, cmdArg=[parameter].
|
|
364
|
+
if (!deviceIdArg) {
|
|
353
365
|
throw new UsageError('Command name is required (e.g. turnOn, turnOff, setAll).');
|
|
354
366
|
}
|
|
355
|
-
|
|
356
|
-
|
|
367
|
+
cmd = deviceIdArg;
|
|
368
|
+
if (cmdArg !== undefined) {
|
|
369
|
+
if (parameter !== undefined) {
|
|
370
|
+
throw new UsageError('Too many positional arguments after --name. Expected: --name <query> <cmd> [parameter].');
|
|
371
|
+
}
|
|
372
|
+
parameter = cmdArg;
|
|
373
|
+
}
|
|
357
374
|
effectiveDeviceIdArg = undefined;
|
|
358
375
|
}
|
|
359
376
|
else {
|
|
@@ -363,7 +380,13 @@ Examples:
|
|
|
363
380
|
cmd = cmdArg;
|
|
364
381
|
effectiveDeviceIdArg = deviceIdArg;
|
|
365
382
|
}
|
|
366
|
-
const deviceId = resolveDeviceId(effectiveDeviceIdArg, options.name
|
|
383
|
+
const deviceId = resolveDeviceId(effectiveDeviceIdArg, options.name, {
|
|
384
|
+
// Mutating command → default require-unique (never silently pick between ambiguous matches).
|
|
385
|
+
strategy: options.nameStrategy ?? 'require-unique',
|
|
386
|
+
type: options.nameType,
|
|
387
|
+
category: options.nameCategory,
|
|
388
|
+
room: options.nameRoom,
|
|
389
|
+
});
|
|
367
390
|
if (!getCachedDevice(deviceId)) {
|
|
368
391
|
console.error(`Note: device ${deviceId} is not in the local cache — run 'switchbot devices list' first to enable command validation.`);
|
|
369
392
|
}
|
|
@@ -391,6 +414,37 @@ Examples:
|
|
|
391
414
|
}
|
|
392
415
|
process.exit(2);
|
|
393
416
|
}
|
|
417
|
+
// Case-only mismatch: emit a warning and continue with the canonical name.
|
|
418
|
+
if (validation.caseNormalizedFrom && validation.normalized) {
|
|
419
|
+
console.error(`Note: '${validation.caseNormalizedFrom}' normalized to '${validation.normalized}' (case mismatch). Use exact casing to silence this warning.`);
|
|
420
|
+
cmd = validation.normalized;
|
|
421
|
+
}
|
|
422
|
+
else if (validation.normalized) {
|
|
423
|
+
cmd = validation.normalized;
|
|
424
|
+
}
|
|
425
|
+
// Raw-parameter validation (runs for known (deviceType, command) pairs only).
|
|
426
|
+
const cachedForParam = getCachedDevice(deviceId);
|
|
427
|
+
if (cachedForParam && options.type === 'command') {
|
|
428
|
+
const paramCheck = validateParameter(cachedForParam.type, cmd, parameter);
|
|
429
|
+
if (!paramCheck.ok) {
|
|
430
|
+
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
|
+
}));
|
|
439
|
+
}
|
|
440
|
+
else {
|
|
441
|
+
console.error(`Error: ${paramCheck.error}`);
|
|
442
|
+
}
|
|
443
|
+
process.exit(2);
|
|
444
|
+
}
|
|
445
|
+
if (paramCheck.normalized !== undefined)
|
|
446
|
+
parameter = paramCheck.normalized;
|
|
447
|
+
}
|
|
394
448
|
const cachedForGuard = getCachedDevice(deviceId);
|
|
395
449
|
if (!options.yes &&
|
|
396
450
|
!isDryRun() &&
|
|
@@ -434,10 +488,19 @@ Examples:
|
|
|
434
488
|
}
|
|
435
489
|
const body = await executeCommand(deviceId, cmd, parsedParam, options.type, undefined, { idempotencyKey: options.idempotencyKey });
|
|
436
490
|
const isIr = getCachedDevice(deviceId)?.category === 'ir';
|
|
491
|
+
const verification = isIr
|
|
492
|
+
? {
|
|
493
|
+
verifiable: false,
|
|
494
|
+
reason: 'IR transmission is unidirectional; no receipt acknowledgment is possible.',
|
|
495
|
+
suggestedFollowup: 'Confirm visible change manually or via a paired state sensor.',
|
|
496
|
+
}
|
|
497
|
+
: null;
|
|
437
498
|
if (isJsonMode()) {
|
|
438
499
|
const result = { ok: true, command: cmd, deviceId };
|
|
439
|
-
if (isIr)
|
|
500
|
+
if (isIr) {
|
|
440
501
|
result.subKind = 'ir-no-feedback';
|
|
502
|
+
result.verification = verification;
|
|
503
|
+
}
|
|
441
504
|
if (body && typeof body === 'object' && Object.keys(body).length > 0) {
|
|
442
505
|
Object.assign(result, body);
|
|
443
506
|
}
|
|
@@ -446,6 +509,7 @@ Examples:
|
|
|
446
509
|
}
|
|
447
510
|
if (isIr) {
|
|
448
511
|
console.log(`→ IR signal sent: ${cmd} (no feedback — fire-and-forget)`);
|
|
512
|
+
console.error('⚠ IR (unverifiable) — no receipt acknowledgment. Confirm state manually.');
|
|
449
513
|
}
|
|
450
514
|
else {
|
|
451
515
|
console.log(`✓ Command sent: ${cmd}`);
|
|
@@ -546,6 +610,10 @@ Examples:
|
|
|
546
610
|
.description('Describe a device by ID: metadata + supported commands + status fields (1 API call)')
|
|
547
611
|
.argument('[deviceId]', 'Target device ID (or use --name)')
|
|
548
612
|
.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'))
|
|
614
|
+
.option('--name-type <type>', 'Narrow --name by device type', stringArg('--name-type'))
|
|
615
|
+
.option('--name-category <cat>', 'Narrow --name by category: physical|ir', enumArg('--name-category', ['physical', 'ir']))
|
|
616
|
+
.option('--name-room <room>', 'Narrow --name by room name (substring match)', stringArg('--name-room'))
|
|
549
617
|
.option('--live', 'Also fetch live status values and merge them into capabilities (costs 1 extra API call)')
|
|
550
618
|
.addHelpText('after', `
|
|
551
619
|
Makes a GET /v1.1/devices call to look up the device's type, then prints its
|
|
@@ -577,7 +645,12 @@ Examples:
|
|
|
577
645
|
`)
|
|
578
646
|
.action(async (deviceIdArg, options) => {
|
|
579
647
|
try {
|
|
580
|
-
const deviceId = resolveDeviceId(deviceIdArg, options.name
|
|
648
|
+
const deviceId = resolveDeviceId(deviceIdArg, options.name, {
|
|
649
|
+
strategy: options.nameStrategy ?? 'fuzzy',
|
|
650
|
+
type: options.nameType,
|
|
651
|
+
category: options.nameCategory,
|
|
652
|
+
room: options.nameRoom,
|
|
653
|
+
});
|
|
581
654
|
const result = await describeDevice(deviceId, options);
|
|
582
655
|
const { device, isPhysical, typeName, controlType, catalog, capabilities, source, suggestedActions: picks } = result;
|
|
583
656
|
if (isJsonMode()) {
|
package/dist/commands/doctor.js
CHANGED
|
@@ -3,8 +3,9 @@ import os from 'node:os';
|
|
|
3
3
|
import path from 'node:path';
|
|
4
4
|
import { printJson, isJsonMode } from '../utils/output.js';
|
|
5
5
|
import { getEffectiveCatalog } from '../devices/catalog.js';
|
|
6
|
-
import { configFilePath, listProfiles } from '../config.js';
|
|
6
|
+
import { configFilePath, listProfiles, readProfileMeta } from '../config.js';
|
|
7
7
|
import { describeCache } from '../devices/cache.js';
|
|
8
|
+
export const DOCTOR_SCHEMA_VERSION = 1;
|
|
8
9
|
async function checkCredentials() {
|
|
9
10
|
const envOk = Boolean(process.env.SWITCHBOT_TOKEN && process.env.SWITCHBOT_SECRET);
|
|
10
11
|
if (envOk)
|
|
@@ -39,21 +40,97 @@ function checkProfiles() {
|
|
|
39
40
|
return { name: 'profiles', status: 'ok', detail: 'no profile dir (default profile only)' };
|
|
40
41
|
}
|
|
41
42
|
const profiles = listProfiles();
|
|
43
|
+
if (profiles.length === 0) {
|
|
44
|
+
return { name: 'profiles', status: 'ok', detail: 'profile dir empty' };
|
|
45
|
+
}
|
|
46
|
+
const labelled = profiles.map((p) => {
|
|
47
|
+
const meta = readProfileMeta(p);
|
|
48
|
+
if (meta?.label)
|
|
49
|
+
return `${p} (${meta.label})`;
|
|
50
|
+
return p;
|
|
51
|
+
});
|
|
42
52
|
return {
|
|
43
53
|
name: 'profiles',
|
|
44
54
|
status: 'ok',
|
|
45
|
-
detail:
|
|
55
|
+
detail: `found ${profiles.length}: ${labelled.join(', ')}`,
|
|
46
56
|
};
|
|
47
57
|
}
|
|
48
|
-
function checkClockSkew() {
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
//
|
|
52
|
-
//
|
|
53
|
-
//
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
58
|
+
async function checkClockSkew() {
|
|
59
|
+
// Real probe: HEAD the SwitchBot API endpoint and compare the server's Date
|
|
60
|
+
// header against local time. No auth required for the Date header — the API
|
|
61
|
+
// returns 401 but still stamps the response. Gracefully degrades to
|
|
62
|
+
// probeSource:'none' if offline / no network reachable.
|
|
63
|
+
//
|
|
64
|
+
// Under vitest, only run the probe if fetch has been stubbed (detected via
|
|
65
|
+
// vi.fn marker) — otherwise skip network I/O to keep unrelated tests fast.
|
|
66
|
+
const underVitest = Boolean(process.env.VITEST);
|
|
67
|
+
const fetchFn = globalThis.fetch;
|
|
68
|
+
const fetchIsMocked = Boolean(fetchFn && typeof fetchFn === 'function' && 'mock' in fetchFn);
|
|
69
|
+
if (underVitest && !fetchIsMocked) {
|
|
70
|
+
return {
|
|
71
|
+
name: 'clock',
|
|
72
|
+
status: 'warn',
|
|
73
|
+
detail: { probeSource: 'none', skewMs: null, message: 'skipped: test environment' },
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
const localBefore = Date.now();
|
|
77
|
+
const ctrl = new AbortController();
|
|
78
|
+
const timer = setTimeout(() => ctrl.abort(), 2500);
|
|
79
|
+
try {
|
|
80
|
+
const res = await fetch('https://api.switch-bot.com/v1.1/devices', {
|
|
81
|
+
method: 'HEAD',
|
|
82
|
+
signal: ctrl.signal,
|
|
83
|
+
});
|
|
84
|
+
const localAfter = Date.now();
|
|
85
|
+
const dateHeader = res.headers.get('date');
|
|
86
|
+
if (!dateHeader) {
|
|
87
|
+
return {
|
|
88
|
+
name: 'clock',
|
|
89
|
+
status: 'warn',
|
|
90
|
+
detail: { probeSource: 'api', skewMs: null, message: 'server returned no Date header' },
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
const serverMs = Date.parse(dateHeader);
|
|
94
|
+
if (!Number.isFinite(serverMs)) {
|
|
95
|
+
return {
|
|
96
|
+
name: 'clock',
|
|
97
|
+
status: 'warn',
|
|
98
|
+
detail: { probeSource: 'api', skewMs: null, message: `unparseable Date header: ${dateHeader}` },
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
// Split the round-trip in half to estimate the local instant that matches
|
|
102
|
+
// the server's Date header. HTTP Date resolution is 1s, so treat anything
|
|
103
|
+
// under 2000ms as ok, 2000–60000ms as warn, beyond that as fail (HMAC
|
|
104
|
+
// auth rejects requests with skew > 5 minutes anyway).
|
|
105
|
+
const midpoint = (localBefore + localAfter) / 2;
|
|
106
|
+
const skewMs = Math.round(midpoint - serverMs);
|
|
107
|
+
const absSkew = Math.abs(skewMs);
|
|
108
|
+
const status = absSkew < 2000 ? 'ok' : absSkew < 60_000 ? 'warn' : 'fail';
|
|
109
|
+
return {
|
|
110
|
+
name: 'clock',
|
|
111
|
+
status,
|
|
112
|
+
detail: {
|
|
113
|
+
probeSource: 'api',
|
|
114
|
+
skewMs,
|
|
115
|
+
localIso: new Date(midpoint).toISOString(),
|
|
116
|
+
serverIso: new Date(serverMs).toISOString(),
|
|
117
|
+
},
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
catch (err) {
|
|
121
|
+
return {
|
|
122
|
+
name: 'clock',
|
|
123
|
+
status: 'warn',
|
|
124
|
+
detail: {
|
|
125
|
+
probeSource: 'none',
|
|
126
|
+
skewMs: null,
|
|
127
|
+
message: `probe failed: ${err instanceof Error ? err.message : String(err)}`,
|
|
128
|
+
},
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
finally {
|
|
132
|
+
clearTimeout(timer);
|
|
133
|
+
}
|
|
57
134
|
}
|
|
58
135
|
function checkCatalog() {
|
|
59
136
|
const catalog = getEffectiveCatalog();
|
|
@@ -153,7 +230,7 @@ Examples:
|
|
|
153
230
|
checkCatalog(),
|
|
154
231
|
checkCache(),
|
|
155
232
|
checkQuotaFile(),
|
|
156
|
-
checkClockSkew(),
|
|
233
|
+
await checkClockSkew(),
|
|
157
234
|
checkMqtt(),
|
|
158
235
|
];
|
|
159
236
|
const summary = {
|
|
@@ -162,13 +239,27 @@ Examples:
|
|
|
162
239
|
fail: checks.filter((c) => c.status === 'fail').length,
|
|
163
240
|
};
|
|
164
241
|
const overallFail = summary.fail > 0;
|
|
242
|
+
const overall = overallFail ? 'fail' : summary.warn > 0 ? 'warn' : 'ok';
|
|
165
243
|
if (isJsonMode()) {
|
|
166
|
-
|
|
244
|
+
// Stable contract (locked as doctor.schemaVersion=1):
|
|
245
|
+
// { ok: boolean, overall: 'ok'|'warn'|'fail', generatedAt, schemaVersion,
|
|
246
|
+
// summary: { ok, warn, fail }, checks: [{ name, status, detail }] }
|
|
247
|
+
// `ok` is an alias of (overall === 'ok') — agents prefer the boolean,
|
|
248
|
+
// humans prefer the string; both are provided.
|
|
249
|
+
printJson({
|
|
250
|
+
ok: overall === 'ok',
|
|
251
|
+
overall,
|
|
252
|
+
generatedAt: new Date().toISOString(),
|
|
253
|
+
schemaVersion: DOCTOR_SCHEMA_VERSION,
|
|
254
|
+
summary,
|
|
255
|
+
checks,
|
|
256
|
+
});
|
|
167
257
|
}
|
|
168
258
|
else {
|
|
169
259
|
for (const c of checks) {
|
|
170
260
|
const icon = c.status === 'ok' ? '✓' : c.status === 'warn' ? '!' : '✗';
|
|
171
|
-
|
|
261
|
+
const detailStr = typeof c.detail === 'string' ? c.detail : JSON.stringify(c.detail);
|
|
262
|
+
console.log(`${icon} ${c.name.padEnd(12)} ${detailStr}`);
|
|
172
263
|
}
|
|
173
264
|
console.log('');
|
|
174
265
|
console.log(`${summary.ok} ok, ${summary.warn} warn, ${summary.fail} fail`);
|
package/dist/commands/events.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import http from 'node:http';
|
|
2
|
+
import crypto from 'node:crypto';
|
|
2
3
|
import { printJson, isJsonMode, handleError, UsageError } from '../utils/output.js';
|
|
3
4
|
import { intArg, stringArg } from '../utils/arg-parsers.js';
|
|
4
5
|
import { SwitchBotMqttClient } from '../mqtt/client.js';
|
|
@@ -16,6 +17,17 @@ import { deviceHistoryStore } from '../mcp/device-history.js';
|
|
|
16
17
|
const DEFAULT_PORT = 3000;
|
|
17
18
|
const DEFAULT_PATH = '/';
|
|
18
19
|
const MAX_BODY_BYTES = 1_000_000;
|
|
20
|
+
function extractEventId(parsed) {
|
|
21
|
+
if (!parsed || typeof parsed !== 'object')
|
|
22
|
+
return null;
|
|
23
|
+
const p = parsed;
|
|
24
|
+
if (typeof p.eventId === 'string' && p.eventId.length > 0)
|
|
25
|
+
return p.eventId;
|
|
26
|
+
const ctx = p.context;
|
|
27
|
+
if (ctx && typeof ctx.eventId === 'string' && ctx.eventId.length > 0)
|
|
28
|
+
return ctx.eventId;
|
|
29
|
+
return null;
|
|
30
|
+
}
|
|
19
31
|
function matchFilter(body, filter) {
|
|
20
32
|
if (!filter)
|
|
21
33
|
return true;
|
|
@@ -220,7 +232,19 @@ Connects to the SwitchBot MQTT service using your existing credentials
|
|
|
220
232
|
No additional MQTT configuration required.
|
|
221
233
|
|
|
222
234
|
Output (JSONL, one event per line):
|
|
223
|
-
{ "t": "<ISO>", "topic": "<mqtt-topic>", "payload": <parsed JSON or raw string> }
|
|
235
|
+
{ "t": "<ISO>", "eventId": "<uuid>", "topic": "<mqtt-topic>", "payload": <parsed JSON or raw string> }
|
|
236
|
+
|
|
237
|
+
Control records (interleaved, no "payload" field — use type-prefix to filter):
|
|
238
|
+
{ "type": "__connect", "at": "<ISO>", "eventId": "<uuid>" } first successful connect
|
|
239
|
+
{ "type": "__reconnect", "at": "<ISO>", "eventId": "<uuid>" } connect after a disconnect
|
|
240
|
+
{ "type": "__disconnect", "at": "<ISO>", "eventId": "<uuid>" } reconnecting or failed
|
|
241
|
+
|
|
242
|
+
Reconnect policy: the MQTT client retries with exponential backoff
|
|
243
|
+
(1s → 30s capped, forever) while the credential is still valid; if the
|
|
244
|
+
credential is rejected or 5 consecutive reconnects fail, state goes to
|
|
245
|
+
'failed' and the command exits non-zero so supervisors can restart it.
|
|
246
|
+
QoS is 0 (at-most-once); agents requiring at-least-once delivery should
|
|
247
|
+
fan-out via --sink file and deduplicate by eventId on the consumer side.
|
|
224
248
|
|
|
225
249
|
Sink types (--sink, repeatable):
|
|
226
250
|
stdout Print JSONL to stdout (default when no --sink given)
|
|
@@ -321,9 +345,14 @@ Examples:
|
|
|
321
345
|
parsed = payload.toString('utf-8');
|
|
322
346
|
}
|
|
323
347
|
const t = new Date().toISOString();
|
|
348
|
+
// Every event carries an eventId so downstream sinks / replay tools
|
|
349
|
+
// can dedupe. If the broker supplied one (some providers do via a
|
|
350
|
+
// header), prefer that; otherwise synth a UUID locally.
|
|
351
|
+
const existingId = extractEventId(parsed);
|
|
352
|
+
const eventId = existingId ?? crypto.randomUUID();
|
|
324
353
|
if (dispatcher) {
|
|
325
354
|
const { deviceId, deviceType, text } = parseSinkEvent(parsed);
|
|
326
|
-
const sinkEvent = { t, topic: msgTopic, deviceId, deviceType, payload: parsed, text };
|
|
355
|
+
const sinkEvent = { t, topic: msgTopic, deviceId, deviceType, payload: parsed, text, eventId };
|
|
327
356
|
deviceHistoryStore.record(deviceId, msgTopic, deviceType, parsed, t);
|
|
328
357
|
dispatcher.dispatch(sinkEvent).catch(() => { });
|
|
329
358
|
}
|
|
@@ -331,7 +360,7 @@ Examples:
|
|
|
331
360
|
// Default behavior: record history + print to stdout
|
|
332
361
|
const { deviceId, deviceType } = parseSinkEvent(parsed);
|
|
333
362
|
deviceHistoryStore.record(deviceId, msgTopic, deviceType, parsed, t);
|
|
334
|
-
const record = { t, topic: msgTopic, payload: parsed };
|
|
363
|
+
const record = { t, eventId, topic: msgTopic, payload: parsed };
|
|
335
364
|
if (isJsonMode()) {
|
|
336
365
|
printJson(record);
|
|
337
366
|
}
|
|
@@ -345,12 +374,32 @@ Examples:
|
|
|
345
374
|
}
|
|
346
375
|
});
|
|
347
376
|
let mqttFailed = false;
|
|
377
|
+
let hasConnectedBefore = false;
|
|
378
|
+
const emitControl = (kind) => {
|
|
379
|
+
const ctl = { type: kind, at: new Date().toISOString(), eventId: crypto.randomUUID() };
|
|
380
|
+
// Control events always go to stdout as JSONL so consumers that
|
|
381
|
+
// filter real events by presence of `payload` can skip them.
|
|
382
|
+
if (isJsonMode()) {
|
|
383
|
+
printJson(ctl);
|
|
384
|
+
}
|
|
385
|
+
else {
|
|
386
|
+
console.log(JSON.stringify(ctl));
|
|
387
|
+
}
|
|
388
|
+
};
|
|
348
389
|
const unsubState = client.onStateChange((state) => {
|
|
349
390
|
if (!isJsonMode()) {
|
|
350
391
|
console.error(`[${new Date().toLocaleTimeString()}] MQTT state: ${state}`);
|
|
351
392
|
}
|
|
352
|
-
if (state === '
|
|
393
|
+
if (state === 'connected') {
|
|
394
|
+
emitControl(hasConnectedBefore ? '__reconnect' : '__connect');
|
|
395
|
+
hasConnectedBefore = true;
|
|
396
|
+
}
|
|
397
|
+
else if (state === 'reconnecting') {
|
|
398
|
+
emitControl('__disconnect');
|
|
399
|
+
}
|
|
400
|
+
else if (state === 'failed') {
|
|
353
401
|
mqttFailed = true;
|
|
402
|
+
emitControl('__disconnect');
|
|
354
403
|
if (!isJsonMode()) {
|
|
355
404
|
console.error('MQTT connection failed permanently (credential expired or reconnect exhausted) — exiting.');
|
|
356
405
|
}
|
package/dist/commands/expand.js
CHANGED
|
@@ -5,83 +5,7 @@ import { executeCommand, isDestructiveCommand, getDestructiveReason } from '../l
|
|
|
5
5
|
import { isDryRun } from '../utils/flags.js';
|
|
6
6
|
import { resolveDeviceId } from '../utils/name-resolver.js';
|
|
7
7
|
import { DryRunSignal } from '../api/client.js';
|
|
8
|
-
|
|
9
|
-
const AC_MODE_MAP = { auto: 1, cool: 2, dry: 3, fan: 4, heat: 5 };
|
|
10
|
-
const AC_FAN_MAP = { auto: 1, low: 2, mid: 3, high: 4 };
|
|
11
|
-
const CURTAIN_MODE_MAP = { default: 'ff', performance: '0', silent: '1' };
|
|
12
|
-
const RELAY_MODE_MAP = { toggle: 0, edge: 1, detached: 2, momentary: 3 };
|
|
13
|
-
const BLIND_DIRECTION = new Set(['up', 'down']);
|
|
14
|
-
// ---- Translators -----------------------------------------------------------
|
|
15
|
-
function buildAcSetAll(opts) {
|
|
16
|
-
if (!opts.temp)
|
|
17
|
-
throw new UsageError('--temp is required for setAll (e.g. --temp 26)');
|
|
18
|
-
if (!opts.mode)
|
|
19
|
-
throw new UsageError('--mode is required for setAll (auto|cool|dry|fan|heat)');
|
|
20
|
-
if (!opts.fan)
|
|
21
|
-
throw new UsageError('--fan is required for setAll (auto|low|mid|high)');
|
|
22
|
-
if (!opts.power)
|
|
23
|
-
throw new UsageError('--power is required for setAll (on|off)');
|
|
24
|
-
const temp = parseInt(opts.temp, 10);
|
|
25
|
-
if (!Number.isFinite(temp) || temp < 16 || temp > 30) {
|
|
26
|
-
throw new UsageError(`--temp must be an integer between 16 and 30 (got "${opts.temp}")`);
|
|
27
|
-
}
|
|
28
|
-
const modeInt = AC_MODE_MAP[opts.mode.toLowerCase()];
|
|
29
|
-
if (modeInt === undefined) {
|
|
30
|
-
throw new UsageError(`--mode must be one of: auto, cool, dry, fan, heat (got "${opts.mode}")`);
|
|
31
|
-
}
|
|
32
|
-
const fanInt = AC_FAN_MAP[opts.fan.toLowerCase()];
|
|
33
|
-
if (fanInt === undefined) {
|
|
34
|
-
throw new UsageError(`--fan must be one of: auto, low, mid, high (got "${opts.fan}")`);
|
|
35
|
-
}
|
|
36
|
-
const power = opts.power.toLowerCase();
|
|
37
|
-
if (power !== 'on' && power !== 'off') {
|
|
38
|
-
throw new UsageError(`--power must be "on" or "off" (got "${opts.power}")`);
|
|
39
|
-
}
|
|
40
|
-
return `${temp},${modeInt},${fanInt},${power}`;
|
|
41
|
-
}
|
|
42
|
-
function buildCurtainSetPosition(opts) {
|
|
43
|
-
if (!opts.position)
|
|
44
|
-
throw new UsageError('--position is required (0-100)');
|
|
45
|
-
const pos = parseInt(opts.position, 10);
|
|
46
|
-
if (!Number.isFinite(pos) || pos < 0 || pos > 100) {
|
|
47
|
-
throw new UsageError(`--position must be an integer between 0 and 100 (got "${opts.position}")`);
|
|
48
|
-
}
|
|
49
|
-
const modeStr = opts.mode ? CURTAIN_MODE_MAP[opts.mode.toLowerCase()] : 'ff';
|
|
50
|
-
if (modeStr === undefined) {
|
|
51
|
-
throw new UsageError(`--mode must be one of: default, performance, silent (got "${opts.mode}")`);
|
|
52
|
-
}
|
|
53
|
-
return `0,${modeStr},${pos}`;
|
|
54
|
-
}
|
|
55
|
-
function buildBlindTiltSetPosition(opts) {
|
|
56
|
-
if (!opts.direction)
|
|
57
|
-
throw new UsageError('--direction is required (up|down)');
|
|
58
|
-
if (!opts.angle)
|
|
59
|
-
throw new UsageError('--angle is required (0-100)');
|
|
60
|
-
const dir = opts.direction.toLowerCase();
|
|
61
|
-
if (!BLIND_DIRECTION.has(dir)) {
|
|
62
|
-
throw new UsageError(`--direction must be "up" or "down" (got "${opts.direction}")`);
|
|
63
|
-
}
|
|
64
|
-
const angle = parseInt(opts.angle, 10);
|
|
65
|
-
if (!Number.isFinite(angle) || angle < 0 || angle > 100) {
|
|
66
|
-
throw new UsageError(`--angle must be an integer between 0 and 100 (got "${opts.angle}")`);
|
|
67
|
-
}
|
|
68
|
-
return `${dir};${angle}`;
|
|
69
|
-
}
|
|
70
|
-
function buildRelaySetMode(opts) {
|
|
71
|
-
if (!opts.channel)
|
|
72
|
-
throw new UsageError('--channel is required (1 or 2)');
|
|
73
|
-
if (!opts.mode)
|
|
74
|
-
throw new UsageError('--mode is required (toggle|edge|detached|momentary)');
|
|
75
|
-
const ch = parseInt(opts.channel, 10);
|
|
76
|
-
if (ch !== 1 && ch !== 2) {
|
|
77
|
-
throw new UsageError(`--channel must be 1 or 2 (got "${opts.channel}")`);
|
|
78
|
-
}
|
|
79
|
-
const modeInt = RELAY_MODE_MAP[opts.mode.toLowerCase()];
|
|
80
|
-
if (modeInt === undefined) {
|
|
81
|
-
throw new UsageError(`--mode must be one of: toggle, edge, detached, momentary (got "${opts.mode}")`);
|
|
82
|
-
}
|
|
83
|
-
return `${ch};${modeInt}`;
|
|
84
|
-
}
|
|
8
|
+
import { buildAcSetAll, buildCurtainSetPosition, buildBlindTiltSetPosition, buildRelaySetMode, } from '../devices/param-validator.js';
|
|
85
9
|
// ---- Registration ----------------------------------------------------------
|
|
86
10
|
export function registerExpandCommand(devices) {
|
|
87
11
|
devices
|