From d36fb20a64f9980ed298921c96d124a044dd106b Mon Sep 17 00:00:00 2001 From: chenliuyun Date: Sun, 19 Apr 2026 19:22:53 +0800 Subject: [PATCH 1/8] docs: add events command section and MQTT env vars to README - Add `events` entry to the table of contents - Add `### events` section covering both `events tail` (webhook receiver) and `events mqtt-tail` (MQTT stream) with usage examples - Extend the Environment variables table with all four MQTT vars --- README.md | 73 +++++++++++++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 68 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 088dcd1..2ae28ba 100644 --- a/README.md +++ b/README.md @@ -43,6 +43,7 @@ Under the hood every surface shares the same catalog, cache, and HMAC client — - [`devices`](#devices--list-status-control) - [`scenes`](#scenes--run-manual-scenes) - [`webhook`](#webhook--receive-device-events-over-http) + - [`events`](#events--receive-device-events) - [`batch`](#batch--run-multiple-commands) - [`watch`](#watch--poll-device-status) - [`mcp`](#mcp--model-context-protocol-server) @@ -307,6 +308,64 @@ switchbot webhook delete https://your.host/hook The CLI validates that `` is an absolute `http://` or `https://` URL before calling the API. `--enable` and `--disable` are mutually exclusive. +### `events` — receive device events + +Two subcommands cover the two ways SwitchBot can push state changes to you. + +#### `events tail` — local webhook receiver + +```bash +# Listen on port 3000 and print every incoming webhook POST +switchbot events tail + +# Filter to one device +switchbot events tail --filter deviceId=ABC123 + +# Stop after 5 matching events +switchbot events tail --filter 'type=WoMeter' --max 5 + +# Custom port / path +switchbot events tail --port 8080 --path /hook --json +``` + +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. + +Output (one JSON line per matched event): +``` +{ "t": "2024-01-01T12:00:00.000Z", "remote": "1.2.3.4:54321", "path": "/", "body": {...}, "matched": true } +``` + +Filter keys: `deviceId=`, `type=` (comma-separated for AND logic). + +#### `events mqtt-tail` — real-time MQTT stream + +```bash +# Stream all shadow-update events from the MQTT broker +switchbot events mqtt-tail + +# Filter to a topic subtree +switchbot events mqtt-tail --topic 'switchbot/#' + +# Stop after 10 events +switchbot events mqtt-tail --max 10 --json +``` + +Requires a SwitchBot-compatible MQTT broker. Set three environment variables before running: + +```bash +export SWITCHBOT_MQTT_HOST=your.broker.host +export SWITCHBOT_MQTT_USERNAME=your_username +export SWITCHBOT_MQTT_PASSWORD=your_password +# SWITCHBOT_MQTT_PORT defaults to 8883 (MQTTS/TLS) +``` + +Output (one JSON line per message): +``` +{ "t": "2024-01-01T12:00:00.000Z", "topic": "switchbot/abc123/status", "payload": {...} } +``` + +Run `switchbot doctor` to verify MQTT is configured correctly before connecting. + ### `completion` — shell tab-completion ```bash @@ -457,11 +516,15 @@ Typical errors bubble up in the form `Error: ` on stderr. The SwitchBot ## Environment variables -| Variable | Description | -| ------------------- | ------------------------------------------------------------------ | -| `SWITCHBOT_TOKEN` | API token — takes priority over the config file | -| `SWITCHBOT_SECRET` | API secret — takes priority over the config file | -| `NO_COLOR` | Disable ANSI colors in all output (automatically respected) | +| Variable | Description | +| --------------------------- | ------------------------------------------------------------------ | +| `SWITCHBOT_TOKEN` | API token — takes priority over the config file | +| `SWITCHBOT_SECRET` | API secret — takes priority over the config file | +| `SWITCHBOT_MQTT_HOST` | MQTT broker hostname (enables real-time events via `events mqtt-tail` and `mcp serve`) | +| `SWITCHBOT_MQTT_PORT` | MQTT broker port (default: `8883`, MQTTS/TLS) | +| `SWITCHBOT_MQTT_USERNAME` | MQTT broker username | +| `SWITCHBOT_MQTT_PASSWORD` | MQTT broker password | +| `NO_COLOR` | Disable ANSI colors in all output (automatically respected) | ## Scripting examples From 0d221535ba9f3e50a50695342059182efc8bb2aa Mon Sep 17 00:00:00 2001 From: chenliuyun Date: Sun, 19 Apr 2026 19:30:07 +0800 Subject: [PATCH 2/8] docs: fix wrong commands and add missing sections to README MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wrong commands fixed: - `devices explain` was described as taking (wrong); corrected to one-shot device summary with --no-live flag - `### watch` used non-existent `switchbot watch` syntax; corrected to `switchbot devices watch` under proper heading - `### batch` described `plan run/validate` with wrong command names; replaced with correct `### plan` section Missing command sections added: doctor, quota, history, catalog, schema, capabilities, devices meta (devices watch moved from wrong top-level position to its own section) Additional fixes: - README tagline updated to mention MCP/AI integration - TOC updated to match actual command names - Project layout updated: correct file labels, added expand.ts, device-meta.ts, capabilities.ts; removed non-existent batch.ts entry - Test count updated: 592 → 692 - npm description improved to communicate AI/MCP value - agent-guide.md: added events mqtt-tail to observability section --- README.md | 154 +++++++++++++++++++++++++++++++++++--------- docs/agent-guide.md | 2 +- package.json | 2 +- 3 files changed, 124 insertions(+), 34 deletions(-) diff --git a/README.md b/README.md index 2ae28ba..a1a84ea 100644 --- a/README.md +++ b/README.md @@ -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) Command-line interface for the [SwitchBot API v1.1](https://github.com/OpenWonderLabs/SwitchBotAPI). -List devices, query live status, send control commands, run scenes, and manage webhooks — all from your terminal or shell scripts. +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. - **npm package:** [`@switchbot/openapi-cli`](https://www.npmjs.com/package/@switchbot/openapi-cli) - **Source code:** [github.com/OpenWonderLabs/switchbot-openapi-cli](https://github.com/OpenWonderLabs/switchbot-openapi-cli) @@ -44,9 +44,15 @@ Under the hood every surface shares the same catalog, cache, and HMAC client — - [`scenes`](#scenes--run-manual-scenes) - [`webhook`](#webhook--receive-device-events-over-http) - [`events`](#events--receive-device-events) - - [`batch`](#batch--run-multiple-commands) - - [`watch`](#watch--poll-device-status) + - [`plan`](#plan--declarative-batch-operations) + - [`devices watch`](#devices-watch--poll-status) - [`mcp`](#mcp--model-context-protocol-server) + - [`doctor`](#doctor--self-check) + - [`quota`](#quota--api-request-counter) + - [`history`](#history--audit-log) + - [`catalog`](#catalog--device-type-catalog) + - [`schema`](#schema--export-catalog-as-json) + - [`capabilities`](#capabilities--cli-manifest) - [`cache`](#cache--inspect-and-clear-local-cache) - [`completion`](#completion--shell-tab-completion) - [Output modes](#output-modes) @@ -68,7 +74,7 @@ Under the hood every surface shares the same catalog, cache, and HMAC client — - 🎨 **Dual output modes** — colorized tables by default; `--json` passthrough for `jq` and scripting - 🔐 **Secure credentials** — HMAC-SHA256 signed requests; config file written with `0600`; env-var override for CI - 🔍 **Dry-run mode** — preview every mutating request before it hits the API -- 🧪 **Fully tested** — 592 Vitest tests, mocked axios, zero network in CI +- 🧪 **Fully tested** — 692 Vitest tests, mocked axios, zero network in CI - ⚡ **Shell completion** — Bash / Zsh / Fish / PowerShell ## Requirements @@ -273,13 +279,29 @@ switchbot devices expand setMode --channel 1 --mode edge Run `switchbot devices expand --help` to see the available flags for any device command. -#### `devices explain` — plain-language command description +#### `devices explain` — one-shot device summary ```bash -switchbot devices explain # e.g. "explain ABC123 setAll" +# Metadata + supported commands + live status in one call +switchbot devices explain + +# Skip live status fetch (catalog-only output, no API call) +switchbot devices explain --no-live +``` + +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. + +#### `devices meta` — local device metadata + +```bash +switchbot devices meta set --alias "Office Light" +switchbot devices meta set --hide # hide from `devices list` +switchbot devices meta get +switchbot devices meta list # show all saved metadata +switchbot devices meta clear ``` -Returns a human-readable description of what the command does and what each parameter means. +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. ### `scenes` — run manual scenes @@ -384,28 +406,36 @@ switchbot completion powershell >> $PROFILE Supported shells: `bash`, `zsh`, `fish`, `powershell` (`pwsh` is accepted as an alias). -### `batch` — run multiple commands +### `plan` — declarative batch operations ```bash -# Run a sequence of commands from a JSON/YAML file -switchbot batch run commands.json -switchbot batch run commands.yaml --dry-run +# Print the plan JSON Schema (give to your agent framework) +switchbot plan schema + +# Validate a plan file without running it +switchbot plan validate plan.json + +# Preview — mutations skipped, GETs still execute +switchbot --dry-run plan run plan.json -# Validate a plan file without executing it -switchbot batch validate commands.json +# Run — pass --yes to allow destructive steps +switchbot plan run plan.json --yes +switchbot plan run plan.json --continue-on-error ``` -A batch file is a JSON array of `{ deviceId, command, parameter?, commandType? }` objects. +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. -### `watch` — poll device status +### `devices watch` — poll status ```bash # Poll a device's status every 30 s until Ctrl-C -switchbot watch -switchbot watch --interval 10s --json +switchbot devices watch + +# Custom interval; emit every tick even when nothing changed +switchbot devices watch --interval 10s --include-unchanged --json ``` -Output is a stream of JSON status objects (with `--json`) or a refreshed table. +Output is a JSONL stream of status-change events (with `--json`) or a refreshed table. Use `--max ` to stop after N ticks. ### `mcp` — Model Context Protocol server @@ -417,6 +447,64 @@ switchbot mcp serve 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. See [`docs/agent-guide.md`](./docs/agent-guide.md) for the full tool reference and safety rules (destructive-command guard). +### `doctor` — self-check + +```bash +switchbot doctor +switchbot doctor --json +``` + +Runs 8 local checks (Node version, credentials, profiles, catalog, cache, quota file, clock, MQTT config) and exits 1 if any check fails. `warn` results exit 0. Use this to diagnose connectivity or config issues before running automation. + +### `quota` — API request counter + +```bash +switchbot quota status # today's usage + last 7 days +switchbot quota reset # delete the counter file +``` + +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. + +### `history` — audit log + +```bash +switchbot history show # recent entries (newest first) +switchbot history show --limit 20 # last 20 entries +switchbot history replay 7 # re-run entry #7 +switchbot --json history show --limit 50 | jq '.entries[] | select(.result=="error")' +``` + +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. + +### `catalog` — device type catalog + +```bash +switchbot catalog show # all 42 built-in types +switchbot catalog show Bot # one type +switchbot catalog diff # what a local overlay changes vs built-in +switchbot catalog path # location of the local overlay file +``` + +The built-in catalog ships with the package. Create `~/.switchbot/catalog-overlay.json` to add, extend, or override type definitions without modifying the package. + +### `schema` — export catalog as JSON + +```bash +switchbot schema export # all types as structured JSON +switchbot schema export --type 'Strip Light' # one type +switchbot schema export --role sensor # filter by role +``` + +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. + +### `capabilities` — CLI manifest + +```bash +switchbot capabilities --json +``` + +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. + ### `cache` — inspect and clear local cache ```bash @@ -547,7 +635,7 @@ npm install npm run dev -- # Run from TypeScript sources via tsx npm run build # Compile to dist/ -npm test # Run the Vitest suite (592 tests) +npm test # Run the Vitest suite (692 tests) npm run test:watch # Watch mode npm run test:coverage # Coverage report (v8, HTML + text) ``` @@ -569,21 +657,23 @@ src/ ├── commands/ │ ├── config.ts │ ├── devices.ts +│ ├── expand.ts # `devices expand` — semantic flag builder +│ ├── explain.ts # `devices explain` — one-shot device summary +│ ├── device-meta.ts # `devices meta` — local aliases / hide flags │ ├── scenes.ts │ ├── webhook.ts -│ ├── batch.ts # `switchbot batch run/validate` -│ ├── watch.ts # `switchbot watch ` -│ ├── mcp.ts # `switchbot mcp serve` (MCP stdio server) -│ ├── cache.ts # `switchbot cache show/clear` -│ ├── history.ts # `switchbot history [replay]` -│ ├── events.ts # `switchbot events` -│ ├── quota.ts # `switchbot quota` -│ ├── explain.ts # `switchbot explain ` -│ ├── plan.ts # `switchbot plan run ` -│ ├── doctor.ts # `switchbot doctor` -│ ├── schema.ts # `switchbot schema export` -│ ├── catalog.ts # `switchbot catalog search` -│ └── completion.ts # `switchbot completion bash|zsh|fish|powershell` +│ ├── watch.ts # `devices watch ` +│ ├── events.ts # `events tail` / `events mqtt-tail` +│ ├── mcp.ts # `mcp serve` (MCP stdio/HTTP server) +│ ├── plan.ts # `plan run/validate` +│ ├── cache.ts # `cache show/clear` +│ ├── history.ts # `history show/replay` +│ ├── quota.ts # `quota status/reset` +│ ├── catalog.ts # `catalog show/diff/path` +│ ├── schema.ts # `schema export` +│ ├── doctor.ts # `doctor` +│ ├── capabilities.ts # `capabilities` +│ └── completion.ts # `completion bash|zsh|fish|powershell` └── utils/ ├── flags.ts # Global flag readers (isVerbose / isDryRun / getCacheMode / …) ├── output.ts # printTable / printKeyValue / printJson / handleError / buildErrorPayload diff --git a/docs/agent-guide.md b/docs/agent-guide.md index 8d02ce4..04d23e6 100644 --- a/docs/agent-guide.md +++ b/docs/agent-guide.md @@ -238,7 +238,7 @@ The audit format is JSONL with this shape: "dryRun": false, "result": "ok" } ``` -Pair with `switchbot devices watch --interval=30s` for continuous state diffs (add `--include-unchanged` to emit every tick even when nothing changed), or `switchbot events tail` to receive webhook pushes locally. +Pair with `switchbot devices watch --interval=30s` for continuous state diffs (add `--include-unchanged` to emit every tick even when nothing changed), `switchbot events tail` to receive webhook pushes locally, or `switchbot events mqtt-tail` for real-time MQTT shadow updates (requires `SWITCHBOT_MQTT_HOST` env vars — see [Environment variables](../README.md#environment-variables)). --- diff --git a/package.json b/package.json index d6d989e..02ae281 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "@switchbot/openapi-cli", "version": "2.0.1", - "description": "Command-line interface for SwitchBot API v1.1", + "description": "SwitchBot smart home CLI — control devices, run scenes, stream real-time events, and integrate AI agents via MCP. Full API v1.1 coverage.", "keywords": [ "switchbot", "cli", From f9c8568814b1a09fdb6f2b434834d9fe10bee114 Mon Sep 17 00:00:00 2001 From: chenliuyun Date: Sun, 19 Apr 2026 19:52:22 +0800 Subject: [PATCH 3/8] feat(mqtt): auto-provision MQTT credentials from SwitchBot API MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace manual SWITCHBOT_MQTT_HOST/USERNAME/PASSWORD env vars with zero-config mTLS client certificates obtained from the SwitchBot credential endpoint (POST /v1.1/iot/credential). Users need only SWITCHBOT_TOKEN + SWITCHBOT_SECRET — no additional MQTT setup required. - credential.ts: add fetchMqttCredential(); remove getMqttConfig() - client.ts: switch from username/password to mTLS TLS client certs - events-subscription.ts: initialize(token, secret) instead of mqttConfig - doctor/capabilities: report MQTT status based on REST credentials - Remove SWITCHBOT_MQTT_* from README env table, index.ts help text --- README.md | 27 +++++---- src/commands/capabilities.ts | 6 +- src/commands/doctor.ts | 29 ++++++--- src/commands/events.ts | 51 +++++++++------- src/commands/mcp.ts | 31 +++++----- src/index.ts | 4 -- src/mcp/events-subscription.ts | 20 ++----- src/mqtt/client.ts | 91 +++++++++++++---------------- src/mqtt/credential.ts | 73 +++++++++++++++-------- tests/commands/capabilities.test.ts | 8 +-- tests/commands/doctor.test.ts | 21 ++----- tests/commands/events.test.ts | 52 +++++++++-------- 12 files changed, 215 insertions(+), 198 deletions(-) diff --git a/README.md b/README.md index a1a84ea..dada9f1 100644 --- a/README.md +++ b/README.md @@ -362,7 +362,7 @@ Filter keys: `deviceId=`, `type=` (comma-separated for AND logic #### `events mqtt-tail` — real-time MQTT stream ```bash -# Stream all shadow-update events from the MQTT broker +# Stream all shadow-update events (runs in foreground until Ctrl-C) switchbot events mqtt-tail # Filter to a topic subtree @@ -372,21 +372,24 @@ switchbot events mqtt-tail --topic 'switchbot/#' switchbot events mqtt-tail --max 10 --json ``` -Requires a SwitchBot-compatible MQTT broker. Set three environment variables before running: - -```bash -export SWITCHBOT_MQTT_HOST=your.broker.host -export SWITCHBOT_MQTT_USERNAME=your_username -export SWITCHBOT_MQTT_PASSWORD=your_password -# SWITCHBOT_MQTT_PORT defaults to 8883 (MQTTS/TLS) -``` +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. Output (one JSON line per message): ``` { "t": "2024-01-01T12:00:00.000Z", "topic": "switchbot/abc123/status", "payload": {...} } ``` -Run `switchbot doctor` to verify MQTT is configured correctly before connecting. +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: + +```bash +# pm2 +pm2 start "switchbot events mqtt-tail --json" --name switchbot-events + +# nohup +nohup switchbot events mqtt-tail --json >> ~/switchbot-events.log 2>&1 & +``` + +Run `switchbot doctor` to verify MQTT credentials are configured correctly before connecting. ### `completion` — shell tab-completion @@ -608,10 +611,6 @@ Typical errors bubble up in the form `Error: ` on stderr. The SwitchBot | --------------------------- | ------------------------------------------------------------------ | | `SWITCHBOT_TOKEN` | API token — takes priority over the config file | | `SWITCHBOT_SECRET` | API secret — takes priority over the config file | -| `SWITCHBOT_MQTT_HOST` | MQTT broker hostname (enables real-time events via `events mqtt-tail` and `mcp serve`) | -| `SWITCHBOT_MQTT_PORT` | MQTT broker port (default: `8883`, MQTTS/TLS) | -| `SWITCHBOT_MQTT_USERNAME` | MQTT broker username | -| `SWITCHBOT_MQTT_PASSWORD` | MQTT broker password | | `NO_COLOR` | Disable ANSI colors in all output (automatically respected) | ## Scripting examples diff --git a/src/commands/capabilities.ts b/src/commands/capabilities.ts index 4573346..0f4e770 100644 --- a/src/commands/capabilities.ts +++ b/src/commands/capabilities.ts @@ -1,7 +1,6 @@ import { Command } from 'commander'; import { getEffectiveCatalog } from '../devices/catalog.js'; import { printJson } from '../utils/output.js'; -import { getMqttConfig } from '../mqtt/credential.js'; const IDENTITY = { product: 'SwitchBot', @@ -75,11 +74,10 @@ export function registerCapabilitiesCommand(program: Command): void { }, mqtt: { mode: 'consumer', - envVars: ['SWITCHBOT_MQTT_HOST', 'SWITCHBOT_MQTT_USERNAME', 'SWITCHBOT_MQTT_PASSWORD', 'SWITCHBOT_MQTT_PORT'], + authSource: 'SWITCHBOT_TOKEN + SWITCHBOT_SECRET (auto-provisioned via POST /v1.1/iot/credential)', cliCmd: 'events mqtt-tail', mcpResource: 'switchbot://events', - protocol: 'MQTTS (TLS, default port 8883)', - configured: getMqttConfig() !== null, + protocol: 'MQTTS with TLS client certificates (AWS IoT)', }, plan: { schemaCmd: 'plan schema', diff --git a/src/commands/doctor.ts b/src/commands/doctor.ts index 69e52dd..20ccbd2 100644 --- a/src/commands/doctor.ts +++ b/src/commands/doctor.ts @@ -6,7 +6,6 @@ import { printJson, isJsonMode } from '../utils/output.js'; import { getEffectiveCatalog } from '../devices/catalog.js'; import { configFilePath, listProfiles } from '../config.js'; import { describeCache } from '../devices/cache.js'; -import { getMqttConfig } from '../mqtt/credential.js'; interface Check { name: string; @@ -114,18 +113,34 @@ function checkNodeVersion(): Check { } function checkMqtt(): Check { - const cfg = getMqttConfig(); - if (!cfg) { + // MQTT credentials are auto-provisioned from the SwitchBot API using the + // account's token+secret — no extra env vars needed. Report availability + // based on whether REST credentials are configured (no network call). + const hasEnvCreds = Boolean(process.env.SWITCHBOT_TOKEN && process.env.SWITCHBOT_SECRET); + if (hasEnvCreds) { return { name: 'mqtt', - status: 'warn', - detail: "not configured — set SWITCHBOT_MQTT_HOST/USERNAME/PASSWORD to enable real-time events", + status: 'ok', + detail: "auto-provisioned from credentials — run 'switchbot events mqtt-tail' to test live connectivity", }; } + const file = configFilePath(); + if (fs.existsSync(file)) { + try { + const cfg = JSON.parse(fs.readFileSync(file, 'utf-8')); + if (cfg.token && cfg.secret) { + return { + name: 'mqtt', + status: 'ok', + detail: "auto-provisioned from credentials — run 'switchbot events mqtt-tail' to test live connectivity", + }; + } + } catch { /* fall through */ } + } return { name: 'mqtt', - status: 'ok', - detail: `configured (mqtts://${cfg.host}:${cfg.port}) — credentials not verified; run 'switchbot events mqtt-tail' to test live connectivity`, + status: 'warn', + detail: "unavailable — configure credentials first (see credentials check above)", }; } diff --git a/src/commands/events.ts b/src/commands/events.ts index f7e16d0..1442c28 100644 --- a/src/commands/events.ts +++ b/src/commands/events.ts @@ -2,7 +2,8 @@ import { Command } from 'commander'; import http from 'node:http'; import { printJson, isJsonMode, handleError, UsageError } from '../utils/output.js'; import { SwitchBotMqttClient } from '../mqtt/client.js'; -import { getMqttConfig } from '../mqtt/credential.js'; +import { fetchMqttCredential } from '../mqtt/credential.js'; +import { loadConfig } from '../config.js'; const DEFAULT_PORT = 3000; const DEFAULT_PATH = '/'; @@ -212,17 +213,15 @@ Examples: events .command('mqtt-tail') - .description('Subscribe to MQTT shadow events and stream them as JSONL (requires SWITCHBOT_MQTT_HOST/USERNAME/PASSWORD)') - .option('--topic ', 'MQTT topic filter (default: "#" — all topics)', '#') + .description('Subscribe to SwitchBot MQTT shadow events and stream them as JSONL') + .option('--topic ', 'MQTT topic filter (default: SwitchBot shadow topic from credential)') .option('--max ', 'Stop after N events (default: run until Ctrl-C)') .addHelpText( 'after', ` -Requires three environment variables: - SWITCHBOT_MQTT_HOST broker hostname - SWITCHBOT_MQTT_USERNAME broker username - SWITCHBOT_MQTT_PASSWORD broker password - SWITCHBOT_MQTT_PORT broker port (default: 8883, MQTTS/TLS) +Connects to the SwitchBot MQTT service using your existing credentials +(SWITCHBOT_TOKEN + SWITCHBOT_SECRET or ~/.switchbot/config.json). +No additional MQTT configuration required. Output (JSONL, one event per line): { "t": "", "topic": "", "payload": } @@ -233,31 +232,43 @@ Examples: $ switchbot events mqtt-tail --max 10 --json `, ) - .action(async (options: { topic: string; max?: string }) => { + .action(async (options: { topic?: string; max?: string }) => { try { - const cfg = getMqttConfig(); - if (!cfg) { - throw new UsageError( - 'MQTT is not configured. Set SWITCHBOT_MQTT_HOST, SWITCHBOT_MQTT_USERNAME, and SWITCHBOT_MQTT_PASSWORD.', - ); - } const maxEvents: number | null = options.max !== undefined ? Number(options.max) : null; if (maxEvents !== null && (!Number.isInteger(maxEvents) || maxEvents < 1)) { throw new UsageError(`Invalid --max "${options.max}". Must be a positive integer.`); } + let creds: { token: string; secret: string }; + try { + creds = loadConfig(); + } catch { + throw new UsageError( + 'No credentials found. Run \'switchbot config set-token\' or set SWITCHBOT_TOKEN and SWITCHBOT_SECRET.', + ); + } + + if (!isJsonMode()) { + console.error('Fetching MQTT credentials from SwitchBot service…'); + } + const credential = await fetchMqttCredential(creds.token, creds.secret); + const topic = options.topic ?? credential.topics.status; + let eventCount = 0; const ac = new AbortController(); - const client = new SwitchBotMqttClient(cfg); + const client = new SwitchBotMqttClient( + credential, + () => fetchMqttCredential(creds.token, creds.secret), + ); - const unsub = client.onMessage((topic, payload) => { + const unsub = client.onMessage((msgTopic, payload) => { let parsed: unknown; try { parsed = JSON.parse(payload.toString('utf-8')); } catch { parsed = payload.toString('utf-8'); } - const record = { t: new Date().toISOString(), topic, payload: parsed }; + const record = { t: new Date().toISOString(), topic: msgTopic, payload: parsed }; if (isJsonMode()) { printJson(record); } else { @@ -270,11 +281,11 @@ Examples: }); if (!isJsonMode()) { - console.error(`Connecting to mqtts://${cfg.host}:${cfg.port} (Ctrl-C to stop)`); + console.error(`Connected to ${credential.brokerUrl} (Ctrl-C to stop)`); } await client.connect(); - client.subscribe(options.topic); + client.subscribe(topic); await new Promise((resolve) => { const cleanup = () => { diff --git a/src/commands/mcp.ts b/src/commands/mcp.ts index d7fcee9..2fb3146 100644 --- a/src/commands/mcp.ts +++ b/src/commands/mcp.ts @@ -25,8 +25,7 @@ import { EventSubscriptionManager } from '../mcp/events-subscription.js'; import { todayUsage } from '../utils/quota.js'; import { describeCache } from '../devices/cache.js'; import { withRequestContext } from '../lib/request-context.js'; -import { profileFilePath } from '../config.js'; -import { getMqttConfig } from '../mqtt/credential.js'; +import { profileFilePath, loadConfig } from '../config.js'; import fs from 'node:fs'; /** @@ -493,7 +492,7 @@ API docs: https://github.com/OpenWonderLabs/SwitchBotAPI`, title: 'SwitchBot real-time shadow events', description: 'Recent device shadow-update events received via MQTT. Returns a JSON snapshot of the ring buffer. ' + - 'State is "disabled" when MQTT credentials are not configured (set SWITCHBOT_MQTT_HOST / USERNAME / PASSWORD).', + 'State is "disabled" when REST credentials (SWITCHBOT_TOKEN + SWITCHBOT_SECRET) are not configured.', mimeType: 'application/json', }, (_uri) => { @@ -530,8 +529,8 @@ The MCP server exposes eight tools: Resource (read-only): - switchbot://events snapshot of recent MQTT shadow events from the ring buffer - Requires SWITCHBOT_MQTT_HOST / SWITCHBOT_MQTT_USERNAME / SWITCHBOT_MQTT_PASSWORD - env vars; returns {state:"disabled"} when not configured. + Auto-provisioned from SWITCHBOT_TOKEN + SWITCHBOT_SECRET; + returns {state:"disabled"} when credentials are not configured. Example Claude Desktop config (~/Library/Application Support/Claude/claude_desktop_config.json): @@ -595,16 +594,16 @@ Inspect locally: const rateLimitMap = new Map(); // Initialize shared EventSubscriptionManager for event streaming. - // If MQTT creds are present, connect in the background so the HTTP server - // starts immediately; /ready reflects the real state. + // Credentials are auto-provisioned from the SwitchBot API using the + // account's token+secret — no extra MQTT env vars needed. const eventManager = new EventSubscriptionManager(); - const mqttConfig = getMqttConfig(); - if (mqttConfig) { - eventManager.initialize(mqttConfig).catch((err: unknown) => { + try { + const { token, secret } = loadConfig(); + eventManager.initialize(token, secret).catch((err: unknown) => { console.error('MQTT initialization failed:', err instanceof Error ? err.message : String(err)); }); - } else { - console.error('MQTT disabled: set SWITCHBOT_MQTT_HOST, SWITCHBOT_MQTT_USERNAME, SWITCHBOT_MQTT_PASSWORD to enable real-time events.'); + } catch { + console.error('MQTT disabled: credentials not configured.'); } // Helper: constant-time token comparison @@ -807,11 +806,13 @@ process_uptime_seconds ${Math.floor(process.uptime())} } const eventManager = new EventSubscriptionManager(); - const mqttConfig = getMqttConfig(); - if (mqttConfig) { - eventManager.initialize(mqttConfig).catch((err: unknown) => { + try { + const { token, secret } = loadConfig(); + eventManager.initialize(token, secret).catch((err: unknown) => { console.error('MQTT initialization failed:', err instanceof Error ? err.message : String(err)); }); + } catch { + // Credentials not yet configured — MQTT will be unavailable until they are. } const server = createSwitchBotMcpServer({ eventManager }); const transport = new StdioServerTransport(); diff --git a/src/index.ts b/src/index.ts index f4648f3..a251aae 100644 --- a/src/index.ts +++ b/src/index.ts @@ -76,10 +76,6 @@ Environment: SWITCHBOT_TOKEN credential token (takes priority over config file) SWITCHBOT_SECRET credential secret (takes priority over config file) NO_COLOR disable ANSI colors (auto-respected via chalk) - SWITCHBOT_MQTT_HOST MQTT broker hostname (enables real-time events via 'events mqtt-tail' and 'mcp serve') - SWITCHBOT_MQTT_PORT MQTT broker port (default: 8883, MQTTS/TLS) - SWITCHBOT_MQTT_USERNAME MQTT broker username - SWITCHBOT_MQTT_PASSWORD MQTT broker password Examples: $ switchbot config set-token diff --git a/src/mcp/events-subscription.ts b/src/mcp/events-subscription.ts index 2fa1ab9..0c7df67 100644 --- a/src/mcp/events-subscription.ts +++ b/src/mcp/events-subscription.ts @@ -1,4 +1,5 @@ import { SwitchBotMqttClient, type MqttState } from '../mqtt/client.js'; +import { fetchMqttCredential } from '../mqtt/credential.js'; import { parseFilter, applyFilter, type FilterSyntaxError } from '../utils/filter.js'; import { fetchDeviceList, type Device } from '../lib/devices.js'; import { getCachedDevice } from '../devices/cache.js'; @@ -45,20 +46,11 @@ export class EventSubscriptionManager { this.getClient = getClient; } - async initialize(mqttConfig: { - host: string; - port: number; - username: string; - password: string; - }): Promise { + async initialize(token: string, secret: string): Promise { if (!this.mqttClient) { - const client = new SwitchBotMqttClient(mqttConfig, async () => { - // Auth refresh callback - would need credential resolution here - return { - username: mqttConfig.username, - password: mqttConfig.password, - }; - }); + const credential = await fetchMqttCredential(token, secret); + + const client = new SwitchBotMqttClient(credential, () => fetchMqttCredential(token, secret)); client.onStateChange((state) => { if (state === 'connected') { @@ -66,7 +58,7 @@ export class EventSubscriptionManager { kind: 'events.reconnected', timestamp: Date.now(), } as SubscriptionEvent); - client.subscribe('$aws/things/+/shadow/update/accepted'); + client.subscribe(credential.topics.status); } }); diff --git a/src/mqtt/client.ts b/src/mqtt/client.ts index 923805e..156867e 100644 --- a/src/mqtt/client.ts +++ b/src/mqtt/client.ts @@ -1,61 +1,60 @@ import type { IClientOptions } from 'mqtt'; import { connect } from 'mqtt'; import type { MqttClient } from 'mqtt'; +import type { MqttCredential } from './credential.js'; export type MqttState = 'connecting' | 'connected' | 'reconnecting' | 'failed' | 'disabled'; -export type AuthRefreshCallback = () => Promise<{ username: string; password: string }> | { username: string; password: string }; - -interface MqttClientConfig { - host: string; - port: number; - username: string; - password: string; - rejectUnauthorized?: boolean; -} +export type CredentialRefreshCallback = () => Promise; export class SwitchBotMqttClient { private client: MqttClient | null = null; - private config: MqttClientConfig; + private credential: MqttCredential; private state: MqttState = 'connecting'; - private authRefreshNeeded = false; + private credentialExpired = false; private reconnectAttempts = 0; private maxReconnectAttempts = 10; private handlers: Set<(state: MqttState) => void> = new Set(); private messageHandlers: Set<(topic: string, payload: Buffer) => void> = new Set(); - private authRefreshCallback?: AuthRefreshCallback; + private credentialRefreshCallback?: CredentialRefreshCallback; private stableTimer: NodeJS.Timeout | null = null; - private lastConnectionAttempt = 0; - constructor(config: MqttClientConfig, onAuthRefreshNeeded?: AuthRefreshCallback) { - this.config = config; - this.authRefreshCallback = onAuthRefreshNeeded; + constructor(credential: MqttCredential, onCredentialExpired?: CredentialRefreshCallback) { + this.credential = credential; + this.credentialRefreshCallback = onCredentialExpired; } async connect(): Promise { - if (this.client && this.state === 'connected') { - return; - } + if (this.client && this.state === 'connected') return; this.setState('connecting'); - this.authRefreshNeeded = false; + this.credentialExpired = false; this.reconnectAttempts = 0; try { + const { tls, brokerUrl, clientId } = this.credential; + const ca = Buffer.from(tls.caBase64, 'base64'); + const cert = Buffer.from(tls.certBase64, 'base64'); + const key = Buffer.from(tls.keyBase64, 'base64'); + const options: IClientOptions = { - username: this.config.username, - password: this.config.password, + clientId, + ca, + cert, + key, + rejectUnauthorized: true, clean: true, - reconnectPeriod: 0, // Manual reconnect control - connectTimeout: 10000, - rejectUnauthorized: this.config.rejectUnauthorized ?? true, + reconnectPeriod: 0, + connectTimeout: 30000, + keepalive: 60, + reschedulePings: true, }; - this.client = connect(`mqtts://${this.config.host}:${this.config.port}`, options); + this.client = connect(brokerUrl, options); this.client.on('connect', () => { this.reconnectAttempts = 0; this.setState('connected'); - this.authRefreshNeeded = false; + this.credentialExpired = false; }); this.client.on('message', (topic, payload) => { @@ -65,21 +64,20 @@ export class SwitchBotMqttClient { }); this.client.on('error', (err) => { - // Check for auth-related errors if ( - (err instanceof Error && - (err.message.includes('401') || - err.message.includes('Unauthorized') || - err.message.includes('EACCES'))) || - (err as NodeJS.ErrnoException).code === 'EACCES' + err instanceof Error && + (err.message.includes('certificate') || + err.message.includes('ECONNRESET') || + err.message.includes('handshake')) ) { - this.authRefreshNeeded = true; + this.credentialExpired = true; } }); this.client.on('close', () => { this.clearStableTimer(); - if (this.authRefreshNeeded) { this.setState('failed'); + if (this.credentialExpired) { + this.setState('failed'); } else if (this.reconnectAttempts < this.maxReconnectAttempts) { this.attemptReconnect(); } else { @@ -87,7 +85,6 @@ export class SwitchBotMqttClient { } }); - // Wait for connection with timeout await new Promise((resolve, reject) => { const timeout = setTimeout(() => { reject(new Error('MQTT connection timeout')); @@ -123,26 +120,22 @@ export class SwitchBotMqttClient { this.reconnectAttempts++; this.setState('reconnecting'); - if (this.authRefreshNeeded && this.authRefreshCallback) { + if (this.credentialExpired && this.credentialRefreshCallback) { try { - const refreshed = await this.authRefreshCallback(); - this.config.username = refreshed.username; - this.config.password = refreshed.password; - this.authRefreshNeeded = false; - } catch (err) { - // Auth refresh failed, mark as failed + this.credential = await this.credentialRefreshCallback(); + this.credentialExpired = false; + } catch { this.setState('failed'); return; } } - // Exponential backoff: 1s, 2s, 4s, 8s, 16s, 30s... const delay = Math.min(30000, 1000 * Math.pow(2, this.reconnectAttempts - 1)); await new Promise((r) => setTimeout(r, delay)); try { await this.connect(); - } catch (err) { + } catch { if (this.reconnectAttempts < this.maxReconnectAttempts) { await this.attemptReconnect(); } else { @@ -203,16 +196,10 @@ export class SwitchBotMqttClient { this.clearStableTimer(); if (this.client) { await new Promise((resolve) => { - this.client?.end(false, () => { - resolve(); - }); + this.client?.end(false, () => resolve()); }); this.client = null; this.setState('failed'); } } - - setAuthRefreshCallback(callback: AuthRefreshCallback): void { - this.authRefreshCallback = callback; - } } diff --git a/src/mqtt/credential.ts b/src/mqtt/credential.ts index 19eb1f0..715f142 100644 --- a/src/mqtt/credential.ts +++ b/src/mqtt/credential.ts @@ -1,31 +1,54 @@ -/** - * Resolve MQTT broker config from environment variables. - * - * Required env vars: - * SWITCHBOT_MQTT_HOST — broker hostname (e.g. mqtt.example.com) - * SWITCHBOT_MQTT_USERNAME — MQTT username - * SWITCHBOT_MQTT_PASSWORD — MQTT password - * - * Optional: - * SWITCHBOT_MQTT_PORT — broker port (default 8883) - */ -export interface MqttConfig { - host: string; - port: number; - username: string; - password: string; +import crypto from 'node:crypto'; +import { buildAuthHeaders } from '../auth.js'; + +export interface MqttCredential { + brokerUrl: string; + region: string; + clientId: string; + topics: { + status: string; + }; + qos: number; + tls: { + enabled: boolean; + caBase64: string; + certBase64: string; + keyBase64: string; + }; } -export function getMqttConfig(): MqttConfig | null { - const host = process.env.SWITCHBOT_MQTT_HOST; - const username = process.env.SWITCHBOT_MQTT_USERNAME; - const password = process.env.SWITCHBOT_MQTT_PASSWORD; +const CREDENTIAL_ENDPOINT = 'https://api.switchbot.net/v1.1/iot/credential'; + +export async function fetchMqttCredential(token: string, secret: string): Promise { + // Derive a stable instance ID per token so the server can track this client. + const instanceId = crypto.createHash('sha256').update(token).digest('hex').slice(0, 16); + + const headers = buildAuthHeaders(token, secret); + const res = await fetch(CREDENTIAL_ENDPOINT, { + method: 'POST', + headers, + body: JSON.stringify({ instanceId }), + signal: AbortSignal.timeout(15000), + }); + + if (!res.ok) { + throw new Error(`MQTT credential request failed: HTTP ${res.status} ${res.statusText}`); + } + + const json = (await res.json()) as { statusCode: number; body: unknown }; + + if (json.statusCode !== 100) { + throw new Error(`MQTT credential API error: statusCode ${json.statusCode}`); + } - if (!host || !username || !password) return null; + // Response shape: { statusCode, body: { body: { channels: { mqtt: ... } } } } + const outer = json.body as Record; + const inner = ((outer.body as Record | undefined) ?? outer) as Record; + const channels = inner.channels as { mqtt: MqttCredential } | undefined; - const rawPort = process.env.SWITCHBOT_MQTT_PORT; - const port = rawPort ? Number(rawPort) : 8883; - if (!Number.isFinite(port) || port <= 0 || port > 65535) return null; + if (!channels?.mqtt) { + throw new Error('Unexpected MQTT credential response — channels.mqtt missing'); + } - return { host, port, username, password }; + return channels.mqtt; } diff --git a/tests/commands/capabilities.test.ts b/tests/commands/capabilities.test.ts index 7d6fa1e..4ac79bd 100644 --- a/tests/commands/capabilities.test.ts +++ b/tests/commands/capabilities.test.ts @@ -125,16 +125,16 @@ describe('capabilities', () => { expect(mcp.resources).toEqual(['switchbot://events']); }); - it('surfaces.mqtt exposes envVars, cliCmd, mcpResource, and configured flag', async () => { + it('surfaces.mqtt exposes authSource, cliCmd, mcpResource, and protocol', async () => { const out = await runCapabilities(); const mqtt = (out.surfaces as Record>).mqtt; expect(mqtt).toBeDefined(); expect(mqtt.mode).toBe('consumer'); - expect(Array.isArray(mqtt.envVars)).toBe(true); - expect((mqtt.envVars as string[])).toContain('SWITCHBOT_MQTT_HOST'); + expect(typeof mqtt.authSource).toBe('string'); + expect((mqtt.authSource as string)).toContain('SWITCHBOT_TOKEN'); expect(mqtt.cliCmd).toBe('events mqtt-tail'); expect(mqtt.mcpResource).toBe('switchbot://events'); - expect(typeof mqtt.configured).toBe('boolean'); + expect(mqtt.protocol).toMatch(/MQTTS/); }); it('version matches semver format', async () => { diff --git a/tests/commands/doctor.test.ts b/tests/commands/doctor.test.ts index 29fbfd1..5174ff2 100644 --- a/tests/commands/doctor.test.ts +++ b/tests/commands/doctor.test.ts @@ -79,34 +79,25 @@ describe('doctor command', () => { expect(cat.detail).toMatch(/\d+ types loaded/); }); - it('mqtt check is warn when env vars are missing', async () => { - process.env.SWITCHBOT_TOKEN = 't'; - process.env.SWITCHBOT_SECRET = 's'; - delete process.env.SWITCHBOT_MQTT_HOST; - delete process.env.SWITCHBOT_MQTT_USERNAME; - delete process.env.SWITCHBOT_MQTT_PASSWORD; + it('mqtt check is warn when REST credentials are missing', async () => { + delete process.env.SWITCHBOT_TOKEN; + delete process.env.SWITCHBOT_SECRET; const res = await runCli(registerDoctorCommand, ['--json', 'doctor']); const payload = JSON.parse(res.stdout.filter((l) => l.trim().startsWith('{')).join('')); const mqtt = payload.data.checks.find((c: { name: string }) => c.name === 'mqtt'); expect(mqtt).toBeDefined(); expect(mqtt.status).toBe('warn'); - expect(mqtt.detail).toMatch(/SWITCHBOT_MQTT_HOST/); + expect(mqtt.detail).toMatch(/credentials/i); }); - it('mqtt check is ok when env vars are set', async () => { + it('mqtt check is ok when REST credentials are set', async () => { process.env.SWITCHBOT_TOKEN = 't'; process.env.SWITCHBOT_SECRET = 's'; - process.env.SWITCHBOT_MQTT_HOST = 'broker.example.com'; - process.env.SWITCHBOT_MQTT_USERNAME = 'user'; - process.env.SWITCHBOT_MQTT_PASSWORD = 'pass'; const res = await runCli(registerDoctorCommand, ['--json', 'doctor']); const payload = JSON.parse(res.stdout.filter((l) => l.trim().startsWith('{')).join('')); const mqtt = payload.data.checks.find((c: { name: string }) => c.name === 'mqtt'); expect(mqtt).toBeDefined(); expect(mqtt.status).toBe('ok'); - expect(mqtt.detail).toMatch(/mqtts:\/\/broker\.example\.com/); - delete process.env.SWITCHBOT_MQTT_HOST; - delete process.env.SWITCHBOT_MQTT_USERNAME; - delete process.env.SWITCHBOT_MQTT_PASSWORD; + expect(mqtt.detail).toMatch(/auto-provisioned/); }); }); diff --git a/tests/commands/events.test.ts b/tests/commands/events.test.ts index deb8acc..0a9b2be 100644 --- a/tests/commands/events.test.ts +++ b/tests/commands/events.test.ts @@ -45,7 +45,18 @@ vi.mock('../../src/mqtt/client.js', () => { }); vi.mock('../../src/mqtt/credential.js', () => ({ - getMqttConfig: vi.fn().mockReturnValue(null), + fetchMqttCredential: vi.fn().mockResolvedValue({ + brokerUrl: 'mqtts://broker.test:8883', + region: 'us-east-1', + clientId: 'test-client-id', + topics: { status: '$aws/things/+/shadow/update/accepted' }, + qos: 0, + tls: { enabled: true, caBase64: 'Y2E=', certBase64: 'Y2VydA==', keyBase64: 'a2V5' }, + }), +})); + +vi.mock('../../src/config.js', () => ({ + loadConfig: vi.fn().mockReturnValue({ token: 'test-token', secret: 'test-secret' }), })); async function postJson(port: number, path: string, body: unknown): Promise { @@ -213,33 +224,38 @@ describe('events tail receiver', () => { // --------------------------------------------------------------------------- // mqtt-tail subcommand tests // --------------------------------------------------------------------------- -import { getMqttConfig } from '../../src/mqtt/credential.js'; +import { fetchMqttCredential } from '../../src/mqtt/credential.js'; +import { loadConfig } from '../../src/config.js'; + +const mockCredential = { + brokerUrl: 'mqtts://broker.test:8883', + region: 'us-east-1', + clientId: 'test-client-id', + topics: { status: '$aws/things/+/shadow/update/accepted' }, + qos: 0, + tls: { enabled: true, caBase64: 'Y2E=', certBase64: 'Y2VydA==', keyBase64: 'a2V5' }, +}; describe('events mqtt-tail', () => { beforeEach(() => { mqttMock.messageHandler = null; mqttMock.connectShouldFireMessage = false; - vi.mocked(getMqttConfig).mockReturnValue(null); + vi.mocked(fetchMqttCredential).mockResolvedValue(mockCredential); + vi.mocked(loadConfig).mockReturnValue({ token: 'test-token', secret: 'test-secret' }); }); afterEach(() => { vi.clearAllMocks(); }); - it('exits 2 with UsageError when MQTT env vars are missing', async () => { - vi.mocked(getMqttConfig).mockReturnValue(null); + it('exits 2 with UsageError when no credentials are configured', async () => { + vi.mocked(loadConfig).mockImplementation(() => { throw new Error('no config'); }); const res = await runCli(registerEventsCommand, ['events', 'mqtt-tail']); expect(res.exitCode).toBe(2); - expect(res.stderr.some((l) => l.includes('SWITCHBOT_MQTT_HOST'))).toBe(true); + expect(res.stderr.some((l) => l.includes('credentials'))).toBe(true); }); it('outputs JSONL and stops after --max 1', async () => { - vi.mocked(getMqttConfig).mockReturnValue({ - host: 'broker.test', - port: 8883, - username: 'user', - password: 'pass', - }); mqttMock.connectShouldFireMessage = true; const res = await runCli(registerEventsCommand, ['events', 'mqtt-tail', '--max', '1']); @@ -253,12 +269,6 @@ describe('events mqtt-tail', () => { }); it('wraps output in envelope with --json --max 1', async () => { - vi.mocked(getMqttConfig).mockReturnValue({ - host: 'broker.test', - port: 8883, - username: 'user', - password: 'pass', - }); mqttMock.connectShouldFireMessage = true; const res = await runCli(registerEventsCommand, ['--json', 'events', 'mqtt-tail', '--max', '1']); @@ -271,12 +281,6 @@ describe('events mqtt-tail', () => { }); it('exits 2 when --max is not a positive integer', async () => { - vi.mocked(getMqttConfig).mockReturnValue({ - host: 'broker.test', - port: 8883, - username: 'user', - password: 'pass', - }); const res = await runCli(registerEventsCommand, ['events', 'mqtt-tail', '--max', '0']); expect(res.exitCode).toBe(2); }); From f06da59f1301c1ca942857b5503a2a91cfa14108 Mon Sep 17 00:00:00 2001 From: chenliuyun Date: Sun, 19 Apr 2026 20:02:21 +0800 Subject: [PATCH 4/8] fix(mqtt): pass PEM strings directly instead of base64-decoding them The SwitchBot credential API returns full PEM strings in the tls.caBase64, certBase64, and keyBase64 fields despite the misleading field names. Decoding them as base64 produces garbage bytes that fail TLS handshake. --- src/mqtt/client.ts | 11 ++++------- 1 file changed, 4 insertions(+), 7 deletions(-) diff --git a/src/mqtt/client.ts b/src/mqtt/client.ts index 156867e..c04459e 100644 --- a/src/mqtt/client.ts +++ b/src/mqtt/client.ts @@ -32,15 +32,12 @@ export class SwitchBotMqttClient { try { const { tls, brokerUrl, clientId } = this.credential; - const ca = Buffer.from(tls.caBase64, 'base64'); - const cert = Buffer.from(tls.certBase64, 'base64'); - const key = Buffer.from(tls.keyBase64, 'base64'); - + // tls.ca/cert/keyBase64 are PEM strings despite the misleading field name const options: IClientOptions = { clientId, - ca, - cert, - key, + ca: tls.caBase64, + cert: tls.certBase64, + key: tls.keyBase64, rejectUnauthorized: true, clean: true, reconnectPeriod: 0, From d807e7bacebded0b24cf22f7aaf6ead076f74fea Mon Sep 17 00:00:00 2001 From: chenliuyun Date: Sun, 19 Apr 2026 20:08:21 +0800 Subject: [PATCH 5/8] fix(mqtt): fix three correctness bugs in MQTT initialization and teardown 1. Add tryLoadConfig() that returns null instead of process.exit(1), so mcp serve can genuinely degrade gracefully when credentials are absent. The try/catch around loadConfig() never caught anything because loadConfig calls process.exit, not throw. 2. Use tryLoadConfig() in events mqtt-tail so the UsageError path actually executes instead of the process exiting before the catch block runs. 3. Add a disconnecting flag to SwitchBotMqttClient so that intentional disconnect() calls suppress the close->attemptReconnect() chain. Without this, --max N, Ctrl-C and MCP shutdown all race against a reconnect loop. Also remove the unused stableTimer dead code. --- src/commands/events.ts | 8 ++++---- src/commands/mcp.ts | 18 ++++++++---------- src/config.ts | 21 +++++++++++++++++++++ src/mqtt/client.ts | 16 +++++----------- tests/commands/events.test.ts | 7 ++++--- 5 files changed, 42 insertions(+), 28 deletions(-) diff --git a/src/commands/events.ts b/src/commands/events.ts index 1442c28..dfaefd6 100644 --- a/src/commands/events.ts +++ b/src/commands/events.ts @@ -3,7 +3,7 @@ import http from 'node:http'; import { printJson, isJsonMode, handleError, UsageError } from '../utils/output.js'; import { SwitchBotMqttClient } from '../mqtt/client.js'; import { fetchMqttCredential } from '../mqtt/credential.js'; -import { loadConfig } from '../config.js'; +import { tryLoadConfig } from '../config.js'; const DEFAULT_PORT = 3000; const DEFAULT_PATH = '/'; @@ -240,13 +240,13 @@ Examples: } let creds: { token: string; secret: string }; - try { - creds = loadConfig(); - } catch { + const loaded = tryLoadConfig(); + if (!loaded) { throw new UsageError( 'No credentials found. Run \'switchbot config set-token\' or set SWITCHBOT_TOKEN and SWITCHBOT_SECRET.', ); } + creds = loaded; if (!isJsonMode()) { console.error('Fetching MQTT credentials from SwitchBot service…'); diff --git a/src/commands/mcp.ts b/src/commands/mcp.ts index 2fb3146..4905099 100644 --- a/src/commands/mcp.ts +++ b/src/commands/mcp.ts @@ -25,7 +25,7 @@ import { EventSubscriptionManager } from '../mcp/events-subscription.js'; import { todayUsage } from '../utils/quota.js'; import { describeCache } from '../devices/cache.js'; import { withRequestContext } from '../lib/request-context.js'; -import { profileFilePath, loadConfig } from '../config.js'; +import { profileFilePath, loadConfig, tryLoadConfig } from '../config.js'; import fs from 'node:fs'; /** @@ -597,12 +597,12 @@ Inspect locally: // Credentials are auto-provisioned from the SwitchBot API using the // account's token+secret — no extra MQTT env vars needed. const eventManager = new EventSubscriptionManager(); - try { - const { token, secret } = loadConfig(); - eventManager.initialize(token, secret).catch((err: unknown) => { + const mqttCreds = tryLoadConfig(); + if (mqttCreds) { + eventManager.initialize(mqttCreds.token, mqttCreds.secret).catch((err: unknown) => { console.error('MQTT initialization failed:', err instanceof Error ? err.message : String(err)); }); - } catch { + } else { console.error('MQTT disabled: credentials not configured.'); } @@ -806,13 +806,11 @@ process_uptime_seconds ${Math.floor(process.uptime())} } const eventManager = new EventSubscriptionManager(); - try { - const { token, secret } = loadConfig(); - eventManager.initialize(token, secret).catch((err: unknown) => { + const mqttCreds = tryLoadConfig(); + if (mqttCreds) { + eventManager.initialize(mqttCreds.token, mqttCreds.secret).catch((err: unknown) => { console.error('MQTT initialization failed:', err instanceof Error ? err.message : String(err)); }); - } catch { - // Credentials not yet configured — MQTT will be unavailable until they are. } const server = createSwitchBotMcpServer({ eventManager }); const transport = new StdioServerTransport(); diff --git a/src/config.ts b/src/config.ts index 3cbab03..d9355f8 100644 --- a/src/config.ts +++ b/src/config.ts @@ -71,6 +71,27 @@ export function loadConfig(): SwitchBotConfig { } } +/** + * Like loadConfig but returns null instead of exiting. Use this in code paths + * that want graceful degradation (e.g. optional MQTT init in `mcp serve`). + */ +export function tryLoadConfig(): SwitchBotConfig | null { + const envToken = process.env.SWITCHBOT_TOKEN; + const envSecret = process.env.SWITCHBOT_SECRET; + if (envToken && envSecret) return { token: envToken, secret: envSecret }; + + const file = configFilePath(); + if (!fs.existsSync(file)) return null; + try { + const raw = fs.readFileSync(file, 'utf-8'); + const cfg = JSON.parse(raw) as SwitchBotConfig; + if (!cfg.token || !cfg.secret) return null; + return cfg; + } catch { + return null; + } +} + export function saveConfig(token: string, secret: string): void { const file = configFilePath(); const dir = path.dirname(file); diff --git a/src/mqtt/client.ts b/src/mqtt/client.ts index c04459e..dca65e7 100644 --- a/src/mqtt/client.ts +++ b/src/mqtt/client.ts @@ -13,10 +13,10 @@ export class SwitchBotMqttClient { private credentialExpired = false; private reconnectAttempts = 0; private maxReconnectAttempts = 10; + private disconnecting = false; private handlers: Set<(state: MqttState) => void> = new Set(); private messageHandlers: Set<(topic: string, payload: Buffer) => void> = new Set(); private credentialRefreshCallback?: CredentialRefreshCallback; - private stableTimer: NodeJS.Timeout | null = null; constructor(credential: MqttCredential, onCredentialExpired?: CredentialRefreshCallback) { this.credential = credential; @@ -72,7 +72,7 @@ export class SwitchBotMqttClient { }); this.client.on('close', () => { - this.clearStableTimer(); + if (this.disconnecting) return; if (this.credentialExpired) { this.setState('failed'); } else if (this.reconnectAttempts < this.maxReconnectAttempts) { @@ -150,13 +150,6 @@ export class SwitchBotMqttClient { } } - private clearStableTimer(): void { - if (this.stableTimer) { - clearTimeout(this.stableTimer); - this.stableTimer = null; - } - } - subscribe(topic: string): void { if (this.client && this.state === 'connected') { this.client.subscribe(topic, (err) => { @@ -190,13 +183,14 @@ export class SwitchBotMqttClient { } async disconnect(): Promise { - this.clearStableTimer(); + this.disconnecting = true; if (this.client) { await new Promise((resolve) => { this.client?.end(false, () => resolve()); }); this.client = null; - this.setState('failed'); } + this.disconnecting = false; + this.setState('failed'); } } diff --git a/tests/commands/events.test.ts b/tests/commands/events.test.ts index 0a9b2be..882cbb2 100644 --- a/tests/commands/events.test.ts +++ b/tests/commands/events.test.ts @@ -57,6 +57,7 @@ vi.mock('../../src/mqtt/credential.js', () => ({ vi.mock('../../src/config.js', () => ({ loadConfig: vi.fn().mockReturnValue({ token: 'test-token', secret: 'test-secret' }), + tryLoadConfig: vi.fn().mockReturnValue({ token: 'test-token', secret: 'test-secret' }), })); async function postJson(port: number, path: string, body: unknown): Promise { @@ -225,7 +226,7 @@ describe('events tail receiver', () => { // mqtt-tail subcommand tests // --------------------------------------------------------------------------- import { fetchMqttCredential } from '../../src/mqtt/credential.js'; -import { loadConfig } from '../../src/config.js'; +import { tryLoadConfig } from '../../src/config.js'; const mockCredential = { brokerUrl: 'mqtts://broker.test:8883', @@ -241,7 +242,7 @@ describe('events mqtt-tail', () => { mqttMock.messageHandler = null; mqttMock.connectShouldFireMessage = false; vi.mocked(fetchMqttCredential).mockResolvedValue(mockCredential); - vi.mocked(loadConfig).mockReturnValue({ token: 'test-token', secret: 'test-secret' }); + vi.mocked(tryLoadConfig).mockReturnValue({ token: 'test-token', secret: 'test-secret' }); }); afterEach(() => { @@ -249,7 +250,7 @@ describe('events mqtt-tail', () => { }); it('exits 2 with UsageError when no credentials are configured', async () => { - vi.mocked(loadConfig).mockImplementation(() => { throw new Error('no config'); }); + vi.mocked(tryLoadConfig).mockReturnValue(null); const res = await runCli(registerEventsCommand, ['events', 'mqtt-tail']); expect(res.exitCode).toBe(2); expect(res.stderr.some((l) => l.includes('credentials'))).toBe(true); From 4aa62258caf214428532015c80bd05b8e2e12a36 Mon Sep 17 00:00:00 2001 From: chenliuyun Date: Sun, 19 Apr 2026 20:12:47 +0800 Subject: [PATCH 6/8] fix(mqtt): fix stale schema description and connection banner order - account_overview.mqtt description now says "auto-provisioned via REST credentials" instead of the outdated "MQTT env vars" wording - Move "Connected to..." banner in mqtt-tail to after client.connect() resolves, so the message only appears when the connection is confirmed --- src/commands/events.ts | 6 +++--- src/commands/mcp.ts | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/commands/events.ts b/src/commands/events.ts index dfaefd6..2d50e8b 100644 --- a/src/commands/events.ts +++ b/src/commands/events.ts @@ -280,13 +280,13 @@ Examples: } }); + await client.connect(); + client.subscribe(topic); + if (!isJsonMode()) { console.error(`Connected to ${credential.brokerUrl} (Ctrl-C to stop)`); } - await client.connect(); - client.subscribe(topic); - await new Promise((resolve) => { const cleanup = () => { process.removeListener('SIGINT', cleanup); diff --git a/src/commands/mcp.ts b/src/commands/mcp.ts index 4905099..178365d 100644 --- a/src/commands/mcp.ts +++ b/src/commands/mcp.ts @@ -435,7 +435,7 @@ API docs: https://github.com/OpenWonderLabs/SwitchBotAPI`, mqtt: z.object({ state: z.string(), subscribers: z.number(), - }).optional().describe('MQTT connection state (present when MQTT env vars are configured)'), + }).optional().describe('MQTT connection state (present when REST credentials are configured; auto-provisioned via POST /v1.1/iot/credential)'), }, }, async () => { From 6db02afb97200ddbd4ecb413ef8ee115a9514900 Mon Sep 17 00:00:00 2001 From: chenliuyun Date: Sun, 19 Apr 2026 20:18:05 +0800 Subject: [PATCH 7/8] docs: fill README gaps found in completeness audit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add devices batch section and TOC entry (was completely undocumented) - Add config list-profiles to config section - Add catalog refresh to catalog section - Fix doctor description: 'MQTT config' → clarify auto-provisioned from REST credentials - Restore mcp TOC entry that was accidentally dropped in previous edit --- README.md | 26 ++++++++++++++++++++++++-- 1 file changed, 24 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index dada9f1..b191478 100644 --- a/README.md +++ b/README.md @@ -41,11 +41,12 @@ Under the hood every surface shares the same catalog, cache, and HMAC client — - [Commands](#commands) - [`config`](#config--credential-management) - [`devices`](#devices--list-status-control) + - [`devices batch`](#devices-batch--bulk-commands) + - [`devices watch`](#devices-watch--poll-status) - [`scenes`](#scenes--run-manual-scenes) - [`webhook`](#webhook--receive-device-events-over-http) - [`events`](#events--receive-device-events) - [`plan`](#plan--declarative-batch-operations) - - [`devices watch`](#devices-watch--poll-status) - [`mcp`](#mcp--model-context-protocol-server) - [`doctor`](#doctor--self-check) - [`quota`](#quota--api-request-counter) @@ -194,6 +195,7 @@ switchbot devices command ABC123 turnOn --dry-run ```bash switchbot config set-token # Save to ~/.switchbot/config.json switchbot config show # Print current source + masked secret +switchbot config list-profiles # List saved profiles ``` ### `devices` — list, status, control @@ -303,6 +305,25 @@ switchbot devices meta clear 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. +#### `devices batch` — bulk commands + +```bash +# Send the same command to every device matching a filter +switchbot devices batch turnOff --filter 'type=Bot' +switchbot devices batch setBrightness 50 --filter 'type~=Light,family=Living' + +# Explicit device IDs (comma-separated) +switchbot devices batch turnOn --ids ID1,ID2,ID3 + +# Pipe device IDs from `devices list` +switchbot devices list --format=id --filter 'type=Bot' | switchbot devices batch toggle - + +# Destructive commands require --yes +switchbot devices batch unlock --filter 'type=Smart Lock' --yes +``` + +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. + ### `scenes` — run manual scenes ```bash @@ -457,7 +478,7 @@ switchbot doctor switchbot doctor --json ``` -Runs 8 local checks (Node version, credentials, profiles, catalog, cache, quota file, clock, MQTT config) and exits 1 if any check fails. `warn` results exit 0. Use this to diagnose connectivity or config issues before running automation. +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. ### `quota` — API request counter @@ -486,6 +507,7 @@ switchbot catalog show # all 42 built-in types switchbot catalog show Bot # one type switchbot catalog diff # what a local overlay changes vs built-in switchbot catalog path # location of the local overlay file +switchbot catalog refresh # reload local overlay (clears in-process cache) ``` The built-in catalog ships with the package. Create `~/.switchbot/catalog-overlay.json` to add, extend, or override type definitions without modifying the package. From 9579fbe0c6a804a3d8cc53f9e920dc0f987495fe Mon Sep 17 00:00:00 2001 From: chenliuyun Date: Sun, 19 Apr 2026 20:20:01 +0800 Subject: [PATCH 8/8] chore: bump version to 2.1.0 --- package-lock.json | 4 ++-- package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index 7a7b50c..8c0ee2e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@switchbot/openapi-cli", - "version": "2.0.1", + "version": "2.1.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@switchbot/openapi-cli", - "version": "2.0.1", + "version": "2.1.0", "license": "MIT", "dependencies": { "@modelcontextprotocol/sdk": "^1.29.0", diff --git a/package.json b/package.json index 02ae281..736ae20 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@switchbot/openapi-cli", - "version": "2.0.1", + "version": "2.1.0", "description": "SwitchBot smart home CLI — control devices, run scenes, stream real-time events, and integrate AI agents via MCP. Full API v1.1 coverage.", "keywords": [ "switchbot",