Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
248 changes: 211 additions & 37 deletions README.md

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion docs/agent-guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)).

---

Expand Down
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 2 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
6 changes: 2 additions & 4 deletions src/commands/capabilities.ts
Original file line number Diff line number Diff line change
@@ -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',
Expand Down Expand Up @@ -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',
Expand Down
29 changes: 22 additions & 7 deletions src/commands/doctor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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)",
};
}

Expand Down
55 changes: 33 additions & 22 deletions src/commands/events.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = '/';
Expand Down Expand Up @@ -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 <pattern>', 'MQTT topic filter (default: "#" — all topics)', '#')
.description('Subscribe to SwitchBot MQTT shadow events and stream them as JSONL')
.option('--topic <pattern>', 'MQTT topic filter (default: SwitchBot shadow topic from credential)')
.option('--max <n>', '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": "<ISO>", "topic": "<mqtt-topic>", "payload": <parsed JSON or raw string> }
Expand All @@ -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 {
Expand All @@ -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<void>((resolve) => {
const cleanup = () => {
process.removeListener('SIGINT', cleanup);
Expand Down
29 changes: 14 additions & 15 deletions src/commands/mcp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

/**
Expand Down Expand Up @@ -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 () => {
Expand Down Expand Up @@ -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) => {
Expand Down Expand Up @@ -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):

Expand Down Expand Up @@ -595,16 +594,16 @@ Inspect locally:
const rateLimitMap = new Map<string, { count: number; resetAt: number }>();

// 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
Expand Down Expand Up @@ -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));
});
}
Expand Down
21 changes: 21 additions & 0 deletions src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
4 changes: 0 additions & 4 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 <token> <secret>
Expand Down
20 changes: 6 additions & 14 deletions src/mcp/events-subscription.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -45,28 +46,19 @@ export class EventSubscriptionManager {
this.getClient = getClient;
}

async initialize(mqttConfig: {
host: string;
port: number;
username: string;
password: string;
}): Promise<void> {
async initialize(token: string, secret: string): Promise<void> {
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') {
this.emit({
kind: 'events.reconnected',
timestamp: Date.now(),
} as SubscriptionEvent);
client.subscribe('$aws/things/+/shadow/update/accepted');
client.subscribe(credential.topics.status);
}
});

Expand Down
Loading
Loading