@switchbot/openapi-cli 2.3.0 → 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/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 +42 -4
- package/dist/commands/doctor.js +105 -14
- package/dist/commands/events.js +53 -4
- 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/index.js +12 -2
- package/dist/lib/devices.js +8 -1
- package/dist/lib/idempotency.js +56 -22
- package/dist/mcp/device-history.js +75 -7
- 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/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/history.js
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
import path from 'node:path';
|
|
2
2
|
import os from 'node:os';
|
|
3
3
|
import { intArg, stringArg } from '../utils/arg-parsers.js';
|
|
4
|
-
import { printJson, isJsonMode, handleError } from '../utils/output.js';
|
|
5
|
-
import { readAudit } from '../utils/audit.js';
|
|
4
|
+
import { printJson, isJsonMode, handleError, UsageError } from '../utils/output.js';
|
|
5
|
+
import { readAudit, verifyAudit } from '../utils/audit.js';
|
|
6
6
|
import { executeCommand } from '../lib/devices.js';
|
|
7
|
+
import { queryDeviceHistory, queryDeviceHistoryStats, } from '../devices/history-query.js';
|
|
7
8
|
const DEFAULT_AUDIT = path.join(os.homedir(), '.switchbot', 'audit.log');
|
|
8
9
|
export function registerHistoryCommand(program) {
|
|
9
10
|
const history = program
|
|
@@ -101,4 +102,125 @@ Examples:
|
|
|
101
102
|
handleError(err);
|
|
102
103
|
}
|
|
103
104
|
});
|
|
105
|
+
history
|
|
106
|
+
.command('range')
|
|
107
|
+
.description('Query time-ranged device history from JSONL storage (populated by events mqtt-tail / MCP)')
|
|
108
|
+
.argument('<deviceId>', 'Device ID to query')
|
|
109
|
+
.option('--since <duration>', 'Relative window ending now, e.g. "30s", "15m", "1h", "7d" (mutually exclusive with --from/--to)', stringArg('--since'))
|
|
110
|
+
.option('--from <iso>', 'Range start (ISO-8601)', stringArg('--from'))
|
|
111
|
+
.option('--to <iso>', 'Range end (ISO-8601)', stringArg('--to'))
|
|
112
|
+
.option('--field <name>', 'Project a payload field (repeat to keep multiple)', (v, acc = []) => acc.concat(v), [])
|
|
113
|
+
.option('--limit <n>', 'Maximum records to return (default 1000)', intArg('--limit', { min: 1 }))
|
|
114
|
+
.addHelpText('after', `
|
|
115
|
+
History is the append-only JSONL mirror of the per-device ring buffer: every
|
|
116
|
+
'events mqtt-tail' event and every MCP tool status-refresh is written to
|
|
117
|
+
~/.switchbot/device-history/<deviceId>.jsonl (rotates at 50MB × 3 files).
|
|
118
|
+
|
|
119
|
+
Examples:
|
|
120
|
+
$ switchbot history range <id> --since 7d --json
|
|
121
|
+
$ switchbot history range <id> --since 1h --field temperature --field humidity
|
|
122
|
+
$ switchbot history range <id> --from 2026-04-18T00:00:00Z --to 2026-04-19T00:00:00Z
|
|
123
|
+
`)
|
|
124
|
+
.action(async (deviceId, options) => {
|
|
125
|
+
// Usage-level validation: keep synchronous and pre-query so handleError
|
|
126
|
+
// maps these to exit 2 (via UsageError) rather than runtime exit 1.
|
|
127
|
+
if (options.since && (options.from || options.to)) {
|
|
128
|
+
handleError(new UsageError('--since is mutually exclusive with --from/--to.'));
|
|
129
|
+
}
|
|
130
|
+
try {
|
|
131
|
+
const records = await queryDeviceHistory(deviceId, {
|
|
132
|
+
since: options.since,
|
|
133
|
+
from: options.from,
|
|
134
|
+
to: options.to,
|
|
135
|
+
fields: options.field ?? [],
|
|
136
|
+
limit: options.limit !== undefined ? Number(options.limit) : undefined,
|
|
137
|
+
});
|
|
138
|
+
if (isJsonMode()) {
|
|
139
|
+
printJson({ deviceId, count: records.length, records });
|
|
140
|
+
return;
|
|
141
|
+
}
|
|
142
|
+
if (records.length === 0) {
|
|
143
|
+
console.log(`(no history records for ${deviceId} in requested range)`);
|
|
144
|
+
return;
|
|
145
|
+
}
|
|
146
|
+
for (const r of records) {
|
|
147
|
+
const payloadStr = JSON.stringify(r.payload);
|
|
148
|
+
console.log(`${r.t} ${r.topic} ${payloadStr}`);
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
catch (err) {
|
|
152
|
+
// Convert history-query's plain Error range messages into UsageError so
|
|
153
|
+
// they exit 2 instead of 1.
|
|
154
|
+
if (err instanceof Error && /^(Invalid --|--from|--since)/i.test(err.message)) {
|
|
155
|
+
handleError(new UsageError(err.message));
|
|
156
|
+
}
|
|
157
|
+
handleError(err);
|
|
158
|
+
}
|
|
159
|
+
});
|
|
160
|
+
history
|
|
161
|
+
.command('stats')
|
|
162
|
+
.description('Show on-disk size + record counts for a device history')
|
|
163
|
+
.argument('<deviceId>', 'Device ID to inspect')
|
|
164
|
+
.action((deviceId) => {
|
|
165
|
+
try {
|
|
166
|
+
const stats = queryDeviceHistoryStats(deviceId);
|
|
167
|
+
if (isJsonMode()) {
|
|
168
|
+
printJson(stats);
|
|
169
|
+
return;
|
|
170
|
+
}
|
|
171
|
+
console.log(`Device: ${stats.deviceId}`);
|
|
172
|
+
console.log(`History dir: ${stats.historyDir}`);
|
|
173
|
+
console.log(`JSONL files: ${stats.fileCount} (${stats.jsonlFiles.join(', ') || '—'})`);
|
|
174
|
+
console.log(`Total size: ${stats.totalBytes.toLocaleString()} bytes`);
|
|
175
|
+
console.log(`Record count: ${stats.recordCount}`);
|
|
176
|
+
console.log(`Oldest: ${stats.oldest ?? '—'}`);
|
|
177
|
+
console.log(`Newest: ${stats.newest ?? '—'}`);
|
|
178
|
+
}
|
|
179
|
+
catch (err) {
|
|
180
|
+
handleError(err);
|
|
181
|
+
}
|
|
182
|
+
});
|
|
183
|
+
history
|
|
184
|
+
.command('verify')
|
|
185
|
+
.description('Check the audit log for malformed lines and schema-version drift')
|
|
186
|
+
.option('--file <path>', `Path to the audit log (default ${DEFAULT_AUDIT})`, stringArg('--file'))
|
|
187
|
+
.addHelpText('after', `
|
|
188
|
+
See docs/audit-log.md for the audit log format. Exit code:
|
|
189
|
+
0 every line parses and carries the current auditVersion
|
|
190
|
+
1 one or more lines are malformed OR the file is missing
|
|
191
|
+
2 (usage) — not emitted by this subcommand
|
|
192
|
+
|
|
193
|
+
Examples:
|
|
194
|
+
$ switchbot history verify
|
|
195
|
+
$ switchbot history verify --file ./custom.log --json
|
|
196
|
+
`)
|
|
197
|
+
.action((options) => {
|
|
198
|
+
const file = options.file ?? DEFAULT_AUDIT;
|
|
199
|
+
const report = verifyAudit(file);
|
|
200
|
+
if (isJsonMode()) {
|
|
201
|
+
printJson(report);
|
|
202
|
+
}
|
|
203
|
+
else {
|
|
204
|
+
console.log(`Audit log: ${report.file}`);
|
|
205
|
+
console.log(`Parsed lines: ${report.parsedLines} / ${report.totalLines}`);
|
|
206
|
+
console.log(`Malformed: ${report.malformedLines}`);
|
|
207
|
+
console.log(`Unversioned: ${report.unversionedEntries}`);
|
|
208
|
+
const versions = Object.entries(report.versionCounts)
|
|
209
|
+
.map(([v, n]) => `${v}:${n}`)
|
|
210
|
+
.join(', ');
|
|
211
|
+
console.log(`Version counts: ${versions || '—'}`);
|
|
212
|
+
if (report.earliest)
|
|
213
|
+
console.log(`Earliest: ${report.earliest}`);
|
|
214
|
+
if (report.latest)
|
|
215
|
+
console.log(`Latest: ${report.latest}`);
|
|
216
|
+
if (report.problems.length > 0) {
|
|
217
|
+
console.log('\nProblems:');
|
|
218
|
+
for (const p of report.problems) {
|
|
219
|
+
console.log(` line ${p.line}: ${p.reason}${p.preview ? ` — "${p.preview}"` : ''}`);
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
const ok = report.malformedLines === 0 && report.problems.length === 0;
|
|
224
|
+
process.exit(ok ? 0 : 1);
|
|
225
|
+
});
|
|
104
226
|
}
|
package/dist/commands/mcp.js
CHANGED
|
@@ -10,6 +10,7 @@ import { findCatalogEntry } from '../devices/catalog.js';
|
|
|
10
10
|
import { getCachedDevice } from '../devices/cache.js';
|
|
11
11
|
import { EventSubscriptionManager } from '../mcp/events-subscription.js';
|
|
12
12
|
import { deviceHistoryStore } from '../mcp/device-history.js';
|
|
13
|
+
import { queryDeviceHistory } from '../devices/history-query.js';
|
|
13
14
|
import { todayUsage } from '../utils/quota.js';
|
|
14
15
|
import { describeCache } from '../devices/cache.js';
|
|
15
16
|
import { withRequestContext } from '../lib/request-context.js';
|
|
@@ -146,6 +147,47 @@ API docs: https://github.com/OpenWonderLabs/SwitchBotAPI`,
|
|
|
146
147
|
structuredContent: result,
|
|
147
148
|
};
|
|
148
149
|
});
|
|
150
|
+
// ---- query_device_history --------------------------------------------------
|
|
151
|
+
server.registerTool('query_device_history', {
|
|
152
|
+
title: 'Query time-ranged device history',
|
|
153
|
+
description: 'Return records from the append-only JSONL history (~/.switchbot/device-history/<deviceId>.jsonl) ' +
|
|
154
|
+
'filtered by a relative duration (since) or absolute ISO-8601 range (from/to). ' +
|
|
155
|
+
'No API call — zero quota cost. Use for trend questions like "how many times did this switch turn on last week".',
|
|
156
|
+
inputSchema: {
|
|
157
|
+
deviceId: z.string().describe('Device ID to query'),
|
|
158
|
+
since: z.string().optional().describe('Relative window ending now, e.g. "30s", "15m", "1h", "7d". Mutually exclusive with from/to.'),
|
|
159
|
+
from: z.string().optional().describe('Range start (ISO-8601).'),
|
|
160
|
+
to: z.string().optional().describe('Range end (ISO-8601).'),
|
|
161
|
+
fields: z.array(z.string()).optional().describe('Project these payload fields; omit for the full payload.'),
|
|
162
|
+
limit: z.number().int().min(1).max(10000).optional().describe('Max records to return (default 1000).'),
|
|
163
|
+
},
|
|
164
|
+
outputSchema: {
|
|
165
|
+
deviceId: z.string(),
|
|
166
|
+
count: z.number().int(),
|
|
167
|
+
records: z.array(z.object({
|
|
168
|
+
t: z.string(),
|
|
169
|
+
topic: z.string(),
|
|
170
|
+
deviceType: z.string().optional(),
|
|
171
|
+
payload: z.unknown(),
|
|
172
|
+
})),
|
|
173
|
+
},
|
|
174
|
+
}, async ({ deviceId, since, from, to, fields, limit }) => {
|
|
175
|
+
if (since && (from || to)) {
|
|
176
|
+
return mcpError('usage', 2, '--since is mutually exclusive with --from/--to.');
|
|
177
|
+
}
|
|
178
|
+
try {
|
|
179
|
+
const records = await queryDeviceHistory(deviceId, { since, from, to, fields, limit });
|
|
180
|
+
const result = { deviceId, count: records.length, records };
|
|
181
|
+
return {
|
|
182
|
+
content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
|
|
183
|
+
structuredContent: result,
|
|
184
|
+
};
|
|
185
|
+
}
|
|
186
|
+
catch (err) {
|
|
187
|
+
const msg = err instanceof Error ? err.message : 'history query failed';
|
|
188
|
+
return mcpError('usage', 2, msg);
|
|
189
|
+
}
|
|
190
|
+
});
|
|
149
191
|
// ---- send_command ---------------------------------------------------------
|
|
150
192
|
server.registerTool('send_command', {
|
|
151
193
|
title: 'Send a control command to a device',
|
|
@@ -167,14 +209,26 @@ API docs: https://github.com/OpenWonderLabs/SwitchBotAPI`,
|
|
|
167
209
|
.optional()
|
|
168
210
|
.default(false)
|
|
169
211
|
.describe('Required true for destructive commands (unlock, garage open, createKey, ...)'),
|
|
212
|
+
idempotencyKey: z
|
|
213
|
+
.string()
|
|
214
|
+
.optional()
|
|
215
|
+
.describe('Deduplication key — repeat calls with the same key within 60s replay the first result (adds replayed:true). Same key + different (command, parameter) within 60s returns an idempotency_conflict guard error.'),
|
|
170
216
|
},
|
|
171
217
|
outputSchema: {
|
|
172
218
|
ok: z.literal(true),
|
|
173
219
|
command: z.string(),
|
|
174
220
|
deviceId: z.string(),
|
|
175
221
|
result: z.unknown().describe('API response body from SwitchBot'),
|
|
222
|
+
verification: z
|
|
223
|
+
.object({
|
|
224
|
+
verifiable: z.boolean(),
|
|
225
|
+
reason: z.string(),
|
|
226
|
+
suggestedFollowup: z.string(),
|
|
227
|
+
})
|
|
228
|
+
.optional()
|
|
229
|
+
.describe('Present when the target is an IR device. IR is unidirectional — agents should treat the success as "signal sent" not "state changed".'),
|
|
176
230
|
},
|
|
177
|
-
}, async ({ deviceId, command, parameter, commandType, confirm }) => {
|
|
231
|
+
}, async ({ deviceId, command, parameter, commandType, confirm, idempotencyKey }) => {
|
|
178
232
|
const effectiveType = commandType ?? 'command';
|
|
179
233
|
// Resolve the device's catalog type via cache or a fresh lookup so we
|
|
180
234
|
// can evaluate destructive/validation without an extra round-trip if
|
|
@@ -217,8 +271,33 @@ API docs: https://github.com/OpenWonderLabs/SwitchBotAPI`,
|
|
|
217
271
|
if (!validation.ok) {
|
|
218
272
|
return mcpError('usage', 2, validation.error.message, { hint: validation.error.hint, context: { validationKind: validation.error.kind } });
|
|
219
273
|
}
|
|
220
|
-
|
|
274
|
+
let result;
|
|
275
|
+
try {
|
|
276
|
+
result = await executeCommand(deviceId, command, parameter, effectiveType, undefined, {
|
|
277
|
+
idempotencyKey,
|
|
278
|
+
});
|
|
279
|
+
}
|
|
280
|
+
catch (err) {
|
|
281
|
+
if (err instanceof Error && err.name === 'IdempotencyConflictError') {
|
|
282
|
+
return mcpError('guard', 2, err.message, {
|
|
283
|
+
hint: 'Use a fresh idempotencyKey, or wait for the prior key to expire (60s TTL).',
|
|
284
|
+
context: {
|
|
285
|
+
existingShape: err.existingShape,
|
|
286
|
+
newShape: err.newShape,
|
|
287
|
+
},
|
|
288
|
+
});
|
|
289
|
+
}
|
|
290
|
+
throw err;
|
|
291
|
+
}
|
|
292
|
+
const isIr = getCachedDevice(deviceId)?.category === 'ir';
|
|
221
293
|
const structured = { ok: true, command, deviceId, result };
|
|
294
|
+
if (isIr) {
|
|
295
|
+
structured.verification = {
|
|
296
|
+
verifiable: false,
|
|
297
|
+
reason: 'IR transmission is unidirectional; no receipt acknowledgment is possible.',
|
|
298
|
+
suggestedFollowup: 'Confirm visible change manually or via a paired state sensor.',
|
|
299
|
+
};
|
|
300
|
+
}
|
|
222
301
|
return {
|
|
223
302
|
content: [{ type: 'text', text: JSON.stringify(structured, null, 2) }],
|
|
224
303
|
structuredContent: structured,
|
package/dist/commands/quota.js
CHANGED
|
@@ -11,17 +11,19 @@ is a best-effort mirror of the SwitchBot 10,000/day limit — it does not
|
|
|
11
11
|
include requests made outside this CLI (mobile app, other scripts).
|
|
12
12
|
|
|
13
13
|
Subcommands:
|
|
14
|
-
status Show today's usage and the last 7 days
|
|
14
|
+
status Show today's usage and the last 7 days (alias: show)
|
|
15
15
|
reset Delete the local counter file
|
|
16
16
|
|
|
17
17
|
Examples:
|
|
18
18
|
$ switchbot quota status
|
|
19
|
+
$ switchbot quota show # alias of 'status'
|
|
19
20
|
$ switchbot quota status --json
|
|
20
21
|
$ switchbot quota reset
|
|
21
22
|
`);
|
|
22
23
|
quota
|
|
23
24
|
.command('status')
|
|
24
|
-
.
|
|
25
|
+
.alias('show')
|
|
26
|
+
.description("Show today's usage and the last 7 days (alias: show)")
|
|
25
27
|
.action(() => {
|
|
26
28
|
const usage = todayUsage();
|
|
27
29
|
const history = loadQuota();
|