@switchbot/openapi-cli 2.1.0 → 2.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +58 -3
- package/dist/commands/batch.js +7 -5
- package/dist/commands/cache.js +3 -1
- package/dist/commands/capabilities.js +30 -20
- package/dist/commands/catalog.js +3 -1
- package/dist/commands/completion.js +139 -12
- package/dist/commands/config.js +4 -3
- package/dist/commands/device-meta.js +3 -2
- package/dist/commands/devices.js +217 -71
- package/dist/commands/events.js +133 -15
- package/dist/commands/expand.js +29 -11
- package/dist/commands/history.js +4 -3
- package/dist/commands/mcp.js +41 -5
- package/dist/commands/plan.js +10 -2
- package/dist/commands/scenes.js +1 -1
- package/dist/commands/schema.js +6 -3
- package/dist/commands/watch.js +16 -4
- package/dist/commands/webhook.js +2 -1
- package/dist/config.js +7 -2
- package/dist/index.js +49 -19
- package/dist/lib/devices.js +16 -1
- package/dist/mcp/device-history.js +66 -0
- package/dist/mcp/events-subscription.js +10 -3
- package/dist/mqtt/client.js +8 -0
- package/dist/mqtt/credential.js +3 -2
- package/dist/sinks/dispatcher.js +12 -0
- package/dist/sinks/file.js +19 -0
- package/dist/sinks/format.js +56 -0
- package/dist/sinks/homeassistant.js +44 -0
- package/dist/sinks/openclaw.js +33 -0
- package/dist/sinks/stdout.js +5 -0
- package/dist/sinks/telegram.js +28 -0
- package/dist/sinks/types.js +1 -0
- package/dist/sinks/webhook.js +22 -0
- package/dist/utils/arg-parsers.js +62 -0
- package/dist/utils/flags.js +13 -12
- package/dist/utils/format.js +6 -5
- package/package.json +1 -1
package/dist/commands/events.js
CHANGED
|
@@ -1,8 +1,18 @@
|
|
|
1
1
|
import http from 'node:http';
|
|
2
2
|
import { printJson, isJsonMode, handleError, UsageError } from '../utils/output.js';
|
|
3
|
+
import { intArg, stringArg } from '../utils/arg-parsers.js';
|
|
3
4
|
import { SwitchBotMqttClient } from '../mqtt/client.js';
|
|
4
5
|
import { fetchMqttCredential } from '../mqtt/credential.js';
|
|
5
6
|
import { tryLoadConfig } from '../config.js';
|
|
7
|
+
import { SinkDispatcher } from '../sinks/dispatcher.js';
|
|
8
|
+
import { StdoutSink } from '../sinks/stdout.js';
|
|
9
|
+
import { FileSink } from '../sinks/file.js';
|
|
10
|
+
import { WebhookSink } from '../sinks/webhook.js';
|
|
11
|
+
import { OpenClawSink } from '../sinks/openclaw.js';
|
|
12
|
+
import { TelegramSink } from '../sinks/telegram.js';
|
|
13
|
+
import { HomeAssistantSink } from '../sinks/homeassistant.js';
|
|
14
|
+
import { parseSinkEvent } from '../sinks/format.js';
|
|
15
|
+
import { deviceHistoryStore } from '../mcp/device-history.js';
|
|
6
16
|
const DEFAULT_PORT = 3000;
|
|
7
17
|
const DEFAULT_PATH = '/';
|
|
8
18
|
const MAX_BODY_BYTES = 1_000_000;
|
|
@@ -109,10 +119,10 @@ export function registerEventsCommand(program) {
|
|
|
109
119
|
events
|
|
110
120
|
.command('tail')
|
|
111
121
|
.description('Run a local HTTP receiver and print incoming webhook events as JSONL')
|
|
112
|
-
.option('--port <n>', `Local port to listen on (default ${DEFAULT_PORT})`, String(DEFAULT_PORT))
|
|
113
|
-
.option('--path <p>', `HTTP path to match (default "${DEFAULT_PATH}"; use "*" for all paths)`, DEFAULT_PATH)
|
|
114
|
-
.option('--filter <expr>', 'Filter events, e.g. "deviceId=ABC123" or "type=Bot" (comma-separated)')
|
|
115
|
-
.option('--max <n>', 'Stop after N matching events (default: run until Ctrl-C)')
|
|
122
|
+
.option('--port <n>', `Local port to listen on (default ${DEFAULT_PORT})`, intArg('--port', { min: 1, max: 65535 }), String(DEFAULT_PORT))
|
|
123
|
+
.option('--path <p>', `HTTP path to match (default "${DEFAULT_PATH}"; use "*" for all paths)`, stringArg('--path'), DEFAULT_PATH)
|
|
124
|
+
.option('--filter <expr>', 'Filter events, e.g. "deviceId=ABC123" or "type=Bot" (comma-separated)', stringArg('--filter'))
|
|
125
|
+
.option('--max <n>', 'Stop after N matching events (default: run until Ctrl-C)', intArg('--max', { min: 1 }))
|
|
116
126
|
.addHelpText('after', `
|
|
117
127
|
SwitchBot posts events to a single webhook URL configured via:
|
|
118
128
|
$ switchbot webhook setup https://<your-public-host>/<path>
|
|
@@ -190,8 +200,20 @@ Examples:
|
|
|
190
200
|
events
|
|
191
201
|
.command('mqtt-tail')
|
|
192
202
|
.description('Subscribe to SwitchBot MQTT shadow events and stream them as JSONL')
|
|
193
|
-
.option('--topic <pattern>', 'MQTT topic filter (default: SwitchBot shadow topic from credential)')
|
|
194
|
-
.option('--max <n>', 'Stop after N events (default: run until Ctrl-C)')
|
|
203
|
+
.option('--topic <pattern>', 'MQTT topic filter (default: SwitchBot shadow topic from credential)', stringArg('--topic'))
|
|
204
|
+
.option('--max <n>', 'Stop after N events (default: run until Ctrl-C)', intArg('--max', { min: 1 }))
|
|
205
|
+
.option('--sink <type>', 'Output sink: stdout (default), file, webhook, openclaw, telegram, homeassistant (repeatable)', (val, prev) => [...prev, val], [])
|
|
206
|
+
.option('--sink-file <path>', 'File path for file sink', stringArg('--sink-file'))
|
|
207
|
+
.option('--webhook-url <url>', 'Webhook URL for webhook sink', stringArg('--webhook-url'))
|
|
208
|
+
.option('--openclaw-url <url>', 'OpenClaw gateway URL (default: http://localhost:18789)', stringArg('--openclaw-url'))
|
|
209
|
+
.option('--openclaw-token <token>', 'Bearer token for OpenClaw (or env OPENCLAW_TOKEN)', stringArg('--openclaw-token'))
|
|
210
|
+
.option('--openclaw-model <id>', 'OpenClaw agent model ID to route events to', stringArg('--openclaw-model'))
|
|
211
|
+
.option('--telegram-token <token>', 'Telegram bot token (or env TELEGRAM_TOKEN)', stringArg('--telegram-token'))
|
|
212
|
+
.option('--telegram-chat <id>', 'Telegram chat/channel ID to send messages to', stringArg('--telegram-chat'))
|
|
213
|
+
.option('--ha-url <url>', 'Home Assistant base URL (e.g. http://homeassistant.local:8123)', stringArg('--ha-url'))
|
|
214
|
+
.option('--ha-token <token>', 'HA long-lived access token (for REST event API)', stringArg('--ha-token'))
|
|
215
|
+
.option('--ha-webhook-id <id>', 'HA webhook ID (no auth; takes priority over --ha-token)', stringArg('--ha-webhook-id'))
|
|
216
|
+
.option('--ha-event-type <type>', 'HA event type for REST API (default: switchbot_event)', stringArg('--ha-event-type'))
|
|
195
217
|
.addHelpText('after', `
|
|
196
218
|
Connects to the SwitchBot MQTT service using your existing credentials
|
|
197
219
|
(SWITCHBOT_TOKEN + SWITCHBOT_SECRET or ~/.switchbot/config.json).
|
|
@@ -200,10 +222,25 @@ No additional MQTT configuration required.
|
|
|
200
222
|
Output (JSONL, one event per line):
|
|
201
223
|
{ "t": "<ISO>", "topic": "<mqtt-topic>", "payload": <parsed JSON or raw string> }
|
|
202
224
|
|
|
225
|
+
Sink types (--sink, repeatable):
|
|
226
|
+
stdout Print JSONL to stdout (default when no --sink given)
|
|
227
|
+
file Append JSONL to --sink-file <path>
|
|
228
|
+
webhook HTTP POST to --webhook-url <url>
|
|
229
|
+
openclaw POST to OpenClaw via --openclaw-url / --openclaw-token / --openclaw-model
|
|
230
|
+
telegram Send to Telegram via --telegram-token / --telegram-chat
|
|
231
|
+
homeassistant POST to HA via --ha-url + --ha-webhook-id (or --ha-token)
|
|
232
|
+
|
|
233
|
+
Device state is also persisted to ~/.switchbot/device-history/<deviceId>.json
|
|
234
|
+
regardless of sink configuration.
|
|
235
|
+
|
|
203
236
|
Examples:
|
|
204
237
|
$ switchbot events mqtt-tail
|
|
205
|
-
$ switchbot events mqtt-tail --topic 'switchbot/#'
|
|
206
238
|
$ switchbot events mqtt-tail --max 10 --json
|
|
239
|
+
$ switchbot events mqtt-tail --sink file --sink-file ~/.switchbot/events.jsonl
|
|
240
|
+
$ switchbot events mqtt-tail --sink openclaw --openclaw-token abc --openclaw-model home-agent
|
|
241
|
+
$ switchbot events mqtt-tail --sink telegram --telegram-token <token> --telegram-chat <chatId>
|
|
242
|
+
$ switchbot events mqtt-tail --sink homeassistant --ha-url http://ha.local:8123 --ha-webhook-id switchbot
|
|
243
|
+
$ switchbot events mqtt-tail --sink stdout --sink openclaw --openclaw-token abc --openclaw-model home
|
|
207
244
|
`)
|
|
208
245
|
.action(async (options) => {
|
|
209
246
|
try {
|
|
@@ -211,20 +248,70 @@ Examples:
|
|
|
211
248
|
if (maxEvents !== null && (!Number.isInteger(maxEvents) || maxEvents < 1)) {
|
|
212
249
|
throw new UsageError(`Invalid --max "${options.max}". Must be a positive integer.`);
|
|
213
250
|
}
|
|
214
|
-
let creds;
|
|
215
251
|
const loaded = tryLoadConfig();
|
|
216
252
|
if (!loaded) {
|
|
217
253
|
throw new UsageError('No credentials found. Run \'switchbot config set-token\' or set SWITCHBOT_TOKEN and SWITCHBOT_SECRET.');
|
|
218
254
|
}
|
|
219
|
-
|
|
255
|
+
const sinkTypes = options.sink;
|
|
256
|
+
let dispatcher = null;
|
|
257
|
+
if (sinkTypes.length > 0) {
|
|
258
|
+
const sinks = sinkTypes.map((type) => {
|
|
259
|
+
switch (type) {
|
|
260
|
+
case 'stdout':
|
|
261
|
+
return new StdoutSink();
|
|
262
|
+
case 'file': {
|
|
263
|
+
if (!options.sinkFile)
|
|
264
|
+
throw new UsageError('--sink file requires --sink-file <path>');
|
|
265
|
+
return new FileSink(options.sinkFile);
|
|
266
|
+
}
|
|
267
|
+
case 'webhook': {
|
|
268
|
+
if (!options.webhookUrl)
|
|
269
|
+
throw new UsageError('--sink webhook requires --webhook-url <url>');
|
|
270
|
+
return new WebhookSink(options.webhookUrl);
|
|
271
|
+
}
|
|
272
|
+
case 'openclaw': {
|
|
273
|
+
const token = options.openclawToken ?? process.env.OPENCLAW_TOKEN;
|
|
274
|
+
if (!token)
|
|
275
|
+
throw new UsageError('--sink openclaw requires --openclaw-token or env OPENCLAW_TOKEN');
|
|
276
|
+
if (!options.openclawModel)
|
|
277
|
+
throw new UsageError('--sink openclaw requires --openclaw-model <id>');
|
|
278
|
+
return new OpenClawSink({ url: options.openclawUrl, token, model: options.openclawModel });
|
|
279
|
+
}
|
|
280
|
+
case 'telegram': {
|
|
281
|
+
const token = options.telegramToken ?? process.env.TELEGRAM_TOKEN;
|
|
282
|
+
if (!token)
|
|
283
|
+
throw new UsageError('--sink telegram requires --telegram-token or env TELEGRAM_TOKEN');
|
|
284
|
+
if (!options.telegramChat)
|
|
285
|
+
throw new UsageError('--sink telegram requires --telegram-chat <id>');
|
|
286
|
+
return new TelegramSink({ token, chatId: options.telegramChat });
|
|
287
|
+
}
|
|
288
|
+
case 'homeassistant': {
|
|
289
|
+
if (!options.haUrl)
|
|
290
|
+
throw new UsageError('--sink homeassistant requires --ha-url <url>');
|
|
291
|
+
if (!options.haWebhookId && !options.haToken) {
|
|
292
|
+
throw new UsageError('--sink homeassistant requires --ha-webhook-id or --ha-token');
|
|
293
|
+
}
|
|
294
|
+
return new HomeAssistantSink({
|
|
295
|
+
url: options.haUrl,
|
|
296
|
+
token: options.haToken,
|
|
297
|
+
webhookId: options.haWebhookId,
|
|
298
|
+
eventType: options.haEventType,
|
|
299
|
+
});
|
|
300
|
+
}
|
|
301
|
+
default:
|
|
302
|
+
throw new UsageError(`Unknown --sink type "${type}". Supported: stdout, file, webhook, openclaw, telegram, homeassistant`);
|
|
303
|
+
}
|
|
304
|
+
});
|
|
305
|
+
dispatcher = new SinkDispatcher(sinks);
|
|
306
|
+
}
|
|
220
307
|
if (!isJsonMode()) {
|
|
221
308
|
console.error('Fetching MQTT credentials from SwitchBot service…');
|
|
222
309
|
}
|
|
223
|
-
const credential = await fetchMqttCredential(
|
|
310
|
+
const credential = await fetchMqttCredential(loaded.token, loaded.secret);
|
|
224
311
|
const topic = options.topic ?? credential.topics.status;
|
|
225
312
|
let eventCount = 0;
|
|
226
313
|
const ac = new AbortController();
|
|
227
|
-
const client = new SwitchBotMqttClient(credential, () => fetchMqttCredential(
|
|
314
|
+
const client = new SwitchBotMqttClient(credential, () => fetchMqttCredential(loaded.token, loaded.secret));
|
|
228
315
|
const unsub = client.onMessage((msgTopic, payload) => {
|
|
229
316
|
let parsed;
|
|
230
317
|
try {
|
|
@@ -233,18 +320,43 @@ Examples:
|
|
|
233
320
|
catch {
|
|
234
321
|
parsed = payload.toString('utf-8');
|
|
235
322
|
}
|
|
236
|
-
const
|
|
237
|
-
if (
|
|
238
|
-
|
|
323
|
+
const t = new Date().toISOString();
|
|
324
|
+
if (dispatcher) {
|
|
325
|
+
const { deviceId, deviceType, text } = parseSinkEvent(parsed);
|
|
326
|
+
const sinkEvent = { t, topic: msgTopic, deviceId, deviceType, payload: parsed, text };
|
|
327
|
+
deviceHistoryStore.record(deviceId, msgTopic, deviceType, parsed, t);
|
|
328
|
+
dispatcher.dispatch(sinkEvent).catch(() => { });
|
|
239
329
|
}
|
|
240
330
|
else {
|
|
241
|
-
|
|
331
|
+
// Default behavior: record history + print to stdout
|
|
332
|
+
const { deviceId, deviceType } = parseSinkEvent(parsed);
|
|
333
|
+
deviceHistoryStore.record(deviceId, msgTopic, deviceType, parsed, t);
|
|
334
|
+
const record = { t, topic: msgTopic, payload: parsed };
|
|
335
|
+
if (isJsonMode()) {
|
|
336
|
+
printJson(record);
|
|
337
|
+
}
|
|
338
|
+
else {
|
|
339
|
+
console.log(JSON.stringify(record));
|
|
340
|
+
}
|
|
242
341
|
}
|
|
243
342
|
eventCount++;
|
|
244
343
|
if (maxEvents !== null && eventCount >= maxEvents) {
|
|
245
344
|
ac.abort();
|
|
246
345
|
}
|
|
247
346
|
});
|
|
347
|
+
let mqttFailed = false;
|
|
348
|
+
const unsubState = client.onStateChange((state) => {
|
|
349
|
+
if (!isJsonMode()) {
|
|
350
|
+
console.error(`[${new Date().toLocaleTimeString()}] MQTT state: ${state}`);
|
|
351
|
+
}
|
|
352
|
+
if (state === 'failed') {
|
|
353
|
+
mqttFailed = true;
|
|
354
|
+
if (!isJsonMode()) {
|
|
355
|
+
console.error('MQTT connection failed permanently (credential expired or reconnect exhausted) — exiting.');
|
|
356
|
+
}
|
|
357
|
+
ac.abort();
|
|
358
|
+
}
|
|
359
|
+
});
|
|
248
360
|
await client.connect();
|
|
249
361
|
client.subscribe(topic);
|
|
250
362
|
if (!isJsonMode()) {
|
|
@@ -255,12 +367,18 @@ Examples:
|
|
|
255
367
|
process.removeListener('SIGINT', cleanup);
|
|
256
368
|
process.removeListener('SIGTERM', cleanup);
|
|
257
369
|
unsub();
|
|
370
|
+
unsubState();
|
|
371
|
+
dispatcher?.close().catch(() => { });
|
|
258
372
|
client.disconnect().then(resolve).catch(resolve);
|
|
259
373
|
};
|
|
260
374
|
process.once('SIGINT', cleanup);
|
|
261
375
|
process.once('SIGTERM', cleanup);
|
|
262
376
|
ac.signal.addEventListener('abort', cleanup, { once: true });
|
|
263
377
|
});
|
|
378
|
+
if (mqttFailed) {
|
|
379
|
+
// Surface as a runtime error so supervisors (pm2, systemd) can restart.
|
|
380
|
+
process.exit(1);
|
|
381
|
+
}
|
|
264
382
|
}
|
|
265
383
|
catch (error) {
|
|
266
384
|
handleError(error);
|
package/dist/commands/expand.js
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
|
+
import { intArg, stringArg } from '../utils/arg-parsers.js';
|
|
1
2
|
import { handleError, isJsonMode, printJson, UsageError } from '../utils/output.js';
|
|
2
3
|
import { getCachedDevice } from '../devices/cache.js';
|
|
3
4
|
import { executeCommand, isDestructiveCommand, getDestructiveReason } from '../lib/devices.js';
|
|
4
5
|
import { isDryRun } from '../utils/flags.js';
|
|
6
|
+
import { resolveDeviceId } from '../utils/name-resolver.js';
|
|
5
7
|
import { DryRunSignal } from '../api/client.js';
|
|
6
8
|
// ---- Mapping tables --------------------------------------------------------
|
|
7
9
|
const AC_MODE_MAP = { auto: 1, cool: 2, dry: 3, fan: 4, heat: 5 };
|
|
@@ -85,16 +87,17 @@ export function registerExpandCommand(devices) {
|
|
|
85
87
|
devices
|
|
86
88
|
.command('expand')
|
|
87
89
|
.description('Send a command with semantic flags instead of raw positional parameters')
|
|
88
|
-
.argument('
|
|
89
|
-
.argument('
|
|
90
|
-
.option('--
|
|
91
|
-
.option('--
|
|
92
|
-
.option('--
|
|
93
|
-
.option('--
|
|
94
|
-
.option('--
|
|
95
|
-
.option('--
|
|
96
|
-
.option('--
|
|
97
|
-
.option('--
|
|
90
|
+
.argument('[deviceId]', 'Target device ID from "devices list" (or use --name)')
|
|
91
|
+
.argument('[command]', 'Command name: setAll (AC), setPosition (Curtain/Blind Tilt), setMode (Relay Switch 2)')
|
|
92
|
+
.option('--name <query>', 'Resolve device by fuzzy name instead of deviceId', stringArg('--name'))
|
|
93
|
+
.option('--temp <celsius>', 'AC setAll: temperature in Celsius (16-30)', intArg('--temp', { min: 16, max: 30 }))
|
|
94
|
+
.option('--mode <mode>', 'AC: auto|cool|dry|fan|heat Curtain: default|performance|silent Relay: toggle|edge|detached|momentary', stringArg('--mode'))
|
|
95
|
+
.option('--fan <speed>', 'AC setAll: fan speed auto|low|mid|high', stringArg('--fan'))
|
|
96
|
+
.option('--power <state>', 'AC setAll: on|off', stringArg('--power'))
|
|
97
|
+
.option('--position <percent>', 'Curtain setPosition: 0-100 (0=open, 100=closed)', intArg('--position', { min: 0, max: 100 }))
|
|
98
|
+
.option('--direction <dir>', 'Blind Tilt setPosition: up|down', stringArg('--direction'))
|
|
99
|
+
.option('--angle <percent>', 'Blind Tilt setPosition: 0-100 (0=closed, 100=open)', intArg('--angle', { min: 0, max: 100 }))
|
|
100
|
+
.option('--channel <n>', 'Relay Switch 2 setMode: channel 1 or 2', intArg('--channel', { min: 1, max: 2 }))
|
|
98
101
|
.option('--yes', 'Confirm destructive commands')
|
|
99
102
|
.addHelpText('after', `
|
|
100
103
|
Translates semantic flags into the wire parameter format, then sends the command.
|
|
@@ -123,9 +126,24 @@ Examples:
|
|
|
123
126
|
$ switchbot devices expand <blindId> setPosition --direction up --angle 50
|
|
124
127
|
$ switchbot devices expand <relayId> setMode --channel 1 --mode edge
|
|
125
128
|
$ switchbot devices expand <acId> setAll --temp 22 --mode heat --fan auto --power on --dry-run
|
|
129
|
+
$ switchbot devices expand --name "客厅空调" setAll --temp 26 --mode cool --fan low --power on
|
|
126
130
|
`)
|
|
127
|
-
.action(async (
|
|
131
|
+
.action(async (deviceIdArg, commandArg, options) => {
|
|
132
|
+
let deviceId = '';
|
|
133
|
+
let command = '';
|
|
128
134
|
try {
|
|
135
|
+
// When --name is provided, Commander assigns the first positional to deviceIdArg
|
|
136
|
+
// and leaves commandArg undefined. Detect and shift.
|
|
137
|
+
let effectiveDeviceIdArg = deviceIdArg;
|
|
138
|
+
let effectiveCommand = commandArg;
|
|
139
|
+
if (options.name && deviceIdArg && !commandArg) {
|
|
140
|
+
effectiveCommand = deviceIdArg;
|
|
141
|
+
effectiveDeviceIdArg = undefined;
|
|
142
|
+
}
|
|
143
|
+
deviceId = resolveDeviceId(effectiveDeviceIdArg, options.name);
|
|
144
|
+
if (!effectiveCommand)
|
|
145
|
+
throw new UsageError('A command argument is required (setAll, setPosition, setMode).');
|
|
146
|
+
command = effectiveCommand;
|
|
129
147
|
const cached = getCachedDevice(deviceId);
|
|
130
148
|
const deviceType = cached?.type ?? '';
|
|
131
149
|
let parameter;
|
package/dist/commands/history.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import path from 'node:path';
|
|
2
2
|
import os from 'node:os';
|
|
3
|
+
import { intArg, stringArg } from '../utils/arg-parsers.js';
|
|
3
4
|
import { printJson, isJsonMode, handleError } from '../utils/output.js';
|
|
4
5
|
import { readAudit } from '../utils/audit.js';
|
|
5
6
|
import { executeCommand } from '../lib/devices.js';
|
|
@@ -21,8 +22,8 @@ Examples:
|
|
|
21
22
|
history
|
|
22
23
|
.command('show')
|
|
23
24
|
.description('Print recent audit entries')
|
|
24
|
-
.option('--file <path>', `Path to the audit log (default ${DEFAULT_AUDIT})
|
|
25
|
-
.option('--limit <n>', 'Show only the last N entries')
|
|
25
|
+
.option('--file <path>', `Path to the audit log (default ${DEFAULT_AUDIT})`, stringArg('--file'))
|
|
26
|
+
.option('--limit <n>', 'Show only the last N entries', intArg('--limit', { min: 1 }))
|
|
26
27
|
.action((options) => {
|
|
27
28
|
const file = options.file ?? DEFAULT_AUDIT;
|
|
28
29
|
const entries = readAudit(file);
|
|
@@ -52,7 +53,7 @@ Examples:
|
|
|
52
53
|
.command('replay')
|
|
53
54
|
.description('Re-run a recorded command by its 1-indexed position')
|
|
54
55
|
.argument('<index>', 'Entry index (1 = oldest; as shown by "history show")')
|
|
55
|
-
.option('--file <path>', `Path to the audit log (default ${DEFAULT_AUDIT})
|
|
56
|
+
.option('--file <path>', `Path to the audit log (default ${DEFAULT_AUDIT})`, stringArg('--file'))
|
|
56
57
|
.addHelpText('after', `
|
|
57
58
|
Dry-run-honouring: pass --dry-run on the parent command to preview without
|
|
58
59
|
sending the actual call. Errors from the recorded entry are NOT replayed —
|
package/dist/commands/mcp.js
CHANGED
|
@@ -2,12 +2,14 @@ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
|
2
2
|
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
|
3
3
|
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
|
|
4
4
|
import { z } from 'zod';
|
|
5
|
+
import { intArg, stringArg } from '../utils/arg-parsers.js';
|
|
5
6
|
import { handleError, isJsonMode } from '../utils/output.js';
|
|
6
7
|
import { fetchDeviceList, fetchDeviceStatus, executeCommand, describeDevice, validateCommand, isDestructiveCommand, getDestructiveReason, searchCatalog, DeviceNotFoundError, toMcpDescribeShape, toMcpDeviceListShape, toMcpIrDeviceShape, } from '../lib/devices.js';
|
|
7
8
|
import { fetchScenes, executeScene } from '../lib/scenes.js';
|
|
8
9
|
import { findCatalogEntry } from '../devices/catalog.js';
|
|
9
10
|
import { getCachedDevice } from '../devices/cache.js';
|
|
10
11
|
import { EventSubscriptionManager } from '../mcp/events-subscription.js';
|
|
12
|
+
import { deviceHistoryStore } from '../mcp/device-history.js';
|
|
11
13
|
import { todayUsage } from '../utils/quota.js';
|
|
12
14
|
import { describeCache } from '../devices/cache.js';
|
|
13
15
|
import { withRequestContext } from '../lib/request-context.js';
|
|
@@ -110,6 +112,40 @@ API docs: https://github.com/OpenWonderLabs/SwitchBotAPI`,
|
|
|
110
112
|
structuredContent: { status: body },
|
|
111
113
|
};
|
|
112
114
|
});
|
|
115
|
+
// ---- get_device_history ----------------------------------------------------
|
|
116
|
+
server.registerTool('get_device_history', {
|
|
117
|
+
title: 'Get locally-persisted device state history',
|
|
118
|
+
description: 'Return device state history recorded from MQTT events (persisted to ~/.switchbot/device-history/). ' +
|
|
119
|
+
'No API call — zero quota cost. Use when you need recent historical readings or want to avoid a live API call. ' +
|
|
120
|
+
'Omit deviceId to list all devices with stored history.',
|
|
121
|
+
inputSchema: {
|
|
122
|
+
deviceId: z.string().optional().describe('Device MAC address (deviceId). Omit to list all devices with history.'),
|
|
123
|
+
limit: z.number().int().min(1).max(100).optional().describe('Max history entries to return (default 20, max 100)'),
|
|
124
|
+
},
|
|
125
|
+
outputSchema: {
|
|
126
|
+
deviceId: z.string().optional(),
|
|
127
|
+
latest: z.unknown().optional(),
|
|
128
|
+
history: z.array(z.unknown()).optional(),
|
|
129
|
+
devices: z.array(z.object({ deviceId: z.string(), latest: z.unknown() })).optional(),
|
|
130
|
+
},
|
|
131
|
+
}, async ({ deviceId, limit }) => {
|
|
132
|
+
if (deviceId) {
|
|
133
|
+
const latest = deviceHistoryStore.getLatest(deviceId);
|
|
134
|
+
const history = deviceHistoryStore.getHistory(deviceId, limit ?? 20);
|
|
135
|
+
const result = { deviceId, latest, history };
|
|
136
|
+
return {
|
|
137
|
+
content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
|
|
138
|
+
structuredContent: result,
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
const ids = deviceHistoryStore.listDevices();
|
|
142
|
+
const devices = ids.map((id) => ({ deviceId: id, latest: deviceHistoryStore.getLatest(id) }));
|
|
143
|
+
const result = { devices };
|
|
144
|
+
return {
|
|
145
|
+
content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
|
|
146
|
+
structuredContent: result,
|
|
147
|
+
};
|
|
148
|
+
});
|
|
113
149
|
// ---- send_command ---------------------------------------------------------
|
|
114
150
|
server.registerTool('send_command', {
|
|
115
151
|
title: 'Send a control command to a device',
|
|
@@ -454,11 +490,11 @@ Inspect locally:
|
|
|
454
490
|
mcp
|
|
455
491
|
.command('serve')
|
|
456
492
|
.description('Start the MCP server on stdio (default) or HTTP (--port)')
|
|
457
|
-
.option('--port <n>', 'Listen on HTTP instead of stdio (Streamable HTTP transport)')
|
|
458
|
-
.option('--bind <host>', 'IP address to bind (default 127.0.0.1; use 0.0.0.0 to accept external connections)', '127.0.0.1')
|
|
459
|
-
.option('--auth-token <token>', 'Bearer token for HTTP requests (required for --bind 0.0.0.0; falls back to SWITCHBOT_MCP_TOKEN env var)')
|
|
460
|
-
.option('--cors-origin <url>', 'Allowed CORS origin(s) for HTTP (repeatable)')
|
|
461
|
-
.option('--rate-limit <n>', 'Max requests per minute per profile (default 60)', '60')
|
|
493
|
+
.option('--port <n>', 'Listen on HTTP instead of stdio (Streamable HTTP transport)', intArg('--port', { min: 1, max: 65535 }))
|
|
494
|
+
.option('--bind <host>', 'IP address to bind (default 127.0.0.1; use 0.0.0.0 to accept external connections)', stringArg('--bind'), '127.0.0.1')
|
|
495
|
+
.option('--auth-token <token>', 'Bearer token for HTTP requests (required for --bind 0.0.0.0; falls back to SWITCHBOT_MCP_TOKEN env var)', stringArg('--auth-token'))
|
|
496
|
+
.option('--cors-origin <url>', 'Allowed CORS origin(s) for HTTP (repeatable)', stringArg('--cors-origin'))
|
|
497
|
+
.option('--rate-limit <n>', 'Max requests per minute per profile (default 60)', intArg('--rate-limit', { min: 1 }), '60')
|
|
462
498
|
.action(async (options) => {
|
|
463
499
|
try {
|
|
464
500
|
if (options.port) {
|
package/dist/commands/plan.js
CHANGED
|
@@ -210,10 +210,18 @@ Workflow:
|
|
|
210
210
|
process.exit(2);
|
|
211
211
|
}
|
|
212
212
|
if (isJsonMode()) {
|
|
213
|
-
|
|
213
|
+
const out = { valid: true, steps: result.plan.steps.length };
|
|
214
|
+
if (result.plan.steps.length === 0)
|
|
215
|
+
out.warning = 'plan has no steps — nothing will execute';
|
|
216
|
+
printJson(out);
|
|
214
217
|
}
|
|
215
218
|
else {
|
|
216
|
-
|
|
219
|
+
if (result.plan.steps.length === 0) {
|
|
220
|
+
console.log('✓ plan valid — but 0 steps: nothing will execute');
|
|
221
|
+
}
|
|
222
|
+
else {
|
|
223
|
+
console.log(`✓ plan valid (${result.plan.steps.length} step${result.plan.steps.length === 1 ? '' : 's'})`);
|
|
224
|
+
}
|
|
217
225
|
}
|
|
218
226
|
});
|
|
219
227
|
plan
|
package/dist/commands/scenes.js
CHANGED
|
@@ -27,7 +27,7 @@ Examples:
|
|
|
27
27
|
printJson(scenes);
|
|
28
28
|
return;
|
|
29
29
|
}
|
|
30
|
-
renderRows(['sceneId', 'sceneName'], scenes.map((s) => [s.sceneId, s.sceneName]), fmt, resolveFields());
|
|
30
|
+
renderRows(['sceneId', 'sceneName'], scenes.map((s) => [s.sceneId, s.sceneName]), fmt, resolveFields(), { id: 'sceneId', name: 'sceneName' });
|
|
31
31
|
if (fmt === 'table' && scenes.length === 0) {
|
|
32
32
|
console.log('No scenes found');
|
|
33
33
|
}
|
package/dist/commands/schema.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { enumArg, stringArg } from '../utils/arg-parsers.js';
|
|
1
2
|
import { printJson } from '../utils/output.js';
|
|
2
3
|
import { getEffectiveCatalog } from '../devices/catalog.js';
|
|
3
4
|
function toSchemaEntry(e) {
|
|
@@ -24,15 +25,17 @@ function toSchemaCommand(c) {
|
|
|
24
25
|
};
|
|
25
26
|
}
|
|
26
27
|
export function registerSchemaCommand(program) {
|
|
28
|
+
const ROLES = ['lighting', 'security', 'sensor', 'climate', 'media', 'cleaning', 'curtain', 'fan', 'power', 'hub', 'other'];
|
|
29
|
+
const CATEGORIES = ['physical', 'ir'];
|
|
27
30
|
const schema = program
|
|
28
31
|
.command('schema')
|
|
29
32
|
.description('Export the device catalog as structured JSON (for agent prompts / tooling)');
|
|
30
33
|
schema
|
|
31
34
|
.command('export')
|
|
32
35
|
.description('Print the full catalog as structured JSON (one object per type)')
|
|
33
|
-
.option('--type <type>', 'Restrict to a single device type (e.g. "Strip Light")')
|
|
34
|
-
.option('--role <role>', 'Restrict to a functional role: lighting, security, sensor, climate, media, cleaning, curtain, fan, power, hub, other')
|
|
35
|
-
.option('--category <cat>', 'Restrict to "physical" or "ir"')
|
|
36
|
+
.option('--type <type>', 'Restrict to a single device type (e.g. "Strip Light")', stringArg('--type'))
|
|
37
|
+
.option('--role <role>', 'Restrict to a functional role: lighting, security, sensor, climate, media, cleaning, curtain, fan, power, hub, other', enumArg('--role', ROLES))
|
|
38
|
+
.option('--category <cat>', 'Restrict to "physical" or "ir"', enumArg('--category', CATEGORIES))
|
|
36
39
|
.addHelpText('after', `
|
|
37
40
|
Output is always JSON (this command ignores --format). The output is a
|
|
38
41
|
catalog export — not a formal JSON Schema standard document — suitable for
|
package/dist/commands/watch.js
CHANGED
|
@@ -2,7 +2,9 @@ import { printJson, isJsonMode, handleError, UsageError } from '../utils/output.
|
|
|
2
2
|
import { fetchDeviceStatus } from '../lib/devices.js';
|
|
3
3
|
import { getCachedDevice } from '../devices/cache.js';
|
|
4
4
|
import { parseDurationToMs, getFields } from '../utils/flags.js';
|
|
5
|
+
import { intArg, durationArg, stringArg } from '../utils/arg-parsers.js';
|
|
5
6
|
import { createClient } from '../api/client.js';
|
|
7
|
+
import { resolveDeviceId } from '../utils/name-resolver.js';
|
|
6
8
|
const DEFAULT_INTERVAL_MS = 30_000;
|
|
7
9
|
const MIN_INTERVAL_MS = 1_000;
|
|
8
10
|
function diff(prev, next, fields) {
|
|
@@ -55,9 +57,10 @@ export function registerWatchCommand(devices) {
|
|
|
55
57
|
devices
|
|
56
58
|
.command('watch')
|
|
57
59
|
.description('Poll device status on an interval and emit field-level changes (JSONL)')
|
|
58
|
-
.argument('
|
|
59
|
-
.option('--
|
|
60
|
-
.option('--
|
|
60
|
+
.argument('[deviceId...]', 'One or more deviceIds to watch (or use --name for one device)')
|
|
61
|
+
.option('--name <query>', 'Resolve one device by fuzzy name (combined with any positional IDs)', stringArg('--name'))
|
|
62
|
+
.option('--interval <dur>', `Polling interval: "30s", "1m", "500ms", ... (default 30s, min ${MIN_INTERVAL_MS / 1000}s)`, durationArg('--interval'), '30s')
|
|
63
|
+
.option('--max <n>', 'Stop after N ticks (default: run until Ctrl-C)', intArg('--max', { min: 1 }))
|
|
61
64
|
.option('--include-unchanged', 'Emit a tick even when no field changed')
|
|
62
65
|
.addHelpText('after', `
|
|
63
66
|
Each poll emits one JSON line per deviceId with the shape:
|
|
@@ -71,9 +74,18 @@ Examples:
|
|
|
71
74
|
$ switchbot devices watch ABC123 --fields battery,power --interval 1m
|
|
72
75
|
$ switchbot devices watch ABC123 DEF456 --interval 30s --max 10
|
|
73
76
|
$ switchbot devices watch ABC123 --json | jq 'select(.changed.power)'
|
|
77
|
+
$ switchbot devices watch --name "客厅空调" --interval 10s
|
|
74
78
|
`)
|
|
75
79
|
.action(async (deviceIds, options) => {
|
|
76
80
|
try {
|
|
81
|
+
const allIds = [...deviceIds];
|
|
82
|
+
if (options.name) {
|
|
83
|
+
const resolved = resolveDeviceId(undefined, options.name);
|
|
84
|
+
if (!allIds.includes(resolved))
|
|
85
|
+
allIds.push(resolved);
|
|
86
|
+
}
|
|
87
|
+
if (allIds.length === 0)
|
|
88
|
+
throw new UsageError('Provide at least one deviceId argument or --name.');
|
|
77
89
|
const parsed = parseDurationToMs(options.interval);
|
|
78
90
|
if (parsed === null || parsed < MIN_INTERVAL_MS) {
|
|
79
91
|
throw new UsageError(`Invalid --interval "${options.interval}". Minimum is ${MIN_INTERVAL_MS / 1000}s.`);
|
|
@@ -101,7 +113,7 @@ Examples:
|
|
|
101
113
|
const t = new Date().toISOString();
|
|
102
114
|
// Poll all devices in parallel; one failure per device doesn't stop
|
|
103
115
|
// the others.
|
|
104
|
-
await Promise.all(
|
|
116
|
+
await Promise.all(allIds.map(async (id) => {
|
|
105
117
|
const cached = getCachedDevice(id);
|
|
106
118
|
try {
|
|
107
119
|
const body = await fetchDeviceStatus(id, client);
|
package/dist/commands/webhook.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { stringArg } from '../utils/arg-parsers.js';
|
|
1
2
|
import { createClient } from '../api/client.js';
|
|
2
3
|
import { printKeyValue, printJson, isJsonMode, handleError, UsageError } from '../utils/output.js';
|
|
3
4
|
import chalk from 'chalk';
|
|
@@ -54,7 +55,7 @@ Example:
|
|
|
54
55
|
webhook
|
|
55
56
|
.command('query')
|
|
56
57
|
.description('Query webhook configuration')
|
|
57
|
-
.option('--details <url>', 'Query detailed configuration (enable/deviceList/timestamps) for a specific URL')
|
|
58
|
+
.option('--details <url>', 'Query detailed configuration (enable/deviceList/timestamps) for a specific URL', stringArg('--details'))
|
|
58
59
|
.addHelpText('after', `
|
|
59
60
|
Without --details, lists all configured webhook URLs.
|
|
60
61
|
With --details, prints enable/deviceList/createTime/lastUpdateTime for the given URL.
|
package/dist/config.js
CHANGED
|
@@ -99,7 +99,7 @@ export function showConfig() {
|
|
|
99
99
|
const envSecret = process.env.SWITCHBOT_SECRET;
|
|
100
100
|
if (envToken && envSecret) {
|
|
101
101
|
console.log('Credential source: environment variables');
|
|
102
|
-
console.log(`token : ${envToken}`);
|
|
102
|
+
console.log(`token : ${maskCredential(envToken)}`);
|
|
103
103
|
console.log(`secret: ${maskSecret(envSecret)}`);
|
|
104
104
|
return;
|
|
105
105
|
}
|
|
@@ -112,13 +112,18 @@ export function showConfig() {
|
|
|
112
112
|
const raw = fs.readFileSync(file, 'utf-8');
|
|
113
113
|
const cfg = JSON.parse(raw);
|
|
114
114
|
console.log(`Credential source: ${file}`);
|
|
115
|
-
console.log(`token : ${cfg.token}`);
|
|
115
|
+
console.log(`token : ${maskCredential(cfg.token)}`);
|
|
116
116
|
console.log(`secret: ${maskSecret(cfg.secret)}`);
|
|
117
117
|
}
|
|
118
118
|
catch {
|
|
119
119
|
console.error('Failed to read config file');
|
|
120
120
|
}
|
|
121
121
|
}
|
|
122
|
+
function maskCredential(token) {
|
|
123
|
+
if (token.length <= 8)
|
|
124
|
+
return '*'.repeat(Math.max(4, token.length));
|
|
125
|
+
return token.slice(0, 4) + '*'.repeat(token.length - 8) + token.slice(-4);
|
|
126
|
+
}
|
|
122
127
|
function maskSecret(secret) {
|
|
123
128
|
if (secret.length <= 4)
|
|
124
129
|
return '****';
|