@switchbot/openapi-cli 2.0.1 → 2.1.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/README.md CHANGED
@@ -7,7 +7,7 @@
7
7
  [![CI](https://github.com/OpenWonderLabs/switchbot-openapi-cli/actions/workflows/ci.yml/badge.svg)](https://github.com/OpenWonderLabs/switchbot-openapi-cli/actions/workflows/ci.yml)
8
8
 
9
9
  Command-line interface for the [SwitchBot API v1.1](https://github.com/OpenWonderLabs/SwitchBotAPI).
10
- List devices, query live status, send control commands, run scenes, and manage webhooks — all from your terminal or shell scripts.
10
+ List devices, query live status, send control commands, run scenes, receive real-time events, and connect AI agents via the built-in MCP server — all from your terminal or shell scripts.
11
11
 
12
12
  - **npm package:** [`@switchbot/openapi-cli`](https://www.npmjs.com/package/@switchbot/openapi-cli)
13
13
  - **Source code:** [github.com/OpenWonderLabs/switchbot-openapi-cli](https://github.com/OpenWonderLabs/switchbot-openapi-cli)
@@ -41,11 +41,19 @@ Under the hood every surface shares the same catalog, cache, and HMAC client —
41
41
  - [Commands](#commands)
42
42
  - [`config`](#config--credential-management)
43
43
  - [`devices`](#devices--list-status-control)
44
+ - [`devices batch`](#devices-batch--bulk-commands)
45
+ - [`devices watch`](#devices-watch--poll-status)
44
46
  - [`scenes`](#scenes--run-manual-scenes)
45
47
  - [`webhook`](#webhook--receive-device-events-over-http)
46
- - [`batch`](#batch--run-multiple-commands)
47
- - [`watch`](#watch--poll-device-status)
48
+ - [`events`](#events--receive-device-events)
49
+ - [`plan`](#plan--declarative-batch-operations)
48
50
  - [`mcp`](#mcp--model-context-protocol-server)
51
+ - [`doctor`](#doctor--self-check)
52
+ - [`quota`](#quota--api-request-counter)
53
+ - [`history`](#history--audit-log)
54
+ - [`catalog`](#catalog--device-type-catalog)
55
+ - [`schema`](#schema--export-catalog-as-json)
56
+ - [`capabilities`](#capabilities--cli-manifest)
49
57
  - [`cache`](#cache--inspect-and-clear-local-cache)
50
58
  - [`completion`](#completion--shell-tab-completion)
51
59
  - [Output modes](#output-modes)
@@ -67,7 +75,7 @@ Under the hood every surface shares the same catalog, cache, and HMAC client —
67
75
  - 🎨 **Dual output modes** — colorized tables by default; `--json` passthrough for `jq` and scripting
68
76
  - 🔐 **Secure credentials** — HMAC-SHA256 signed requests; config file written with `0600`; env-var override for CI
69
77
  - 🔍 **Dry-run mode** — preview every mutating request before it hits the API
70
- - 🧪 **Fully tested** — 592 Vitest tests, mocked axios, zero network in CI
78
+ - 🧪 **Fully tested** — 692 Vitest tests, mocked axios, zero network in CI
71
79
  - ⚡ **Shell completion** — Bash / Zsh / Fish / PowerShell
72
80
 
73
81
  ## Requirements
@@ -187,6 +195,7 @@ switchbot devices command ABC123 turnOn --dry-run
187
195
  ```bash
188
196
  switchbot config set-token <token> <secret> # Save to ~/.switchbot/config.json
189
197
  switchbot config show # Print current source + masked secret
198
+ switchbot config list-profiles # List saved profiles
190
199
  ```
191
200
 
192
201
  ### `devices` — list, status, control
@@ -272,13 +281,48 @@ switchbot devices expand <relayId> setMode --channel 1 --mode edge
272
281
 
273
282
  Run `switchbot devices expand <id> <command> --help` to see the available flags for any device command.
274
283
 
275
- #### `devices explain` — plain-language command description
284
+ #### `devices explain` — one-shot device summary
276
285
 
277
286
  ```bash
278
- switchbot devices explain <deviceId> <command> # e.g. "explain ABC123 setAll"
287
+ # Metadata + supported commands + live status in one call
288
+ switchbot devices explain <deviceId>
289
+
290
+ # Skip live status fetch (catalog-only output, no API call)
291
+ switchbot devices explain <deviceId> --no-live
292
+ ```
293
+
294
+ Returns a combined view: static catalog info (commands, parameters, status fields) merged with the current live status. For Hub devices, also lists connected child devices. Prefer this over separate `status` + `describe` calls.
295
+
296
+ #### `devices meta` — local device metadata
297
+
298
+ ```bash
299
+ switchbot devices meta set <deviceId> --alias "Office Light"
300
+ switchbot devices meta set <deviceId> --hide # hide from `devices list`
301
+ switchbot devices meta get <deviceId>
302
+ switchbot devices meta list # show all saved metadata
303
+ switchbot devices meta clear <deviceId>
304
+ ```
305
+
306
+ Stores local annotations (alias, hidden flag, notes) in `~/.switchbot/device-meta.json`. The alias is used as a display name; `--show-hidden` on `devices list` reveals hidden devices.
307
+
308
+ #### `devices batch` — bulk commands
309
+
310
+ ```bash
311
+ # Send the same command to every device matching a filter
312
+ switchbot devices batch turnOff --filter 'type=Bot'
313
+ switchbot devices batch setBrightness 50 --filter 'type~=Light,family=Living'
314
+
315
+ # Explicit device IDs (comma-separated)
316
+ switchbot devices batch turnOn --ids ID1,ID2,ID3
317
+
318
+ # Pipe device IDs from `devices list`
319
+ switchbot devices list --format=id --filter 'type=Bot' | switchbot devices batch toggle -
320
+
321
+ # Destructive commands require --yes
322
+ switchbot devices batch unlock --filter 'type=Smart Lock' --yes
279
323
  ```
280
324
 
281
- Returns a human-readable description of what the command does and what each parameter means.
325
+ Sends the same command to many devices in one run. Uses the same `--filter` expressions as `devices list`. Destructive commands (Smart Lock unlock, Garage Door Opener, etc.) require `--yes` to prevent accidents.
282
326
 
283
327
  ### `scenes` — run manual scenes
284
328
 
@@ -307,6 +351,67 @@ switchbot webhook delete https://your.host/hook
307
351
 
308
352
  The CLI validates that `<url>` is an absolute `http://` or `https://` URL before calling the API. `--enable` and `--disable` are mutually exclusive.
309
353
 
354
+ ### `events` — receive device events
355
+
356
+ Two subcommands cover the two ways SwitchBot can push state changes to you.
357
+
358
+ #### `events tail` — local webhook receiver
359
+
360
+ ```bash
361
+ # Listen on port 3000 and print every incoming webhook POST
362
+ switchbot events tail
363
+
364
+ # Filter to one device
365
+ switchbot events tail --filter deviceId=ABC123
366
+
367
+ # Stop after 5 matching events
368
+ switchbot events tail --filter 'type=WoMeter' --max 5
369
+
370
+ # Custom port / path
371
+ switchbot events tail --port 8080 --path /hook --json
372
+ ```
373
+
374
+ Run `switchbot webhook setup https://your.host/hook` first to tell SwitchBot where to send events, then expose the local port via ngrok/cloudflared and point the webhook URL at it. `events tail` only runs the local receiver — tunnelling is up to you.
375
+
376
+ Output (one JSON line per matched event):
377
+ ```
378
+ { "t": "2024-01-01T12:00:00.000Z", "remote": "1.2.3.4:54321", "path": "/", "body": {...}, "matched": true }
379
+ ```
380
+
381
+ Filter keys: `deviceId=<id>`, `type=<deviceType>` (comma-separated for AND logic).
382
+
383
+ #### `events mqtt-tail` — real-time MQTT stream
384
+
385
+ ```bash
386
+ # Stream all shadow-update events (runs in foreground until Ctrl-C)
387
+ switchbot events mqtt-tail
388
+
389
+ # Filter to a topic subtree
390
+ switchbot events mqtt-tail --topic 'switchbot/#'
391
+
392
+ # Stop after 10 events
393
+ switchbot events mqtt-tail --max 10 --json
394
+ ```
395
+
396
+ Connects to the SwitchBot MQTT service automatically using the same credentials configured for the REST API (`SWITCHBOT_TOKEN` + `SWITCHBOT_SECRET`). No additional MQTT configuration is required — the client certificates are provisioned on first use.
397
+
398
+ Output (one JSON line per message):
399
+ ```
400
+ { "t": "2024-01-01T12:00:00.000Z", "topic": "switchbot/abc123/status", "payload": {...} }
401
+ ```
402
+
403
+ This command runs in the foreground and streams events until you press Ctrl-C. To run it persistently in the background, use a process manager:
404
+
405
+ ```bash
406
+ # pm2
407
+ pm2 start "switchbot events mqtt-tail --json" --name switchbot-events
408
+
409
+ # nohup
410
+ nohup switchbot events mqtt-tail --json >> ~/switchbot-events.log 2>&1 &
411
+ ```
412
+
413
+ Run `switchbot doctor` to verify MQTT credentials are configured correctly before connecting.
414
+
310
415
  ### `completion` — shell tab-completion
311
416
 
312
417
  ```bash
@@ -325,28 +430,36 @@ switchbot completion powershell >> $PROFILE
325
430
 
326
431
  Supported shells: `bash`, `zsh`, `fish`, `powershell` (`pwsh` is accepted as an alias).
327
432
 
328
- ### `batch` — run multiple commands
433
+ ### `plan` — declarative batch operations
329
434
 
330
435
  ```bash
331
- # Run a sequence of commands from a JSON/YAML file
332
- switchbot batch run commands.json
333
- switchbot batch run commands.yaml --dry-run
436
+ # Print the plan JSON Schema (give to your agent framework)
437
+ switchbot plan schema
334
438
 
335
- # Validate a plan file without executing it
336
- switchbot batch validate commands.json
439
+ # Validate a plan file without running it
440
+ switchbot plan validate plan.json
441
+
442
+ # Preview — mutations skipped, GETs still execute
443
+ switchbot --dry-run plan run plan.json
444
+
445
+ # Run — pass --yes to allow destructive steps
446
+ switchbot plan run plan.json --yes
447
+ switchbot plan run plan.json --continue-on-error
337
448
  ```
338
449
 
339
- A batch file is a JSON array of `{ deviceId, command, parameter?, commandType? }` objects.
450
+ A plan file is a JSON document with `version`, `description`, and a `steps` array of `command`, `scene`, or `wait` steps. Steps execute sequentially; a failed step stops the run unless `--continue-on-error` is set. See [`docs/agent-guide.md`](./docs/agent-guide.md) for the full schema and agent integration patterns.
340
451
 
341
- ### `watch` — poll device status
452
+ ### `devices watch` — poll status
342
453
 
343
454
  ```bash
344
455
  # Poll a device's status every 30 s until Ctrl-C
345
- switchbot watch <deviceId>
346
- switchbot watch <deviceId> --interval 10s --json
456
+ switchbot devices watch <deviceId>
457
+
458
+ # Custom interval; emit every tick even when nothing changed
459
+ switchbot devices watch <deviceId> --interval 10s --include-unchanged --json
347
460
  ```
348
461
 
349
- Output is a stream of JSON status objects (with `--json`) or a refreshed table.
462
+ Output is a JSONL stream of status-change events (with `--json`) or a refreshed table. Use `--max <n>` to stop after N ticks.
350
463
 
351
464
  ### `mcp` — Model Context Protocol server
352
465
 
@@ -358,6 +471,65 @@ switchbot mcp serve
358
471
  Exposes 8 MCP tools (`list_devices`, `describe_device`, `get_device_status`, `send_command`, `list_scenes`, `run_scene`, `search_catalog`, `account_overview`) plus a `switchbot://events` resource for real-time shadow updates.
359
472
  See [`docs/agent-guide.md`](./docs/agent-guide.md) for the full tool reference and safety rules (destructive-command guard).
360
473
 
474
+ ### `doctor` — self-check
475
+
476
+ ```bash
477
+ switchbot doctor
478
+ switchbot doctor --json
479
+ ```
480
+
481
+ Runs 8 local checks (Node version, credentials, profiles, catalog, cache, quota file, clock, MQTT) and exits 1 if any check fails. `warn` results exit 0. The MQTT check reports `ok` when REST credentials are configured (auto-provisioned on first use). Use this to diagnose connectivity or config issues before running automation.
482
+
483
+ ### `quota` — API request counter
484
+
485
+ ```bash
486
+ switchbot quota status # today's usage + last 7 days
487
+ switchbot quota reset # delete the counter file
488
+ ```
489
+
490
+ Tracks daily API calls against the 10,000/day account limit. The counter is stored in `~/.switchbot/quota.json` and incremented on every mutating request. Pass `--no-quota` to skip tracking for a single run.
491
+
492
+ ### `history` — audit log
493
+
494
+ ```bash
495
+ switchbot history show # recent entries (newest first)
496
+ switchbot history show --limit 20 # last 20 entries
497
+ switchbot history replay 7 # re-run entry #7
498
+ switchbot --json history show --limit 50 | jq '.entries[] | select(.result=="error")'
499
+ ```
500
+
501
+ Reads the JSONL audit log (`~/.switchbot/audit.log` by default; override with `--audit-log`). Each entry records the timestamp, command, device ID, result, and dry-run flag. `replay` re-runs the original command with the original arguments.
502
+
503
+ ### `catalog` — device type catalog
504
+
505
+ ```bash
506
+ switchbot catalog show # all 42 built-in types
507
+ switchbot catalog show Bot # one type
508
+ switchbot catalog diff # what a local overlay changes vs built-in
509
+ switchbot catalog path # location of the local overlay file
510
+ switchbot catalog refresh # reload local overlay (clears in-process cache)
511
+ ```
512
+
513
+ The built-in catalog ships with the package. Create `~/.switchbot/catalog-overlay.json` to add, extend, or override type definitions without modifying the package.
514
+
515
+ ### `schema` — export catalog as JSON
516
+
517
+ ```bash
518
+ switchbot schema export # all types as structured JSON
519
+ switchbot schema export --type 'Strip Light' # one type
520
+ switchbot schema export --role sensor # filter by role
521
+ ```
522
+
523
+ Exports the effective catalog in a machine-readable format. Pipe the output into an agent's system prompt or tool schema to give it a complete picture of controllable devices.
524
+
525
+ ### `capabilities` — CLI manifest
526
+
527
+ ```bash
528
+ switchbot capabilities --json
529
+ ```
530
+
531
+ Prints a versioned JSON manifest describing available surfaces (CLI, MCP, MQTT, plan runner), commands, and environment variables. Designed for agents and tooling that need to discover the CLI's capabilities programmatically.
532
+
361
533
  ### `cache` — inspect and clear local cache
362
534
 
363
535
  ```bash
@@ -457,11 +629,11 @@ Typical errors bubble up in the form `Error: <message>` on stderr. The SwitchBot
457
629
 
458
630
  ## Environment variables
459
631
 
460
- | Variable | Description |
461
- | ------------------- | ------------------------------------------------------------------ |
462
- | `SWITCHBOT_TOKEN` | API token — takes priority over the config file |
463
- | `SWITCHBOT_SECRET` | API secret — takes priority over the config file |
464
- | `NO_COLOR` | Disable ANSI colors in all output (automatically respected) |
632
+ | Variable | Description |
633
+ | --------------------------- | ------------------------------------------------------------------ |
634
+ | `SWITCHBOT_TOKEN` | API token — takes priority over the config file |
635
+ | `SWITCHBOT_SECRET` | API secret — takes priority over the config file |
636
+ | `NO_COLOR` | Disable ANSI colors in all output (automatically respected) |
465
637
 
466
638
  ## Scripting examples
467
639
 
@@ -484,7 +656,7 @@ npm install
484
656
 
485
657
  npm run dev -- <args> # Run from TypeScript sources via tsx
486
658
  npm run build # Compile to dist/
487
- npm test # Run the Vitest suite (592 tests)
659
+ npm test # Run the Vitest suite (692 tests)
488
660
  npm run test:watch # Watch mode
489
661
  npm run test:coverage # Coverage report (v8, HTML + text)
490
662
  ```
@@ -506,21 +678,23 @@ src/
506
678
  ├── commands/
507
679
  │ ├── config.ts
508
680
  │ ├── devices.ts
681
+ │ ├── expand.ts # `devices expand` — semantic flag builder
682
+ │ ├── explain.ts # `devices explain` — one-shot device summary
683
+ │ ├── device-meta.ts # `devices meta` — local aliases / hide flags
509
684
  │ ├── scenes.ts
510
685
  │ ├── webhook.ts
511
- │ ├── batch.ts # `switchbot batch run/validate`
512
- │ ├── watch.ts # `switchbot watch <deviceId>`
513
- │ ├── mcp.ts # `switchbot mcp serve` (MCP stdio server)
514
- │ ├── cache.ts # `switchbot cache show/clear`
515
- │ ├── history.ts # `switchbot history [replay]`
516
- │ ├── events.ts # `switchbot events`
517
- │ ├── quota.ts # `switchbot quota`
518
- │ ├── explain.ts # `switchbot explain <deviceId>`
519
- │ ├── plan.ts # `switchbot plan run <file>`
520
- │ ├── doctor.ts # `switchbot doctor`
521
- │ ├── schema.ts # `switchbot schema export`
522
- ├── catalog.ts # `switchbot catalog search`
523
- │ └── completion.ts # `switchbot completion bash|zsh|fish|powershell`
686
+ │ ├── watch.ts # `devices watch <deviceId>`
687
+ │ ├── events.ts # `events tail` / `events mqtt-tail`
688
+ │ ├── mcp.ts # `mcp serve` (MCP stdio/HTTP server)
689
+ │ ├── plan.ts # `plan run/validate`
690
+ │ ├── cache.ts # `cache show/clear`
691
+ │ ├── history.ts # `history show/replay`
692
+ │ ├── quota.ts # `quota status/reset`
693
+ │ ├── catalog.ts # `catalog show/diff/path`
694
+ │ ├── schema.ts # `schema export`
695
+ │ ├── doctor.ts # `doctor`
696
+ │ ├── capabilities.ts # `capabilities`
697
+ └── completion.ts # `completion bash|zsh|fish|powershell`
524
698
  └── utils/
525
699
  ├── flags.ts # Global flag readers (isVerbose / isDryRun / getCacheMode / …)
526
700
  ├── output.ts # printTable / printKeyValue / printJson / handleError / buildErrorPayload
@@ -68,6 +68,13 @@ export function registerCapabilitiesCommand(program) {
68
68
  tools: MCP_TOOLS,
69
69
  resources: ['switchbot://events'],
70
70
  },
71
+ mqtt: {
72
+ mode: 'consumer',
73
+ authSource: 'SWITCHBOT_TOKEN + SWITCHBOT_SECRET (auto-provisioned via POST /v1.1/iot/credential)',
74
+ cliCmd: 'events mqtt-tail',
75
+ mcpResource: 'switchbot://events',
76
+ protocol: 'MQTTS with TLS client certificates (AWS IoT)',
77
+ },
71
78
  plan: {
72
79
  schemaCmd: 'plan schema',
73
80
  validateCmd: 'plan validate -',
@@ -101,6 +101,38 @@ function checkNodeVersion() {
101
101
  }
102
102
  return { name: 'node', status: 'ok', detail: `Node ${process.versions.node}` };
103
103
  }
104
+ function checkMqtt() {
105
+ // MQTT credentials are auto-provisioned from the SwitchBot API using the
106
+ // account's token+secret — no extra env vars needed. Report availability
107
+ // based on whether REST credentials are configured (no network call).
108
+ const hasEnvCreds = Boolean(process.env.SWITCHBOT_TOKEN && process.env.SWITCHBOT_SECRET);
109
+ if (hasEnvCreds) {
110
+ return {
111
+ name: 'mqtt',
112
+ status: 'ok',
113
+ detail: "auto-provisioned from credentials — run 'switchbot events mqtt-tail' to test live connectivity",
114
+ };
115
+ }
116
+ const file = configFilePath();
117
+ if (fs.existsSync(file)) {
118
+ try {
119
+ const cfg = JSON.parse(fs.readFileSync(file, 'utf-8'));
120
+ if (cfg.token && cfg.secret) {
121
+ return {
122
+ name: 'mqtt',
123
+ status: 'ok',
124
+ detail: "auto-provisioned from credentials — run 'switchbot events mqtt-tail' to test live connectivity",
125
+ };
126
+ }
127
+ }
128
+ catch { /* fall through */ }
129
+ }
130
+ return {
131
+ name: 'mqtt',
132
+ status: 'warn',
133
+ detail: "unavailable — configure credentials first (see credentials check above)",
134
+ };
135
+ }
104
136
  export function registerDoctorCommand(program) {
105
137
  program
106
138
  .command('doctor')
@@ -122,6 +154,7 @@ Examples:
122
154
  checkCache(),
123
155
  checkQuotaFile(),
124
156
  checkClockSkew(),
157
+ checkMqtt(),
125
158
  ];
126
159
  const summary = {
127
160
  ok: checks.filter((c) => c.status === 'ok').length,
@@ -1,5 +1,8 @@
1
1
  import http from 'node:http';
2
2
  import { printJson, isJsonMode, handleError, UsageError } from '../utils/output.js';
3
+ import { SwitchBotMqttClient } from '../mqtt/client.js';
4
+ import { fetchMqttCredential } from '../mqtt/credential.js';
5
+ import { tryLoadConfig } from '../config.js';
3
6
  const DEFAULT_PORT = 3000;
4
7
  const DEFAULT_PATH = '/';
5
8
  const MAX_BODY_BYTES = 1_000_000;
@@ -102,7 +105,7 @@ export function startReceiver(port, pathMatch, filter, onEvent) {
102
105
  export function registerEventsCommand(program) {
103
106
  const events = program
104
107
  .command('events')
105
- .description('Subscribe to local webhook events forwarded by SwitchBot');
108
+ .description('Receive SwitchBot device events — webhook receiver (tail) or MQTT stream (mqtt-tail)');
106
109
  events
107
110
  .command('tail')
108
111
  .description('Run a local HTTP receiver and print incoming webhook events as JSONL')
@@ -184,4 +187,83 @@ Examples:
184
187
  handleError(error);
185
188
  }
186
189
  });
190
+ events
191
+ .command('mqtt-tail')
192
+ .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)')
195
+ .addHelpText('after', `
196
+ Connects to the SwitchBot MQTT service using your existing credentials
197
+ (SWITCHBOT_TOKEN + SWITCHBOT_SECRET or ~/.switchbot/config.json).
198
+ No additional MQTT configuration required.
199
+
200
+ Output (JSONL, one event per line):
201
+ { "t": "<ISO>", "topic": "<mqtt-topic>", "payload": <parsed JSON or raw string> }
202
+
203
+ Examples:
204
+ $ switchbot events mqtt-tail
205
+ $ switchbot events mqtt-tail --topic 'switchbot/#'
206
+ $ switchbot events mqtt-tail --max 10 --json
207
+ `)
208
+ .action(async (options) => {
209
+ try {
210
+ const maxEvents = options.max !== undefined ? Number(options.max) : null;
211
+ if (maxEvents !== null && (!Number.isInteger(maxEvents) || maxEvents < 1)) {
212
+ throw new UsageError(`Invalid --max "${options.max}". Must be a positive integer.`);
213
+ }
214
+ let creds;
215
+ const loaded = tryLoadConfig();
216
+ if (!loaded) {
217
+ throw new UsageError('No credentials found. Run \'switchbot config set-token\' or set SWITCHBOT_TOKEN and SWITCHBOT_SECRET.');
218
+ }
219
+ creds = loaded;
220
+ if (!isJsonMode()) {
221
+ console.error('Fetching MQTT credentials from SwitchBot service…');
222
+ }
223
+ const credential = await fetchMqttCredential(creds.token, creds.secret);
224
+ const topic = options.topic ?? credential.topics.status;
225
+ let eventCount = 0;
226
+ const ac = new AbortController();
227
+ const client = new SwitchBotMqttClient(credential, () => fetchMqttCredential(creds.token, creds.secret));
228
+ const unsub = client.onMessage((msgTopic, payload) => {
229
+ let parsed;
230
+ try {
231
+ parsed = JSON.parse(payload.toString('utf-8'));
232
+ }
233
+ catch {
234
+ parsed = payload.toString('utf-8');
235
+ }
236
+ const record = { t: new Date().toISOString(), topic: msgTopic, payload: parsed };
237
+ if (isJsonMode()) {
238
+ printJson(record);
239
+ }
240
+ else {
241
+ console.log(JSON.stringify(record));
242
+ }
243
+ eventCount++;
244
+ if (maxEvents !== null && eventCount >= maxEvents) {
245
+ ac.abort();
246
+ }
247
+ });
248
+ await client.connect();
249
+ client.subscribe(topic);
250
+ if (!isJsonMode()) {
251
+ console.error(`Connected to ${credential.brokerUrl} (Ctrl-C to stop)`);
252
+ }
253
+ await new Promise((resolve) => {
254
+ const cleanup = () => {
255
+ process.removeListener('SIGINT', cleanup);
256
+ process.removeListener('SIGTERM', cleanup);
257
+ unsub();
258
+ client.disconnect().then(resolve).catch(resolve);
259
+ };
260
+ process.once('SIGINT', cleanup);
261
+ process.once('SIGTERM', cleanup);
262
+ ac.signal.addEventListener('abort', cleanup, { once: true });
263
+ });
264
+ }
265
+ catch (error) {
266
+ handleError(error);
267
+ }
268
+ });
187
269
  }
@@ -11,8 +11,7 @@ import { EventSubscriptionManager } from '../mcp/events-subscription.js';
11
11
  import { todayUsage } from '../utils/quota.js';
12
12
  import { describeCache } from '../devices/cache.js';
13
13
  import { withRequestContext } from '../lib/request-context.js';
14
- import { profileFilePath } from '../config.js';
15
- import { getMqttConfig } from '../mqtt/credential.js';
14
+ import { profileFilePath, tryLoadConfig } from '../config.js';
16
15
  import fs from 'node:fs';
17
16
  function mcpError(kind, code, message, options) {
18
17
  const obj = { code, kind, message };
@@ -350,7 +349,7 @@ API docs: https://github.com/OpenWonderLabs/SwitchBotAPI`,
350
349
  mqtt: z.object({
351
350
  state: z.string(),
352
351
  subscribers: z.number(),
353
- }).optional().describe('MQTT connection state (HTTP mode only)'),
352
+ }).optional().describe('MQTT connection state (present when REST credentials are configured; auto-provisioned via POST /v1.1/iot/credential)'),
354
353
  },
355
354
  }, async () => {
356
355
  const deviceList = await fetchDeviceList();
@@ -398,7 +397,7 @@ API docs: https://github.com/OpenWonderLabs/SwitchBotAPI`,
398
397
  server.registerResource('events', 'switchbot://events', {
399
398
  title: 'SwitchBot real-time shadow events',
400
399
  description: 'Recent device shadow-update events received via MQTT. Returns a JSON snapshot of the ring buffer. ' +
401
- 'State is "disabled" when MQTT credentials are not configured (set SWITCHBOT_MQTT_HOST / USERNAME / PASSWORD).',
400
+ 'State is "disabled" when REST credentials (SWITCHBOT_TOKEN + SWITCHBOT_SECRET) are not configured.',
402
401
  mimeType: 'application/json',
403
402
  }, (_uri) => {
404
403
  const state = eventManager.getState();
@@ -419,7 +418,7 @@ export function registerMcpCommand(program) {
419
418
  .command('mcp')
420
419
  .description('Run as a Model Context Protocol server so AI agents can call SwitchBot tools')
421
420
  .addHelpText('after', `
422
- The MCP server exposes seven tools over stdio:
421
+ The MCP server exposes eight tools:
423
422
  - list_devices fetch all physical + IR devices
424
423
  - get_device_status live status for a physical device
425
424
  - send_command control a device (destructive commands need confirm:true)
@@ -427,6 +426,12 @@ The MCP server exposes seven tools over stdio:
427
426
  - run_scene execute a manual scene
428
427
  - search_catalog offline catalog search by type/alias
429
428
  - describe_device metadata + commands + (optionally) live status for one device
429
+ - account_overview single cold-start snapshot: devices + scenes + quota + cache + MQTT state
430
+
431
+ Resource (read-only):
432
+ - switchbot://events snapshot of recent MQTT shadow events from the ring buffer
433
+ Auto-provisioned from SWITCHBOT_TOKEN + SWITCHBOT_SECRET;
434
+ returns {state:"disabled"} when credentials are not configured.
430
435
 
431
436
  Example Claude Desktop config (~/Library/Application Support/Claude/claude_desktop_config.json):
432
437
 
@@ -487,17 +492,17 @@ Inspect locally:
487
492
  const { createServer } = await import('node:http');
488
493
  const rateLimitMap = new Map();
489
494
  // Initialize shared EventSubscriptionManager for event streaming.
490
- // If MQTT creds are present, connect in the background so the HTTP server
491
- // starts immediately; /ready reflects the real state.
495
+ // Credentials are auto-provisioned from the SwitchBot API using the
496
+ // account's token+secret no extra MQTT env vars needed.
492
497
  const eventManager = new EventSubscriptionManager();
493
- const mqttConfig = getMqttConfig();
494
- if (mqttConfig) {
495
- eventManager.initialize(mqttConfig).catch((err) => {
498
+ const mqttCreds = tryLoadConfig();
499
+ if (mqttCreds) {
500
+ eventManager.initialize(mqttCreds.token, mqttCreds.secret).catch((err) => {
496
501
  console.error('MQTT initialization failed:', err instanceof Error ? err.message : String(err));
497
502
  });
498
503
  }
499
504
  else {
500
- console.error('MQTT disabled: set SWITCHBOT_MQTT_HOST, SWITCHBOT_MQTT_USERNAME, SWITCHBOT_MQTT_PASSWORD to enable real-time events.');
505
+ console.error('MQTT disabled: credentials not configured.');
501
506
  }
502
507
  // Helper: constant-time token comparison
503
508
  const tokenMatch = (provided) => {
@@ -691,7 +696,14 @@ process_uptime_seconds ${Math.floor(process.uptime())}
691
696
  });
692
697
  return;
693
698
  }
694
- const server = createSwitchBotMcpServer();
699
+ const eventManager = new EventSubscriptionManager();
700
+ const mqttCreds = tryLoadConfig();
701
+ if (mqttCreds) {
702
+ eventManager.initialize(mqttCreds.token, mqttCreds.secret).catch((err) => {
703
+ console.error('MQTT initialization failed:', err instanceof Error ? err.message : String(err));
704
+ });
705
+ }
706
+ const server = createSwitchBotMcpServer({ eventManager });
695
707
  const transport = new StdioServerTransport();
696
708
  await server.connect(transport);
697
709
  }
package/dist/config.js CHANGED
@@ -62,6 +62,29 @@ export function loadConfig() {
62
62
  process.exit(1);
63
63
  }
64
64
  }
65
+ /**
66
+ * Like loadConfig but returns null instead of exiting. Use this in code paths
67
+ * that want graceful degradation (e.g. optional MQTT init in `mcp serve`).
68
+ */
69
+ export function tryLoadConfig() {
70
+ const envToken = process.env.SWITCHBOT_TOKEN;
71
+ const envSecret = process.env.SWITCHBOT_SECRET;
72
+ if (envToken && envSecret)
73
+ return { token: envToken, secret: envSecret };
74
+ const file = configFilePath();
75
+ if (!fs.existsSync(file))
76
+ return null;
77
+ try {
78
+ const raw = fs.readFileSync(file, 'utf-8');
79
+ const cfg = JSON.parse(raw);
80
+ if (!cfg.token || !cfg.secret)
81
+ return null;
82
+ return cfg;
83
+ }
84
+ catch {
85
+ return null;
86
+ }
87
+ }
65
88
  export function saveConfig(token, secret) {
66
89
  const file = configFilePath();
67
90
  const dir = path.dirname(file);
package/dist/index.js CHANGED
@@ -68,9 +68,9 @@ Exit codes:
68
68
  2 usage error (bad flag, unknown subcommand, invalid argument, unknown device type)
69
69
 
70
70
  Environment:
71
- SWITCHBOT_TOKEN credential token (takes priority over config file)
72
- SWITCHBOT_SECRET credential secret (takes priority over config file)
73
- NO_COLOR disable ANSI colors (auto-respected via chalk)
71
+ SWITCHBOT_TOKEN credential token (takes priority over config file)
72
+ SWITCHBOT_SECRET credential secret (takes priority over config file)
73
+ NO_COLOR disable ANSI colors (auto-respected via chalk)
74
74
 
75
75
  Examples:
76
76
  $ switchbot config set-token <token> <secret>
@@ -1,4 +1,5 @@
1
1
  import { SwitchBotMqttClient } from '../mqtt/client.js';
2
+ import { fetchMqttCredential } from '../mqtt/credential.js';
2
3
  import { parseFilter, applyFilter } from '../utils/filter.js';
3
4
  import { fetchDeviceList } from '../lib/devices.js';
4
5
  import { getCachedDevice } from '../devices/cache.js';
@@ -18,22 +19,17 @@ export class EventSubscriptionManager {
18
19
  this.mqttClient = mqttClient || null;
19
20
  this.getClient = getClient;
20
21
  }
21
- async initialize(mqttConfig) {
22
+ async initialize(token, secret) {
22
23
  if (!this.mqttClient) {
23
- const client = new SwitchBotMqttClient(mqttConfig, async () => {
24
- // Auth refresh callback - would need credential resolution here
25
- return {
26
- username: mqttConfig.username,
27
- password: mqttConfig.password,
28
- };
29
- });
24
+ const credential = await fetchMqttCredential(token, secret);
25
+ const client = new SwitchBotMqttClient(credential, () => fetchMqttCredential(token, secret));
30
26
  client.onStateChange((state) => {
31
27
  if (state === 'connected') {
32
28
  this.emit({
33
29
  kind: 'events.reconnected',
34
30
  timestamp: Date.now(),
35
31
  });
36
- client.subscribe('$aws/things/+/shadow/update/accepted');
32
+ client.subscribe(credential.topics.status);
37
33
  }
38
34
  });
39
35
  client.onMessage((topic, payload) => {
@@ -1,41 +1,45 @@
1
1
  import { connect } from 'mqtt';
2
2
  export class SwitchBotMqttClient {
3
3
  client = null;
4
- config;
4
+ credential;
5
5
  state = 'connecting';
6
- authRefreshNeeded = false;
6
+ credentialExpired = false;
7
7
  reconnectAttempts = 0;
8
8
  maxReconnectAttempts = 10;
9
+ disconnecting = false;
9
10
  handlers = new Set();
10
11
  messageHandlers = new Set();
11
- authRefreshCallback;
12
- stableTimer = null;
13
- lastConnectionAttempt = 0;
14
- constructor(config, onAuthRefreshNeeded) {
15
- this.config = config;
16
- this.authRefreshCallback = onAuthRefreshNeeded;
12
+ credentialRefreshCallback;
13
+ constructor(credential, onCredentialExpired) {
14
+ this.credential = credential;
15
+ this.credentialRefreshCallback = onCredentialExpired;
17
16
  }
18
17
  async connect() {
19
- if (this.client && this.state === 'connected') {
18
+ if (this.client && this.state === 'connected')
20
19
  return;
21
- }
22
20
  this.setState('connecting');
23
- this.authRefreshNeeded = false;
21
+ this.credentialExpired = false;
24
22
  this.reconnectAttempts = 0;
25
23
  try {
24
+ const { tls, brokerUrl, clientId } = this.credential;
25
+ // tls.ca/cert/keyBase64 are PEM strings despite the misleading field name
26
26
  const options = {
27
- username: this.config.username,
28
- password: this.config.password,
27
+ clientId,
28
+ ca: tls.caBase64,
29
+ cert: tls.certBase64,
30
+ key: tls.keyBase64,
31
+ rejectUnauthorized: true,
29
32
  clean: true,
30
- reconnectPeriod: 0, // Manual reconnect control
31
- connectTimeout: 10000,
32
- rejectUnauthorized: this.config.rejectUnauthorized ?? true,
33
+ reconnectPeriod: 0,
34
+ connectTimeout: 30000,
35
+ keepalive: 60,
36
+ reschedulePings: true,
33
37
  };
34
- this.client = connect(`mqtts://${this.config.host}:${this.config.port}`, options);
38
+ this.client = connect(brokerUrl, options);
35
39
  this.client.on('connect', () => {
36
40
  this.reconnectAttempts = 0;
37
41
  this.setState('connected');
38
- this.authRefreshNeeded = false;
42
+ this.credentialExpired = false;
39
43
  });
40
44
  this.client.on('message', (topic, payload) => {
41
45
  for (const handler of this.messageHandlers) {
@@ -43,18 +47,17 @@ export class SwitchBotMqttClient {
43
47
  }
44
48
  });
45
49
  this.client.on('error', (err) => {
46
- // Check for auth-related errors
47
- if ((err instanceof Error &&
48
- (err.message.includes('401') ||
49
- err.message.includes('Unauthorized') ||
50
- err.message.includes('EACCES'))) ||
51
- err.code === 'EACCES') {
52
- this.authRefreshNeeded = true;
50
+ if (err instanceof Error &&
51
+ (err.message.includes('certificate') ||
52
+ err.message.includes('ECONNRESET') ||
53
+ err.message.includes('handshake'))) {
54
+ this.credentialExpired = true;
53
55
  }
54
56
  });
55
57
  this.client.on('close', () => {
56
- this.clearStableTimer();
57
- if (this.authRefreshNeeded) {
58
+ if (this.disconnecting)
59
+ return;
60
+ if (this.credentialExpired) {
58
61
  this.setState('failed');
59
62
  }
60
63
  else if (this.reconnectAttempts < this.maxReconnectAttempts) {
@@ -64,7 +67,6 @@ export class SwitchBotMqttClient {
64
67
  this.setState('failed');
65
68
  }
66
69
  });
67
- // Wait for connection with timeout
68
70
  await new Promise((resolve, reject) => {
69
71
  const timeout = setTimeout(() => {
70
72
  reject(new Error('MQTT connection timeout'));
@@ -97,26 +99,22 @@ export class SwitchBotMqttClient {
97
99
  async attemptReconnect() {
98
100
  this.reconnectAttempts++;
99
101
  this.setState('reconnecting');
100
- if (this.authRefreshNeeded && this.authRefreshCallback) {
102
+ if (this.credentialExpired && this.credentialRefreshCallback) {
101
103
  try {
102
- const refreshed = await this.authRefreshCallback();
103
- this.config.username = refreshed.username;
104
- this.config.password = refreshed.password;
105
- this.authRefreshNeeded = false;
104
+ this.credential = await this.credentialRefreshCallback();
105
+ this.credentialExpired = false;
106
106
  }
107
- catch (err) {
108
- // Auth refresh failed, mark as failed
107
+ catch {
109
108
  this.setState('failed');
110
109
  return;
111
110
  }
112
111
  }
113
- // Exponential backoff: 1s, 2s, 4s, 8s, 16s, 30s...
114
112
  const delay = Math.min(30000, 1000 * Math.pow(2, this.reconnectAttempts - 1));
115
113
  await new Promise((r) => setTimeout(r, delay));
116
114
  try {
117
115
  await this.connect();
118
116
  }
119
- catch (err) {
117
+ catch {
120
118
  if (this.reconnectAttempts < this.maxReconnectAttempts) {
121
119
  await this.attemptReconnect();
122
120
  }
@@ -133,12 +131,6 @@ export class SwitchBotMqttClient {
133
131
  }
134
132
  }
135
133
  }
136
- clearStableTimer() {
137
- if (this.stableTimer) {
138
- clearTimeout(this.stableTimer);
139
- this.stableTimer = null;
140
- }
141
- }
142
134
  subscribe(topic) {
143
135
  if (this.client && this.state === 'connected') {
144
136
  this.client.subscribe(topic, (err) => {
@@ -167,18 +159,14 @@ export class SwitchBotMqttClient {
167
159
  return this.state === 'connected' && this.client?.connected === true;
168
160
  }
169
161
  async disconnect() {
170
- this.clearStableTimer();
162
+ this.disconnecting = true;
171
163
  if (this.client) {
172
164
  await new Promise((resolve) => {
173
- this.client?.end(false, () => {
174
- resolve();
175
- });
165
+ this.client?.end(false, () => resolve());
176
166
  });
177
167
  this.client = null;
178
- this.setState('failed');
179
168
  }
180
- }
181
- setAuthRefreshCallback(callback) {
182
- this.authRefreshCallback = callback;
169
+ this.disconnecting = false;
170
+ this.setState('failed');
183
171
  }
184
172
  }
@@ -1,12 +1,29 @@
1
- export function getMqttConfig() {
2
- const host = process.env.SWITCHBOT_MQTT_HOST;
3
- const username = process.env.SWITCHBOT_MQTT_USERNAME;
4
- const password = process.env.SWITCHBOT_MQTT_PASSWORD;
5
- if (!host || !username || !password)
6
- return null;
7
- const rawPort = process.env.SWITCHBOT_MQTT_PORT;
8
- const port = rawPort ? Number(rawPort) : 8883;
9
- if (!Number.isFinite(port) || port <= 0 || port > 65535)
10
- return null;
11
- return { host, port, username, password };
1
+ import crypto from 'node:crypto';
2
+ import { buildAuthHeaders } from '../auth.js';
3
+ const CREDENTIAL_ENDPOINT = 'https://api.switchbot.net/v1.1/iot/credential';
4
+ export async function fetchMqttCredential(token, secret) {
5
+ // Derive a stable instance ID per token so the server can track this client.
6
+ const instanceId = crypto.createHash('sha256').update(token).digest('hex').slice(0, 16);
7
+ const headers = buildAuthHeaders(token, secret);
8
+ const res = await fetch(CREDENTIAL_ENDPOINT, {
9
+ method: 'POST',
10
+ headers,
11
+ body: JSON.stringify({ instanceId }),
12
+ signal: AbortSignal.timeout(15000),
13
+ });
14
+ if (!res.ok) {
15
+ throw new Error(`MQTT credential request failed: HTTP ${res.status} ${res.statusText}`);
16
+ }
17
+ const json = (await res.json());
18
+ if (json.statusCode !== 100) {
19
+ throw new Error(`MQTT credential API error: statusCode ${json.statusCode}`);
20
+ }
21
+ // Response shape: { statusCode, body: { body: { channels: { mqtt: ... } } } }
22
+ const outer = json.body;
23
+ const inner = (outer.body ?? outer);
24
+ const channels = inner.channels;
25
+ if (!channels?.mqtt) {
26
+ throw new Error('Unexpected MQTT credential response — channels.mqtt missing');
27
+ }
28
+ return channels.mqtt;
12
29
  }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@switchbot/openapi-cli",
3
- "version": "2.0.1",
4
- "description": "Command-line interface for SwitchBot API v1.1",
3
+ "version": "2.1.0",
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",
7
7
  "cli",