Skip to content
Closed
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
9 changes: 9 additions & 0 deletions src/commands/capabilities.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
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 @@ -72,6 +73,14 @@ export function registerCapabilitiesCommand(program: Command): void {
tools: MCP_TOOLS,
resources: ['switchbot://events'],
},
mqtt: {
mode: 'consumer',
envVars: ['SWITCHBOT_MQTT_HOST', 'SWITCHBOT_MQTT_USERNAME', 'SWITCHBOT_MQTT_PASSWORD', 'SWITCHBOT_MQTT_PORT'],
cliCmd: 'events mqtt-tail',
mcpResource: 'switchbot://events',
protocol: 'MQTTS (TLS, default port 8883)',
configured: getMqttConfig() !== null,
},
plan: {
schemaCmd: 'plan schema',
validateCmd: 'plan validate -',
Expand Down
18 changes: 18 additions & 0 deletions src/commands/doctor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ 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 @@ -112,6 +113,22 @@ function checkNodeVersion(): Check {
return { name: 'node', status: 'ok', detail: `Node ${process.versions.node}` };
}

function checkMqtt(): Check {
const cfg = getMqttConfig();
if (!cfg) {
return {
name: 'mqtt',
status: 'warn',
detail: "not configured — set SWITCHBOT_MQTT_HOST/USERNAME/PASSWORD to enable real-time events",
};
}
return {
name: 'mqtt',
status: 'ok',
detail: `configured (mqtts://${cfg.host}:${cfg.port}) — credentials not verified; run 'switchbot events mqtt-tail' to test live connectivity`,
};
}

export function registerDoctorCommand(program: Command): void {
program
.command('doctor')
Expand All @@ -133,6 +150,7 @@ Examples:
checkCache(),
checkQuotaFile(),
checkClockSkew(),
checkMqtt(),
];
const summary = {
ok: checks.filter((c) => c.status === 'ok').length,
Expand Down
84 changes: 83 additions & 1 deletion src/commands/events.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,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';

const DEFAULT_PORT = 3000;
const DEFAULT_PATH = '/';
Expand Down Expand Up @@ -123,7 +125,7 @@
export function registerEventsCommand(program: Command): void {
const events = program
.command('events')
.description('Subscribe to local webhook events forwarded by SwitchBot');
.description('Receive SwitchBot device events — webhook receiver (tail) or MQTT stream (mqtt-tail)');

events
.command('tail')
Expand Down Expand Up @@ -207,4 +209,84 @@
handleError(error);
}
});

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)', '#')
.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)

Output (JSONL, one event per line):
{ "t": "<ISO>", "topic": "<mqtt-topic>", "payload": <parsed JSON or raw string> }

Examples:
$ switchbot events mqtt-tail
$ switchbot events mqtt-tail --topic 'switchbot/#'
$ switchbot events mqtt-tail --max 10 --json
`,
)
.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 eventCount = 0;
const ac = new AbortController();
const client = new SwitchBotMqttClient(cfg);

const unsub = client.onMessage((topic, 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 };
if (isJsonMode()) {
printJson(record);
} else {
console.log(JSON.stringify(record));
}
eventCount++;
if (maxEvents !== null && eventCount >= maxEvents) {
ac.abort();
}
});

if (!isJsonMode()) {
console.error(`Connecting to mqtts://${cfg.host}:${cfg.port} (Ctrl-C to stop)`);
}

await client.connect();
client.subscribe(options.topic);

