@switchbot/openapi-cli 2.0.0 → 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 +237 -35
- package/dist/commands/capabilities.js +9 -0
- package/dist/commands/doctor.js +33 -0
- package/dist/commands/events.js +83 -1
- package/dist/commands/mcp.js +24 -12
- package/dist/config.js +23 -0
- package/dist/index.js +3 -3
- package/dist/mcp/events-subscription.js +5 -9
- package/dist/mqtt/client.js +39 -51
- package/dist/mqtt/credential.js +28 -11
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
[](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
|
|
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
|
-
- [`
|
|
47
|
-
- [`
|
|
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** —
|
|
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
|
|
@@ -252,6 +261,69 @@ Generic parameter shapes (which one applies is decided by the device — see the
|
|
|
252
261
|
|
|
253
262
|
For the complete per-device command reference, see the [SwitchBot API docs](https://github.com/OpenWonderLabs/SwitchBotAPI#send-device-control-commands).
|
|
254
263
|
|
|
264
|
+
#### `devices expand` — named flags for packed parameters
|
|
265
|
+
|
|
266
|
+
Some commands require a packed string like `"26,2,2,on"`. `devices expand` builds it from readable flags:
|
|
267
|
+
|
|
268
|
+
```bash
|
|
269
|
+
# Air Conditioner — setAll
|
|
270
|
+
switchbot devices expand <acId> setAll --temp 26 --mode cool --fan low --power on
|
|
271
|
+
|
|
272
|
+
# Curtain / Roller Shade — setPosition
|
|
273
|
+
switchbot devices expand <curtainId> setPosition --position 50 --mode silent
|
|
274
|
+
|
|
275
|
+
# Blind Tilt — setPosition
|
|
276
|
+
switchbot devices expand <blindId> setPosition --direction up --angle 50
|
|
277
|
+
|
|
278
|
+
# Relay Switch — setMode
|
|
279
|
+
switchbot devices expand <relayId> setMode --channel 1 --mode edge
|
|
280
|
+
```
|
|
281
|
+
|
|
282
|
+
Run `switchbot devices expand <id> <command> --help` to see the available flags for any device command.
|
|
283
|
+
|
|
284
|
+
#### `devices explain` — one-shot device summary
|
|
285
|
+
|
|
286
|
+
```bash
|
|
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
|
|
323
|
+
```
|
|
324
|
+
|
|
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.
|
|
326
|
+
|
|
255
327
|
### `scenes` — run manual scenes
|
|
256
328
|
|
|
257
329
|
```bash
|
|
@@ -279,6 +351,67 @@ switchbot webhook delete https://your.host/hook
|
|
|
279
351
|
|
|
280
352
|
The CLI validates that `<url>` is an absolute `http://` or `https://` URL before calling the API. `--enable` and `--disable` are mutually exclusive.
|
|
281
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
|
+
|
|
282
415
|
### `completion` — shell tab-completion
|
|
283
416
|
|
|
284
417
|
```bash
|
|
@@ -297,28 +430,36 @@ switchbot completion powershell >> $PROFILE
|
|
|
297
430
|
|
|
298
431
|
Supported shells: `bash`, `zsh`, `fish`, `powershell` (`pwsh` is accepted as an alias).
|
|
299
432
|
|
|
300
|
-
### `
|
|
433
|
+
### `plan` — declarative batch operations
|
|
301
434
|
|
|
302
435
|
```bash
|
|
303
|
-
#
|
|
304
|
-
switchbot
|
|
305
|
-
switchbot batch run commands.yaml --dry-run
|
|
436
|
+
# Print the plan JSON Schema (give to your agent framework)
|
|
437
|
+
switchbot plan schema
|
|
306
438
|
|
|
307
|
-
# Validate a plan file without
|
|
308
|
-
switchbot
|
|
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
|
|
309
448
|
```
|
|
310
449
|
|
|
311
|
-
A
|
|
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.
|
|
312
451
|
|
|
313
|
-
### `watch` — poll
|
|
452
|
+
### `devices watch` — poll status
|
|
314
453
|
|
|
315
454
|
```bash
|
|
316
455
|
# Poll a device's status every 30 s until Ctrl-C
|
|
317
|
-
switchbot watch <deviceId>
|
|
318
|
-
|
|
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
|
|
319
460
|
```
|
|
320
461
|
|
|
321
|
-
Output is a stream of
|
|
462
|
+
Output is a JSONL stream of status-change events (with `--json`) or a refreshed table. Use `--max <n>` to stop after N ticks.
|
|
322
463
|
|
|
323
464
|
### `mcp` — Model Context Protocol server
|
|
324
465
|
|
|
@@ -327,9 +468,68 @@ Output is a stream of JSON status objects (with `--json`) or a refreshed table.
|
|
|
327
468
|
switchbot mcp serve
|
|
328
469
|
```
|
|
329
470
|
|
|
330
|
-
Exposes
|
|
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.
|
|
331
472
|
See [`docs/agent-guide.md`](./docs/agent-guide.md) for the full tool reference and safety rules (destructive-command guard).
|
|
332
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
|
+
|
|
333
533
|
### `cache` — inspect and clear local cache
|
|
334
534
|
|
|
335
535
|
```bash
|
|
@@ -429,11 +629,11 @@ Typical errors bubble up in the form `Error: <message>` on stderr. The SwitchBot
|
|
|
429
629
|
|
|
430
630
|
## Environment variables
|
|
431
631
|
|
|
432
|
-
| Variable
|
|
433
|
-
|
|
|
434
|
-
| `SWITCHBOT_TOKEN`
|
|
435
|
-
| `SWITCHBOT_SECRET`
|
|
436
|
-
| `NO_COLOR`
|
|
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) |
|
|
437
637
|
|
|
438
638
|
## Scripting examples
|
|
439
639
|
|
|
@@ -456,7 +656,7 @@ npm install
|
|
|
456
656
|
|
|
457
657
|
npm run dev -- <args> # Run from TypeScript sources via tsx
|
|
458
658
|
npm run build # Compile to dist/
|
|
459
|
-
npm test # Run the Vitest suite (
|
|
659
|
+
npm test # Run the Vitest suite (692 tests)
|
|
460
660
|
npm run test:watch # Watch mode
|
|
461
661
|
npm run test:coverage # Coverage report (v8, HTML + text)
|
|
462
662
|
```
|
|
@@ -478,21 +678,23 @@ src/
|
|
|
478
678
|
├── commands/
|
|
479
679
|
│ ├── config.ts
|
|
480
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
|
|
481
684
|
│ ├── scenes.ts
|
|
482
685
|
│ ├── webhook.ts
|
|
483
|
-
│ ├──
|
|
484
|
-
│ ├──
|
|
485
|
-
│ ├── mcp.ts # `
|
|
486
|
-
│ ├──
|
|
487
|
-
│ ├──
|
|
488
|
-
│ ├──
|
|
489
|
-
│ ├── quota.ts # `
|
|
490
|
-
│ ├──
|
|
491
|
-
│ ├──
|
|
492
|
-
│ ├── doctor.ts # `
|
|
493
|
-
│ ├──
|
|
494
|
-
│
|
|
495
|
-
│ └── 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`
|
|
496
698
|
└── utils/
|
|
497
699
|
├── flags.ts # Global flag readers (isVerbose / isDryRun / getCacheMode / …)
|
|
498
700
|
├── output.ts # printTable / printKeyValue / printJson / handleError / buildErrorPayload
|
|
@@ -25,6 +25,7 @@ const MCP_TOOLS = [
|
|
|
25
25
|
'list_scenes',
|
|
26
26
|
'run_scene',
|
|
27
27
|
'search_catalog',
|
|
28
|
+
'account_overview',
|
|
28
29
|
];
|
|
29
30
|
export function registerCapabilitiesCommand(program) {
|
|
30
31
|
program
|
|
@@ -65,6 +66,14 @@ export function registerCapabilitiesCommand(program) {
|
|
|
65
66
|
entry: 'mcp serve',
|
|
66
67
|
protocol: 'stdio (default) or --port <n> for HTTP',
|
|
67
68
|
tools: MCP_TOOLS,
|
|
69
|
+
resources: ['switchbot://events'],
|
|
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)',
|
|
68
77
|
},
|
|
69
78
|
plan: {
|
|
70
79
|
schemaCmd: 'plan schema',
|
package/dist/commands/doctor.js
CHANGED
|
@@ -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,
|
package/dist/commands/events.js
CHANGED
|
@@ -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('
|
|
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
|
}
|
package/dist/commands/mcp.js
CHANGED
|
@@ -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 (
|
|
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
|
|
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
|
|
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
|
-
//
|
|
491
|
-
//
|
|
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
|
|
494
|
-
if (
|
|
495
|
-
eventManager.initialize(
|
|
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:
|
|
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
|
|
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
|
|
72
|
-
SWITCHBOT_SECRET
|
|
73
|
-
NO_COLOR
|
|
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(
|
|
22
|
+
async initialize(token, secret) {
|
|
22
23
|
if (!this.mqttClient) {
|
|
23
|
-
const
|
|
24
|
-
|
|
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(
|
|
32
|
+
client.subscribe(credential.topics.status);
|
|
37
33
|
}
|
|
38
34
|
});
|
|
39
35
|
client.onMessage((topic, payload) => {
|
package/dist/mqtt/client.js
CHANGED
|
@@ -1,41 +1,45 @@
|
|
|
1
1
|
import { connect } from 'mqtt';
|
|
2
2
|
export class SwitchBotMqttClient {
|
|
3
3
|
client = null;
|
|
4
|
-
|
|
4
|
+
credential;
|
|
5
5
|
state = 'connecting';
|
|
6
|
-
|
|
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
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
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.
|
|
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
|
-
|
|
28
|
-
|
|
27
|
+
clientId,
|
|
28
|
+
ca: tls.caBase64,
|
|
29
|
+
cert: tls.certBase64,
|
|
30
|
+
key: tls.keyBase64,
|
|
31
|
+
rejectUnauthorized: true,
|
|
29
32
|
clean: true,
|
|
30
|
-
reconnectPeriod: 0,
|
|
31
|
-
connectTimeout:
|
|
32
|
-
|
|
33
|
+
reconnectPeriod: 0,
|
|
34
|
+
connectTimeout: 30000,
|
|
35
|
+
keepalive: 60,
|
|
36
|
+
reschedulePings: true,
|
|
33
37
|
};
|
|
34
|
-
this.client = connect(
|
|
38
|
+
this.client = connect(brokerUrl, options);
|
|
35
39
|
this.client.on('connect', () => {
|
|
36
40
|
this.reconnectAttempts = 0;
|
|
37
41
|
this.setState('connected');
|
|
38
|
-
this.
|
|
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
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
err.message.includes('
|
|
50
|
-
|
|
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.
|
|
57
|
-
|
|
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.
|
|
102
|
+
if (this.credentialExpired && this.credentialRefreshCallback) {
|
|
101
103
|
try {
|
|
102
|
-
|
|
103
|
-
this.
|
|
104
|
-
this.config.password = refreshed.password;
|
|
105
|
-
this.authRefreshNeeded = false;
|
|
104
|
+
this.credential = await this.credentialRefreshCallback();
|
|
105
|
+
this.credentialExpired = false;
|
|
106
106
|
}
|
|
107
|
-
catch
|
|
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
|
|
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.
|
|
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
|
-
|
|
182
|
-
this.authRefreshCallback = callback;
|
|
169
|
+
this.disconnecting = false;
|
|
170
|
+
this.setState('failed');
|
|
183
171
|
}
|
|
184
172
|
}
|
package/dist/mqtt/credential.js
CHANGED
|
@@ -1,12 +1,29 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
const
|
|
8
|
-
const
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
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.
|
|
4
|
-
"description": "
|
|
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",
|