diff --git a/src/commands/capabilities.ts b/src/commands/capabilities.ts index cc5134f..4573346 100644 --- a/src/commands/capabilities.ts +++ b/src/commands/capabilities.ts @@ -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', @@ -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 -', diff --git a/src/commands/doctor.ts b/src/commands/doctor.ts index d77b571..69e52dd 100644 --- a/src/commands/doctor.ts +++ b/src/commands/doctor.ts @@ -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; @@ -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') @@ -133,6 +150,7 @@ Examples: checkCache(), checkQuotaFile(), checkClockSkew(), + checkMqtt(), ]; const summary = { ok: checks.filter((c) => c.status === 'ok').length, diff --git a/src/commands/events.ts b/src/commands/events.ts index f28c07f..413a940 100644 --- a/src/commands/events.ts +++ b/src/commands/events.ts @@ -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 = '/'; @@ -123,7 +125,7 @@ export function startReceiver( 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') @@ -207,4 +209,84 @@ Examples: 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 ', 'MQTT topic filter (default: "#" — all topics)', '#') + .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) + +Output (JSONL, one event per line): + { "t": "", "topic": "", "payload": } + +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((resolve) => { + const cleanup = () => { + unsub(); + client.disconnect().then(resolve).catch(resolve); + }; + process.once('SIGINT', cleanup); + process.once('SIGTERM', cleanup); + ac.signal.addEventListener('abort', cleanup, { once: true }); + }); + } catch (error) { + handleError(error); + } + }); } diff --git a/src/commands/mcp.ts b/src/commands/mcp.ts index 5166a93..d7fcee9 100644 --- a/src/commands/mcp.ts +++ b/src/commands/mcp.ts @@ -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 () => { @@ -518,7 +518,7 @@ 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) @@ -526,6 +526,12 @@ The MCP server exposes seven tools over stdio: - 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): @@ -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) { diff --git a/src/index.ts b/src/index.ts index e335388..f4648f3 100644 --- a/src/index.ts +++ b/src/index.ts @@ -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 diff --git a/tests/commands/capabilities.test.ts b/tests/commands/capabilities.test.ts index 6dc740b..7d6fa1e 100644 --- a/tests/commands/capabilities.test.ts +++ b/tests/commands/capabilities.test.ts @@ -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>).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+/); diff --git a/tests/commands/doctor.test.ts b/tests/commands/doctor.test.ts index 56d40ab..29fbfd1 100644 --- a/tests/commands/doctor.test.ts +++ b/tests/commands/doctor.test.ts @@ -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; + }); }); diff --git a/tests/commands/events.test.ts b/tests/commands/events.test.ts index 1f60035..deb8acc 100644 --- a/tests/commands/events.test.ts +++ b/tests/commands/events.test.ts @@ -1,8 +1,52 @@ -import { describe, it, expect } from 'vitest'; +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import http from 'node:http'; import { once } from 'node:events'; import { AddressInfo } from 'node:net'; -import { startReceiver } from '../../src/commands/events.js'; +import { startReceiver, registerEventsCommand } from '../../src/commands/events.js'; +import { runCli } from '../helpers/cli.js'; + +// --------------------------------------------------------------------------- +// Shared mock state for SwitchBotMqttClient — hoisted so the factory can use it +// --------------------------------------------------------------------------- +const mqttMock = vi.hoisted(() => ({ + messageHandler: null as ((topic: string, payload: Buffer) => void) | null, + connectShouldFireMessage: false, + instance: null as { + connect: ReturnType; + disconnect: ReturnType; + subscribe: ReturnType; + onMessage: ReturnType; + } | null, +})); + +vi.mock('../../src/mqtt/client.js', () => { + const MockSwitchBotMqttClient = vi.fn(function (this: unknown) { + const inst = { + connect: vi.fn(async () => { + if (mqttMock.connectShouldFireMessage) { + setTimeout(() => { + if (mqttMock.messageHandler) { + mqttMock.messageHandler('test/topic', Buffer.from(JSON.stringify({ state: 'on' }))); + } + }, 0); + } + }), + disconnect: vi.fn().mockResolvedValue(undefined), + subscribe: vi.fn(), + onMessage: vi.fn((handler: (topic: string, payload: Buffer) => void) => { + mqttMock.messageHandler = handler; + return () => { mqttMock.messageHandler = null; }; + }), + }; + mqttMock.instance = inst; + return inst; + }); + return { SwitchBotMqttClient: MockSwitchBotMqttClient }; +}); + +vi.mock('../../src/mqtt/credential.js', () => ({ + getMqttConfig: vi.fn().mockReturnValue(null), +})); async function postJson(port: number, path: string, body: unknown): Promise { const payload = typeof body === 'string' ? body : JSON.stringify(body); @@ -165,3 +209,75 @@ describe('events tail receiver', () => { expect(status).toBe(413); }); }); + +// --------------------------------------------------------------------------- +// mqtt-tail subcommand tests +// --------------------------------------------------------------------------- +import { getMqttConfig } from '../../src/mqtt/credential.js'; + +describe('events mqtt-tail', () => { + beforeEach(() => { + mqttMock.messageHandler = null; + mqttMock.connectShouldFireMessage = false; + vi.mocked(getMqttConfig).mockReturnValue(null); + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + it('exits 2 with UsageError when MQTT env vars are missing', async () => { + vi.mocked(getMqttConfig).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); + }); + + 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']); + expect(res.exitCode).toBe(null); + const jsonLines = res.stdout.filter((l) => l.trim().startsWith('{')); + expect(jsonLines).toHaveLength(1); + const parsed = JSON.parse(jsonLines[0]) as { t: string; topic: string; payload: unknown }; + expect(parsed.topic).toBe('test/topic'); + expect(parsed.payload).toEqual({ state: 'on' }); + expect(typeof parsed.t).toBe('string'); + }); + + 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']); + expect(res.exitCode).toBe(null); + const jsonLines = res.stdout.filter((l) => l.trim().startsWith('{')); + expect(jsonLines).toHaveLength(1); + const parsed = JSON.parse(jsonLines[0]) as { schemaVersion: string; data: { topic: string } }; + expect(parsed.schemaVersion).toBe('1.1'); + expect(parsed.data.topic).toBe('test/topic'); + }); + + 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); + }); +});