await new Promise<void>((resolve) => {
const cleanup = () => {
unsub();
client.disconnect().then(resolve).catch(resolve);

Check failure on line 282 in src/commands/events.ts

View workflow job for this annotation

GitHub Actions / test (20.x)

Unhandled error

TypeError: Cannot read properties of undefined (reading 'then') ❯ process.cleanup src/commands/events.ts:282:31 ❯ Object.onceWrapper node:events:639:26 ❯ process.emit node:events:536:35 This error originated in "tests/commands/events.test.ts" test file. It doesn't mean the error was thrown inside the file itself, but while it was running. This error was caught after test environment was torn down. Make sure to cancel any running tasks before test finishes: - cancel timeouts using clearTimeout and clearInterval - wait for promises to resolve using the await keyword

Check failure on line 282 in src/commands/events.ts

View workflow job for this annotation

GitHub Actions / test (22.x)

Unhandled error

TypeError: Cannot read properties of undefined (reading 'then') ❯ process.cleanup src/commands/events.ts:282:31 ❯ Object.onceWrapper node:events:634:26 ❯ process.emit node:events:531:35 This error originated in "tests/commands/events.test.ts" test file. It doesn't mean the error was thrown inside the file itself, but while it was running. This error was caught after test environment was torn down. Make sure to cancel any running tasks before test finishes: - cancel timeouts using clearTimeout and clearInterval - wait for promises to resolve using the await keyword

Check failure on line 282 in src/commands/events.ts

View workflow job for this annotation

GitHub Actions / test (18.x)

Unhandled error

TypeError: Cannot read properties of undefined (reading 'then') ❯ process.cleanup src/commands/events.ts:282:31 ❯ Object.onceWrapper node:events:632:26 ❯ process.emit node:events:529:35 This error originated in "tests/commands/events.test.ts" test file. It doesn't mean the error was thrown inside the file itself, but while it was running. This error was caught after test environment was torn down. Make sure to cancel any running tasks before test finishes: - cancel timeouts using clearTimeout and clearInterval - wait for promises to resolve using the await keyword

Check failure on line 282 in src/commands/events.ts

View workflow job for this annotation

GitHub Actions / test (22.x)

Unhandled error

TypeError: Cannot read properties of undefined (reading 'then') ❯ process.cleanup src/commands/events.ts:282:31 ❯ Object.onceWrapper node:events:634:26 ❯ process.emit node:events:531:35 This error originated in "tests/commands/events.test.ts" test file. It doesn't mean the error was thrown inside the file itself, but while it was running. This error was caught after test environment was torn down. Make sure to cancel any running tasks before test finishes: - cancel timeouts using clearTimeout and clearInterval - wait for promises to resolve using the await keyword

Check failure on line 282 in src/commands/events.ts

View workflow job for this annotation

GitHub Actions / test (18.x)

Unhandled error

TypeError: Cannot read properties of undefined (reading 'then') ❯ process.cleanup src/commands/events.ts:282:31 ❯ Object.onceWrapper node:events:632:26 ❯ process.emit node:events:529:35 This error originated in "tests/commands/events.test.ts" test file. It doesn't mean the error was thrown inside the file itself, but while it was running. This error was caught after test environment was torn down. Make sure to cancel any running tasks before test finishes: - cancel timeouts using clearTimeout and clearInterval - wait for promises to resolve using the await keyword

Check failure on line 282 in src/commands/events.ts

View workflow job for this annotation

GitHub Actions / test (20.x)

Unhandled error

TypeError: Cannot read properties of undefined (reading 'then') ❯ process.cleanup src/commands/events.ts:282:31 ❯ Object.onceWrapper node:events:639:26 ❯ process.emit node:events:536:35 This error originated in "tests/commands/events.test.ts" test file. It doesn't mean the error was thrown inside the file itself, but while it was running. This error was caught after test environment was torn down. Make sure to cancel any running tasks before test finishes: - cancel timeouts using clearTimeout and clearInterval - wait for promises to resolve using the await keyword
};
process.once('SIGINT', cleanup);
process.once('SIGTERM', cleanup);
ac.signal.addEventListener('abort', cleanup, { once: true });
});
} catch (error) {
handleError(error);
}
});
}
19 changes: 16 additions & 3 deletions src/commands/mcp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -436,7 +436,7 @@ API docs: https://github.com/OpenWonderLabs/SwitchBotAPI`,
mqtt: z.object({
state: z.string(),
subscribers: z.number(),
}).optional().describe('MQTT connection state (HTTP mode only)'),
}).optional().describe('MQTT connection state (present when MQTT env vars are configured)'),
},
},
async () => {
Expand Down Expand Up @@ -518,14 +518,20 @@ export function registerMcpCommand(program: Command): void {
.command('mcp')
.description('Run as a Model Context Protocol server so AI agents can call SwitchBot tools')
.addHelpText('after', `
The MCP server exposes seven tools over stdio:
The MCP server exposes eight tools:
- list_devices fetch all physical + IR devices
- get_device_status live status for a physical device
- send_command control a device (destructive commands need confirm:true)
- list_scenes list all manual scenes
- run_scene execute a manual scene
- search_catalog offline catalog search by type/alias
- describe_device metadata + commands + (optionally) live status for one device
- account_overview single cold-start snapshot: devices + scenes + quota + cache + MQTT state

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.

Example Claude Desktop config (~/Library/Application Support/Claude/claude_desktop_config.json):

Expand Down Expand Up @@ -800,7 +806,14 @@ process_uptime_seconds ${Math.floor(process.uptime())}
return;
}

const server = createSwitchBotMcpServer();
const eventManager = new EventSubscriptionManager();
const mqttConfig = getMqttConfig();
if (mqttConfig) {
eventManager.initialize(mqttConfig).catch((err: unknown) => {
console.error('MQTT initialization failed:', err instanceof Error ? err.message : String(err));
});
}
const server = createSwitchBotMcpServer({ eventManager });
const transport = new StdioServerTransport();
await server.connect(transport);
} catch (error) {
Expand Down
10 changes: 7 additions & 3 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,9 +73,13 @@ Exit codes:
2 usage error (bad flag, unknown subcommand, invalid argument, unknown device type)

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_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
12 changes: 12 additions & 0 deletions tests/commands/capabilities.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,18 @@ describe('capabilities', () => {
expect(mcp.resources).toEqual(['switchbot://events']);
});

it('surfaces.mqtt exposes envVars, cliCmd, mcpResource, and configured flag', async () => {
const out = await runCapabilities();
const mqtt = (out.surfaces as Record<string, Record<string, unknown>>).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(mqtt.cliCmd).toBe('events mqtt-tail');
expect(mqtt.mcpResource).toBe('switchbot://events');
expect(typeof mqtt.configured).toBe('boolean');
});

it('version matches semver format', async () => {
const out = await runCapabilities();
expect(out.version as string).toMatch(/^\d+\.\d+\.\d+/);
Expand Down
31 changes: 31 additions & 0 deletions tests/commands/doctor.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,4 +78,35 @@ describe('doctor command', () => {
const cat = payload.data.checks.find((c: { name: string }) => c.name === 'catalog');
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;
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/);
});

it('mqtt check is ok when env vars 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;
});
});
Loading
Loading