diff --git a/README.md b/README.md index b191478..572c58b 100644 --- a/README.md +++ b/README.md @@ -165,7 +165,8 @@ switchbot config show | `--no-retry` | Disable automatic 429 retries | | `--backoff ` | Retry backoff: `exponential` (default) or `linear` | | `--no-quota` | Disable local request-quota tracking | -| `--audit-log [path]` | Append mutating commands to a JSONL audit log (default path: `~/.switchbot/audit.log`) | +| `--audit-log` | Append mutating commands to a JSONL audit log (default path: `~/.switchbot/audit.log`) | +| `--audit-log-path ` | Custom audit log path; use together with `--audit-log` | | `-V`, `--version` | Print the CLI version | | `-h`, `--help` | Show help for any command or subcommand | @@ -212,6 +213,11 @@ switchbot devices list --json | jq '.deviceList[].deviceId' # Physical: category = "physical" switchbot devices list --format=tsv --fields=deviceId,type,category +# Filter devices by type / name / category / room (server-side filter keys) +switchbot devices list --filter category=physical +switchbot devices list --filter type=Bot +switchbot devices list --filter name=living,category=physical + # Filter by family / room (family & room info requires the 'src: OpenClaw' # header, which this CLI sends on every request) switchbot devices list --json | jq '.deviceList[] | select(.familyName == "Home")' @@ -221,6 +227,16 @@ switchbot devices list --json | jq '[.deviceList[], .infraredRemoteList[]] | gro switchbot devices status switchbot devices status --json +# Resolve device by fuzzy name instead of ID (status, command, describe, expand, watch) +switchbot devices status --name "客厅空调" +switchbot devices command --name "Office Light" turnOn +switchbot devices describe --name "Kitchen Bot" + +# Batch status across multiple devices +switchbot devices status --ids ABC,DEF,GHI +switchbot devices status --ids ABC,DEF --fields power,battery # only show specific fields +switchbot devices status --ids ABC,DEF --format jsonl # one JSON line per device + # Send a control command switchbot devices command [parameter] [--type command|customize] @@ -229,7 +245,7 @@ switchbot devices describe switchbot devices describe --json # Discover what's supported (offline reference, no API call) -switchbot devices types # List all device types + IR remote types +switchbot devices types # List all device types + IR remote types (incl. role column) switchbot devices commands # Show commands, parameter formats, and status fields switchbot devices commands Bot switchbot devices commands "Smart Lock" @@ -268,6 +284,8 @@ Some commands require a packed string like `"26,2,2,on"`. `devices expand` build ```bash # Air Conditioner — setAll switchbot devices expand setAll --temp 26 --mode cool --fan low --power on +# Resolve by name +switchbot devices expand --name "客厅空调" setAll --temp 26 --mode cool --fan low --power on # Curtain / Roller Shade — setPosition switchbot devices expand setPosition --position 50 --mode silent @@ -412,6 +430,40 @@ nohup switchbot events mqtt-tail --json >> ~/switchbot-events.log 2>&1 & Run `switchbot doctor` to verify MQTT credentials are configured correctly before connecting. +#### `mqtt-tail` sinks — route events to external services + +By default `mqtt-tail` prints JSONL to stdout. Use `--sink` (repeatable) to route events to one or more destinations instead: + +| Sink | Required flags | +|---|---| +| `stdout` | (default when no `--sink` given) | +| `file` | `--sink-file ` — append JSONL | +| `webhook` | `--webhook-url ` — HTTP POST each event | +| `openclaw` | `--openclaw-url`, `--openclaw-token` (or `$OPENCLAW_TOKEN`), `--openclaw-model` | +| `telegram` | `--telegram-token` (or `$TELEGRAM_TOKEN`), `--telegram-chat ` | +| `homeassistant` | `--ha-url ` + `--ha-webhook-id` (no auth) or `--ha-token` (REST event API) | + +```bash +# Push events to an OpenClaw agent (replaces the SwitchBot channel plugin) +switchbot events mqtt-tail \ + --sink openclaw \ + --openclaw-token \ + --openclaw-model my-home-agent + +# Write to file + push to OpenClaw simultaneously +switchbot events mqtt-tail \ + --sink file --sink-file ~/.switchbot/events.jsonl \ + --sink openclaw --openclaw-token --openclaw-model home + +# Generic webhook (n8n, Make, etc.) +switchbot events mqtt-tail --sink webhook --webhook-url https://n8n.local/hook/abc + +# Forward to Home Assistant via webhook trigger +switchbot events mqtt-tail --sink homeassistant --ha-url http://homeassistant.local:8123 --ha-webhook-id switchbot +``` + +Device state is also persisted to `~/.switchbot/device-history/.json` (latest + 100-entry ring buffer) regardless of sink configuration. This enables the `get_device_history` MCP tool to answer state queries without an API call. + ### `completion` — shell tab-completion ```bash @@ -498,7 +550,7 @@ 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. +Reads the JSONL audit log (`~/.switchbot/audit.log` by default; override with `--audit-log --audit-log-path `). 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 diff --git a/docs/agent-guide.md b/docs/agent-guide.md index 04d23e6..0bb5ad8 100644 --- a/docs/agent-guide.md +++ b/docs/agent-guide.md @@ -57,21 +57,38 @@ Add to `~/Library/Application Support/Claude/claude_desktop_config.json` (macOS) } ``` -### Available tools (8) - -| Tool | Purpose | Destructive-guard? | -|---------------------|-------------------------------------------------------------------|--------------------------| -| `list_devices` | Enumerate physical devices + IR remotes | — | -| `get_device_status` | Live status for one device | — | -| `send_command` | Dispatch a built-in or customize command | yes (`confirm: true` required) | -| `list_scenes` | Enumerate saved manual scenes | — | -| `run_scene` | Execute a saved manual scene | — | -| `search_catalog` | Look up device type by name/alias | — | -| `describe_device` | Live status **plus** catalog-derived commands + suggested actions | — | -| `account_overview` | Single cold-start snapshot — devices, scenes, quota, cache, MQTT state. Call this first in a new agent session to avoid multiple round-trips. | — | +### Available tools (9) + +| Tool | Purpose | Destructive-guard? | +|------------------------|-------------------------------------------------------------------|--------------------------| +| `list_devices` | Enumerate physical devices + IR remotes | — | +| `get_device_status` | Live status for one device | — | +| `send_command` | Dispatch a built-in or customize command | yes (`confirm: true` required) | +| `list_scenes` | Enumerate saved manual scenes | — | +| `run_scene` | Execute a saved manual scene | — | +| `search_catalog` | Look up device type by name/alias | — | +| `describe_device` | Live status **plus** catalog-derived commands + suggested actions | — | +| `account_overview` | Single cold-start snapshot — devices, scenes, quota, cache, MQTT state. Call this first in a new agent session to avoid multiple round-trips. | — | +| `get_device_history` | Latest state + rolling history from disk — zero quota cost | — | The MCP server refuses destructive commands (Smart Lock `unlock`, Garage Door `open`, etc.) unless the tool call includes `confirm: true`. The allowed list is the `destructive: true` commands in the catalog — `switchbot schema export | jq '[.data.types[].commands[] | select(.destructive)]'` shows every one. +### `get_device_history` — zero-cost state lookup + +Reads `~/.switchbot/device-history/.json` written by `events mqtt-tail`. Requires no API call and costs zero quota. + +```json +// Without deviceId — list all devices with stored history +{ "tool": "get_device_history" } +// → { "devices": [{ "deviceId": "ABC123", "latest": { "t": "...", "payload": {...} } }] } + +// With deviceId — latest + rolling history (default 20, max 100 entries) +{ "tool": "get_device_history", "deviceId": "ABC123", "limit": 5 } +// → { "deviceId": "ABC123", "latest": {...}, "history": [{...}, ...] } +``` + +**Workflow**: run `switchbot events mqtt-tail` in the background (e.g. with pm2) to keep the history files fresh; then call `get_device_history` from any MCP session without consuming REST quota. + ### MCP resource: `switchbot://events` Read-only snapshot of recent MQTT shadow-update events from the ring buffer. Returns `{state, count, events[]}`. @@ -238,7 +255,25 @@ 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), `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)). +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. + +#### Routing MQTT events to an OpenClaw agent + +Run `mqtt-tail` once with `--sink openclaw` to replace the SwitchBot channel plugin entirely — no separate plugin installation required: + +```bash +switchbot events mqtt-tail \ + --sink openclaw \ + --openclaw-token \ + --openclaw-model my-home-agent + +# Persist history at the same time: +switchbot events mqtt-tail \ + --sink file --sink-file ~/.switchbot/events.jsonl \ + --sink openclaw --openclaw-token --openclaw-model home +``` + +OpenClaw exposes an OpenAI-compatible HTTP API at `http://localhost:18789/v1/chat/completions`. The sink formats each event as a short text message (e.g. `📱 Climate Panel: 27.5°C / 51%`) and POSTs it to the agent directly. --- diff --git a/package-lock.json b/package-lock.json index 8c0ee2e..2926e3e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@switchbot/openapi-cli", - "version": "2.1.0", + "version": "2.2.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@switchbot/openapi-cli", - "version": "2.1.0", + "version": "2.2.0", "license": "MIT", "dependencies": { "@modelcontextprotocol/sdk": "^1.29.0", diff --git a/package.json b/package.json index 736ae20..fa3211b 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@switchbot/openapi-cli", - "version": "2.1.0", + "version": "2.2.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", diff --git a/src/commands/capabilities.ts b/src/commands/capabilities.ts index 0f4e770..0dd607e 100644 --- a/src/commands/capabilities.ts +++ b/src/commands/capabilities.ts @@ -29,20 +29,28 @@ const MCP_TOOLS = [ 'run_scene', 'search_catalog', 'account_overview', + 'get_device_history', ]; export function registerCapabilitiesCommand(program: Command): void { program .command('capabilities') .description('Print a machine-readable manifest of CLI capabilities (for agent bootstrap)') - .action(() => { + .option('--minimal', 'Omit per-subcommand flag details to reduce output size') + .action((opts: { minimal?: boolean }) => { const catalog = getEffectiveCatalog(); - const commands = program.commands - .filter((c) => c.name() !== 'capabilities') - .map((c) => ({ + const allCommands = [ + ...program.commands, + // Commander adds 'help' implicitly; include it explicitly so it appears in the manifest + { name: () => 'help', description: () => 'Display help for a command', commands: [], options: [], registeredArguments: [] } as unknown as Command, + ]; + const commands = allCommands.map((c) => { + const entry: Record = { name: c.name(), description: c.description(), - subcommands: c.commands.map((s) => ({ + }; + if (!opts.minimal) { + entry.subcommands = c.commands.map((s) => ({ name: s.name(), description: s.description(), args: s.registeredArguments.map((a) => ({ @@ -54,8 +62,10 @@ export function registerCapabilitiesCommand(program: Command): void { flags: o.flags, description: o.description, })), - })), - })); + })); + } + return entry; + }); const globalFlags = program.options.map((opt) => ({ flags: opt.flags, description: opt.description, diff --git a/src/commands/devices.ts b/src/commands/devices.ts index f85c753..3329150 100644 --- a/src/commands/devices.ts +++ b/src/commands/devices.ts @@ -80,23 +80,64 @@ Examples: $ switchbot devices list --format tsv --fields deviceId,deviceName,type,category $ switchbot devices list --json | jq '.deviceList[] | select(.familyName == "家里")' $ switchbot devices list --json | jq '[.deviceList[], .infraredRemoteList[]] | group_by(.familyName)' + $ switchbot devices list --filter type="Air Conditioner" + $ switchbot devices list --filter category=ir + $ switchbot devices list --filter name=living,category=physical `) .option('--wide', 'Show all columns (controlType, family, roomID, room, hub, cloud)') .option('--show-hidden', 'Include devices hidden via "devices meta set --hide"') - .action(async (options: { wide?: boolean; showHidden?: boolean }) => { + .option('--filter ', 'Filter devices: "type=X", "name=X", "category=physical|ir", "room=X" (comma-separated key=value pairs)') + .action(async (options: { wide?: boolean; showHidden?: boolean; filter?: string }) => { try { const body = await fetchDeviceList(); const { deviceList, infraredRemoteList } = body; const fmt = resolveFormat(); const deviceMeta = loadDeviceMeta(); + const hubLocation = buildHubLocationMap(deviceList); + + // Parse --filter into a simple predicate map + interface ListFilter { type?: string; name?: string; category?: string; room?: string; } + let listFilter: ListFilter | null = null; + if (options.filter) { + listFilter = {}; + for (const pair of options.filter.split(',')) { + const eq = pair.indexOf('='); + if (eq === -1) throw new UsageError(`Invalid --filter pair "${pair.trim()}". Expected key=value.`); + const k = pair.slice(0, eq).trim(); + const v = pair.slice(eq + 1).trim(); + if (!['type', 'name', 'category', 'room'].includes(k)) { + throw new UsageError(`Unknown --filter key "${k}". Supported: type, name, category, room.`); + } + (listFilter as Record)[k] = v.toLowerCase(); + } + } + + const matchesFilter = (entry: { type: string; name: string; category: 'physical' | 'ir'; room: string }) => { + if (!listFilter) return true; + if (listFilter.type && !entry.type.toLowerCase().includes(listFilter.type)) return false; + if (listFilter.name && !entry.name.toLowerCase().includes(listFilter.name)) return false; + if (listFilter.category && entry.category !== listFilter.category) return false; + if (listFilter.room && !entry.room.toLowerCase().includes(listFilter.room)) return false; + return true; + }; + if (fmt === 'json' && process.argv.includes('--json')) { - printJson(body); + if (listFilter) { + const filteredDeviceList = deviceList.filter((d) => + matchesFilter({ type: d.deviceType || '', name: d.deviceName, category: 'physical', room: d.roomName || '' }) + ); + const filteredIrList = infraredRemoteList.filter((d) => { + const inherited = hubLocation.get(d.hubDeviceId); + return matchesFilter({ type: d.remoteType, name: d.deviceName, category: 'ir', room: inherited?.room || '' }); + }); + printJson({ ok: true, deviceList: filteredDeviceList, infraredRemoteList: filteredIrList }); + } else { + printJson({ ok: true, ...(body as object) }); + } return; } - const hubLocation = buildHubLocationMap(deviceList); - const narrowHeaders = ['deviceId', 'deviceName', 'type', 'category']; const wideHeaders = ['deviceId', 'deviceName', 'type', 'category', 'controlType', 'family', 'roomID', 'room', 'hub', 'cloud', 'alias']; const userFields = resolveFields(); @@ -105,6 +146,7 @@ Examples: for (const d of deviceList) { if (!options.showHidden && deviceMeta.devices[d.deviceId]?.hidden) continue; + if (!matchesFilter({ type: d.deviceType || '', name: d.deviceName, category: 'physical', room: d.roomName || '' })) continue; rows.push([ d.deviceId, d.deviceName, @@ -123,6 +165,7 @@ Examples: for (const d of infraredRemoteList) { if (!options.showHidden && deviceMeta.devices[d.deviceId]?.hidden) continue; const inherited = hubLocation.get(d.hubDeviceId); + if (!matchesFilter({ type: d.remoteType, name: d.deviceName, category: 'ir', room: inherited?.room || '' })) continue; rows.push([ d.deviceId, d.deviceName, @@ -139,14 +182,23 @@ Examples: } if (rows.length === 0 && fmt === 'table') { - console.log('No devices found'); + console.log(listFilter ? 'No devices matched the filter.' : 'No devices found'); return; } const defaultFields = options.wide ? undefined : narrowHeaders; - renderRows(wideHeaders, rows, fmt, userFields ?? defaultFields); + // Accept API field names and short aliases alongside canonical column names + const DEVICE_LIST_ALIASES: Record = { + name: 'deviceName', deviceType: 'type', type: 'type', + roomName: 'room', familyName: 'family', + hubDeviceId: 'hub', enableCloudService: 'cloud', + }; + renderRows(wideHeaders, rows, fmt, userFields ?? defaultFields, DEVICE_LIST_ALIASES); if (fmt === 'table') { - console.log(`\nTotal: ${deviceList.length} physical device(s), ${infraredRemoteList.length} IR remote device(s)`); + const totalLabel = listFilter + ? `${rows.length} match(es) (${deviceList.length} physical + ${infraredRemoteList.length} IR before filter)` + : `${deviceList.length} physical device(s), ${infraredRemoteList.length} IR remote device(s)`; + console.log(`\nTotal: ${totalLabel}`); console.log(`Tip: 'switchbot devices describe ' shows a device's supported commands.`); } } catch (error) { @@ -158,8 +210,9 @@ Examples: devices .command('status') .description('Query the real-time status of a specific device') - .argument('[deviceId]', 'Device ID from "devices list" (or use --name)') + .argument('[deviceId]', 'Device ID from "devices list" (or use --name or --ids)') .option('--name ', 'Resolve device by fuzzy name instead of deviceId') + .option('--ids ', 'Comma-separated device IDs for batch status (incompatible with --name)') .addHelpText('after', ` Status fields vary by device type. To discover them without a live call: @@ -174,28 +227,71 @@ Examples: $ switchbot devices status ABC123DEF456 --json $ switchbot devices status ABC123DEF456 --format yaml $ switchbot devices status ABC123DEF456 --format tsv --fields power,battery - $ switchbot devices status ABC123DEF456 --json | jq '.battery' + $ switchbot devices status ABC123DEF456 --json | jq '.data.battery' + $ switchbot devices status --ids ABC123,DEF456,GHI789 + $ switchbot devices status --ids ABC123,DEF456 --fields power,battery `) - .action(async (deviceIdArg: string | undefined, options: { name?: string }) => { + .action(async (deviceIdArg: string | undefined, options: { name?: string; ids?: string }) => { try { + // Batch mode: --ids id1,id2,id3 + if (options.ids) { + if (options.name) throw new UsageError('--ids and --name cannot be used together.'); + const ids = options.ids.split(',').map((s) => s.trim()).filter(Boolean); + if (ids.length === 0) throw new UsageError('--ids requires at least one device ID.'); + const results = await Promise.allSettled(ids.map((id) => fetchDeviceStatus(id))); + const fetchedAt = new Date().toISOString(); + const batch = results.map((r, i) => + r.status === 'fulfilled' + ? { deviceId: ids[i], ok: true, _fetchedAt: fetchedAt, ...(r.value as object) } + : { deviceId: ids[i], ok: false, error: (r.reason as Error)?.message ?? String(r.reason) }, + ); + const batchFmt = resolveFormat(); + if (isJsonMode() || batchFmt === 'json') { + printJson(batch); + } else if (batchFmt === 'jsonl') { + for (const entry of batch) { + console.log(JSON.stringify(entry)); + } + } else { + const fields = resolveFields(); + for (const entry of batch) { + const { deviceId, ok, error, _fetchedAt: ts, ...status } = entry as Record; + console.log(`\n─── ${String(deviceId)} ───`); + if (!ok) { + console.error(` error: ${String(error)}`); + } else { + const displayStatus: Record = fields + ? Object.fromEntries(fields.map((f) => [f, (status as Record)[f] ?? null])) + : (status as Record); + printKeyValue(displayStatus); + console.error(` fetched at ${String(ts)}`); + } + } + } + return; + } + const deviceId = resolveDeviceId(deviceIdArg, options.name); const body = await fetchDeviceStatus(deviceId); + const fetchedAt = new Date().toISOString(); const fmt = resolveFormat(); if (fmt === 'json' && process.argv.includes('--json')) { - printJson(body); + printJson({ ...(body as object), _fetchedAt: fetchedAt }); return; } if (fmt !== 'table') { - const allHeaders = Object.keys(body); - const allRows = [Object.values(body) as unknown[]]; + const statusWithTs = { ...(body as Record), _fetchedAt: fetchedAt }; + const allHeaders = Object.keys(statusWithTs); + const allRows = [Object.values(statusWithTs) as unknown[]]; const fields = resolveFields(); renderRows(allHeaders, allRows, fmt, fields); return; } printKeyValue(body); + console.error(`\nfetched at ${fetchedAt}`); } catch (error) { handleError(error); } @@ -206,7 +302,7 @@ Examples: .command('command') .description('Send a control command to a device') .argument('[deviceId]', 'Target device ID (or use --name)') - .argument('', 'Command name, e.g. turnOn, turnOff, setColor, setBrightness, setAll, startClean') + .argument('[cmd]', 'Command name, e.g. turnOn, turnOff, setColor, setBrightness, setAll, startClean') .argument('[parameter]', 'Command parameter. Omit for commands like turnOn/turnOff (defaults to "default"). Format depends on the command (see below).') .option('--name ', 'Resolve device by fuzzy name instead of deviceId') .option('--type ', 'Command type: "command" for built-in commands (default), "customize" for user-defined IR buttons', 'command') @@ -257,8 +353,31 @@ Examples: $ switchbot devices command ABC123 "MyButton" --type customize $ switchbot devices command unlock --yes `) - .action(async (deviceIdArg: string | undefined, cmd: string, parameter: string | undefined, options: { name?: string; type: string; yes?: boolean; idempotencyKey?: string }) => { - const deviceId = resolveDeviceId(deviceIdArg, options.name); + .action(async (deviceIdArg: string | undefined, cmdArg: string | undefined, parameter: string | undefined, options: { name?: string; type: string; yes?: boolean; idempotencyKey?: string }) => { + try { + // BUG-FIX: When --name is provided, Commander misassigns the first positional + // to [deviceId] instead of [cmd]. Detect and shift positionals accordingly. + let cmd: string; + let effectiveDeviceIdArg: string | undefined; + if (options.name) { + if (deviceIdArg && cmdArg) { + throw new UsageError('Provide either a deviceId argument or --name, not both.'); + } + if (!deviceIdArg && !cmdArg) { + throw new UsageError('Command name is required (e.g. turnOn, turnOff, setAll).'); + } + // --name "x" turnOn → deviceIdArg="turnOn", cmdArg=undefined → shift + cmd = (deviceIdArg ?? cmdArg) as string; + effectiveDeviceIdArg = undefined; + } else { + if (!cmdArg) { + throw new UsageError('Command name is required (e.g. turnOn, turnOff, setAll).'); + } + cmd = cmdArg; + effectiveDeviceIdArg = deviceIdArg; + } + + const deviceId = resolveDeviceId(effectiveDeviceIdArg, options.name); const validation = validateCommand(deviceId, cmd, parameter, options.type); if (!validation.ok) { const err = validation.error; @@ -317,7 +436,11 @@ Examples: process.exit(2); } - try { + // Warn when --yes is given but the command is not destructive (no-op flag) + if (options.yes && !isDestructiveCommand(cachedForGuard?.type, cmd, options.type) && !isDryRun()) { + console.error(`Note: --yes has no effect; "${cmd}" is not a destructive command.`); + } + // parameter may be a JSON object string (e.g. S10 startClean) or a plain string let parsedParam: unknown = parameter ?? 'default'; if (parameter) { @@ -349,12 +472,18 @@ Examples: return; } - console.log(`✓ Command sent: ${cmd}`); - if (isIr) console.log(' Note: IR command sent — no device confirmation (fire-and-forget).'); - if (body && typeof body === 'object' && Object.keys(body as object).length > 0) { - printKeyValue(body as Record); + if (isIr) { + console.log(`→ IR signal sent: ${cmd} (no feedback — fire-and-forget)`); + } else { + console.log(`✓ Command sent: ${cmd}`); + if (body && typeof body === 'object' && Object.keys(body as object).length > 0) { + printKeyValue(body as Record); + } } } catch (error) { + // Re-throw mock process.exit signals (Vitest intercepts process.exit as thrown + // Error('__exit__')) so they aren't double-handled and the exit code is preserved. + if (error instanceof Error && error.message === '__exit__') throw error; handleError(error); } }); @@ -379,9 +508,10 @@ Examples: printJson(catalog); return; } - const headers = ['type', 'category', 'commands', 'aliases']; + const headers = ['type', 'role', 'category', 'commands', 'aliases']; const rows = catalog.map((e) => [ e.type, + e.role ?? '—', e.category, String(e.commands.length), (e.aliases ?? []).join(', ') || '—', @@ -581,14 +711,19 @@ function renderCatalogEntry(entry: DeviceCatalogEntry): void { console.log('\nCommands: (none — status-only device)'); } else { console.log('\nCommands:'); + const hasExamples = entry.commands.some((c) => c.exampleParams && c.exampleParams.length > 0); const rows = entry.commands.map((c) => { const flags: string[] = []; if (c.commandType === 'customize') flags.push('customize'); if (c.destructive) flags.push('!destructive'); const label = flags.length > 0 ? `${c.command} [${flags.join(', ')}]` : c.command; - return [label, c.parameter, c.description]; + const base = [label, c.parameter, c.description]; + return hasExamples ? [...base, (c.exampleParams ?? []).join(' | ') || ''] : base; }); - printTable(['command', 'parameter', 'description'], rows); + const tableHeaders = hasExamples + ? ['command', 'parameter', 'description', 'example'] + : ['command', 'parameter', 'description']; + printTable(tableHeaders, rows); const hasDestructive = entry.commands.some((c) => c.destructive); if (hasDestructive) { console.log('\n[!destructive] commands have hard-to-reverse real-world effects — confirm before issuing.'); diff --git a/src/commands/events.ts b/src/commands/events.ts index 2d50e8b..be83125 100644 --- a/src/commands/events.ts +++ b/src/commands/events.ts @@ -4,6 +4,16 @@ import { printJson, isJsonMode, handleError, UsageError } from '../utils/output. import { SwitchBotMqttClient } from '../mqtt/client.js'; import { fetchMqttCredential } from '../mqtt/credential.js'; import { tryLoadConfig } from '../config.js'; +import { SinkDispatcher } from '../sinks/dispatcher.js'; +import { StdoutSink } from '../sinks/stdout.js'; +import { FileSink } from '../sinks/file.js'; +import { WebhookSink } from '../sinks/webhook.js'; +import { OpenClawSink } from '../sinks/openclaw.js'; +import { TelegramSink } from '../sinks/telegram.js'; +import { HomeAssistantSink } from '../sinks/homeassistant.js'; +import { parseSinkEvent } from '../sinks/format.js'; +import type { MqttSinkEvent } from '../sinks/types.js'; +import { deviceHistoryStore } from '../mcp/device-history.js'; const DEFAULT_PORT = 3000; const DEFAULT_PATH = '/'; @@ -216,6 +226,23 @@ Examples: .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)') + .option( + '--sink ', + 'Output sink: stdout (default), file, webhook, openclaw, telegram, homeassistant (repeatable)', + (val: string, prev: string[]) => [...prev, val], + [] as string[], + ) + .option('--sink-file ', 'File path for file sink') + .option('--webhook-url ', 'Webhook URL for webhook sink') + .option('--openclaw-url ', 'OpenClaw gateway URL (default: http://localhost:18789)') + .option('--openclaw-token ', 'Bearer token for OpenClaw (or env OPENCLAW_TOKEN)') + .option('--openclaw-model ', 'OpenClaw agent model ID to route events to') + .option('--telegram-token ', 'Telegram bot token (or env TELEGRAM_TOKEN)') + .option('--telegram-chat ', 'Telegram chat/channel ID to send messages to') + .option('--ha-url ', 'Home Assistant base URL (e.g. http://homeassistant.local:8123)') + .option('--ha-token ', 'HA long-lived access token (for REST event API)') + .option('--ha-webhook-id ', 'HA webhook ID (no auth; takes priority over --ha-token)') + .option('--ha-event-type ', 'HA event type for REST API (default: switchbot_event)') .addHelpText( 'after', ` @@ -226,39 +253,114 @@ No additional MQTT configuration required. Output (JSONL, one event per line): { "t": "", "topic": "", "payload": } +Sink types (--sink, repeatable): + stdout Print JSONL to stdout (default when no --sink given) + file Append JSONL to --sink-file + webhook HTTP POST to --webhook-url + openclaw POST to OpenClaw via --openclaw-url / --openclaw-token / --openclaw-model + telegram Send to Telegram via --telegram-token / --telegram-chat + homeassistant POST to HA via --ha-url + --ha-webhook-id (or --ha-token) + +Device state is also persisted to ~/.switchbot/device-history/.json +regardless of sink configuration. + Examples: $ switchbot events mqtt-tail - $ switchbot events mqtt-tail --topic 'switchbot/#' $ switchbot events mqtt-tail --max 10 --json + $ switchbot events mqtt-tail --sink file --sink-file ~/.switchbot/events.jsonl + $ switchbot events mqtt-tail --sink openclaw --openclaw-token abc --openclaw-model home-agent + $ switchbot events mqtt-tail --sink telegram --telegram-token --telegram-chat + $ switchbot events mqtt-tail --sink homeassistant --ha-url http://ha.local:8123 --ha-webhook-id switchbot + $ switchbot events mqtt-tail --sink stdout --sink openclaw --openclaw-token abc --openclaw-model home `, ) - .action(async (options: { topic?: string; max?: string }) => { + .action(async (options: { + topic?: string; + max?: string; + sink: string[]; + sinkFile?: string; + webhookUrl?: string; + openclawUrl?: string; + openclawToken?: string; + openclawModel?: string; + telegramToken?: string; + telegramChat?: string; + haUrl?: string; + haToken?: string; + haWebhookId?: string; + haEventType?: string; + }) => { try { 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; + + const sinkTypes = options.sink; + let dispatcher: SinkDispatcher | null = null; + + if (sinkTypes.length > 0) { + const sinks = sinkTypes.map((type) => { + switch (type) { + case 'stdout': + return new StdoutSink(); + case 'file': { + if (!options.sinkFile) throw new UsageError('--sink file requires --sink-file '); + return new FileSink(options.sinkFile); + } + case 'webhook': { + if (!options.webhookUrl) throw new UsageError('--sink webhook requires --webhook-url '); + return new WebhookSink(options.webhookUrl); + } + case 'openclaw': { + const token = options.openclawToken ?? process.env.OPENCLAW_TOKEN; + if (!token) throw new UsageError('--sink openclaw requires --openclaw-token or env OPENCLAW_TOKEN'); + if (!options.openclawModel) throw new UsageError('--sink openclaw requires --openclaw-model '); + return new OpenClawSink({ url: options.openclawUrl, token, model: options.openclawModel }); + } + case 'telegram': { + const token = options.telegramToken ?? process.env.TELEGRAM_TOKEN; + if (!token) throw new UsageError('--sink telegram requires --telegram-token or env TELEGRAM_TOKEN'); + if (!options.telegramChat) throw new UsageError('--sink telegram requires --telegram-chat '); + return new TelegramSink({ token, chatId: options.telegramChat }); + } + case 'homeassistant': { + if (!options.haUrl) throw new UsageError('--sink homeassistant requires --ha-url '); + if (!options.haWebhookId && !options.haToken) { + throw new UsageError('--sink homeassistant requires --ha-webhook-id or --ha-token'); + } + return new HomeAssistantSink({ + url: options.haUrl, + token: options.haToken, + webhookId: options.haWebhookId, + eventType: options.haEventType, + }); + } + default: + throw new UsageError(`Unknown --sink type "${type}". Supported: stdout, file, webhook, openclaw, telegram, homeassistant`); + } + }); + dispatcher = new SinkDispatcher(sinks); + } if (!isJsonMode()) { console.error('Fetching MQTT credentials from SwitchBot service…'); } - const credential = await fetchMqttCredential(creds.token, creds.secret); + const credential = await fetchMqttCredential(loaded.token, loaded.secret); const topic = options.topic ?? credential.topics.status; let eventCount = 0; const ac = new AbortController(); const client = new SwitchBotMqttClient( credential, - () => fetchMqttCredential(creds.token, creds.secret), + () => fetchMqttCredential(loaded.token, loaded.secret), ); const unsub = client.onMessage((msgTopic, payload) => { @@ -268,18 +370,38 @@ Examples: } catch { parsed = payload.toString('utf-8'); } - const record = { t: new Date().toISOString(), topic: msgTopic, payload: parsed }; - if (isJsonMode()) { - printJson(record); + + const t = new Date().toISOString(); + + if (dispatcher) { + const { deviceId, deviceType, text } = parseSinkEvent(parsed); + const sinkEvent: MqttSinkEvent = { t, topic: msgTopic, deviceId, deviceType, payload: parsed, text }; + deviceHistoryStore.record(deviceId, msgTopic, deviceType, parsed, t); + dispatcher.dispatch(sinkEvent).catch(() => {}); } else { - console.log(JSON.stringify(record)); + // Default behavior: record history + print to stdout + const { deviceId, deviceType } = parseSinkEvent(parsed); + deviceHistoryStore.record(deviceId, msgTopic, deviceType, parsed, t); + const record = { t, topic: msgTopic, payload: parsed }; + if (isJsonMode()) { + printJson(record); + } else { + console.log(JSON.stringify(record)); + } } + eventCount++; if (maxEvents !== null && eventCount >= maxEvents) { ac.abort(); } }); + const unsubState = client.onStateChange((state) => { + if (!isJsonMode()) { + console.error(`[${new Date().toLocaleTimeString()}] MQTT state: ${state}`); + } + }); + await client.connect(); client.subscribe(topic); @@ -292,6 +414,8 @@ Examples: process.removeListener('SIGINT', cleanup); process.removeListener('SIGTERM', cleanup); unsub(); + unsubState(); + dispatcher?.close().catch(() => {}); client.disconnect().then(resolve).catch(resolve); }; process.once('SIGINT', cleanup); diff --git a/src/commands/expand.ts b/src/commands/expand.ts index a7d9576..e042af9 100644 --- a/src/commands/expand.ts +++ b/src/commands/expand.ts @@ -3,6 +3,7 @@ import { handleError, isJsonMode, printJson, UsageError } from '../utils/output. import { getCachedDevice } from '../devices/cache.js'; import { executeCommand, isDestructiveCommand, getDestructiveReason } from '../lib/devices.js'; import { isDryRun } from '../utils/flags.js'; +import { resolveDeviceId } from '../utils/name-resolver.js'; import { DryRunSignal } from '../api/client.js'; // ---- Mapping tables -------------------------------------------------------- @@ -95,8 +96,9 @@ export function registerExpandCommand(devices: Command): void { devices .command('expand') .description('Send a command with semantic flags instead of raw positional parameters') - .argument('', 'Target device ID from "devices list"') - .argument('', 'Command name: setAll (AC), setPosition (Curtain/Blind Tilt), setMode (Relay Switch 2)') + .argument('[deviceId]', 'Target device ID from "devices list" (or use --name)') + .argument('[command]', 'Command name: setAll (AC), setPosition (Curtain/Blind Tilt), setMode (Relay Switch 2)') + .option('--name ', 'Resolve device by fuzzy name instead of deviceId') .option('--temp ', 'AC setAll: temperature in Celsius (16-30)') .option('--mode ', 'AC: auto|cool|dry|fan|heat Curtain: default|performance|silent Relay: toggle|edge|detached|momentary') .option('--fan ', 'AC setAll: fan speed auto|low|mid|high') @@ -133,17 +135,34 @@ Examples: $ switchbot devices expand setPosition --direction up --angle 50 $ switchbot devices expand setMode --channel 1 --mode edge $ switchbot devices expand setAll --temp 22 --mode heat --fan auto --power on --dry-run + $ switchbot devices expand --name "客厅空调" setAll --temp 26 --mode cool --fan low --power on `) .action(async ( - deviceId: string, - command: string, + deviceIdArg: string | undefined, + commandArg: string | undefined, options: { + name?: string; temp?: string; mode?: string; fan?: string; power?: string; position?: string; direction?: string; angle?: string; channel?: string; yes?: boolean; } ) => { + let deviceId = ''; + let command = ''; try { + // When --name is provided, Commander assigns the first positional to deviceIdArg + // and leaves commandArg undefined. Detect and shift. + let effectiveDeviceIdArg = deviceIdArg; + let effectiveCommand = commandArg; + if (options.name && deviceIdArg && !commandArg) { + effectiveCommand = deviceIdArg; + effectiveDeviceIdArg = undefined; + } + + deviceId = resolveDeviceId(effectiveDeviceIdArg, options.name); + if (!effectiveCommand) throw new UsageError('A command argument is required (setAll, setPosition, setMode).'); + + command = effectiveCommand; const cached = getCachedDevice(deviceId); const deviceType = cached?.type ?? ''; diff --git a/src/commands/mcp.ts b/src/commands/mcp.ts index 178365d..c47aea4 100644 --- a/src/commands/mcp.ts +++ b/src/commands/mcp.ts @@ -22,6 +22,7 @@ import { fetchScenes, executeScene } from '../lib/scenes.js'; import { findCatalogEntry } from '../devices/catalog.js'; import { getCachedDevice } from '../devices/cache.js'; import { EventSubscriptionManager } from '../mcp/events-subscription.js'; +import { deviceHistoryStore } from '../mcp/device-history.js'; import { todayUsage } from '../utils/quota.js'; import { describeCache } from '../devices/cache.js'; import { withRequestContext } from '../lib/request-context.js'; @@ -152,6 +153,46 @@ API docs: https://github.com/OpenWonderLabs/SwitchBotAPI`, } ); + // ---- get_device_history ---------------------------------------------------- + server.registerTool( + 'get_device_history', + { + title: 'Get locally-persisted device state history', + description: + 'Return device state history recorded from MQTT events (persisted to ~/.switchbot/device-history/). ' + + 'No API call — zero quota cost. Use when you need recent historical readings or want to avoid a live API call. ' + + 'Omit deviceId to list all devices with stored history.', + inputSchema: { + deviceId: z.string().optional().describe('Device MAC address (deviceId). Omit to list all devices with history.'), + limit: z.number().int().min(1).max(100).optional().describe('Max history entries to return (default 20, max 100)'), + }, + outputSchema: { + deviceId: z.string().optional(), + latest: z.unknown().optional(), + history: z.array(z.unknown()).optional(), + devices: z.array(z.object({ deviceId: z.string(), latest: z.unknown() })).optional(), + }, + }, + async ({ deviceId, limit }) => { + if (deviceId) { + const latest = deviceHistoryStore.getLatest(deviceId); + const history = deviceHistoryStore.getHistory(deviceId, limit ?? 20); + const result = { deviceId, latest, history }; + return { + content: [{ type: 'text', text: JSON.stringify(result, null, 2) }], + structuredContent: result, + }; + } + const ids = deviceHistoryStore.listDevices(); + const devices = ids.map((id) => ({ deviceId: id, latest: deviceHistoryStore.getLatest(id) })); + const result = { devices }; + return { + content: [{ type: 'text', text: JSON.stringify(result, null, 2) }], + structuredContent: result, + }; + } + ); + // ---- send_command --------------------------------------------------------- server.registerTool( 'send_command', diff --git a/src/commands/plan.ts b/src/commands/plan.ts index cbbcaf2..79e0ba8 100644 --- a/src/commands/plan.ts +++ b/src/commands/plan.ts @@ -267,9 +267,15 @@ Workflow: process.exit(2); } if (isJsonMode()) { - printJson({ valid: true, steps: result.plan.steps.length }); + const out: Record = { valid: true, steps: result.plan.steps.length }; + if (result.plan.steps.length === 0) out.warning = 'plan has no steps — nothing will execute'; + printJson(out); } else { - console.log(`✓ plan valid (${result.plan.steps.length} step${result.plan.steps.length === 1 ? '' : 's'})`); + if (result.plan.steps.length === 0) { + console.log('✓ plan valid — but 0 steps: nothing will execute'); + } else { + console.log(`✓ plan valid (${result.plan.steps.length} step${result.plan.steps.length === 1 ? '' : 's'})`); + } } }); diff --git a/src/commands/scenes.ts b/src/commands/scenes.ts index 72d011a..38b6130 100644 --- a/src/commands/scenes.ts +++ b/src/commands/scenes.ts @@ -37,6 +37,7 @@ Examples: scenes.map((s) => [s.sceneId, s.sceneName]), fmt, resolveFields(), + { id: 'sceneId', name: 'sceneName' }, ); if (fmt === 'table' && scenes.length === 0) { console.log('No scenes found'); diff --git a/src/commands/watch.ts b/src/commands/watch.ts index 250ed6b..91879df 100644 --- a/src/commands/watch.ts +++ b/src/commands/watch.ts @@ -4,6 +4,7 @@ import { fetchDeviceStatus } from '../lib/devices.js'; import { getCachedDevice } from '../devices/cache.js'; import { parseDurationToMs, getFields } from '../utils/flags.js'; import { createClient } from '../api/client.js'; +import { resolveDeviceId } from '../utils/name-resolver.js'; const DEFAULT_INTERVAL_MS = 30_000; const MIN_INTERVAL_MS = 1_000; @@ -70,7 +71,8 @@ export function registerWatchCommand(devices: Command): void { devices .command('watch') .description('Poll device status on an interval and emit field-level changes (JSONL)') - .argument('', 'One or more deviceIds to watch') + .argument('[deviceId...]', 'One or more deviceIds to watch (or use --name for one device)') + .option('--name ', 'Resolve one device by fuzzy name (combined with any positional IDs)') .option( '--interval ', `Polling interval: "30s", "1m", "500ms", ... (default 30s, min ${MIN_INTERVAL_MS / 1000}s)`, @@ -92,18 +94,26 @@ Examples: $ switchbot devices watch ABC123 --fields battery,power --interval 1m $ switchbot devices watch ABC123 DEF456 --interval 30s --max 10 $ switchbot devices watch ABC123 --json | jq 'select(.changed.power)' + $ switchbot devices watch --name "客厅空调" --interval 10s `, ) .action( async ( deviceIds: string[], options: { + name?: string; interval: string; max?: string; includeUnchanged?: boolean; }, ) => { try { + const allIds = [...deviceIds]; + if (options.name) { + const resolved = resolveDeviceId(undefined, options.name); + if (!allIds.includes(resolved)) allIds.push(resolved); + } + if (allIds.length === 0) throw new UsageError('Provide at least one deviceId argument or --name.'); const parsed = parseDurationToMs(options.interval); if (parsed === null || parsed < MIN_INTERVAL_MS) { throw new UsageError( @@ -138,7 +148,7 @@ Examples: // Poll all devices in parallel; one failure per device doesn't stop // the others. await Promise.all( - deviceIds.map(async (id) => { + allIds.map(async (id) => { const cached = getCachedDevice(id); try { const body = await fetchDeviceStatus(id, client); diff --git a/src/index.ts b/src/index.ts index a251aae..6291f14 100644 --- a/src/index.ts +++ b/src/index.ts @@ -40,7 +40,8 @@ program .option('--no-cache', 'Disable cache reads (equivalent to --cache off)') .option('--config ', 'Override credential file location (default: ~/.switchbot/config.json)') .option('--profile ', 'Use a named profile: ~/.switchbot/profiles/.json') - .option('--audit-log [path]', 'Append every mutating command to JSONL audit log (default ~/.switchbot/audit.log)') + .option('--audit-log', 'Append every mutating command to JSONL audit log (default path: ~/.switchbot/audit.log)') + .option('--audit-log-path ', 'Custom audit log file path; use together with --audit-log') .showHelpAfterError('(run with --help to see usage)') .showSuggestionAfterError(); diff --git a/src/lib/devices.ts b/src/lib/devices.ts index 3b6c1f3..69b0aa9 100644 --- a/src/lib/devices.ts +++ b/src/lib/devices.ts @@ -76,7 +76,7 @@ export class DeviceNotFoundError extends Error { export class CommandValidationError extends Error { constructor( message: string, - public readonly kind: 'unknown-command' | 'unexpected-parameter', + public readonly kind: 'unknown-command' | 'unexpected-parameter' | 'missing-parameter', public readonly hint?: string ) { super(message); @@ -229,12 +229,16 @@ export function validateCommand( const spec = builtinCommands.find((c) => c.command === cmd); if (!spec) { const unique = [...new Set(builtinCommands.map((c) => c.command))]; + const caseMatch = unique.find((c) => c.toLowerCase() === cmd.toLowerCase()); + const hint = caseMatch + ? `Did you mean "${caseMatch}"? Supported commands: ${unique.join(', ')}` + : `Supported commands: ${unique.join(', ')}`; return { ok: false, error: new CommandValidationError( `"${cmd}" is not a supported command for ${cached.name} (${cached.type}).`, 'unknown-command', - `Supported commands: ${unique.join(', ')}` + hint ), }; } @@ -252,6 +256,22 @@ export function validateCommand( }; } + // Warn when a parameter is required but the user omitted it + const paramRequired = !noParamExpected && spec.parameter !== 'default'; + if (paramRequired && !userProvidedParam) { + const example = (spec as { exampleParams?: string[] }).exampleParams?.[0]; + return { + ok: false, + error: new CommandValidationError( + `"${cmd}" requires a parameter (${spec.parameter}).`, + 'missing-parameter', + example + ? `Example: switchbot devices command ${cmd} "${example}"` + : `See: switchbot devices commands ${cached.type}`, + ), + }; + } + return { ok: true }; } diff --git a/src/mcp/device-history.ts b/src/mcp/device-history.ts new file mode 100644 index 0000000..873aa73 --- /dev/null +++ b/src/mcp/device-history.ts @@ -0,0 +1,79 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import os from 'node:os'; + +export interface HistoryEntry { + t: string; + topic: string; + deviceType: string; + payload: unknown; +} + +export interface DeviceHistory { + latest: HistoryEntry | null; + history: HistoryEntry[]; +} + +const MAX_HISTORY = 100; + +function historyDir(): string { + return path.join(os.homedir(), '.switchbot', 'device-history'); +} + +export class DeviceHistoryStore { + private dir: string; + + constructor() { + this.dir = historyDir(); + } + + record(deviceId: string, topic: string, deviceType: string, payload: unknown, t?: string): void { + try { + if (!fs.existsSync(this.dir)) fs.mkdirSync(this.dir, { recursive: true }); + const file = path.join(this.dir, `${deviceId}.json`); + const existing: DeviceHistory = fs.existsSync(file) + ? (JSON.parse(fs.readFileSync(file, 'utf-8')) as DeviceHistory) + : { latest: null, history: [] }; + const entry: HistoryEntry = { t: t ?? new Date().toISOString(), topic, deviceType, payload }; + existing.latest = entry; + existing.history = [entry, ...existing.history].slice(0, MAX_HISTORY); + fs.writeFileSync(file, JSON.stringify(existing, null, 2), { mode: 0o600 }); + } catch { + // best-effort — history loss is non-fatal + } + } + + getLatest(deviceId: string): HistoryEntry | null { + try { + const file = path.join(this.dir, `${deviceId}.json`); + if (!fs.existsSync(file)) return null; + return (JSON.parse(fs.readFileSync(file, 'utf-8')) as DeviceHistory).latest; + } catch { + return null; + } + } + + getHistory(deviceId: string, limit = 20): HistoryEntry[] { + try { + const file = path.join(this.dir, `${deviceId}.json`); + if (!fs.existsSync(file)) return []; + const data = JSON.parse(fs.readFileSync(file, 'utf-8')) as DeviceHistory; + return data.history.slice(0, Math.min(limit, MAX_HISTORY)); + } catch { + return []; + } + } + + listDevices(): string[] { + try { + if (!fs.existsSync(this.dir)) return []; + return fs.readdirSync(this.dir) + .filter((f) => f.endsWith('.json')) + .map((f) => f.slice(0, -5)); + } catch { + return []; + } + } +} + +export const deviceHistoryStore = new DeviceHistoryStore(); diff --git a/src/mcp/events-subscription.ts b/src/mcp/events-subscription.ts index 0c7df67..b35699a 100644 --- a/src/mcp/events-subscription.ts +++ b/src/mcp/events-subscription.ts @@ -6,6 +6,7 @@ import { getCachedDevice } from '../devices/cache.js'; import type { AxiosInstance } from 'axios'; import { createClient } from '../api/client.js'; import { log } from '../logger.js'; +import { deviceHistoryStore } from './device-history.js'; export interface ShadowEvent { kind: 'shadow.updated'; @@ -65,12 +66,18 @@ export class EventSubscriptionManager { client.onMessage((topic, payload) => { try { const data = JSON.parse(payload.toString()); - const deviceId = this.extractDeviceId(topic); - if (deviceId && data.state) { + // Support SwitchBot direct format: { eventType, context: { deviceMac, deviceType, ... } } + // and AWS IoT shadow format: $aws/things//shadow/... with data.state + const context = data.context as Record | undefined; + const deviceId = (context?.deviceMac as string | undefined) ?? this.extractDeviceId(topic); + const payloadData = context ?? data.state; + const deviceType = String(context?.deviceType ?? 'Unknown'); + if (deviceId && payloadData) { + deviceHistoryStore.record(deviceId, topic, deviceType, payloadData); this.addEvent({ kind: 'shadow.updated', deviceId, - payload: data.state, + payload: payloadData, timestamp: Date.now(), }); } diff --git a/src/mqtt/client.ts b/src/mqtt/client.ts index dca65e7..cecd002 100644 --- a/src/mqtt/client.ts +++ b/src/mqtt/client.ts @@ -26,6 +26,15 @@ export class SwitchBotMqttClient { async connect(): Promise { if (this.client && this.state === 'connected') return; + // Remove stale listeners before replacing the client instance, otherwise + // the old client's close event fires after the new connection is established + // (AWS IoT drops the old session), triggering a spurious reconnect loop. + if (this.client) { + this.client.removeAllListeners(); + this.client.end(true); + this.client = null; + } + this.setState('connecting'); this.credentialExpired = false; this.reconnectAttempts = 0; diff --git a/src/mqtt/credential.ts b/src/mqtt/credential.ts index 715f142..62977fc 100644 --- a/src/mqtt/credential.ts +++ b/src/mqtt/credential.ts @@ -20,8 +20,9 @@ export interface MqttCredential { 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); + // Use a random instanceId so each CLI session gets its own clientId, avoiding + // conflicts with the SwitchBot cloud service that shares the same account credentials. + const instanceId = crypto.randomUUID().replace(/-/g, '').slice(0, 16); const headers = buildAuthHeaders(token, secret); const res = await fetch(CREDENTIAL_ENDPOINT, { diff --git a/src/sinks/dispatcher.ts b/src/sinks/dispatcher.ts new file mode 100644 index 0000000..1d8609e --- /dev/null +++ b/src/sinks/dispatcher.ts @@ -0,0 +1,17 @@ +import type { Sink, MqttSinkEvent } from './types.js'; + +export class SinkDispatcher { + private sinks: Sink[]; + + constructor(sinks: Sink[]) { + this.sinks = sinks; + } + + async dispatch(event: MqttSinkEvent): Promise { + await Promise.allSettled(this.sinks.map((s) => s.write(event))); + } + + async close(): Promise { + await Promise.allSettled(this.sinks.map((s) => s.close?.())); + } +} diff --git a/src/sinks/file.ts b/src/sinks/file.ts new file mode 100644 index 0000000..cd9ff6d --- /dev/null +++ b/src/sinks/file.ts @@ -0,0 +1,21 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import type { Sink, MqttSinkEvent } from './types.js'; + +export class FileSink implements Sink { + private filePath: string; + + constructor(filePath: string) { + this.filePath = path.resolve(filePath); + const dir = path.dirname(this.filePath); + if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true }); + } + + async write(event: MqttSinkEvent): Promise { + try { + fs.appendFileSync(this.filePath, JSON.stringify(event) + '\n', { encoding: 'utf-8' }); + } catch { + // best-effort + } + } +} diff --git a/src/sinks/format.ts b/src/sinks/format.ts new file mode 100644 index 0000000..c4ad717 --- /dev/null +++ b/src/sinks/format.ts @@ -0,0 +1,73 @@ +export interface DeviceContext { + deviceMac?: string; + deviceType?: string; + temperature?: number; + humidity?: number; + power?: string; + battery?: number; + brightness?: string; + detectionState?: string; + openState?: string; + lockState?: string; + lightLevel?: number; + [key: string]: unknown; +} + +const ICONS: Record = { + 'Bot': '🤖', + 'Curtain': '🪟', + 'Hub': '📡', + 'Hub 2': '📡', + 'Hub 3': '📡', + 'Hub Mini': '📡', + 'Smart Lock': '🔒', + 'Smart Lock Pro': '🔒', + 'Plug': '🔌', + 'Plug Mini (US)': '🔌', + 'Plug Mini (JP)': '🔌', + 'Color Bulb': '💡', + 'Strip Light': '💡', + 'Contact Sensor': '🚪', + 'Motion Sensor': '👁', + 'Meter': '🌡', + 'MeterPro': '🌡', + 'Climate Panel': '🌡', + 'WoMeter': '🌡', + 'WoIOSensor': '🌡', +}; + +function icon(deviceType: string): string { + return ICONS[deviceType] ?? '📱'; +} + +export function formatEventText(context: DeviceContext): string { + const type = context.deviceType ?? 'Unknown'; + const pfx = `${icon(type)} ${type}`; + const parts: string[] = []; + + if (context.temperature !== undefined) parts.push(`${context.temperature}°C`); + if (context.humidity !== undefined) parts.push(`${context.humidity}%`); + if (parts.length) return `${pfx}: ${parts.join(' / ')}`; + + if (context.power !== undefined) return `${pfx}: ${context.power}`; + if (context.lockState !== undefined) return `${pfx}: ${context.lockState}`; + if (context.openState !== undefined) return `${pfx}: ${context.openState}`; + if (context.detectionState !== undefined) return `${pfx}: ${context.detectionState}`; + if (context.brightness !== undefined) return `${pfx}: ${context.brightness}`; + + return `${pfx}: state change`; +} + +export function parseSinkEvent(payload: unknown): { + deviceId: string; + deviceType: string; + text: string; +} { + const p = payload as Record | null | undefined; + const context = ((p?.context ?? {}) as DeviceContext); + return { + deviceId: String(context.deviceMac ?? 'unknown'), + deviceType: String(context.deviceType ?? 'Unknown'), + text: formatEventText(context), + }; +} diff --git a/src/sinks/homeassistant.ts b/src/sinks/homeassistant.ts new file mode 100644 index 0000000..a5fa51d --- /dev/null +++ b/src/sinks/homeassistant.ts @@ -0,0 +1,57 @@ +import type { Sink, MqttSinkEvent } from './types.js'; + +export interface HomeAssistantSinkOptions { + url: string; + /** Long-lived access token — used when webhookId is not set */ + token?: string; + /** Webhook ID — no auth required, takes priority over token */ + webhookId?: string; + /** Event type for REST event API (default: switchbot_event) */ + eventType?: string; +} + +export class HomeAssistantSink implements Sink { + private url: string; + private token?: string; + private webhookId?: string; + private eventType: string; + + constructor(opts: HomeAssistantSinkOptions) { + this.url = opts.url.replace(/\/$/, ''); + this.token = opts.token; + this.webhookId = opts.webhookId; + this.eventType = opts.eventType ?? 'switchbot_event'; + } + + async write(event: MqttSinkEvent): Promise { + try { + let endpoint: string; + const headers: Record = { 'content-type': 'application/json' }; + + if (this.webhookId) { + // Webhook mode: no auth needed, HA triggers automations directly + endpoint = `${this.url}/api/webhook/${this.webhookId}`; + } else if (this.token) { + // REST event API: fires a custom event on the HA event bus + endpoint = `${this.url}/api/events/${this.eventType}`; + headers['authorization'] = `Bearer ${this.token}`; + } else { + console.error('[homeassistant] requires --ha-webhook-id or --ha-token'); + return; + } + + const res = await fetch(endpoint, { + method: 'POST', + headers, + body: JSON.stringify(event), + signal: AbortSignal.timeout(10000), + }); + if (!res.ok) { + const body = await res.text().catch(() => ''); + console.error(`[homeassistant] POST failed: HTTP ${res.status} ${body.slice(0, 200)}`); + } + } catch (err) { + console.error(`[homeassistant] error: ${err instanceof Error ? err.message : String(err)}`); + } + } +} diff --git a/src/sinks/openclaw.ts b/src/sinks/openclaw.ts new file mode 100644 index 0000000..769e839 --- /dev/null +++ b/src/sinks/openclaw.ts @@ -0,0 +1,42 @@ +import type { Sink, MqttSinkEvent } from './types.js'; + +export interface OpenClawSinkOptions { + url?: string; + token: string; + model: string; +} + +export class OpenClawSink implements Sink { + private url: string; + private token: string; + private model: string; + + constructor(opts: OpenClawSinkOptions) { + this.url = (opts.url ?? 'http://localhost:18789').replace(/\/$/, ''); + this.token = opts.token; + this.model = opts.model; + } + + async write(event: MqttSinkEvent): Promise { + try { + const res = await fetch(`${this.url}/v1/chat/completions`, { + method: 'POST', + headers: { + 'content-type': 'application/json', + 'authorization': `Bearer ${this.token}`, + }, + body: JSON.stringify({ + model: this.model, + messages: [{ role: 'user', content: event.text }], + }), + signal: AbortSignal.timeout(10000), + }); + if (!res.ok) { + const body = await res.text().catch(() => ''); + console.error(`[openclaw] POST failed: HTTP ${res.status} ${body.slice(0, 200)}`); + } + } catch (err) { + console.error(`[openclaw] error: ${err instanceof Error ? err.message : String(err)}`); + } + } +} diff --git a/src/sinks/stdout.ts b/src/sinks/stdout.ts new file mode 100644 index 0000000..8c6b579 --- /dev/null +++ b/src/sinks/stdout.ts @@ -0,0 +1,7 @@ +import type { Sink, MqttSinkEvent } from './types.js'; + +export class StdoutSink implements Sink { + async write(event: MqttSinkEvent): Promise { + console.log(JSON.stringify(event)); + } +} diff --git a/src/sinks/telegram.ts b/src/sinks/telegram.ts new file mode 100644 index 0000000..47d5de8 --- /dev/null +++ b/src/sinks/telegram.ts @@ -0,0 +1,36 @@ +import type { Sink, MqttSinkEvent } from './types.js'; + +export interface TelegramSinkOptions { + token: string; + chatId: string; +} + +export class TelegramSink implements Sink { + private token: string; + private chatId: string; + + constructor(opts: TelegramSinkOptions) { + this.token = opts.token; + this.chatId = opts.chatId; + } + + async write(event: MqttSinkEvent): Promise { + try { + const res = await fetch(`https://api.telegram.org/bot${this.token}/sendMessage`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + chat_id: this.chatId, + text: event.text, + }), + signal: AbortSignal.timeout(10000), + }); + if (!res.ok) { + const body = await res.text().catch(() => ''); + console.error(`[telegram] POST failed: HTTP ${res.status} ${body.slice(0, 200)}`); + } + } catch (err) { + console.error(`[telegram] error: ${err instanceof Error ? err.message : String(err)}`); + } + } +} diff --git a/src/sinks/types.ts b/src/sinks/types.ts new file mode 100644 index 0000000..623908a --- /dev/null +++ b/src/sinks/types.ts @@ -0,0 +1,13 @@ +export interface MqttSinkEvent { + t: string; + topic: string; + deviceId: string; + deviceType: string; + payload: unknown; + text: string; +} + +export interface Sink { + write(event: MqttSinkEvent): Promise; + close?(): Promise; +} diff --git a/src/sinks/webhook.ts b/src/sinks/webhook.ts new file mode 100644 index 0000000..5e97263 --- /dev/null +++ b/src/sinks/webhook.ts @@ -0,0 +1,25 @@ +import type { Sink, MqttSinkEvent } from './types.js'; + +export class WebhookSink implements Sink { + private url: string; + + constructor(url: string) { + this.url = url; + } + + async write(event: MqttSinkEvent): Promise { + try { + const res = await fetch(this.url, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(event), + signal: AbortSignal.timeout(10000), + }); + if (!res.ok) { + console.error(`[webhook] POST failed: HTTP ${res.status}`); + } + } catch (err) { + console.error(`[webhook] error: ${err instanceof Error ? err.message : String(err)}`); + } + } +} diff --git a/src/utils/flags.ts b/src/utils/flags.ts index a0956ae..a763cbb 100644 --- a/src/utils/flags.ts +++ b/src/utils/flags.ts @@ -21,12 +21,16 @@ export function isDryRun(): boolean { return process.argv.includes('--dry-run'); } -/** HTTP request timeout in milliseconds. Default 30s. */ +/** HTTP request timeout in milliseconds. Default 30s. Minimum 100ms (values below 100ms are ignored). */ export function getTimeout(): number { const v = getFlagValue('--timeout'); if (!v) return 30_000; const n = Number(v); if (!Number.isFinite(n) || n <= 0) return 30_000; + if (n < 100) { + process.stderr.write(`Warning: --timeout ${n}ms is too low to complete any request; using 100ms minimum.\n`); + return 100; + } return n; } @@ -41,19 +45,15 @@ export function getProfile(): string | undefined { } /** - * Audit log path. `--audit-log ` enables JSONL append on every mutating - * command; default path is ~/.switchbot/audit.log when `--audit-log` is given - * without a value. Returns null when the flag is absent. + * Audit log path. `--audit-log` enables JSONL append on every mutating command. + * Use `--audit-log-path ` to specify a custom file; otherwise defaults to + * ~/.switchbot/audit.log. Returns null when --audit-log is absent. */ export function getAuditLog(): string | null { - const idx = process.argv.indexOf('--audit-log'); - if (idx === -1) return null; - const next = process.argv[idx + 1]; - if (!next || next.startsWith('-')) { - // bare --audit-log → default location - return `${process.env.HOME ?? process.env.USERPROFILE ?? '.'}/.switchbot/audit.log`; - } - return next; + if (!process.argv.includes('--audit-log')) return null; + const customPath = getFlagValue('--audit-log-path'); + if (customPath) return customPath; + return `${process.env.HOME ?? process.env.USERPROFILE ?? '.'}/.switchbot/audit.log`; } /** diff --git a/src/utils/format.ts b/src/utils/format.ts index 4790b26..0edafcf 100644 --- a/src/utils/format.ts +++ b/src/utils/format.ts @@ -39,16 +39,18 @@ export function filterFields( headers: string[], rows: unknown[][], fields: string[] | undefined, + aliases?: Record, ): { headers: string[]; rows: unknown[][] } { if (!fields || fields.length === 0) return { headers, rows }; - const unknown = fields.filter((f) => !headers.includes(f)); + const resolved = aliases ? fields.map((f) => aliases[f] ?? f) : fields; + const unknown = fields.filter((_, i) => !headers.includes(resolved[i])); if (unknown.length > 0) { throw new UsageError( `Unknown field(s): ${unknown.map((f) => `"${f}"`).join(', ')}. ` + `Allowed: ${headers.map((f) => `"${f}"`).join(', ')}.`, ); } - const indices = fields.map((f) => headers.indexOf(f)); + const indices = resolved.map((f) => headers.indexOf(f)); return { headers: indices.map((i) => headers[i]), rows: rows.map((row) => indices.map((i) => row[i])), @@ -75,8 +77,9 @@ export function renderRows( rows: unknown[][], format: OutputFormat, fields?: string[], + aliases?: Record, ): void { - const filtered = filterFields(headers, rows, fields); + const filtered = filterFields(headers, rows, fields, aliases); const h = filtered.headers; const r = filtered.rows; diff --git a/tests/commands/capabilities.test.ts b/tests/commands/capabilities.test.ts index 4ac79bd..b96c2ca 100644 --- a/tests/commands/capabilities.test.ts +++ b/tests/commands/capabilities.test.ts @@ -116,12 +116,13 @@ describe('capabilities', () => { expect(cat.typeCount as number).toBeGreaterThan(10); }); - it('surfaces.mcp.tools has 8 entries including send_command and account_overview', async () => { + it('surfaces.mcp.tools has 9 entries including send_command, account_overview and get_device_history', async () => { const out = await runCapabilities(); const mcp = (out.surfaces as Record).mcp; - expect(mcp.tools).toHaveLength(8); + expect(mcp.tools).toHaveLength(9); expect(mcp.tools).toContain('send_command'); expect(mcp.tools).toContain('account_overview'); + expect(mcp.tools).toContain('get_device_history'); expect(mcp.resources).toEqual(['switchbot://events']); }); diff --git a/tests/commands/devices.test.ts b/tests/commands/devices.test.ts index 8796457..d19f02b 100644 --- a/tests/commands/devices.test.ts +++ b/tests/commands/devices.test.ts @@ -408,6 +408,30 @@ describe('devices command', () => { const out = res.stdout.join('\n'); expect(out).not.toContain('physical device'); }); + + it('--filter category=physical shows only physical devices in table mode', async () => { + apiMock.__instance.get.mockResolvedValue({ data: { body: sampleBody } }); + const res = await runCli(registerDevicesCommand, ['devices', 'list', '--filter', 'category=physical']); + const out = res.stdout.join('\n'); + expect(out).toContain('BLE-001'); + expect(out).not.toContain('IR-001'); + }); + + it('--filter category=ir shows only IR remotes in table mode', async () => { + apiMock.__instance.get.mockResolvedValue({ data: { body: sampleBody } }); + const res = await runCli(registerDevicesCommand, ['devices', 'list', '--filter', 'category=ir']); + const out = res.stdout.join('\n'); + expect(out).not.toContain('BLE-001'); + expect(out).toContain('IR-001'); + }); + + it('--filter --json applies filter to JSON output', async () => { + apiMock.__instance.get.mockResolvedValue({ data: { body: sampleBody } }); + const res = await runCli(registerDevicesCommand, ['devices', 'list', '--filter', 'category=physical', '--json']); + const out = JSON.parse(res.stdout.join('\n')); + expect(out.data.deviceList).toHaveLength(3); + expect(out.data.infraredRemoteList).toHaveLength(0); + }); }); // ===================================================================== @@ -473,7 +497,10 @@ describe('devices command', () => { ]); const parsed = JSON.parse(res.stdout.join('\n')); expect(Array.isArray(parsed.data)).toBe(true); - expect(parsed.data[0]).toEqual({ power: 'off', battery: 50 }); + // _fetchedAt is added by the CLI; verify other fields are present + expect(parsed.data[0].power).toBe('off'); + expect(parsed.data[0].battery).toBe(50); + expect(parsed.data[0]._fetchedAt).toMatch(/^\d{4}-\d{2}-\d{2}T/); }); it('serializes nested objects to JSON strings in tsv output', async () => { @@ -527,8 +554,8 @@ describe('devices command', () => { 'devices', 'status', 'DEV3', '--format', 'tsv', ]); const lines = res.stdout.join('\n').split('\n'); - // null maps to empty string in cellToString - expect(lines[1]).toBe('on\t'); + // null maps to empty string in cellToString; _fetchedAt column is also present + expect(lines[1]).toMatch(/^on\t\t/); }); }); @@ -1189,7 +1216,7 @@ describe('devices command', () => { it('--format=tsv outputs tab-separated catalog rows', async () => { const res = await runCli(registerDevicesCommand, ['devices', 'types', '--format', 'tsv']); const lines = res.stdout.join('\n').split('\n'); - expect(lines[0]).toBe('type\tcategory\tcommands\taliases'); + expect(lines[0]).toBe('type\trole\tcategory\tcommands\taliases'); expect(lines.find((l) => l.startsWith('Bot\t'))).toBeDefined(); }); diff --git a/tests/commands/events.test.ts b/tests/commands/events.test.ts index 882cbb2..cb877fd 100644 --- a/tests/commands/events.test.ts +++ b/tests/commands/events.test.ts @@ -37,6 +37,7 @@ vi.mock('../../src/mqtt/client.js', () => { mqttMock.messageHandler = handler; return () => { mqttMock.messageHandler = null; }; }), + onStateChange: vi.fn(() => () => {}), }; mqttMock.instance = inst; return inst; diff --git a/tests/commands/expand.test.ts b/tests/commands/expand.test.ts index 8568ebc..e99a10d 100644 --- a/tests/commands/expand.test.ts +++ b/tests/commands/expand.test.ts @@ -194,4 +194,16 @@ describe('devices expand', () => { expect(res.exitCode).toBe(2); expect(res.stderr.join('\n')).toContain("'expand' does not support"); }); + + it('--name resolves device by fuzzy name', async () => { + const res = await runCli(registerDevicesCommand, [ + 'devices', 'expand', '--name', 'Curtain', 'setPosition', + '--position', '50', + ]); + expect(res.exitCode).toBe(null); + expect(apiMock.__instance.post).toHaveBeenCalledWith( + `/v1.1/devices/${CURTAIN_ID}/commands`, + expect.objectContaining({ command: 'setPosition' }) + ); + }); }); diff --git a/tests/commands/mcp.test.ts b/tests/commands/mcp.test.ts index 80eef82..276bced 100644 --- a/tests/commands/mcp.test.ts +++ b/tests/commands/mcp.test.ts @@ -76,7 +76,7 @@ describe('mcp server', () => { cacheMock.updateCacheFromDeviceList.mockClear(); }); - it('exposes the eight tools with titles and input schemas', async () => { + it('exposes the nine tools with titles and input schemas', async () => { const { client } = await pair(); const { tools } = await client.listTools(); @@ -85,6 +85,7 @@ describe('mcp server', () => { [ 'account_overview', 'describe_device', + 'get_device_history', 'get_device_status', 'list_devices', 'list_scenes', diff --git a/tests/commands/watch.test.ts b/tests/commands/watch.test.ts index 5c00ff8..0b97199 100644 --- a/tests/commands/watch.test.ts +++ b/tests/commands/watch.test.ts @@ -229,4 +229,12 @@ describe('devices watch', () => { expect(byId.BOT1.error).toMatch(/boom/); expect(byId.BOT2.changed.power).toEqual({ from: null, to: 'on' }); }); + + it('exits 2 when no deviceId and no --name', async () => { + const res = await runCli(registerDevicesCommand, [ + 'devices', 'watch', '--max', '1', + ]); + expect(res.exitCode).toBe(2); + expect(res.stderr.join('\n')).toMatch(/deviceId.*--name|--name.*deviceId/i); + }); }); diff --git a/tests/utils/audit.test.ts b/tests/utils/audit.test.ts index 0625711..e2f01eb 100644 --- a/tests/utils/audit.test.ts +++ b/tests/utils/audit.test.ts @@ -35,7 +35,7 @@ describe('audit log', () => { it('writeAudit appends JSONL when --audit-log is set', () => { const file = path.join(tmp, 'audit.log'); - process.argv = ['node', 'cli', '--audit-log', file]; + process.argv = ['node', 'cli', '--audit-log', '--audit-log-path', file]; writeAudit({ t: '2026-04-18T10:00:00.000Z', kind: 'command', @@ -65,7 +65,7 @@ describe('audit log', () => { it('writeAudit creates the parent directory if missing', () => { const file = path.join(tmp, 'nested', 'sub', 'audit.log'); - process.argv = ['node', 'cli', '--audit-log', file]; + process.argv = ['node', 'cli', '--audit-log', '--audit-log-path', file]; writeAudit({ t: '2026-04-18T10:00:00.000Z', kind: 'command',