diff --git a/README.md b/README.md index 088dcd1..b191478 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) @@ -41,11 +41,19 @@ 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) - - [`batch`](#batch--run-multiple-commands) - - [`watch`](#watch--poll-device-status) + - [`events`](#events--receive-device-events) + - [`plan`](#plan--declarative-batch-operations) - [`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) @@ -67,7 +75,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 @@ -187,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 @@ -272,13 +281,48 @@ 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 +``` + +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 ``` -Returns a human-readable description of what the command does and what each parameter means. +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 @@ -307,6 +351,67 @@ 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 (runs in foreground until Ctrl-C) +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 +``` + +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": {...} } +``` + +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 ```bash @@ -325,28 +430,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 executing it -switchbot batch validate commands.json +# 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 + +# 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 @@ -358,6 +471,65 @@ 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) 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 + +```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 +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. + +### `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 @@ -457,11 +629,11 @@ 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 | +| `NO_COLOR` | Disable ANSI colors in all output (automatically respected) | ## Scripting examples @@ -484,7 +656,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) ``` @@ -506,21 +678,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-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 d6d989e..736ae20 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", + "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", "cli", 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..2d50e8b 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 { tryLoadConfig } 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 }; + 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…'); + } + 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 { @@ -269,13 +280,13 @@ Examples: } }); + await client.connect(); + client.subscribe(topic); + 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); - await new Promise((resolve) => { const cleanup = () => { process.removeListener('SIGINT', cleanup); diff --git a/src/commands/mcp.ts b/src/commands/mcp.ts index d7fcee9..178365d 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, tryLoadConfig } from '../config.js'; import fs from 'node:fs'; /** @@ -436,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 () => { @@ -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) => { + 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)); }); } else { - console.error('MQTT disabled: set SWITCHBOT_MQTT_HOST, SWITCHBOT_MQTT_USERNAME, SWITCHBOT_MQTT_PASSWORD to enable real-time events.'); + console.error('MQTT disabled: credentials not configured.'); } // Helper: constant-time token comparison @@ -807,9 +806,9 @@ process_uptime_seconds ${Math.floor(process.uptime())} } const eventManager = new EventSubscriptionManager(); - const mqttConfig = getMqttConfig(); - if (mqttConfig) { - eventManager.initialize(mqttConfig).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)); }); } 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/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..dca65e7 100644 --- a/src/mqtt/client.ts +++ b/src/mqtt/client.ts @@ -1,61 +1,57 @@ 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 disconnecting = false; private handlers: Set<(state: MqttState) => void> = new Set(); private messageHandlers: Set<(topic: string, payload: Buffer) => void> = new Set(); - private authRefreshCallback?: AuthRefreshCallback; - private stableTimer: NodeJS.Timeout | null = null; - private lastConnectionAttempt = 0; + private credentialRefreshCallback?: CredentialRefreshCallback; - 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; + // tls.ca/cert/keyBase64 are PEM strings despite the misleading field name const options: IClientOptions = { - username: this.config.username, - password: this.config.password, + clientId, + ca: tls.caBase64, + cert: tls.certBase64, + key: tls.keyBase64, + 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 +61,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.disconnecting) return; + if (this.credentialExpired) { + this.setState('failed'); } else if (this.reconnectAttempts < this.maxReconnectAttempts) { this.attemptReconnect(); } else { @@ -87,7 +82,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 +117,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 { @@ -160,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) => { @@ -200,19 +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?.end(false, () => resolve()); }); this.client = null; - this.setState('failed'); } - } - - setAuthRefreshCallback(callback: AuthRefreshCallback): void { - this.authRefreshCallback = callback; + this.disconnecting = false; + this.setState('failed'); } } 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..882cbb2 100644 --- a/tests/commands/events.test.ts +++ b/tests/commands/events.test.ts @@ -45,7 +45,19 @@ 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' }), + tryLoadConfig: vi.fn().mockReturnValue({ token: 'test-token', secret: 'test-secret' }), })); async function postJson(port: number, path: string, body: unknown): Promise { @@ -213,33 +225,38 @@ describe('events tail receiver', () => { // --------------------------------------------------------------------------- // mqtt-tail subcommand tests // --------------------------------------------------------------------------- -import { getMqttConfig } from '../../src/mqtt/credential.js'; +import { fetchMqttCredential } from '../../src/mqtt/credential.js'; +import { tryLoadConfig } 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(tryLoadConfig).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(tryLoadConfig).mockReturnValue(null); 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 +270,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 +282,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); });