@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/mcp.js
CHANGED
|
@@ -4,12 +4,15 @@ 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';
|
|
10
11
|
import { getCachedDevice } from '../devices/cache.js';
|
|
11
12
|
import { EventSubscriptionManager } from '../mcp/events-subscription.js';
|
|
12
13
|
import { deviceHistoryStore } from '../mcp/device-history.js';
|
|
14
|
+
import { queryDeviceHistory } from '../devices/history-query.js';
|
|
15
|
+
import { aggregateDeviceHistory, ALL_AGG_FNS, MAX_SAMPLE_CAP, } from '../devices/history-agg.js';
|
|
13
16
|
import { todayUsage } from '../utils/quota.js';
|
|
14
17
|
import { describeCache } from '../devices/cache.js';
|
|
15
18
|
import { withRequestContext } from '../lib/request-context.js';
|
|
@@ -32,7 +35,7 @@ export function createSwitchBotMcpServer(options) {
|
|
|
32
35
|
const eventManager = options?.eventManager;
|
|
33
36
|
const server = new McpServer({
|
|
34
37
|
name: 'switchbot',
|
|
35
|
-
version:
|
|
38
|
+
version: VERSION,
|
|
36
39
|
}, {
|
|
37
40
|
capabilities: { tools: {}, resources: {} },
|
|
38
41
|
instructions: `SwitchBot is an IoT smart home brand by Wonderlabs, Inc. This MCP server controls physical devices \
|
|
@@ -59,7 +62,8 @@ API docs: https://github.com/OpenWonderLabs/SwitchBotAPI`,
|
|
|
59
62
|
server.registerTool('list_devices', {
|
|
60
63
|
title: 'List all devices on the account',
|
|
61
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.',
|
|
62
|
-
|
|
65
|
+
_meta: { agentSafetyTier: 'read' },
|
|
66
|
+
inputSchema: z.object({}).strict(),
|
|
63
67
|
outputSchema: {
|
|
64
68
|
deviceList: z.array(z.object({
|
|
65
69
|
deviceId: z.string(),
|
|
@@ -94,9 +98,10 @@ API docs: https://github.com/OpenWonderLabs/SwitchBotAPI`,
|
|
|
94
98
|
server.registerTool('get_device_status', {
|
|
95
99
|
title: 'Get live status for a device',
|
|
96
100
|
description: 'Query the real-time status payload for a physical device. IR remotes have no status channel and will error.',
|
|
97
|
-
|
|
101
|
+
_meta: { agentSafetyTier: 'read' },
|
|
102
|
+
inputSchema: z.object({
|
|
98
103
|
deviceId: z.string().describe('Device ID from list_devices'),
|
|
99
|
-
},
|
|
104
|
+
}).strict(),
|
|
100
105
|
outputSchema: {
|
|
101
106
|
status: z.object({
|
|
102
107
|
deviceId: z.string().optional(),
|
|
@@ -118,10 +123,11 @@ API docs: https://github.com/OpenWonderLabs/SwitchBotAPI`,
|
|
|
118
123
|
description: 'Return device state history recorded from MQTT events (persisted to ~/.switchbot/device-history/). ' +
|
|
119
124
|
'No API call — zero quota cost. Use when you need recent historical readings or want to avoid a live API call. ' +
|
|
120
125
|
'Omit deviceId to list all devices with stored history.',
|
|
121
|
-
|
|
126
|
+
_meta: { agentSafetyTier: 'read' },
|
|
127
|
+
inputSchema: z.object({
|
|
122
128
|
deviceId: z.string().optional().describe('Device MAC address (deviceId). Omit to list all devices with history.'),
|
|
123
129
|
limit: z.number().int().min(1).max(100).optional().describe('Max history entries to return (default 20, max 100)'),
|
|
124
|
-
},
|
|
130
|
+
}).strict(),
|
|
125
131
|
outputSchema: {
|
|
126
132
|
deviceId: z.string().optional(),
|
|
127
133
|
latest: z.unknown().optional(),
|
|
@@ -146,11 +152,54 @@ API docs: https://github.com/OpenWonderLabs/SwitchBotAPI`,
|
|
|
146
152
|
structuredContent: result,
|
|
147
153
|
};
|
|
148
154
|
});
|
|
155
|
+
// ---- query_device_history --------------------------------------------------
|
|
156
|
+
server.registerTool('query_device_history', {
|
|
157
|
+
title: 'Query time-ranged device history',
|
|
158
|
+
description: 'Return records from the append-only JSONL history (~/.switchbot/device-history/<deviceId>.jsonl) ' +
|
|
159
|
+
'filtered by a relative duration (since) or absolute ISO-8601 range (from/to). ' +
|
|
160
|
+
'No API call — zero quota cost. Use for trend questions like "how many times did this switch turn on last week".',
|
|
161
|
+
_meta: { agentSafetyTier: 'read' },
|
|
162
|
+
inputSchema: z.object({
|
|
163
|
+
deviceId: z.string().describe('Device ID to query'),
|
|
164
|
+
since: z.string().optional().describe('Relative window ending now, e.g. "30s", "15m", "1h", "7d". Mutually exclusive with from/to.'),
|
|
165
|
+
from: z.string().optional().describe('Range start (ISO-8601).'),
|
|
166
|
+
to: z.string().optional().describe('Range end (ISO-8601).'),
|
|
167
|
+
fields: z.array(z.string()).optional().describe('Project these payload fields; omit for the full payload.'),
|
|
168
|
+
limit: z.number().int().min(1).max(10000).optional().describe('Max records to return (default 1000).'),
|
|
169
|
+
}).strict(),
|
|
170
|
+
outputSchema: {
|
|
171
|
+
deviceId: z.string(),
|
|
172
|
+
count: z.number().int(),
|
|
173
|
+
records: z.array(z.object({
|
|
174
|
+
t: z.string(),
|
|
175
|
+
topic: z.string(),
|
|
176
|
+
deviceType: z.string().optional(),
|
|
177
|
+
payload: z.unknown(),
|
|
178
|
+
})),
|
|
179
|
+
},
|
|
180
|
+
}, async ({ deviceId, since, from, to, fields, limit }) => {
|
|
181
|
+
if (since && (from || to)) {
|
|
182
|
+
return mcpError('usage', 2, '--since is mutually exclusive with --from/--to.');
|
|
183
|
+
}
|
|
184
|
+
try {
|
|
185
|
+
const records = await queryDeviceHistory(deviceId, { since, from, to, fields, limit });
|
|
186
|
+
const result = { deviceId, count: records.length, records };
|
|
187
|
+
return {
|
|
188
|
+
content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
|
|
189
|
+
structuredContent: result,
|
|
190
|
+
};
|
|
191
|
+
}
|
|
192
|
+
catch (err) {
|
|
193
|
+
const msg = err instanceof Error ? err.message : 'history query failed';
|
|
194
|
+
return mcpError('usage', 2, msg);
|
|
195
|
+
}
|
|
196
|
+
});
|
|
149
197
|
// ---- send_command ---------------------------------------------------------
|
|
150
198
|
server.registerTool('send_command', {
|
|
151
199
|
title: 'Send a control command to a device',
|
|
152
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.',
|
|
153
|
-
|
|
201
|
+
_meta: { agentSafetyTier: 'action' },
|
|
202
|
+
inputSchema: z.object({
|
|
154
203
|
deviceId: z.string().describe('Device ID from list_devices'),
|
|
155
204
|
command: z.string().describe('Command name, case-sensitive (e.g. turnOn, setColor, unlock)'),
|
|
156
205
|
parameter: z
|
|
@@ -167,15 +216,52 @@ API docs: https://github.com/OpenWonderLabs/SwitchBotAPI`,
|
|
|
167
216
|
.optional()
|
|
168
217
|
.default(false)
|
|
169
218
|
.describe('Required true for destructive commands (unlock, garage open, createKey, ...)'),
|
|
170
|
-
|
|
219
|
+
idempotencyKey: z
|
|
220
|
+
.string()
|
|
221
|
+
.optional()
|
|
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.'),
|
|
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(),
|
|
171
228
|
outputSchema: {
|
|
172
229
|
ok: z.literal(true),
|
|
173
|
-
command: z.string(),
|
|
174
|
-
deviceId: z.string(),
|
|
175
|
-
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)'),
|
|
233
|
+
verification: z
|
|
234
|
+
.object({
|
|
235
|
+
verifiable: z.boolean(),
|
|
236
|
+
reason: z.string(),
|
|
237
|
+
suggestedFollowup: z.string(),
|
|
238
|
+
})
|
|
239
|
+
.optional()
|
|
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)'),
|
|
176
248
|
},
|
|
177
|
-
}, async ({ deviceId, command, parameter, commandType, confirm }) => {
|
|
249
|
+
}, async ({ deviceId, command, parameter, commandType, confirm, idempotencyKey, dryRun }) => {
|
|
178
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
|
+
}
|
|
179
265
|
// Resolve the device's catalog type via cache or a fresh lookup so we
|
|
180
266
|
// can evaluate destructive/validation without an extra round-trip if
|
|
181
267
|
// the cache is warm.
|
|
@@ -217,8 +303,33 @@ API docs: https://github.com/OpenWonderLabs/SwitchBotAPI`,
|
|
|
217
303
|
if (!validation.ok) {
|
|
218
304
|
return mcpError('usage', 2, validation.error.message, { hint: validation.error.hint, context: { validationKind: validation.error.kind } });
|
|
219
305
|
}
|
|
220
|
-
|
|
306
|
+
let result;
|
|
307
|
+
try {
|
|
308
|
+
result = await executeCommand(deviceId, command, parameter, effectiveType, undefined, {
|
|
309
|
+
idempotencyKey,
|
|
310
|
+
});
|
|
311
|
+
}
|
|
312
|
+
catch (err) {
|
|
313
|
+
if (err instanceof Error && err.name === 'IdempotencyConflictError') {
|
|
314
|
+
return mcpError('guard', 2, err.message, {
|
|
315
|
+
hint: 'Use a fresh idempotencyKey, or wait for the prior key to expire (60s TTL).',
|
|
316
|
+
context: {
|
|
317
|
+
existingShape: err.existingShape,
|
|
318
|
+
newShape: err.newShape,
|
|
319
|
+
},
|
|
320
|
+
});
|
|
321
|
+
}
|
|
322
|
+
throw err;
|
|
323
|
+
}
|
|
324
|
+
const isIr = getCachedDevice(deviceId)?.category === 'ir';
|
|
221
325
|
const structured = { ok: true, command, deviceId, result };
|
|
326
|
+
if (isIr) {
|
|
327
|
+
structured.verification = {
|
|
328
|
+
verifiable: false,
|
|
329
|
+
reason: 'IR transmission is unidirectional; no receipt acknowledgment is possible.',
|
|
330
|
+
suggestedFollowup: 'Confirm visible change manually or via a paired state sensor.',
|
|
331
|
+
};
|
|
332
|
+
}
|
|
222
333
|
return {
|
|
223
334
|
content: [{ type: 'text', text: JSON.stringify(structured, null, 2) }],
|
|
224
335
|
structuredContent: structured,
|
|
@@ -228,14 +339,31 @@ API docs: https://github.com/OpenWonderLabs/SwitchBotAPI`,
|
|
|
228
339
|
server.registerTool('run_scene', {
|
|
229
340
|
title: 'Execute a manual scene',
|
|
230
341
|
description: 'Execute a manual SwitchBot scene by its sceneId (from list_scenes).',
|
|
231
|
-
|
|
342
|
+
_meta: { agentSafetyTier: 'action' },
|
|
343
|
+
inputSchema: z.object({
|
|
232
344
|
sceneId: z.string().describe('Scene ID from list_scenes'),
|
|
233
|
-
|
|
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(),
|
|
234
350
|
outputSchema: {
|
|
235
351
|
ok: z.literal(true),
|
|
236
|
-
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)'),
|
|
237
357
|
},
|
|
238
|
-
}, 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
|
+
}
|
|
239
367
|
await executeScene(sceneId);
|
|
240
368
|
const structured = { ok: true, sceneId };
|
|
241
369
|
return {
|
|
@@ -247,7 +375,8 @@ API docs: https://github.com/OpenWonderLabs/SwitchBotAPI`,
|
|
|
247
375
|
server.registerTool('list_scenes', {
|
|
248
376
|
title: 'List all manual scenes',
|
|
249
377
|
description: 'Fetch all manual scenes configured in the SwitchBot app.',
|
|
250
|
-
|
|
378
|
+
_meta: { agentSafetyTier: 'read' },
|
|
379
|
+
inputSchema: z.object({}).strict(),
|
|
251
380
|
outputSchema: {
|
|
252
381
|
scenes: z.array(z.object({ sceneId: z.string(), sceneName: z.string() })),
|
|
253
382
|
},
|
|
@@ -262,10 +391,11 @@ API docs: https://github.com/OpenWonderLabs/SwitchBotAPI`,
|
|
|
262
391
|
server.registerTool('search_catalog', {
|
|
263
392
|
title: 'Search the offline device catalog',
|
|
264
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.',
|
|
265
|
-
|
|
394
|
+
_meta: { agentSafetyTier: 'read' },
|
|
395
|
+
inputSchema: z.object({
|
|
266
396
|
query: z.string().describe('Search query (matches type and aliases, case-insensitive). Use empty string to list all.'),
|
|
267
397
|
limit: z.number().int().min(1).max(100).optional().default(20).describe('Max entries returned (default 20)'),
|
|
268
|
-
},
|
|
398
|
+
}).strict(),
|
|
269
399
|
outputSchema: {
|
|
270
400
|
results: z.array(z.object({
|
|
271
401
|
type: z.string(),
|
|
@@ -297,10 +427,11 @@ API docs: https://github.com/OpenWonderLabs/SwitchBotAPI`,
|
|
|
297
427
|
server.registerTool('describe_device', {
|
|
298
428
|
title: 'Describe a specific device',
|
|
299
429
|
description: 'Resolve a deviceId to its metadata + catalog entry + suggested safe actions. Pass live:true to also fetch real-time status values.',
|
|
300
|
-
|
|
430
|
+
_meta: { agentSafetyTier: 'read' },
|
|
431
|
+
inputSchema: z.object({
|
|
301
432
|
deviceId: z.string().describe('Device ID from list_devices'),
|
|
302
433
|
live: z.boolean().optional().default(false).describe('Also fetch live /status values (costs 1 extra API call)'),
|
|
303
|
-
},
|
|
434
|
+
}).strict(),
|
|
304
435
|
outputSchema: {
|
|
305
436
|
device: z.object({
|
|
306
437
|
device: z.object({ deviceId: z.string(), deviceName: z.string() }).passthrough(),
|
|
@@ -338,11 +469,45 @@ API docs: https://github.com/OpenWonderLabs/SwitchBotAPI`,
|
|
|
338
469
|
throw err;
|
|
339
470
|
}
|
|
340
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
|
+
});
|
|
341
505
|
// ---- account_overview ---------------------------------------------------
|
|
342
506
|
server.registerTool('account_overview', {
|
|
343
507
|
title: 'Bootstrap account overview',
|
|
344
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.',
|
|
345
|
-
|
|
509
|
+
_meta: { agentSafetyTier: 'read' },
|
|
510
|
+
inputSchema: z.object({}).strict(),
|
|
346
511
|
outputSchema: {
|
|
347
512
|
version: z.string(),
|
|
348
513
|
schemaVersion: z.string(),
|
|
@@ -393,7 +558,7 @@ API docs: https://github.com/OpenWonderLabs/SwitchBotAPI`,
|
|
|
393
558
|
const cacheInfo = describeCache();
|
|
394
559
|
const quota = todayUsage();
|
|
395
560
|
const overview = {
|
|
396
|
-
version:
|
|
561
|
+
version: VERSION,
|
|
397
562
|
schemaVersion: '1.1',
|
|
398
563
|
devices: deviceList.deviceList.map(toMcpDeviceListShape),
|
|
399
564
|
infraredRemotes: deviceList.infraredRemoteList.map(toMcpIrDeviceShape),
|
|
@@ -454,15 +619,18 @@ export function registerMcpCommand(program) {
|
|
|
454
619
|
.command('mcp')
|
|
455
620
|
.description('Run as a Model Context Protocol server so AI agents can call SwitchBot tools')
|
|
456
621
|
.addHelpText('after', `
|
|
457
|
-
The MCP server exposes
|
|
458
|
-
- list_devices
|
|
459
|
-
- get_device_status
|
|
460
|
-
- send_command
|
|
461
|
-
- list_scenes
|
|
462
|
-
- run_scene
|
|
463
|
-
- search_catalog
|
|
464
|
-
- describe_device
|
|
465
|
-
- 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
|
|
466
634
|
|
|
467
635
|
Resource (read-only):
|
|
468
636
|
- switchbot://events snapshot of recent MQTT shadow events from the ring buffer
|
|
@@ -571,7 +739,7 @@ Inspect locally:
|
|
|
571
739
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
572
740
|
res.end(JSON.stringify({
|
|
573
741
|
ok: true,
|
|
574
|
-
version:
|
|
742
|
+
version: VERSION,
|
|
575
743
|
pid: process.pid,
|
|
576
744
|
uptimeSec: Math.floor(process.uptime()),
|
|
577
745
|
}));
|
|
@@ -581,7 +749,7 @@ Inspect locally:
|
|
|
581
749
|
const state = eventManager.getState();
|
|
582
750
|
const ready = state !== 'failed' && state !== 'disabled';
|
|
583
751
|
const status = ready ? 200 : 503;
|
|
584
|
-
const body = { ready, version:
|
|
752
|
+
const body = { ready, version: VERSION, mqtt: state };
|
|
585
753
|
if (!ready)
|
|
586
754
|
body.reason = state === 'disabled' ? 'mqtt disabled' : 'mqtt failed';
|
|
587
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/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();
|
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
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { enumArg, stringArg } from '../utils/arg-parsers.js';
|
|
2
2
|
import { printJson } from '../utils/output.js';
|
|
3
3
|
import { getEffectiveCatalog } from '../devices/catalog.js';
|
|
4
|
+
import { loadCache } from '../devices/cache.js';
|
|
4
5
|
function toSchemaEntry(e) {
|
|
5
6
|
return {
|
|
6
7
|
type: e.type,
|
|
@@ -24,6 +25,30 @@ function toSchemaCommand(c) {
|
|
|
24
25
|
...(c.exampleParams ? { exampleParams: c.exampleParams } : {}),
|
|
25
26
|
};
|
|
26
27
|
}
|
|
28
|
+
function toCompactEntry(e) {
|
|
29
|
+
return {
|
|
30
|
+
type: e.type,
|
|
31
|
+
category: e.category,
|
|
32
|
+
role: e.role ?? null,
|
|
33
|
+
readOnly: e.readOnly ?? false,
|
|
34
|
+
commands: e.commands.map((c) => ({
|
|
35
|
+
command: c.command,
|
|
36
|
+
parameter: c.parameter,
|
|
37
|
+
commandType: (c.commandType ?? 'command'),
|
|
38
|
+
idempotent: Boolean(c.idempotent),
|
|
39
|
+
destructive: Boolean(c.destructive),
|
|
40
|
+
})),
|
|
41
|
+
statusFields: e.statusFields ?? [],
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
function projectFields(entry, fields) {
|
|
45
|
+
const out = {};
|
|
46
|
+
for (const f of fields) {
|
|
47
|
+
if (f in entry)
|
|
48
|
+
out[f] = entry[f];
|
|
49
|
+
}
|
|
50
|
+
return out;
|
|
51
|
+
}
|
|
27
52
|
export function registerSchemaCommand(program) {
|
|
28
53
|
const ROLES = ['lighting', 'security', 'sensor', 'climate', 'media', 'cleaning', 'curtain', 'fan', 'power', 'hub', 'other'];
|
|
29
54
|
const CATEGORIES = ['physical', 'ir'];
|
|
@@ -32,20 +57,41 @@ export function registerSchemaCommand(program) {
|
|
|
32
57
|
.description('Export the device catalog as structured JSON (for agent prompts / tooling)');
|
|
33
58
|
schema
|
|
34
59
|
.command('export')
|
|
35
|
-
.description('Print the
|
|
60
|
+
.description('Print the catalog as structured JSON (one object per type)')
|
|
36
61
|
.option('--type <type>', 'Restrict to a single device type (e.g. "Strip Light")', stringArg('--type'))
|
|
62
|
+
.option('--types <csv>', 'Restrict to multiple device types (comma-separated)', stringArg('--types'))
|
|
37
63
|
.option('--role <role>', 'Restrict to a functional role: lighting, security, sensor, climate, media, cleaning, curtain, fan, power, hub, other', enumArg('--role', ROLES))
|
|
38
64
|
.option('--category <cat>', 'Restrict to "physical" or "ir"', enumArg('--category', CATEGORIES))
|
|
65
|
+
.option('--compact', 'Drop descriptions/aliases/example params — emit ~60% smaller payload. Useful for agent prompts.')
|
|
66
|
+
.option('--used', 'Restrict to device types present in the local devices cache (run "devices list" first)')
|
|
67
|
+
.option('--project <csv>', 'Project per-type fields (e.g. --project type,commands,statusFields)', stringArg('--project'))
|
|
39
68
|
.addHelpText('after', `
|
|
40
69
|
Output is always JSON (this command ignores --format). The output is a
|
|
41
70
|
catalog export — not a formal JSON Schema standard document — suitable for
|
|
42
71
|
pre-baking LLM prompts or regenerating docs when the catalog changes.
|
|
43
72
|
|
|
73
|
+
Size tips:
|
|
74
|
+
--compact --used Smallest realistic payload for a given account
|
|
75
|
+
(< 15 KB on most accounts).
|
|
76
|
+
--fields type,commands Strip statusFields / role / etc. when only
|
|
77
|
+
commands are needed.
|
|
78
|
+
--type + --compact Inspect one type with minimum footprint.
|
|
79
|
+
|
|
80
|
+
Common top-level fields:
|
|
81
|
+
schemaVersion CLI schema version (stable for agent contracts)
|
|
82
|
+
data.version Catalog schema version
|
|
83
|
+
data.types Array of SchemaEntry (or CompactSchemaEntry with --compact)
|
|
84
|
+
data._fetchedAt CLI-added; present on live-query responses ('devices status'),
|
|
85
|
+
not on this offline export.
|
|
86
|
+
|
|
44
87
|
Examples:
|
|
45
88
|
$ switchbot schema export > catalog.json
|
|
46
|
-
$ switchbot schema export --
|
|
47
|
-
$ switchbot schema export --
|
|
89
|
+
$ switchbot schema export --compact --used | wc -c # small prompt-ready payload
|
|
90
|
+
$ switchbot schema export --type Bot | jq '.data.types[0].commands'
|
|
91
|
+
$ switchbot schema export --types "Bot,Curtain,Color Bulb"
|
|
92
|
+
$ switchbot schema export --role lighting | jq '[.data.types[].type]'
|
|
48
93
|
$ switchbot schema export --role security --category physical
|
|
94
|
+
$ switchbot schema export --project type,commands,statusFields
|
|
49
95
|
`)
|
|
50
96
|
.action((options) => {
|
|
51
97
|
const catalog = getEffectiveCatalog();
|
|
@@ -55,6 +101,11 @@ Examples:
|
|
|
55
101
|
filtered = filtered.filter((e) => e.type.toLowerCase() === q ||
|
|
56
102
|
(e.aliases ?? []).some((a) => a.toLowerCase() === q));
|
|
57
103
|
}
|
|
104
|
+
if (options.types) {
|
|
105
|
+
const set = new Set(options.types.split(',').map((s) => s.trim().toLowerCase()).filter(Boolean));
|
|
106
|
+
filtered = filtered.filter((e) => set.has(e.type.toLowerCase()) ||
|
|
107
|
+
(e.aliases ?? []).some((a) => set.has(a.toLowerCase())));
|
|
108
|
+
}
|
|
58
109
|
if (options.role) {
|
|
59
110
|
const q = options.role.toLowerCase();
|
|
60
111
|
filtered = filtered.filter((e) => (e.role ?? 'other') === q);
|
|
@@ -63,11 +114,56 @@ Examples:
|
|
|
63
114
|
const q = options.category.toLowerCase();
|
|
64
115
|
filtered = filtered.filter((e) => e.category === q);
|
|
65
116
|
}
|
|
117
|
+
if (options.used) {
|
|
118
|
+
const cache = loadCache();
|
|
119
|
+
if (cache) {
|
|
120
|
+
const usedTypes = new Set(Object.values(cache.devices).map((d) => d.type.toLowerCase()));
|
|
121
|
+
filtered = filtered.filter((e) => usedTypes.has(e.type.toLowerCase()) ||
|
|
122
|
+
(e.aliases ?? []).some((a) => usedTypes.has(a.toLowerCase())));
|
|
123
|
+
}
|
|
124
|
+
else {
|
|
125
|
+
filtered = [];
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
const mapped = options.compact
|
|
129
|
+
? filtered.map(toCompactEntry)
|
|
130
|
+
: filtered.map(toSchemaEntry);
|
|
131
|
+
const projected = options.project
|
|
132
|
+
? mapped.map((e) => projectFields(e, options.project.split(',').map((s) => s.trim()).filter(Boolean)))
|
|
133
|
+
: mapped;
|
|
66
134
|
const payload = {
|
|
67
135
|
version: '1.0',
|
|
68
|
-
|
|
69
|
-
types: filtered.map(toSchemaEntry),
|
|
136
|
+
types: projected,
|
|
70
137
|
};
|
|
138
|
+
if (!options.compact) {
|
|
139
|
+
payload.generatedAt = new Date().toISOString();
|
|
140
|
+
payload.cliAddedFields = [
|
|
141
|
+
{
|
|
142
|
+
field: '_fetchedAt',
|
|
143
|
+
appliesTo: ['devices status', 'devices describe'],
|
|
144
|
+
type: 'string (ISO-8601)',
|
|
145
|
+
description: 'CLI-synthesized timestamp indicating when this status response was fetched or served from the cache. Not part of the upstream SwitchBot API.',
|
|
146
|
+
},
|
|
147
|
+
{
|
|
148
|
+
field: 'replayed',
|
|
149
|
+
appliesTo: ['devices command (with --idempotency-key)'],
|
|
150
|
+
type: 'boolean',
|
|
151
|
+
description: 'CLI-synthesized flag — true when the response was served from the idempotency cache instead of re-executing the command.',
|
|
152
|
+
},
|
|
153
|
+
{
|
|
154
|
+
field: 'verification',
|
|
155
|
+
appliesTo: ['devices command'],
|
|
156
|
+
type: 'object',
|
|
157
|
+
description: 'CLI-synthesized receipt-acknowledgment metadata. For IR devices, verifiable:false signals that no device-side confirmation is possible.',
|
|
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
|
+
},
|
|
165
|
+
];
|
|
166
|
+
}
|
|
71
167
|
printJson(payload);
|
|
72
168
|
});
|
|
73
169
|
}
|