@switchbot/openapi-cli 2.4.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/commands/agent-bootstrap.js +3 -0
- package/dist/commands/batch.js +1 -1
- package/dist/commands/cache.js +1 -0
- package/dist/commands/capabilities.js +3 -0
- package/dist/commands/devices.js +1 -1
- package/dist/commands/events.js +7 -0
- package/dist/commands/history.js +124 -21
- package/dist/commands/mcp.js +125 -36
- package/dist/commands/plan.js +6 -1
- package/dist/commands/scenes.js +43 -1
- package/dist/commands/schema.js +6 -0
- 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/audit.js +1 -1
- package/dist/utils/format.js +9 -1
- package/dist/utils/name-resolver.js +10 -2
- package/dist/utils/output.js +2 -2
- package/dist/version.js +4 -0
- package/package.json +1 -1
|
@@ -116,6 +116,9 @@ Examples:
|
|
|
116
116
|
scope: cachedDevices.length > 0 ? 'used' : 'all',
|
|
117
117
|
types: catalogTypes,
|
|
118
118
|
},
|
|
119
|
+
// hints: empty array means no hints to report; always emitted, never null.
|
|
120
|
+
// An empty array signals "nothing to act on" — agents should not treat
|
|
121
|
+
// it as a disabled or missing field.
|
|
119
122
|
hints: cachedDevices.length === 0
|
|
120
123
|
? ['Run `switchbot devices list` once to populate the device cache for richer bootstrap output.']
|
|
121
124
|
: [],
|
package/dist/commands/batch.js
CHANGED
|
@@ -100,7 +100,7 @@ export function registerBatchCommand(devices) {
|
|
|
100
100
|
.option('--yes', 'Allow destructive commands (Smart Lock unlock, garage open, ...)')
|
|
101
101
|
.option('--type <commandType>', '"command" (default) or "customize" for user-defined IR buttons', enumArg('--type', COMMAND_TYPES), 'command')
|
|
102
102
|
.option('--stdin', 'Read deviceIds from stdin, one per line (same as trailing "-")')
|
|
103
|
-
.option('--idempotency-key-prefix <prefix>', '
|
|
103
|
+
.option('--idempotency-key-prefix <prefix>', 'Client-supplied prefix for idempotency keys (key per device: <prefix>-<deviceId>). 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-prefix'))
|
|
104
104
|
.addHelpText('after', `
|
|
105
105
|
Targets are resolved in this priority order:
|
|
106
106
|
1. --ids when present (explicit deviceIds)
|
package/dist/commands/cache.js
CHANGED
|
@@ -28,6 +28,7 @@ const COMMAND_META = {
|
|
|
28
28
|
// scenes
|
|
29
29
|
'scenes list': { mutating: false, consumesQuota: true, idempotencySupported: false, agentSafetyTier: 'read', verifiability: 'local', typicalLatencyMs: 500 },
|
|
30
30
|
'scenes execute': { mutating: true, consumesQuota: true, idempotencySupported: false, agentSafetyTier: 'action', verifiability: 'deviceDependent', typicalLatencyMs: 1500 },
|
|
31
|
+
'scenes describe': { mutating: false, consumesQuota: true, idempotencySupported: false, agentSafetyTier: 'read', verifiability: 'local', typicalLatencyMs: 500 },
|
|
31
32
|
// webhook
|
|
32
33
|
'webhook setup': { mutating: true, consumesQuota: true, idempotencySupported: false, agentSafetyTier: 'action', verifiability: 'local', typicalLatencyMs: 500 },
|
|
33
34
|
'webhook query': { mutating: false, consumesQuota: true, idempotencySupported: false, agentSafetyTier: 'read', verifiability: 'local', typicalLatencyMs: 500 },
|
|
@@ -51,6 +52,7 @@ const COMMAND_META = {
|
|
|
51
52
|
'history replay': { mutating: true, consumesQuota: true, idempotencySupported: true, agentSafetyTier: 'action', verifiability: 'deviceDependent', typicalLatencyMs: 1000 },
|
|
52
53
|
'history range': { mutating: false, consumesQuota: false, idempotencySupported: false, agentSafetyTier: 'read', verifiability: 'local', typicalLatencyMs: 50 },
|
|
53
54
|
'history stats': { mutating: false, consumesQuota: false, idempotencySupported: false, agentSafetyTier: 'read', verifiability: 'local', typicalLatencyMs: 20 },
|
|
55
|
+
'history aggregate': { mutating: false, consumesQuota: false, idempotencySupported: false, agentSafetyTier: 'read', verifiability: 'local', typicalLatencyMs: 80 },
|
|
54
56
|
'plan run': { mutating: true, consumesQuota: true, idempotencySupported: true, agentSafetyTier: 'action', verifiability: 'deviceDependent', typicalLatencyMs: 2000 },
|
|
55
57
|
'plan validate': { mutating: false, consumesQuota: false, idempotencySupported: false, agentSafetyTier: 'read', verifiability: 'local', typicalLatencyMs: 10 },
|
|
56
58
|
'plan schema': { mutating: false, consumesQuota: false, idempotencySupported: false, agentSafetyTier: 'read', verifiability: 'local', typicalLatencyMs: 10 },
|
|
@@ -88,6 +90,7 @@ const MCP_TOOLS = [
|
|
|
88
90
|
'account_overview',
|
|
89
91
|
'get_device_history',
|
|
90
92
|
'query_device_history',
|
|
93
|
+
'aggregate_device_history',
|
|
91
94
|
];
|
|
92
95
|
const IDEMPOTENCY_CONTRACT = {
|
|
93
96
|
flag: '--idempotency-key <key>',
|
package/dist/commands/devices.js
CHANGED
|
@@ -307,7 +307,7 @@ Examples:
|
|
|
307
307
|
.option('--name-room <room>', 'Narrow --name by room name (substring match)', stringArg('--name-room'))
|
|
308
308
|
.option('--type <commandType>', 'Command type: "command" for built-in commands (default), "customize" for user-defined IR buttons', enumArg('--type', COMMAND_TYPES), 'command')
|
|
309
309
|
.option('--yes', 'Confirm a destructive command (Smart Lock unlock, Garage open, …). --dry-run is always allowed without --yes.')
|
|
310
|
-
.option('--idempotency-key <key>', '
|
|
310
|
+
.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
311
|
.addHelpText('after', `
|
|
312
312
|
────────────────────────────────────────────────────────────────────────
|
|
313
313
|
For the full list of commands a specific device supports — and their
|
package/dist/commands/events.js
CHANGED
|
@@ -385,6 +385,13 @@ Examples:
|
|
|
385
385
|
else {
|
|
386
386
|
console.log(JSON.stringify(ctl));
|
|
387
387
|
}
|
|
388
|
+
// Persist to __control.jsonl — best-effort, never blocks the stream.
|
|
389
|
+
try {
|
|
390
|
+
deviceHistoryStore.recordControl(ctl);
|
|
391
|
+
}
|
|
392
|
+
catch {
|
|
393
|
+
// swallow
|
|
394
|
+
}
|
|
388
395
|
};
|
|
389
396
|
const unsubState = client.onStateChange((state) => {
|
|
390
397
|
if (!isJsonMode()) {
|
package/dist/commands/history.js
CHANGED
|
@@ -5,6 +5,7 @@ import { printJson, isJsonMode, handleError, UsageError } from '../utils/output.
|
|
|
5
5
|
import { readAudit, verifyAudit } from '../utils/audit.js';
|
|
6
6
|
import { executeCommand } from '../lib/devices.js';
|
|
7
7
|
import { queryDeviceHistory, queryDeviceHistoryStats, } from '../devices/history-query.js';
|
|
8
|
+
import { aggregateDeviceHistory, ALL_AGG_FNS, } from '../devices/history-agg.js';
|
|
8
9
|
const DEFAULT_AUDIT = path.join(os.homedir(), '.switchbot', 'audit.log');
|
|
9
10
|
export function registerHistoryCommand(program) {
|
|
10
11
|
const history = program
|
|
@@ -186,8 +187,8 @@ Examples:
|
|
|
186
187
|
.option('--file <path>', `Path to the audit log (default ${DEFAULT_AUDIT})`, stringArg('--file'))
|
|
187
188
|
.addHelpText('after', `
|
|
188
189
|
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
|
|
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
|
|
191
192
|
2 (usage) — not emitted by this subcommand
|
|
192
193
|
|
|
193
194
|
Examples:
|
|
@@ -197,30 +198,132 @@ Examples:
|
|
|
197
198
|
.action((options) => {
|
|
198
199
|
const file = options.file ?? DEFAULT_AUDIT;
|
|
199
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
|
+
}
|
|
200
211
|
if (isJsonMode()) {
|
|
201
|
-
|
|
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);
|
|
202
225
|
}
|
|
203
226
|
else {
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
.
|
|
210
|
-
.
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
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
|
+
}
|
|
220
249
|
}
|
|
221
250
|
}
|
|
222
251
|
}
|
|
223
|
-
|
|
224
|
-
|
|
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
|
+
}
|
|
225
328
|
});
|
|
226
329
|
}
|
package/dist/commands/mcp.js
CHANGED
|
@@ -4,6 +4,7 @@ import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/
|
|
|
4
4
|
import { z } from 'zod';
|
|
5
5
|
import { intArg, stringArg } from '../utils/arg-parsers.js';
|
|
6
6
|
import { handleError, isJsonMode } from '../utils/output.js';
|
|
7
|
+
import { VERSION } from '../version.js';
|
|
7
8
|
import { fetchDeviceList, fetchDeviceStatus, executeCommand, describeDevice, validateCommand, isDestructiveCommand, getDestructiveReason, searchCatalog, DeviceNotFoundError, toMcpDescribeShape, toMcpDeviceListShape, toMcpIrDeviceShape, } from '../lib/devices.js';
|
|
8
9
|
import { fetchScenes, executeScene } from '../lib/scenes.js';
|
|
9
10
|
import { findCatalogEntry } from '../devices/catalog.js';
|
|
@@ -11,6 +12,7 @@ import { getCachedDevice } from '../devices/cache.js';
|
|
|
11
12
|
import { EventSubscriptionManager } from '../mcp/events-subscription.js';
|
|
12
13
|
import { deviceHistoryStore } from '../mcp/device-history.js';
|
|
13
14
|
import { queryDeviceHistory } from '../devices/history-query.js';
|
|
15
|
+
import { aggregateDeviceHistory, ALL_AGG_FNS, MAX_SAMPLE_CAP, } from '../devices/history-agg.js';
|
|
14
16
|
import { todayUsage } from '../utils/quota.js';
|
|
15
17
|
import { describeCache } from '../devices/cache.js';
|
|
16
18
|
import { withRequestContext } from '../lib/request-context.js';
|
|
@@ -33,7 +35,7 @@ export function createSwitchBotMcpServer(options) {
|
|
|
33
35
|
const eventManager = options?.eventManager;
|
|
34
36
|
const server = new McpServer({
|
|
35
37
|
name: 'switchbot',
|
|
36
|
-
version:
|
|
38
|
+
version: VERSION,
|
|
37
39
|
}, {
|
|
38
40
|
capabilities: { tools: {}, resources: {} },
|
|
39
41
|
instructions: `SwitchBot is an IoT smart home brand by Wonderlabs, Inc. This MCP server controls physical devices \
|
|
@@ -60,7 +62,8 @@ API docs: https://github.com/OpenWonderLabs/SwitchBotAPI`,
|
|
|
60
62
|
server.registerTool('list_devices', {
|
|
61
63
|
title: 'List all devices on the account',
|
|
62
64
|
description: 'Fetch the complete inventory of physical devices and IR remotes on this SwitchBot account. Refreshes the local metadata cache and groups devices by type. Use this as the bootstrap call to discover available deviceIds. Devices without enableCloudService cannot receive commands via API. IR remotes depend on a Hub for connectivity.',
|
|
63
|
-
|
|
65
|
+
_meta: { agentSafetyTier: 'read' },
|
|
66
|
+
inputSchema: z.object({}).strict(),
|
|
64
67
|
outputSchema: {
|
|
65
68
|
deviceList: z.array(z.object({
|
|
66
69
|
deviceId: z.string(),
|
|
@@ -95,9 +98,10 @@ API docs: https://github.com/OpenWonderLabs/SwitchBotAPI`,
|
|
|
95
98
|
server.registerTool('get_device_status', {
|
|
96
99
|
title: 'Get live status for a device',
|
|
97
100
|
description: 'Query the real-time status payload for a physical device. IR remotes have no status channel and will error.',
|
|
98
|
-
|
|
101
|
+
_meta: { agentSafetyTier: 'read' },
|
|
102
|
+
inputSchema: z.object({
|
|
99
103
|
deviceId: z.string().describe('Device ID from list_devices'),
|
|
100
|
-
},
|
|
104
|
+
}).strict(),
|
|
101
105
|
outputSchema: {
|
|
102
106
|
status: z.object({
|
|
103
107
|
deviceId: z.string().optional(),
|
|
@@ -119,10 +123,11 @@ API docs: https://github.com/OpenWonderLabs/SwitchBotAPI`,
|
|
|
119
123
|
description: 'Return device state history recorded from MQTT events (persisted to ~/.switchbot/device-history/). ' +
|
|
120
124
|
'No API call — zero quota cost. Use when you need recent historical readings or want to avoid a live API call. ' +
|
|
121
125
|
'Omit deviceId to list all devices with stored history.',
|
|
122
|
-
|
|
126
|
+
_meta: { agentSafetyTier: 'read' },
|
|
127
|
+
inputSchema: z.object({
|
|
123
128
|
deviceId: z.string().optional().describe('Device MAC address (deviceId). Omit to list all devices with history.'),
|
|
124
129
|
limit: z.number().int().min(1).max(100).optional().describe('Max history entries to return (default 20, max 100)'),
|
|
125
|
-
},
|
|
130
|
+
}).strict(),
|
|
126
131
|
outputSchema: {
|
|
127
132
|
deviceId: z.string().optional(),
|
|
128
133
|
latest: z.unknown().optional(),
|
|
@@ -153,14 +158,15 @@ API docs: https://github.com/OpenWonderLabs/SwitchBotAPI`,
|
|
|
153
158
|
description: 'Return records from the append-only JSONL history (~/.switchbot/device-history/<deviceId>.jsonl) ' +
|
|
154
159
|
'filtered by a relative duration (since) or absolute ISO-8601 range (from/to). ' +
|
|
155
160
|
'No API call — zero quota cost. Use for trend questions like "how many times did this switch turn on last week".',
|
|
156
|
-
|
|
161
|
+
_meta: { agentSafetyTier: 'read' },
|
|
162
|
+
inputSchema: z.object({
|
|
157
163
|
deviceId: z.string().describe('Device ID to query'),
|
|
158
164
|
since: z.string().optional().describe('Relative window ending now, e.g. "30s", "15m", "1h", "7d". Mutually exclusive with from/to.'),
|
|
159
165
|
from: z.string().optional().describe('Range start (ISO-8601).'),
|
|
160
166
|
to: z.string().optional().describe('Range end (ISO-8601).'),
|
|
161
167
|
fields: z.array(z.string()).optional().describe('Project these payload fields; omit for the full payload.'),
|
|
162
168
|
limit: z.number().int().min(1).max(10000).optional().describe('Max records to return (default 1000).'),
|
|
163
|
-
},
|
|
169
|
+
}).strict(),
|
|
164
170
|
outputSchema: {
|
|
165
171
|
deviceId: z.string(),
|
|
166
172
|
count: z.number().int(),
|
|
@@ -192,7 +198,8 @@ API docs: https://github.com/OpenWonderLabs/SwitchBotAPI`,
|
|
|
192
198
|
server.registerTool('send_command', {
|
|
193
199
|
title: 'Send a control command to a device',
|
|
194
200
|
description: 'Execute a control command on a device (turnOn, setColor, startClean, unlock, openDoor, createKey, etc.). Destructive commands (Smart Lock unlock, Garage Door open, Keypad createKey/deleteKey) require confirm:true to proceed; otherwise rejected. Commands are validated offline against the device catalog. Use idempotencyKey to safely deduplicate retries within 60 seconds.',
|
|
195
|
-
|
|
201
|
+
_meta: { agentSafetyTier: 'action' },
|
|
202
|
+
inputSchema: z.object({
|
|
196
203
|
deviceId: z.string().describe('Device ID from list_devices'),
|
|
197
204
|
command: z.string().describe('Command name, case-sensitive (e.g. turnOn, setColor, unlock)'),
|
|
198
205
|
parameter: z
|
|
@@ -213,12 +220,16 @@ API docs: https://github.com/OpenWonderLabs/SwitchBotAPI`,
|
|
|
213
220
|
.string()
|
|
214
221
|
.optional()
|
|
215
222
|
.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.'),
|
|
216
|
-
|
|
223
|
+
dryRun: z
|
|
224
|
+
.boolean()
|
|
225
|
+
.optional()
|
|
226
|
+
.describe('When true, do not call the API — return { ok:true, dryRun:true, wouldSend:{...} } instead.'),
|
|
227
|
+
}).strict(),
|
|
217
228
|
outputSchema: {
|
|
218
229
|
ok: z.literal(true),
|
|
219
|
-
command: z.string(),
|
|
220
|
-
deviceId: z.string(),
|
|
221
|
-
result: z.unknown().describe('API response body from SwitchBot'),
|
|
230
|
+
command: z.string().optional(),
|
|
231
|
+
deviceId: z.string().optional(),
|
|
232
|
+
result: z.unknown().optional().describe('API response body from SwitchBot (absent on dryRun)'),
|
|
222
233
|
verification: z
|
|
223
234
|
.object({
|
|
224
235
|
verifiable: z.boolean(),
|
|
@@ -227,9 +238,30 @@ API docs: https://github.com/OpenWonderLabs/SwitchBotAPI`,
|
|
|
227
238
|
})
|
|
228
239
|
.optional()
|
|
229
240
|
.describe('Present when the target is an IR device. IR is unidirectional — agents should treat the success as "signal sent" not "state changed".'),
|
|
241
|
+
dryRun: z.literal(true).optional().describe('Present when dryRun:true was requested'),
|
|
242
|
+
wouldSend: z.object({
|
|
243
|
+
deviceId: z.string(),
|
|
244
|
+
command: z.string(),
|
|
245
|
+
parameter: z.unknown(),
|
|
246
|
+
commandType: z.string(),
|
|
247
|
+
}).optional().describe('The request shape that would have been POSTed (present when dryRun:true)'),
|
|
230
248
|
},
|
|
231
|
-
}, async ({ deviceId, command, parameter, commandType, confirm, idempotencyKey }) => {
|
|
249
|
+
}, async ({ deviceId, command, parameter, commandType, confirm, idempotencyKey, dryRun }) => {
|
|
232
250
|
const effectiveType = commandType ?? 'command';
|
|
251
|
+
// dryRun early-return — no API call, no validation against live device list
|
|
252
|
+
if (dryRun) {
|
|
253
|
+
const wouldSend = {
|
|
254
|
+
deviceId,
|
|
255
|
+
command,
|
|
256
|
+
parameter: parameter ?? 'default',
|
|
257
|
+
commandType: effectiveType,
|
|
258
|
+
};
|
|
259
|
+
const structured = { ok: true, dryRun: true, wouldSend };
|
|
260
|
+
return {
|
|
261
|
+
content: [{ type: 'text', text: JSON.stringify(structured, null, 2) }],
|
|
262
|
+
structuredContent: structured,
|
|
263
|
+
};
|
|
264
|
+
}
|
|
233
265
|
// Resolve the device's catalog type via cache or a fresh lookup so we
|
|
234
266
|
// can evaluate destructive/validation without an extra round-trip if
|
|
235
267
|
// the cache is warm.
|
|
@@ -307,14 +339,31 @@ API docs: https://github.com/OpenWonderLabs/SwitchBotAPI`,
|
|
|
307
339
|
server.registerTool('run_scene', {
|
|
308
340
|
title: 'Execute a manual scene',
|
|
309
341
|
description: 'Execute a manual SwitchBot scene by its sceneId (from list_scenes).',
|
|
310
|
-
|
|
342
|
+
_meta: { agentSafetyTier: 'action' },
|
|
343
|
+
inputSchema: z.object({
|
|
311
344
|
sceneId: z.string().describe('Scene ID from list_scenes'),
|
|
312
|
-
|
|
345
|
+
dryRun: z
|
|
346
|
+
.boolean()
|
|
347
|
+
.optional()
|
|
348
|
+
.describe('When true, do not call the API — return { ok:true, dryRun:true, wouldSend:{...} } instead.'),
|
|
349
|
+
}).strict(),
|
|
313
350
|
outputSchema: {
|
|
314
351
|
ok: z.literal(true),
|
|
315
|
-
sceneId: z.string(),
|
|
352
|
+
sceneId: z.string().optional(),
|
|
353
|
+
dryRun: z.literal(true).optional().describe('Present when dryRun:true was requested'),
|
|
354
|
+
wouldSend: z.object({
|
|
355
|
+
sceneId: z.string(),
|
|
356
|
+
}).optional().describe('The request shape that would have been POSTed (present when dryRun:true)'),
|
|
316
357
|
},
|
|
317
|
-
}, async ({ sceneId }) => {
|
|
358
|
+
}, async ({ sceneId, dryRun }) => {
|
|
359
|
+
if (dryRun) {
|
|
360
|
+
const wouldSend = { sceneId };
|
|
361
|
+
const structured = { ok: true, dryRun: true, wouldSend };
|
|
362
|
+
return {
|
|
363
|
+
content: [{ type: 'text', text: JSON.stringify(structured, null, 2) }],
|
|
364
|
+
structuredContent: structured,
|
|
365
|
+
};
|
|
366
|
+
}
|
|
318
367
|
await executeScene(sceneId);
|
|
319
368
|
const structured = { ok: true, sceneId };
|
|
320
369
|
return {
|
|
@@ -326,7 +375,8 @@ API docs: https://github.com/OpenWonderLabs/SwitchBotAPI`,
|
|
|
326
375
|
server.registerTool('list_scenes', {
|
|
327
376
|
title: 'List all manual scenes',
|
|
328
377
|
description: 'Fetch all manual scenes configured in the SwitchBot app.',
|
|
329
|
-
|
|
378
|
+
_meta: { agentSafetyTier: 'read' },
|
|
379
|
+
inputSchema: z.object({}).strict(),
|
|
330
380
|
outputSchema: {
|
|
331
381
|
scenes: z.array(z.object({ sceneId: z.string(), sceneName: z.string() })),
|
|
332
382
|
},
|
|
@@ -341,10 +391,11 @@ API docs: https://github.com/OpenWonderLabs/SwitchBotAPI`,
|
|
|
341
391
|
server.registerTool('search_catalog', {
|
|
342
392
|
title: 'Search the offline device catalog',
|
|
343
393
|
description: 'Search the built-in device catalog by type name or alias. Returns matching entries with their commands, roles, destructive flags, and status fields. No API call.',
|
|
344
|
-
|
|
394
|
+
_meta: { agentSafetyTier: 'read' },
|
|
395
|
+
inputSchema: z.object({
|
|
345
396
|
query: z.string().describe('Search query (matches type and aliases, case-insensitive). Use empty string to list all.'),
|
|
346
397
|
limit: z.number().int().min(1).max(100).optional().default(20).describe('Max entries returned (default 20)'),
|
|
347
|
-
},
|
|
398
|
+
}).strict(),
|
|
348
399
|
outputSchema: {
|
|
349
400
|
results: z.array(z.object({
|
|
350
401
|
type: z.string(),
|
|
@@ -376,10 +427,11 @@ API docs: https://github.com/OpenWonderLabs/SwitchBotAPI`,
|
|
|
376
427
|
server.registerTool('describe_device', {
|
|
377
428
|
title: 'Describe a specific device',
|
|
378
429
|
description: 'Resolve a deviceId to its metadata + catalog entry + suggested safe actions. Pass live:true to also fetch real-time status values.',
|
|
379
|
-
|
|
430
|
+
_meta: { agentSafetyTier: 'read' },
|
|
431
|
+
inputSchema: z.object({
|
|
380
432
|
deviceId: z.string().describe('Device ID from list_devices'),
|
|
381
433
|
live: z.boolean().optional().default(false).describe('Also fetch live /status values (costs 1 extra API call)'),
|
|
382
|
-
},
|
|
434
|
+
}).strict(),
|
|
383
435
|
outputSchema: {
|
|
384
436
|
device: z.object({
|
|
385
437
|
device: z.object({ deviceId: z.string(), deviceName: z.string() }).passthrough(),
|
|
@@ -417,11 +469,45 @@ API docs: https://github.com/OpenWonderLabs/SwitchBotAPI`,
|
|
|
417
469
|
throw err;
|
|
418
470
|
}
|
|
419
471
|
});
|
|
472
|
+
// ---- aggregate_device_history --------------------------------------------
|
|
473
|
+
server.registerTool('aggregate_device_history', {
|
|
474
|
+
title: 'Aggregate device history',
|
|
475
|
+
description: 'Bucketed statistics (count/min/max/avg/sum/p50/p95) over JSONL-recorded device history. Read-only; no network calls.',
|
|
476
|
+
_meta: { agentSafetyTier: 'read' },
|
|
477
|
+
inputSchema: z
|
|
478
|
+
.object({
|
|
479
|
+
deviceId: z.string().min(1),
|
|
480
|
+
since: z.string().optional(),
|
|
481
|
+
from: z.string().optional(),
|
|
482
|
+
to: z.string().optional(),
|
|
483
|
+
metrics: z.array(z.string().min(1)).min(1),
|
|
484
|
+
aggs: z.array(z.enum(ALL_AGG_FNS)).optional(),
|
|
485
|
+
bucket: z.string().optional(),
|
|
486
|
+
maxBucketSamples: z.number().int().positive().max(MAX_SAMPLE_CAP).optional(),
|
|
487
|
+
})
|
|
488
|
+
.strict(),
|
|
489
|
+
}, async (args) => {
|
|
490
|
+
const opts = {
|
|
491
|
+
since: args.since,
|
|
492
|
+
from: args.from,
|
|
493
|
+
to: args.to,
|
|
494
|
+
metrics: args.metrics,
|
|
495
|
+
aggs: args.aggs,
|
|
496
|
+
bucket: args.bucket,
|
|
497
|
+
maxBucketSamples: args.maxBucketSamples,
|
|
498
|
+
};
|
|
499
|
+
const res = await aggregateDeviceHistory(args.deviceId, opts);
|
|
500
|
+
return {
|
|
501
|
+
content: [{ type: 'text', text: JSON.stringify(res, null, 2) }],
|
|
502
|
+
structuredContent: res,
|
|
503
|
+
};
|
|
504
|
+
});
|
|
420
505
|
// ---- account_overview ---------------------------------------------------
|
|
421
506
|
server.registerTool('account_overview', {
|
|
422
507
|
title: 'Bootstrap account overview',
|
|
423
508
|
description: 'Get a complete account snapshot: devices, scenes, quota usage, cache status, and MQTT connection state. Use this for cold-start initialization or periodic health checks.',
|
|
424
|
-
|
|
509
|
+
_meta: { agentSafetyTier: 'read' },
|
|
510
|
+
inputSchema: z.object({}).strict(),
|
|
425
511
|
outputSchema: {
|
|
426
512
|
version: z.string(),
|
|
427
513
|
schemaVersion: z.string(),
|
|
@@ -472,7 +558,7 @@ API docs: https://github.com/OpenWonderLabs/SwitchBotAPI`,
|
|
|
472
558
|
const cacheInfo = describeCache();
|
|
473
559
|
const quota = todayUsage();
|
|
474
560
|
const overview = {
|
|
475
|
-
version:
|
|
561
|
+
version: VERSION,
|
|
476
562
|
schemaVersion: '1.1',
|
|
477
563
|
devices: deviceList.deviceList.map(toMcpDeviceListShape),
|
|
478
564
|
infraredRemotes: deviceList.infraredRemoteList.map(toMcpIrDeviceShape),
|
|
@@ -533,15 +619,18 @@ export function registerMcpCommand(program) {
|
|
|
533
619
|
.command('mcp')
|
|
534
620
|
.description('Run as a Model Context Protocol server so AI agents can call SwitchBot tools')
|
|
535
621
|
.addHelpText('after', `
|
|
536
|
-
The MCP server exposes
|
|
537
|
-
- list_devices
|
|
538
|
-
- get_device_status
|
|
539
|
-
- send_command
|
|
540
|
-
- list_scenes
|
|
541
|
-
- run_scene
|
|
542
|
-
- search_catalog
|
|
543
|
-
- describe_device
|
|
544
|
-
- account_overview
|
|
622
|
+
The MCP server exposes eleven tools:
|
|
623
|
+
- list_devices fetch all physical + IR devices
|
|
624
|
+
- get_device_status live status for a physical device
|
|
625
|
+
- send_command control a device (destructive commands need confirm:true)
|
|
626
|
+
- list_scenes list all manual scenes
|
|
627
|
+
- run_scene execute a manual scene
|
|
628
|
+
- search_catalog offline catalog search by type/alias
|
|
629
|
+
- describe_device metadata + commands + (optionally) live status for one device
|
|
630
|
+
- account_overview single cold-start snapshot: devices + scenes + quota + cache + MQTT state
|
|
631
|
+
- get_device_history fetch raw JSONL history records for a device
|
|
632
|
+
- query_device_history filter + page history records with field/time predicates
|
|
633
|
+
- aggregate_device_history compute count/min/max/avg/sum/p50/p95 over history records
|
|
545
634
|
|
|
546
635
|
Resource (read-only):
|
|
547
636
|
- switchbot://events snapshot of recent MQTT shadow events from the ring buffer
|
|
@@ -650,7 +739,7 @@ Inspect locally:
|
|
|
650
739
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
651
740
|
res.end(JSON.stringify({
|
|
652
741
|
ok: true,
|
|
653
|
-
version:
|
|
742
|
+
version: VERSION,
|
|
654
743
|
pid: process.pid,
|
|
655
744
|
uptimeSec: Math.floor(process.uptime()),
|
|
656
745
|
}));
|
|
@@ -660,7 +749,7 @@ Inspect locally:
|
|
|
660
749
|
const state = eventManager.getState();
|
|
661
750
|
const ready = state !== 'failed' && state !== 'disabled';
|
|
662
751
|
const status = ready ? 200 : 503;
|
|
663
|
-
const body = { ready, version:
|
|
752
|
+
const body = { ready, version: VERSION, mqtt: state };
|
|
664
753
|
if (!ready)
|
|
665
754
|
body.reason = state === 'disabled' ? 'mqtt disabled' : 'mqtt failed';
|
|
666
755
|
res.writeHead(status, { 'Content-Type': 'application/json' });
|
package/dist/commands/plan.js
CHANGED
|
@@ -182,7 +182,12 @@ Workflow:
|
|
|
182
182
|
.command('schema')
|
|
183
183
|
.description('Print the JSON Schema for the plan format')
|
|
184
184
|
.action(() => {
|
|
185
|
-
printJson(
|
|
185
|
+
printJson({
|
|
186
|
+
...PLAN_JSON_SCHEMA,
|
|
187
|
+
agentNotes: {
|
|
188
|
+
deviceNameStrategy: "Plan step `deviceName` fields are resolved with the `require-unique` strategy (same default as `devices command`). Plans that expect a specific device should pin `deviceId` instead.",
|
|
189
|
+
},
|
|
190
|
+
});
|
|
186
191
|
});
|
|
187
192
|
plan
|
|
188
193
|
.command('validate')
|
package/dist/commands/scenes.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { printJson, isJsonMode, handleError } from '../utils/output.js';
|
|
1
|
+
import { printJson, isJsonMode, handleError, StructuredUsageError } from '../utils/output.js';
|
|
2
2
|
import { resolveFormat, resolveFields, renderRows } from '../utils/format.js';
|
|
3
3
|
import { fetchScenes, executeScene } from '../lib/scenes.js';
|
|
4
4
|
export function registerScenesCommand(program) {
|
|
@@ -59,4 +59,46 @@ Example:
|
|
|
59
59
|
handleError(error);
|
|
60
60
|
}
|
|
61
61
|
});
|
|
62
|
+
// switchbot scenes describe <sceneId>
|
|
63
|
+
scenes
|
|
64
|
+
.command('describe')
|
|
65
|
+
.description('Show metadata for a scene by its ID (SwitchBot API v1.1 does not expose step detail)')
|
|
66
|
+
.argument('<sceneId>', 'Scene ID from "scenes list"')
|
|
67
|
+
.addHelpText('after', `
|
|
68
|
+
Note: SwitchBot API v1.1 does not return scene step detail. Only the scene name is available.
|
|
69
|
+
|
|
70
|
+
Example:
|
|
71
|
+
$ switchbot scenes describe T12345678
|
|
72
|
+
`)
|
|
73
|
+
.action(async (sceneId) => {
|
|
74
|
+
try {
|
|
75
|
+
const sceneList = await fetchScenes();
|
|
76
|
+
const found = sceneList.find((s) => s.sceneId === sceneId);
|
|
77
|
+
if (!found) {
|
|
78
|
+
throw new StructuredUsageError(`scene not found: ${sceneId}`, {
|
|
79
|
+
error: 'scene_not_found',
|
|
80
|
+
sceneId,
|
|
81
|
+
candidates: sceneList.map((s) => ({ sceneId: s.sceneId, sceneName: s.sceneName })),
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
const result = {
|
|
85
|
+
sceneId: found.sceneId,
|
|
86
|
+
sceneName: found.sceneName,
|
|
87
|
+
stepCount: null,
|
|
88
|
+
note: 'SwitchBot API v1.1 does not expose scene steps — displayed name only',
|
|
89
|
+
};
|
|
90
|
+
if (isJsonMode()) {
|
|
91
|
+
printJson(result);
|
|
92
|
+
}
|
|
93
|
+
else {
|
|
94
|
+
console.log(`sceneId: ${result.sceneId}`);
|
|
95
|
+
console.log(`sceneName: ${result.sceneName}`);
|
|
96
|
+
console.log(`stepCount: (not available)`);
|
|
97
|
+
console.log(`note: ${result.note}`);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
catch (error) {
|
|
101
|
+
handleError(error);
|
|
102
|
+
}
|
|
103
|
+
});
|
|
62
104
|
}
|
package/dist/commands/schema.js
CHANGED
|
@@ -156,6 +156,12 @@ Examples:
|
|
|
156
156
|
type: 'object',
|
|
157
157
|
description: 'CLI-synthesized receipt-acknowledgment metadata. For IR devices, verifiable:false signals that no device-side confirmation is possible.',
|
|
158
158
|
},
|
|
159
|
+
{
|
|
160
|
+
field: 'hints',
|
|
161
|
+
appliesTo: ['agent-bootstrap'],
|
|
162
|
+
type: 'string[]',
|
|
163
|
+
description: 'CLI-synthesized advisory messages for the calling agent. Always emitted; empty array ([]) means no hints to report — never null and not a disabled-field signal.',
|
|
164
|
+
},
|
|
159
165
|
];
|
|
160
166
|
}
|
|
161
167
|
printJson(payload);
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import readline from 'node:readline';
|
|
3
|
+
import { jsonlFilesForDevice, parseDurationToMs, resolveRange } from './history-query.js';
|
|
4
|
+
export const ALL_AGG_FNS = ['count', 'min', 'max', 'avg', 'sum', 'p50', 'p95'];
|
|
5
|
+
export const DEFAULT_AGGS = ['count', 'avg'];
|
|
6
|
+
export const DEFAULT_SAMPLE_CAP = 10_000;
|
|
7
|
+
export const MAX_SAMPLE_CAP = 100_000;
|
|
8
|
+
export async function aggregateDeviceHistory(deviceId, opts) {
|
|
9
|
+
const { fromMs, toMs } = resolveRange(opts);
|
|
10
|
+
const aggs = (opts.aggs && opts.aggs.length > 0) ? opts.aggs : [...DEFAULT_AGGS];
|
|
11
|
+
const needQuantile = aggs.includes('p50') || aggs.includes('p95');
|
|
12
|
+
let bucketMs = null;
|
|
13
|
+
if (opts.bucket !== undefined) {
|
|
14
|
+
bucketMs = parseDurationToMs(opts.bucket);
|
|
15
|
+
if (bucketMs === null) {
|
|
16
|
+
throw new Error(`Invalid --bucket "${opts.bucket}". Expected e.g. "15m", "1h", "1d".`);
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
const sampleCap = Math.max(1, Math.min(opts.maxBucketSamples ?? DEFAULT_SAMPLE_CAP, MAX_SAMPLE_CAP));
|
|
20
|
+
let partial = false;
|
|
21
|
+
const notes = [];
|
|
22
|
+
// bucketKey (epoch ms; 0 when no --bucket) → metric name → Acc
|
|
23
|
+
const buckets = new Map();
|
|
24
|
+
for (const file of jsonlFilesForDevice(deviceId)) {
|
|
25
|
+
try {
|
|
26
|
+
const st = fs.statSync(file);
|
|
27
|
+
if (st.mtimeMs < fromMs)
|
|
28
|
+
continue;
|
|
29
|
+
}
|
|
30
|
+
catch {
|
|
31
|
+
continue;
|
|
32
|
+
}
|
|
33
|
+
const stream = fs.createReadStream(file, { encoding: 'utf-8' });
|
|
34
|
+
const rl = readline.createInterface({ input: stream, crlfDelay: Infinity });
|
|
35
|
+
for await (const line of rl) {
|
|
36
|
+
if (!line)
|
|
37
|
+
continue;
|
|
38
|
+
let rec;
|
|
39
|
+
try {
|
|
40
|
+
rec = JSON.parse(line);
|
|
41
|
+
}
|
|
42
|
+
catch {
|
|
43
|
+
continue;
|
|
44
|
+
}
|
|
45
|
+
const tMs = Date.parse(rec.t);
|
|
46
|
+
if (!Number.isFinite(tMs) || tMs < fromMs || tMs > toMs)
|
|
47
|
+
continue;
|
|
48
|
+
const key = bucketMs !== null ? Math.floor(tMs / bucketMs) * bucketMs : 0;
|
|
49
|
+
let bkt = buckets.get(key);
|
|
50
|
+
if (!bkt) {
|
|
51
|
+
bkt = new Map();
|
|
52
|
+
buckets.set(key, bkt);
|
|
53
|
+
}
|
|
54
|
+
for (const metric of opts.metrics) {
|
|
55
|
+
const v = rec.payload?.[metric];
|
|
56
|
+
if (typeof v !== 'number' || !Number.isFinite(v))
|
|
57
|
+
continue;
|
|
58
|
+
let acc = bkt.get(metric);
|
|
59
|
+
if (!acc) {
|
|
60
|
+
acc = {
|
|
61
|
+
min: v,
|
|
62
|
+
max: v,
|
|
63
|
+
sum: 0,
|
|
64
|
+
count: 0,
|
|
65
|
+
samples: needQuantile ? [] : null,
|
|
66
|
+
sampleCapHit: false,
|
|
67
|
+
};
|
|
68
|
+
bkt.set(metric, acc);
|
|
69
|
+
}
|
|
70
|
+
acc.min = Math.min(acc.min, v);
|
|
71
|
+
acc.max = Math.max(acc.max, v);
|
|
72
|
+
acc.sum += v;
|
|
73
|
+
acc.count += 1;
|
|
74
|
+
if (acc.samples) {
|
|
75
|
+
if (acc.samples.length < sampleCap) {
|
|
76
|
+
acc.samples.push(v);
|
|
77
|
+
}
|
|
78
|
+
else if (!acc.sampleCapHit) {
|
|
79
|
+
acc.sampleCapHit = true;
|
|
80
|
+
partial = true;
|
|
81
|
+
notes.push(`bucket ${new Date(key).toISOString()} metric ${metric}: sample cap ${sampleCap} reached, quantiles approximate`);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
return finalize(deviceId, opts, aggs, buckets, partial, notes, fromMs, toMs);
|
|
88
|
+
}
|
|
89
|
+
function finalize(deviceId, opts, aggs, buckets, partial, notes, fromMs, toMs) {
|
|
90
|
+
const fromIso = Number.isFinite(fromMs) ? new Date(fromMs).toISOString() : new Date(0).toISOString();
|
|
91
|
+
const toIso = Number.isFinite(toMs) ? new Date(toMs).toISOString() : new Date(Date.now()).toISOString();
|
|
92
|
+
const keys = [...buckets.keys()].sort((a, b) => a - b);
|
|
93
|
+
const outBuckets = [];
|
|
94
|
+
for (const key of keys) {
|
|
95
|
+
const perMetric = buckets.get(key);
|
|
96
|
+
const metricsOut = {};
|
|
97
|
+
for (const [metric, acc] of perMetric.entries()) {
|
|
98
|
+
if (acc.count === 0)
|
|
99
|
+
continue;
|
|
100
|
+
const r = {};
|
|
101
|
+
if (aggs.includes('count'))
|
|
102
|
+
r.count = acc.count;
|
|
103
|
+
if (aggs.includes('min'))
|
|
104
|
+
r.min = acc.min;
|
|
105
|
+
if (aggs.includes('max'))
|
|
106
|
+
r.max = acc.max;
|
|
107
|
+
if (aggs.includes('avg'))
|
|
108
|
+
r.avg = acc.sum / acc.count;
|
|
109
|
+
if (aggs.includes('sum'))
|
|
110
|
+
r.sum = acc.sum;
|
|
111
|
+
if ((aggs.includes('p50') || aggs.includes('p95')) && acc.samples) {
|
|
112
|
+
const sorted = [...acc.samples].sort((a, b) => a - b);
|
|
113
|
+
if (aggs.includes('p50'))
|
|
114
|
+
r.p50 = sorted[Math.floor(0.5 * (sorted.length - 1))];
|
|
115
|
+
if (aggs.includes('p95'))
|
|
116
|
+
r.p95 = sorted[Math.floor(0.95 * (sorted.length - 1))];
|
|
117
|
+
}
|
|
118
|
+
metricsOut[metric] = r;
|
|
119
|
+
}
|
|
120
|
+
if (Object.keys(metricsOut).length === 0)
|
|
121
|
+
continue;
|
|
122
|
+
outBuckets.push({
|
|
123
|
+
t: new Date(key).toISOString(),
|
|
124
|
+
metrics: metricsOut,
|
|
125
|
+
});
|
|
126
|
+
}
|
|
127
|
+
return {
|
|
128
|
+
deviceId,
|
|
129
|
+
bucket: opts.bucket,
|
|
130
|
+
from: fromIso,
|
|
131
|
+
to: toIso,
|
|
132
|
+
metrics: [...opts.metrics],
|
|
133
|
+
aggs: [...aggs],
|
|
134
|
+
buckets: outBuckets,
|
|
135
|
+
partial,
|
|
136
|
+
notes,
|
|
137
|
+
};
|
|
138
|
+
}
|
|
@@ -24,7 +24,7 @@ export function parseInstantToMs(spec) {
|
|
|
24
24
|
const ms = Date.parse(spec);
|
|
25
25
|
return Number.isFinite(ms) ? ms : null;
|
|
26
26
|
}
|
|
27
|
-
function resolveRange(opts) {
|
|
27
|
+
export function resolveRange(opts) {
|
|
28
28
|
let fromMs = Number.NEGATIVE_INFINITY;
|
|
29
29
|
let toMs = Number.POSITIVE_INFINITY;
|
|
30
30
|
if (opts.since && (opts.from || opts.to)) {
|
package/dist/index.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { Command, CommanderError, InvalidArgumentError } from 'commander';
|
|
3
3
|
import { createRequire } from 'node:module';
|
|
4
|
+
import chalk from 'chalk';
|
|
4
5
|
import { intArg, stringArg, enumArg } from './utils/arg-parsers.js';
|
|
5
6
|
import { parseDurationToMs } from './utils/flags.js';
|
|
6
7
|
import { registerConfigCommand } from './commands/config.js';
|
|
@@ -21,6 +22,11 @@ import { registerCapabilitiesCommand } from './commands/capabilities.js';
|
|
|
21
22
|
import { registerAgentBootstrapCommand } from './commands/agent-bootstrap.js';
|
|
22
23
|
const require = createRequire(import.meta.url);
|
|
23
24
|
const { version: pkgVersion } = require('../package.json');
|
|
25
|
+
// Early initialization: check for --no-color flag or NO_COLOR env var and disable chalk.
|
|
26
|
+
// This must happen before any commands run so all chalk output is affected.
|
|
27
|
+
if (process.argv.includes('--no-color') || Boolean(process.env.NO_COLOR)) {
|
|
28
|
+
chalk.level = 0;
|
|
29
|
+
}
|
|
24
30
|
const program = new Command();
|
|
25
31
|
// Top-level subcommand names. Used by stringArg to produce clearer errors when
|
|
26
32
|
// a value is omitted and the next argv token turns out to be a subcommand name.
|
|
@@ -44,6 +50,7 @@ program
|
|
|
44
50
|
.name('switchbot')
|
|
45
51
|
.description('Command-line tool for SwitchBot API v1.1')
|
|
46
52
|
.version(pkgVersion)
|
|
53
|
+
.option('--no-color', 'Disable ANSI colors in output')
|
|
47
54
|
.option('--json', 'Output raw JSON response (disables tables; useful for pipes/scripts)')
|
|
48
55
|
.option('--format <type>', 'Output format: table (default), json, jsonl, tsv, yaml, id, markdown', enumArg('--format', ['table', 'json', 'jsonl', 'tsv', 'yaml', 'id', 'markdown']))
|
|
49
56
|
.option('--fields <csv>', 'Comma-separated list of columns to include (e.g. --fields=id,name,type)', stringArg('--fields', { disallow: TOP_LEVEL_COMMANDS }))
|
|
@@ -31,19 +31,30 @@ export class DeviceHistoryStore {
|
|
|
31
31
|
existing.history = [entry, ...existing.history].slice(0, MAX_HISTORY);
|
|
32
32
|
fs.writeFileSync(file, JSON.stringify(existing, null, 2), { mode: 0o600 });
|
|
33
33
|
// 2. Append-only JSONL for range queries.
|
|
34
|
-
this.
|
|
34
|
+
this.writeJsonl(deviceId, entry);
|
|
35
35
|
}
|
|
36
36
|
catch {
|
|
37
37
|
// best-effort — history loss is non-fatal
|
|
38
38
|
}
|
|
39
39
|
}
|
|
40
|
-
|
|
40
|
+
/** Append a mqtt control event (no deviceId) to the dedicated __control.jsonl file. */
|
|
41
|
+
recordControl(event) {
|
|
41
42
|
try {
|
|
42
|
-
|
|
43
|
-
|
|
43
|
+
if (!fs.existsSync(this.dir))
|
|
44
|
+
fs.mkdirSync(this.dir, { recursive: true });
|
|
45
|
+
this.writeJsonl('__control', event);
|
|
46
|
+
}
|
|
47
|
+
catch {
|
|
48
|
+
// best-effort — never block the event stream
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
writeJsonl(fileKey, record) {
|
|
52
|
+
try {
|
|
53
|
+
const jsonlPath = path.join(this.dir, `${fileKey}.jsonl`);
|
|
54
|
+
const line = JSON.stringify(record) + '\n';
|
|
44
55
|
const lineBytes = Buffer.byteLength(line, 'utf-8');
|
|
45
56
|
// Seed size counter from disk on first touch (avoids drift across restarts).
|
|
46
|
-
let size = this.jsonlSizes.get(
|
|
57
|
+
let size = this.jsonlSizes.get(fileKey);
|
|
47
58
|
if (size === undefined) {
|
|
48
59
|
try {
|
|
49
60
|
size = fs.existsSync(jsonlPath) ? fs.statSync(jsonlPath).size : 0;
|
|
@@ -53,18 +64,18 @@ export class DeviceHistoryStore {
|
|
|
53
64
|
}
|
|
54
65
|
}
|
|
55
66
|
if (size + lineBytes > JSONL_ROTATE_BYTES) {
|
|
56
|
-
this.rotateJsonl(
|
|
67
|
+
this.rotateJsonl(fileKey);
|
|
57
68
|
size = 0;
|
|
58
69
|
}
|
|
59
70
|
fs.appendFileSync(jsonlPath, line, { mode: 0o600 });
|
|
60
|
-
this.jsonlSizes.set(
|
|
71
|
+
this.jsonlSizes.set(fileKey, size + lineBytes);
|
|
61
72
|
}
|
|
62
73
|
catch {
|
|
63
74
|
// best-effort
|
|
64
75
|
}
|
|
65
76
|
}
|
|
66
|
-
rotateJsonl(
|
|
67
|
-
const base = path.join(this.dir, `${
|
|
77
|
+
rotateJsonl(fileKey) {
|
|
78
|
+
const base = path.join(this.dir, `${fileKey}.jsonl`);
|
|
68
79
|
// .jsonl.3 is dropped; .2 → .3, .1 → .2, current → .1
|
|
69
80
|
try {
|
|
70
81
|
const oldest = `${base}.${JSONL_KEEP_ROTATIONS}`;
|
package/dist/utils/audit.js
CHANGED
|
@@ -55,7 +55,7 @@ export function verifyAudit(file) {
|
|
|
55
55
|
problems: [],
|
|
56
56
|
};
|
|
57
57
|
if (!fs.existsSync(file)) {
|
|
58
|
-
report.
|
|
58
|
+
report.fileMissing = true;
|
|
59
59
|
return report;
|
|
60
60
|
}
|
|
61
61
|
const raw = fs.readFileSync(file, 'utf-8');
|
package/dist/utils/format.js
CHANGED
|
@@ -12,8 +12,9 @@ export function parseFormat(flag) {
|
|
|
12
12
|
case 'tsv': return 'tsv';
|
|
13
13
|
case 'yaml': return 'yaml';
|
|
14
14
|
case 'id': return 'id';
|
|
15
|
+
case 'markdown': return 'markdown';
|
|
15
16
|
default: {
|
|
16
|
-
const msg = `Unknown --format "${flag}". Expected: table, json, jsonl, tsv, yaml, id.`;
|
|
17
|
+
const msg = `Unknown --format "${flag}". Expected: table, json, jsonl, tsv, yaml, id, markdown.`;
|
|
17
18
|
if (isJsonMode()) {
|
|
18
19
|
console.error(JSON.stringify({ error: { code: 2, kind: 'usage', message: msg } }));
|
|
19
20
|
}
|
|
@@ -67,6 +68,13 @@ export function renderRows(headers, rows, format, fields, aliases) {
|
|
|
67
68
|
const filtered = filterFields(headers, rows, fields, aliases);
|
|
68
69
|
const h = filtered.headers;
|
|
69
70
|
const r = filtered.rows;
|
|
71
|
+
// Markdown format is rendered as table with markdown style forced regardless
|
|
72
|
+
// of the user's --table-style, so `--format markdown` is a self-contained
|
|
73
|
+
// contract (bug #8).
|
|
74
|
+
if (format === 'markdown') {
|
|
75
|
+
printTable(h, r, 'markdown');
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
70
78
|
switch (format) {
|
|
71
79
|
case 'table':
|
|
72
80
|
printTable(h, r);
|
|
@@ -34,9 +34,17 @@ function resolveDeviceByName(query, opts = {}) {
|
|
|
34
34
|
const alias = meta.devices[deviceId]?.alias;
|
|
35
35
|
const rawName = normalizeDeviceName(device.name);
|
|
36
36
|
const normAlias = alias ? normalizeDeviceName(alias) : null;
|
|
37
|
-
// exact alias/name wins
|
|
37
|
+
// exact alias/name wins immediately for lenient strategies.
|
|
38
|
+
// Under require-unique we must NOT short-circuit: there may be other devices
|
|
39
|
+
// that also match (e.g. via substring), making the result ambiguous. Collect
|
|
40
|
+
// the exact hit as a candidate and let the full ambiguity check decide below.
|
|
38
41
|
if ((normAlias && normAlias === q) || rawName === q) {
|
|
39
|
-
|
|
42
|
+
if (strategy !== 'require-unique') {
|
|
43
|
+
return { ok: true, deviceId };
|
|
44
|
+
}
|
|
45
|
+
// require-unique: treat exact match as a high-priority candidate (score 0)
|
|
46
|
+
candidates.push({ deviceId, name: device.name, score: 0 });
|
|
47
|
+
continue;
|
|
40
48
|
}
|
|
41
49
|
if (strategy === 'exact')
|
|
42
50
|
continue;
|
package/dist/utils/output.js
CHANGED
|
@@ -46,8 +46,8 @@ const ASCII_BORDER_CHARS = {
|
|
|
46
46
|
left: '|', 'left-mid': '+', mid: '-', 'mid-mid': '+',
|
|
47
47
|
right: '|', 'right-mid': '+', middle: '|',
|
|
48
48
|
};
|
|
49
|
-
export function printTable(headers, rows) {
|
|
50
|
-
const style = getTableStyle();
|
|
49
|
+
export function printTable(headers, rows, styleOverride) {
|
|
50
|
+
const style = styleOverride ?? getTableStyle();
|
|
51
51
|
if (style === 'markdown') {
|
|
52
52
|
console.log(renderMarkdownTable(headers, rows));
|
|
53
53
|
return;
|
package/dist/version.js
ADDED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@switchbot/openapi-cli",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.5.0",
|
|
4
4
|
"description": "SwitchBot smart home CLI — control devices, run scenes, stream real-time events, and integrate AI agents via MCP. Full API v1.1 coverage.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"switchbot",
|