@switchbot/openapi-cli 2.3.0 → 2.5.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 +128 -0
- package/dist/commands/batch.js +109 -15
- package/dist/commands/cache.js +1 -0
- package/dist/commands/capabilities.js +203 -62
- package/dist/commands/config.js +132 -9
- package/dist/commands/devices.js +43 -5
- package/dist/commands/doctor.js +105 -14
- package/dist/commands/events.js +60 -4
- package/dist/commands/history.js +227 -2
- package/dist/commands/mcp.js +203 -35
- package/dist/commands/plan.js +6 -1
- package/dist/commands/quota.js +4 -2
- package/dist/commands/scenes.js +43 -1
- package/dist/commands/schema.js +101 -5
- package/dist/config.js +71 -2
- package/dist/devices/history-agg.js +138 -0
- package/dist/devices/history-query.js +181 -0
- package/dist/index.js +19 -2
- package/dist/lib/devices.js +8 -1
- package/dist/lib/idempotency.js +56 -22
- package/dist/mcp/device-history.js +86 -7
- package/dist/utils/audit.js +66 -1
- package/dist/utils/flags.js +18 -0
- package/dist/utils/format.js +9 -1
- package/dist/utils/name-resolver.js +85 -29
- package/dist/utils/output.js +116 -20
- package/dist/utils/quota.js +14 -0
- package/dist/utils/redact.js +68 -0
- package/dist/version.js +4 -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,39 @@ 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
|
+
// Persist to __control.jsonl — best-effort, never blocks the stream.
|
|
389
|
+
try {
|
|
390
|
+
deviceHistoryStore.recordControl(ctl);
|
|
391
|
+
}
|
|
392
|
+
catch {
|
|
393
|
+
// swallow
|
|
394
|
+
}
|
|
395
|
+
};
|
|
348
396
|
const unsubState = client.onStateChange((state) => {
|
|
349
397
|
if (!isJsonMode()) {
|
|
350
398
|
console.error(`[${new Date().toLocaleTimeString()}] MQTT state: ${state}`);
|
|
351
399
|
}
|
|
352
|
-
if (state === '
|
|
400
|
+
if (state === 'connected') {
|
|
401
|
+
emitControl(hasConnectedBefore ? '__reconnect' : '__connect');
|
|
402
|
+
hasConnectedBefore = true;
|
|
403
|
+
}
|
|
404
|
+
else if (state === 'reconnecting') {
|
|
405
|
+
emitControl('__disconnect');
|
|
406
|
+
}
|
|
407
|
+
else if (state === 'failed') {
|
|
353
408
|
mqttFailed = true;
|
|
409
|
+
emitControl('__disconnect');
|
|
354
410
|
if (!isJsonMode()) {
|
|
355
411
|
console.error('MQTT connection failed permanently (credential expired or reconnect exhausted) — exiting.');
|
|
356
412
|
}
|
package/dist/commands/history.js
CHANGED
|
@@ -1,9 +1,11 @@
|
|
|
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';
|
|
8
|
+
import { aggregateDeviceHistory, ALL_AGG_FNS, } from '../devices/history-agg.js';
|
|
7
9
|
const DEFAULT_AUDIT = path.join(os.homedir(), '.switchbot', 'audit.log');
|
|
8
10
|
export function registerHistoryCommand(program) {
|
|
9
11
|
const history = program
|
|
@@ -101,4 +103,227 @@ Examples:
|
|
|
101
103
|
handleError(err);
|
|
102
104
|
}
|
|
103
105
|
});
|
|
106
|
+
history
|
|
107
|
+
.command('range')
|
|
108
|
+
.description('Query time-ranged device history from JSONL storage (populated by events mqtt-tail / MCP)')
|
|
109
|
+
.argument('<deviceId>', 'Device ID to query')
|
|
110
|
+
.option('--since <duration>', 'Relative window ending now, e.g. "30s", "15m", "1h", "7d" (mutually exclusive with --from/--to)', stringArg('--since'))
|
|
111
|
+
.option('--from <iso>', 'Range start (ISO-8601)', stringArg('--from'))
|
|
112
|
+
.option('--to <iso>', 'Range end (ISO-8601)', stringArg('--to'))
|
|
113
|
+
.option('--field <name>', 'Project a payload field (repeat to keep multiple)', (v, acc = []) => acc.concat(v), [])
|
|
114
|
+
.option('--limit <n>', 'Maximum records to return (default 1000)', intArg('--limit', { min: 1 }))
|
|
115
|
+
.addHelpText('after', `
|
|
116
|
+
History is the append-only JSONL mirror of the per-device ring buffer: every
|
|
117
|
+
'events mqtt-tail' event and every MCP tool status-refresh is written to
|
|
118
|
+
~/.switchbot/device-history/<deviceId>.jsonl (rotates at 50MB × 3 files).
|
|
119
|
+
|
|
120
|
+
Examples:
|
|
121
|
+
$ switchbot history range <id> --since 7d --json
|
|
122
|
+
$ switchbot history range <id> --since 1h --field temperature --field humidity
|
|
123
|
+
$ switchbot history range <id> --from 2026-04-18T00:00:00Z --to 2026-04-19T00:00:00Z
|
|
124
|
+
`)
|
|
125
|
+
.action(async (deviceId, options) => {
|
|
126
|
+
// Usage-level validation: keep synchronous and pre-query so handleError
|
|
127
|
+
// maps these to exit 2 (via UsageError) rather than runtime exit 1.
|
|
128
|
+
if (options.since && (options.from || options.to)) {
|
|
129
|
+
handleError(new UsageError('--since is mutually exclusive with --from/--to.'));
|
|
130
|
+
}
|
|
131
|
+
try {
|
|
132
|
+
const records = await queryDeviceHistory(deviceId, {
|
|
133
|
+
since: options.since,
|
|
134
|
+
from: options.from,
|
|
135
|
+
to: options.to,
|
|
136
|
+
fields: options.field ?? [],
|
|
137
|
+
limit: options.limit !== undefined ? Number(options.limit) : undefined,
|
|
138
|
+
});
|
|
139
|
+
if (isJsonMode()) {
|
|
140
|
+
printJson({ deviceId, count: records.length, records });
|
|
141
|
+
return;
|
|
142
|
+
}
|
|
143
|
+
if (records.length === 0) {
|
|
144
|
+
console.log(`(no history records for ${deviceId} in requested range)`);
|
|
145
|
+
return;
|
|
146
|
+
}
|
|
147
|
+
for (const r of records) {
|
|
148
|
+
const payloadStr = JSON.stringify(r.payload);
|
|
149
|
+
console.log(`${r.t} ${r.topic} ${payloadStr}`);
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
catch (err) {
|
|
153
|
+
// Convert history-query's plain Error range messages into UsageError so
|
|
154
|
+
// they exit 2 instead of 1.
|
|
155
|
+
if (err instanceof Error && /^(Invalid --|--from|--since)/i.test(err.message)) {
|
|
156
|
+
handleError(new UsageError(err.message));
|
|
157
|
+
}
|
|
158
|
+
handleError(err);
|
|
159
|
+
}
|
|
160
|
+
});
|
|
161
|
+
history
|
|
162
|
+
.command('stats')
|
|
163
|
+
.description('Show on-disk size + record counts for a device history')
|
|
164
|
+
.argument('<deviceId>', 'Device ID to inspect')
|
|
165
|
+
.action((deviceId) => {
|
|
166
|
+
try {
|
|
167
|
+
const stats = queryDeviceHistoryStats(deviceId);
|
|
168
|
+
if (isJsonMode()) {
|
|
169
|
+
printJson(stats);
|
|
170
|
+
return;
|
|
171
|
+
}
|
|
172
|
+
console.log(`Device: ${stats.deviceId}`);
|
|
173
|
+
console.log(`History dir: ${stats.historyDir}`);
|
|
174
|
+
console.log(`JSONL files: ${stats.fileCount} (${stats.jsonlFiles.join(', ') || '—'})`);
|
|
175
|
+
console.log(`Total size: ${stats.totalBytes.toLocaleString()} bytes`);
|
|
176
|
+
console.log(`Record count: ${stats.recordCount}`);
|
|
177
|
+
console.log(`Oldest: ${stats.oldest ?? '—'}`);
|
|
178
|
+
console.log(`Newest: ${stats.newest ?? '—'}`);
|
|
179
|
+
}
|
|
180
|
+
catch (err) {
|
|
181
|
+
handleError(err);
|
|
182
|
+
}
|
|
183
|
+
});
|
|
184
|
+
history
|
|
185
|
+
.command('verify')
|
|
186
|
+
.description('Check the audit log for malformed lines and schema-version drift')
|
|
187
|
+
.option('--file <path>', `Path to the audit log (default ${DEFAULT_AUDIT})`, stringArg('--file'))
|
|
188
|
+
.addHelpText('after', `
|
|
189
|
+
See docs/audit-log.md for the audit log format. Exit code:
|
|
190
|
+
0 every line parses and carries the current auditVersion, or file is missing (warn)
|
|
191
|
+
1 one or more lines are malformed or schema drift detected
|
|
192
|
+
2 (usage) — not emitted by this subcommand
|
|
193
|
+
|
|
194
|
+
Examples:
|
|
195
|
+
$ switchbot history verify
|
|
196
|
+
$ switchbot history verify --file ./custom.log --json
|
|
197
|
+
`)
|
|
198
|
+
.action((options) => {
|
|
199
|
+
const file = options.file ?? DEFAULT_AUDIT;
|
|
200
|
+
const report = verifyAudit(file);
|
|
201
|
+
// Determine status and exit code
|
|
202
|
+
let status = 'ok';
|
|
203
|
+
let exitCode = 0;
|
|
204
|
+
if (report.fileMissing) {
|
|
205
|
+
status = 'warn';
|
|
206
|
+
}
|
|
207
|
+
else if (report.malformedLines > 0 || report.unversionedEntries > 0) {
|
|
208
|
+
status = 'fail';
|
|
209
|
+
exitCode = 1;
|
|
210
|
+
}
|
|
211
|
+
if (isJsonMode()) {
|
|
212
|
+
const output = {
|
|
213
|
+
status,
|
|
214
|
+
fileMissing: report.fileMissing === true,
|
|
215
|
+
parsed: report.parsedLines,
|
|
216
|
+
malformed: report.malformedLines,
|
|
217
|
+
unversioned: report.unversionedEntries,
|
|
218
|
+
message: report.fileMissing
|
|
219
|
+
? 'Audit log file not found (fresh install)'
|
|
220
|
+
: report.malformedLines > 0 || report.unversionedEntries > 0
|
|
221
|
+
? 'Audit log has malformed or unversioned entries'
|
|
222
|
+
: 'Audit log is valid',
|
|
223
|
+
};
|
|
224
|
+
printJson(output);
|
|
225
|
+
}
|
|
226
|
+
else {
|
|
227
|
+
if (report.fileMissing) {
|
|
228
|
+
console.log(`Audit log: ${report.file} (missing — fresh install)`);
|
|
229
|
+
console.log(`Status: ✓ warn (expected for new accounts)`);
|
|
230
|
+
}
|
|
231
|
+
else {
|
|
232
|
+
console.log(`Audit log: ${report.file}`);
|
|
233
|
+
console.log(`Parsed lines: ${report.parsedLines} / ${report.totalLines}`);
|
|
234
|
+
console.log(`Malformed: ${report.malformedLines}`);
|
|
235
|
+
console.log(`Unversioned: ${report.unversionedEntries}`);
|
|
236
|
+
const versions = Object.entries(report.versionCounts)
|
|
237
|
+
.map(([v, n]) => `${v}:${n}`)
|
|
238
|
+
.join(', ');
|
|
239
|
+
console.log(`Version counts: ${versions || '—'}`);
|
|
240
|
+
if (report.earliest)
|
|
241
|
+
console.log(`Earliest: ${report.earliest}`);
|
|
242
|
+
if (report.latest)
|
|
243
|
+
console.log(`Latest: ${report.latest}`);
|
|
244
|
+
if (report.problems.length > 0) {
|
|
245
|
+
console.log('\nProblems:');
|
|
246
|
+
for (const p of report.problems) {
|
|
247
|
+
console.log(` line ${p.line}: ${p.reason}${p.preview ? ` — "${p.preview}"` : ''}`);
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
process.exit(exitCode);
|
|
253
|
+
});
|
|
254
|
+
history
|
|
255
|
+
.command('aggregate')
|
|
256
|
+
.description('Aggregate time-ranged device history metrics into buckets')
|
|
257
|
+
.argument('<deviceId>', 'Device ID to aggregate')
|
|
258
|
+
.option('--since <duration>', 'Relative window ending now, e.g. "1h", "7d" (mutually exclusive with --from/--to)', stringArg('--since'))
|
|
259
|
+
.option('--from <iso>', 'Range start (ISO-8601)', stringArg('--from'))
|
|
260
|
+
.option('--to <iso>', 'Range end (ISO-8601)', stringArg('--to'))
|
|
261
|
+
.option('--metric <name>', 'Payload field to aggregate (repeat for multiple)', (v, acc = []) => acc.concat(v), [])
|
|
262
|
+
.option('--agg <csv>', 'Comma-separated aggregation functions (count,min,max,avg,sum,p50,p95)', stringArg('--agg'))
|
|
263
|
+
.option('--bucket <duration>', 'Bucket width, e.g. "15m", "1h", "1d"', stringArg('--bucket'))
|
|
264
|
+
.option('--max-bucket-samples <n>', 'Max samples per bucket for quantiles (1–100000)', intArg('--max-bucket-samples', { min: 1, max: 100_000 }))
|
|
265
|
+
.action(async (deviceId, options) => {
|
|
266
|
+
const metrics = options.metric ?? [];
|
|
267
|
+
if (metrics.length === 0) {
|
|
268
|
+
handleError(new UsageError('at least one --metric is required.'));
|
|
269
|
+
}
|
|
270
|
+
if (options.since && (options.from || options.to)) {
|
|
271
|
+
handleError(new UsageError('--since is mutually exclusive with --from/--to.'));
|
|
272
|
+
}
|
|
273
|
+
let aggs;
|
|
274
|
+
if (options.agg !== undefined) {
|
|
275
|
+
const parts = options.agg.split(',').map((s) => s.trim()).filter(Boolean);
|
|
276
|
+
const unknown = parts.filter((p) => !ALL_AGG_FNS.includes(p));
|
|
277
|
+
if (unknown.length > 0) {
|
|
278
|
+
handleError(new UsageError(`Unknown aggregation function(s): ${unknown.join(', ')}. Legal values: ${ALL_AGG_FNS.join(', ')}.`));
|
|
279
|
+
}
|
|
280
|
+
aggs = parts;
|
|
281
|
+
}
|
|
282
|
+
const aggOpts = {
|
|
283
|
+
metrics,
|
|
284
|
+
aggs,
|
|
285
|
+
since: options.since,
|
|
286
|
+
from: options.from,
|
|
287
|
+
to: options.to,
|
|
288
|
+
bucket: options.bucket,
|
|
289
|
+
maxBucketSamples: options.maxBucketSamples !== undefined ? Number(options.maxBucketSamples) : undefined,
|
|
290
|
+
};
|
|
291
|
+
try {
|
|
292
|
+
const res = await aggregateDeviceHistory(deviceId, aggOpts);
|
|
293
|
+
if (isJsonMode()) {
|
|
294
|
+
printJson(res);
|
|
295
|
+
return;
|
|
296
|
+
}
|
|
297
|
+
if (res.buckets.length === 0) {
|
|
298
|
+
console.log(`(no history records for ${deviceId} in requested range)`);
|
|
299
|
+
return;
|
|
300
|
+
}
|
|
301
|
+
const aggCols = res.aggs;
|
|
302
|
+
const cols = ['t', ...res.metrics.flatMap((m) => aggCols.map((a) => `${m}.${a}`))];
|
|
303
|
+
console.log(cols.join('\t'));
|
|
304
|
+
for (const bkt of res.buckets) {
|
|
305
|
+
const row = cols.map((col) => {
|
|
306
|
+
if (col === 't')
|
|
307
|
+
return bkt.t;
|
|
308
|
+
const [metric, agg] = col.split('.');
|
|
309
|
+
const val = bkt.metrics[metric]?.[agg];
|
|
310
|
+
return val !== undefined ? String(val) : '\u2014';
|
|
311
|
+
});
|
|
312
|
+
console.log(row.join('\t'));
|
|
313
|
+
}
|
|
314
|
+
if (res.partial) {
|
|
315
|
+
for (const note of res.notes) {
|
|
316
|
+
console.error('note: ' + note);
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
catch (err) {
|
|
321
|
+
if (err instanceof Error) {
|
|
322
|
+
if (/bucket/i.test(err.message) || /--since/i.test(err.message) || /--from/i.test(err.message) || /--to/i.test(err.message)) {
|
|
323
|
+
handleError(new UsageError(err.message));
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
handleError(err);
|
|
327
|
+
}
|
|
328
|
+
});
|
|
104
329
|
}
|