From 6eb900e9161e0867c58491baa586dd44d1127740 Mon Sep 17 00:00:00 2001 From: chenliuyun Date: Sun, 19 Apr 2026 20:51:07 +0800 Subject: [PATCH 1/2] fix(mqtt): resolve reconnect loop and add connection state logging - Use random instanceId per session so each CLI run gets its own clientId, preventing conflicts with the SwitchBot cloud service that shares the same account credentials and causes an immediate session-kicking loop - Remove stale listeners from old mqtt.js client before replacing it in connect(), preventing the old client's close event from triggering a spurious extra reconnect cycle - Add onStateChange logging in events mqtt-tail so connection state transitions (connected/reconnecting/failed) are visible on stderr - Add onStateChange to test mock to avoid unsubState() TypeError --- src/commands/events.ts | 7 +++++++ src/mqtt/client.ts | 9 +++++++++ src/mqtt/credential.ts | 5 +++-- tests/commands/events.test.ts | 1 + 4 files changed, 20 insertions(+), 2 deletions(-) diff --git a/src/commands/events.ts b/src/commands/events.ts index 2d50e8b..313b5eb 100644 --- a/src/commands/events.ts +++ b/src/commands/events.ts @@ -280,6 +280,12 @@ Examples: } }); + const unsubState = client.onStateChange((state) => { + if (!isJsonMode()) { + console.error(`[${new Date().toLocaleTimeString()}] MQTT state: ${state}`); + } + }); + await client.connect(); client.subscribe(topic); @@ -292,6 +298,7 @@ Examples: process.removeListener('SIGINT', cleanup); process.removeListener('SIGTERM', cleanup); unsub(); + unsubState(); client.disconnect().then(resolve).catch(resolve); }; process.once('SIGINT', cleanup); 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/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; From 9b0d9e27307c4fbebe2f9e4ce6381a38051bd959 Mon Sep 17 00:00:00 2001 From: chenliuyun Date: Sun, 19 Apr 2026 23:15:05 +0800 Subject: [PATCH 2/2] feat: UX improvements and sink system for v2.2.0 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sink system: - Add src/sinks/ — stdout, file, webhook, openclaw, telegram, homeassistant - Add DeviceHistoryStore (src/mcp/device-history.ts) and get_device_history MCP tool - Add --sink, --sink-file, --webhook-url, --openclaw-*, --telegram-*, --ha-* to events mqtt-tail CLI UX fixes (P0/P1/P2): - BUG-1: --audit-log now boolean; add --audit-log-path for explicit path - BUG-2/3: devices command outer try/catch; --name positional shift fix - BUG-7/P1-04: case-insensitive command name suggestion ("Did you mean getStatus?") - P0-05: field aliases in devices list (type, room, hub, cloud short names) - P0-06/07: capabilities --minimal flag; explicit help entry in manifest - P0-08: --timeout minimum 100ms with warning - P0-09: --filter expr for devices list (type, name, category, room) - P1-02: missing-parameter validation for commands requiring a param - P1-03: IR command shows "→ IR signal sent" (not fake ✓) - P1-05: scenes list --fields accepts id/name aliases - P1-06: devices status --ids for batch status - P1-08: --yes warning when passed to non-destructive commands - P1-11: devices list --json returns { ok: true, deviceList, infraredRemoteList } - P1-13: devices status --ids now supports --fields and --format jsonl - P1-14: plan validate warns when plan has 0 steps - P1-15: --name added to devices expand and devices watch - P1-16: devices status --json includes _fetchedAt timestamp - P2: devices list --json now respects --filter - P2: devices types table includes role column - P2: devices commands output shows example params when available Tests: 697 passing (added 5 new tests for --filter JSON, expand --name, watch no-args) --- README.md | 58 ++++++++- docs/agent-guide.md | 61 ++++++++-- package-lock.json | 4 +- package.json | 2 +- src/commands/capabilities.ts | 24 ++-- src/commands/devices.ts | 183 ++++++++++++++++++++++++---- src/commands/events.ts | 137 +++++++++++++++++++-- src/commands/expand.ts | 27 +++- src/commands/mcp.ts | 41 +++++++ src/commands/plan.ts | 10 +- src/commands/scenes.ts | 1 + src/commands/watch.ts | 14 ++- src/index.ts | 3 +- src/lib/devices.ts | 24 +++- src/mcp/device-history.ts | 79 ++++++++++++ src/mcp/events-subscription.ts | 13 +- src/sinks/dispatcher.ts | 17 +++ src/sinks/file.ts | 21 ++++ src/sinks/format.ts | 73 +++++++++++ src/sinks/homeassistant.ts | 57 +++++++++ src/sinks/openclaw.ts | 42 +++++++ src/sinks/stdout.ts | 7 ++ src/sinks/telegram.ts | 36 ++++++ src/sinks/types.ts | 13 ++ src/sinks/webhook.ts | 25 ++++ src/utils/flags.ts | 24 ++-- src/utils/format.ts | 9 +- tests/commands/capabilities.test.ts | 5 +- tests/commands/devices.test.ts | 35 +++++- tests/commands/expand.test.ts | 12 ++ tests/commands/mcp.test.ts | 3 +- tests/commands/watch.test.ts | 8 ++ tests/utils/audit.test.ts | 4 +- 33 files changed, 974 insertions(+), 98 deletions(-) create mode 100644 src/mcp/device-history.ts create mode 100644 src/sinks/dispatcher.ts create mode 100644 src/sinks/file.ts create mode 100644 src/sinks/format.ts create mode 100644 src/sinks/homeassistant.ts create mode 100644 src/sinks/openclaw.ts create mode 100644 src/sinks/stdout.ts create mode 100644 src/sinks/telegram.ts create mode 100644 src/sinks/types.ts create mode 100644 src/sinks/webhook.ts 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 313b5eb..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,12 +370,26 @@ 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(); @@ -299,6 +415,7 @@ Examples: 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/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/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',