@switchbot/openapi-cli 1.0.1 → 1.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +174 -18
- package/dist/api/client.d.ts +7 -1
- package/dist/api/client.js +44 -8
- package/dist/api/client.js.map +1 -1
- package/dist/commands/batch.d.ts +2 -0
- package/dist/commands/batch.js +252 -0
- package/dist/commands/batch.js.map +1 -0
- package/dist/commands/cache.d.ts +2 -0
- package/dist/commands/cache.js +108 -0
- package/dist/commands/cache.js.map +1 -0
- package/dist/commands/capabilities.d.ts +2 -0
- package/dist/commands/capabilities.js +91 -0
- package/dist/commands/capabilities.js.map +1 -0
- package/dist/commands/catalog.d.ts +2 -0
- package/dist/commands/catalog.js +291 -0
- package/dist/commands/catalog.js.map +1 -0
- package/dist/commands/config.js +123 -10
- package/dist/commands/config.js.map +1 -1
- package/dist/commands/devices.js +234 -112
- package/dist/commands/devices.js.map +1 -1
- package/dist/commands/doctor.d.ts +2 -0
- package/dist/commands/doctor.js +147 -0
- package/dist/commands/doctor.js.map +1 -0
- package/dist/commands/events.d.ts +15 -0
- package/dist/commands/events.js +188 -0
- package/dist/commands/events.js.map +1 -0
- package/dist/commands/explain.d.ts +2 -0
- package/dist/commands/explain.js +137 -0
- package/dist/commands/explain.js.map +1 -0
- package/dist/commands/history.d.ts +2 -0
- package/dist/commands/history.js +104 -0
- package/dist/commands/history.js.map +1 -0
- package/dist/commands/mcp.d.ts +4 -0
- package/dist/commands/mcp.js +386 -0
- package/dist/commands/mcp.js.map +1 -0
- package/dist/commands/plan.d.ts +37 -0
- package/dist/commands/plan.js +344 -0
- package/dist/commands/plan.js.map +1 -0
- package/dist/commands/quota.d.ts +2 -0
- package/dist/commands/quota.js +77 -0
- package/dist/commands/quota.js.map +1 -0
- package/dist/commands/scenes.js +19 -13
- package/dist/commands/scenes.js.map +1 -1
- package/dist/commands/schema.d.ts +2 -0
- package/dist/commands/schema.js +77 -0
- package/dist/commands/schema.js.map +1 -0
- package/dist/commands/watch.d.ts +2 -0
- package/dist/commands/watch.js +161 -0
- package/dist/commands/watch.js.map +1 -0
- package/dist/commands/webhook.js +37 -22
- package/dist/commands/webhook.js.map +1 -1
- package/dist/config.d.ts +11 -0
- package/dist/config.js +32 -6
- package/dist/config.js.map +1 -1
- package/dist/devices/cache.d.ts +75 -0
- package/dist/devices/cache.js +225 -0
- package/dist/devices/cache.js.map +1 -0
- package/dist/devices/catalog.d.ts +49 -0
- package/dist/devices/catalog.js +362 -92
- package/dist/devices/catalog.js.map +1 -1
- package/dist/index.js +31 -1
- package/dist/index.js.map +1 -1
- package/dist/lib/devices.d.ts +144 -0
- package/dist/lib/devices.js +329 -0
- package/dist/lib/devices.js.map +1 -0
- package/dist/lib/scenes.d.ts +7 -0
- package/dist/lib/scenes.js +11 -0
- package/dist/lib/scenes.js.map +1 -0
- package/dist/utils/audit.d.ts +13 -0
- package/dist/utils/audit.js +43 -0
- package/dist/utils/audit.js.map +1 -0
- package/dist/utils/filter.d.ts +45 -0
- package/dist/utils/filter.js +96 -0
- package/dist/utils/filter.js.map +1 -0
- package/dist/utils/flags.d.ts +42 -0
- package/dist/utils/flags.js +108 -0
- package/dist/utils/flags.js.map +1 -1
- package/dist/utils/format.d.ts +9 -0
- package/dist/utils/format.js +109 -0
- package/dist/utils/format.js.map +1 -0
- package/dist/utils/output.d.ts +11 -0
- package/dist/utils/output.js +37 -6
- package/dist/utils/output.js.map +1 -1
- package/dist/utils/quota.d.ts +48 -0
- package/dist/utils/quota.js +144 -0
- package/dist/utils/quota.js.map +1 -0
- package/dist/utils/retry.d.ts +23 -0
- package/dist/utils/retry.js +60 -0
- package/dist/utils/retry.js.map +1 -0
- package/package.json +4 -1
package/README.md
CHANGED
|
@@ -15,6 +15,20 @@ List devices, query live status, send control commands, run scenes, and manage w
|
|
|
15
15
|
|
|
16
16
|
---
|
|
17
17
|
|
|
18
|
+
## Who is this for?
|
|
19
|
+
|
|
20
|
+
Three entry points, same binary — pick the one that matches how you use it:
|
|
21
|
+
|
|
22
|
+
| Audience | Where to start | What you get |
|
|
23
|
+
|-----------|---------------------------------------------------------------|---------------------------------------------------------------------------------------------------|
|
|
24
|
+
| **Human** | this README ([Quick start](#quick-start)) | Colored tables, helpful hints on errors, shell completion, `switchbot doctor` self-check. |
|
|
25
|
+
| **Script**| [Output modes](#output-modes), [Scripting examples](#scripting-examples) | `--json`, `--format=tsv/yaml/id`, `--fields`, stable exit codes, `history replay`, audit log. |
|
|
26
|
+
| **Agent** | [`docs/agent-guide.md`](./docs/agent-guide.md) | `switchbot mcp serve` (stdio MCP server), `schema export`, `plan run`, destructive-command guard. |
|
|
27
|
+
|
|
28
|
+
Under the hood every surface shares the same catalog, cache, and HMAC client — switching between them costs nothing.
|
|
29
|
+
|
|
30
|
+
---
|
|
31
|
+
|
|
18
32
|
## Table of contents
|
|
19
33
|
|
|
20
34
|
- [Features](#features)
|
|
@@ -28,8 +42,13 @@ List devices, query live status, send control commands, run scenes, and manage w
|
|
|
28
42
|
- [`devices`](#devices--list-status-control)
|
|
29
43
|
- [`scenes`](#scenes--run-manual-scenes)
|
|
30
44
|
- [`webhook`](#webhook--receive-device-events-over-http)
|
|
45
|
+
- [`batch`](#batch--run-multiple-commands)
|
|
46
|
+
- [`watch`](#watch--poll-device-status)
|
|
47
|
+
- [`mcp`](#mcp--model-context-protocol-server)
|
|
48
|
+
- [`cache`](#cache--inspect-and-clear-local-cache)
|
|
31
49
|
- [`completion`](#completion--shell-tab-completion)
|
|
32
50
|
- [Output modes](#output-modes)
|
|
51
|
+
- [Cache](#cache-1)
|
|
33
52
|
- [Exit codes & error codes](#exit-codes--error-codes)
|
|
34
53
|
- [Environment variables](#environment-variables)
|
|
35
54
|
- [Scripting examples](#scripting-examples)
|
|
@@ -47,7 +66,7 @@ List devices, query live status, send control commands, run scenes, and manage w
|
|
|
47
66
|
- 🎨 **Dual output modes** — colorized tables by default; `--json` passthrough for `jq` and scripting
|
|
48
67
|
- 🔐 **Secure credentials** — HMAC-SHA256 signed requests; config file written with `0600`; env-var override for CI
|
|
49
68
|
- 🔍 **Dry-run mode** — preview every mutating request before it hits the API
|
|
50
|
-
- 🧪 **Fully tested** —
|
|
69
|
+
- 🧪 **Fully tested** — 592 Vitest tests, mocked axios, zero network in CI
|
|
51
70
|
- ⚡ **Shell completion** — Bash / Zsh / Fish / PowerShell
|
|
52
71
|
|
|
53
72
|
## Requirements
|
|
@@ -119,15 +138,27 @@ switchbot config show
|
|
|
119
138
|
|
|
120
139
|
## Global options
|
|
121
140
|
|
|
122
|
-
| Option
|
|
123
|
-
|
|
|
124
|
-
| `--json`
|
|
125
|
-
|
|
|
126
|
-
| `--
|
|
127
|
-
| `--
|
|
128
|
-
| `--
|
|
129
|
-
|
|
|
130
|
-
|
|
|
141
|
+
| Option | Description |
|
|
142
|
+
| --------------------------- | ------------------------------------------------------------------------ |
|
|
143
|
+
| `--json` | Print the raw JSON response instead of a formatted table |
|
|
144
|
+
| `--format <fmt>` | Output format: `tsv`, `yaml`, `jsonl`, `json`, `id` |
|
|
145
|
+
| `--fields <cols>` | Comma-separated column names to include (e.g. `deviceId,type`) |
|
|
146
|
+
| `-v`, `--verbose` | Log HTTP request/response details to stderr |
|
|
147
|
+
| `--dry-run` | Print mutating requests (POST/PUT/DELETE) without sending them |
|
|
148
|
+
| `--timeout <ms>` | HTTP request timeout in milliseconds (default: `30000`) |
|
|
149
|
+
| `--config <path>` | Override credential file location (default: `~/.switchbot/config.json`) |
|
|
150
|
+
| `--profile <name>` | Use a named credential profile (`~/.switchbot/profiles/<name>.json`) |
|
|
151
|
+
| `--cache <dur>` | Set list and status cache TTL, e.g. `5m`, `1h`, `off`, `auto` (default) |
|
|
152
|
+
| `--cache-list <dur>` | Set list-cache TTL independently (overrides `--cache`) |
|
|
153
|
+
| `--cache-status <dur>` | Set status-cache TTL independently (default off; overrides `--cache`) |
|
|
154
|
+
| `--no-cache` | Disable all cache reads for this invocation |
|
|
155
|
+
| `--retry-on-429 <n>` | Max 429 retry attempts (default: `3`) |
|
|
156
|
+
| `--no-retry` | Disable automatic 429 retries |
|
|
157
|
+
| `--backoff <strategy>` | Retry backoff: `exponential` (default) or `linear` |
|
|
158
|
+
| `--no-quota` | Disable local request-quota tracking |
|
|
159
|
+
| `--audit-log [path]` | Append mutating commands to a JSONL audit log (default path: `~/.switchbot/audit.log`) |
|
|
160
|
+
| `-V`, `--version` | Print the CLI version |
|
|
161
|
+
| `-h`, `--help` | Show help for any command or subcommand |
|
|
131
162
|
|
|
132
163
|
Every subcommand supports `--help`, and most include a parameter-format reference and examples.
|
|
133
164
|
|
|
@@ -161,10 +192,16 @@ switchbot config show # Print current source + masked s
|
|
|
161
192
|
|
|
162
193
|
```bash
|
|
163
194
|
# List all physical devices and IR remote devices
|
|
164
|
-
#
|
|
195
|
+
# Default columns (4): deviceId, deviceName, type, category
|
|
196
|
+
# Pass --wide for the full 10-column operator view
|
|
165
197
|
switchbot devices list
|
|
198
|
+
switchbot devices list --wide
|
|
166
199
|
switchbot devices list --json | jq '.deviceList[].deviceId'
|
|
167
200
|
|
|
201
|
+
# IR remotes: type = remoteType (e.g. "TV"), category = "ir"
|
|
202
|
+
# Physical: category = "physical"
|
|
203
|
+
switchbot devices list --format=tsv --fields=deviceId,type,category
|
|
204
|
+
|
|
168
205
|
# Filter by family / room (family & room info requires the 'src: OpenClaw'
|
|
169
206
|
# header, which this CLI sends on every request)
|
|
170
207
|
switchbot devices list --json | jq '.deviceList[] | select(.familyName == "Home")'
|
|
@@ -259,15 +296,115 @@ switchbot completion powershell >> $PROFILE
|
|
|
259
296
|
|
|
260
297
|
Supported shells: `bash`, `zsh`, `fish`, `powershell` (`pwsh` is accepted as an alias).
|
|
261
298
|
|
|
262
|
-
|
|
299
|
+
### `batch` — run multiple commands
|
|
300
|
+
|
|
301
|
+
```bash
|
|
302
|
+
# Run a sequence of commands from a JSON/YAML file
|
|
303
|
+
switchbot batch run commands.json
|
|
304
|
+
switchbot batch run commands.yaml --dry-run
|
|
305
|
+
|
|
306
|
+
# Validate a plan file without executing it
|
|
307
|
+
switchbot batch validate commands.json
|
|
308
|
+
```
|
|
309
|
+
|
|
310
|
+
A batch file is a JSON array of `{ deviceId, command, parameter?, commandType? }` objects.
|
|
311
|
+
|
|
312
|
+
### `watch` — poll device status
|
|
313
|
+
|
|
314
|
+
```bash
|
|
315
|
+
# Poll a device's status every 30 s until Ctrl-C
|
|
316
|
+
switchbot watch <deviceId>
|
|
317
|
+
switchbot watch <deviceId> --interval 10s --json
|
|
318
|
+
```
|
|
319
|
+
|
|
320
|
+
Output is a stream of JSON status objects (with `--json`) or a refreshed table.
|
|
321
|
+
|
|
322
|
+
### `mcp` — Model Context Protocol server
|
|
323
|
+
|
|
324
|
+
```bash
|
|
325
|
+
# Start the stdio MCP server (connect via Claude, Cursor, etc.)
|
|
326
|
+
switchbot mcp serve
|
|
327
|
+
```
|
|
328
|
+
|
|
329
|
+
Exposes 7 MCP tools: `list_devices`, `describe_device`, `get_device_status`, `send_command`, `list_scenes`, `run_scene`, `search_catalog`.
|
|
330
|
+
See [`docs/agent-guide.md`](./docs/agent-guide.md) for the full tool reference and safety rules (destructive-command guard).
|
|
331
|
+
|
|
332
|
+
### `cache` — inspect and clear local cache
|
|
333
|
+
|
|
334
|
+
```bash
|
|
335
|
+
# Show cache status (paths, age, entry counts)
|
|
336
|
+
switchbot cache show
|
|
337
|
+
|
|
338
|
+
# Clear everything
|
|
339
|
+
switchbot cache clear
|
|
340
|
+
|
|
341
|
+
# Clear only the device-list cache or only the status cache
|
|
342
|
+
switchbot cache clear --key list
|
|
343
|
+
switchbot cache clear --key status
|
|
344
|
+
```
|
|
345
|
+
|
|
346
|
+
|
|
263
347
|
|
|
264
348
|
- **Default** — ANSI-colored tables for `list`/`status`, key-value tables for details.
|
|
265
|
-
- **`--json`** — raw JSON
|
|
349
|
+
- **`--json`** — raw API payload passthrough. Output is the exact JSON the SwitchBot API returned, ideal for `jq` and scripting. Errors are also JSON on stderr: `{ "error": { "code", "kind", "message", "hint?" } }`.
|
|
350
|
+
- **`--format=json`** — projected row view. Same JSON structure but built from the CLI's column model (`--fields` applies). Use this when you only want specific fields.
|
|
351
|
+
- **`--format=tsv|yaml|jsonl|id`** — tabular text formats; `--fields` filters columns.
|
|
266
352
|
|
|
267
353
|
```bash
|
|
354
|
+
# Raw API payload (--json)
|
|
268
355
|
switchbot devices list --json | jq '.deviceList[] | {id: .deviceId, name: .deviceName}'
|
|
356
|
+
|
|
357
|
+
# Projected rows with field filter (--format)
|
|
358
|
+
switchbot devices list --format tsv --fields deviceId,deviceName,type,cloud
|
|
359
|
+
switchbot devices list --format id # one deviceId per line
|
|
360
|
+
switchbot devices status <id> --format yaml
|
|
269
361
|
```
|
|
270
362
|
|
|
363
|
+
## Cache
|
|
364
|
+
|
|
365
|
+
The CLI maintains two local disk caches under `~/.switchbot/`:
|
|
366
|
+
|
|
367
|
+
| File | Contents | Default TTL |
|
|
368
|
+
| ---- | -------- | ----------- |
|
|
369
|
+
| `devices.json` | Device metadata (id, name, type, category, hub, room…) | 1 hour |
|
|
370
|
+
| `status.json` | Per-device status bodies | off (0) |
|
|
371
|
+
|
|
372
|
+
The device-list cache powers offline validation (command name checks, destructive-command guard) and the MCP server's `send_command` tool. It is refreshed automatically on every `devices list` call.
|
|
373
|
+
|
|
374
|
+
### Cache control flags
|
|
375
|
+
|
|
376
|
+
```bash
|
|
377
|
+
# Turn off all cache reads for one invocation
|
|
378
|
+
switchbot devices list --no-cache
|
|
379
|
+
|
|
380
|
+
# Set both list and status TTL to 5 minutes
|
|
381
|
+
switchbot devices status <id> --cache 5m
|
|
382
|
+
|
|
383
|
+
# Set TTLs independently
|
|
384
|
+
switchbot devices status <id> --cache-list 2h --cache-status 30s
|
|
385
|
+
|
|
386
|
+
# Disable only the list cache (keep status cache at its current TTL)
|
|
387
|
+
switchbot devices list --cache-list 0
|
|
388
|
+
```
|
|
389
|
+
|
|
390
|
+
### Cache management commands
|
|
391
|
+
|
|
392
|
+
```bash
|
|
393
|
+
# Show paths, age, and entry counts
|
|
394
|
+
switchbot cache show
|
|
395
|
+
|
|
396
|
+
# Clear all cached data
|
|
397
|
+
switchbot cache clear
|
|
398
|
+
|
|
399
|
+
# Scope the clear to one store
|
|
400
|
+
switchbot cache clear --key list
|
|
401
|
+
switchbot cache clear --key status
|
|
402
|
+
```
|
|
403
|
+
|
|
404
|
+
### Status-cache GC
|
|
405
|
+
|
|
406
|
+
`status.json` entries are automatically evicted after 24 hours (or 10× the configured status TTL, whichever is longer), so the file cannot grow without bound even when the status cache is left enabled long-term.
|
|
407
|
+
|
|
271
408
|
## Exit codes & error codes
|
|
272
409
|
|
|
273
410
|
| Code | Meaning |
|
|
@@ -318,7 +455,7 @@ npm install
|
|
|
318
455
|
|
|
319
456
|
npm run dev -- <args> # Run from TypeScript sources via tsx
|
|
320
457
|
npm run build # Compile to dist/
|
|
321
|
-
npm test # Run the Vitest suite (
|
|
458
|
+
npm test # Run the Vitest suite (592 tests)
|
|
322
459
|
npm run test:watch # Watch mode
|
|
323
460
|
npm run test:coverage # Coverage report (v8, HTML + text)
|
|
324
461
|
```
|
|
@@ -332,17 +469,36 @@ src/
|
|
|
332
469
|
├── config.ts # Credential load/save; env > file priority; --config override
|
|
333
470
|
├── api/client.ts # axios instance + request/response interceptors;
|
|
334
471
|
│ # --verbose / --dry-run / --timeout wiring
|
|
335
|
-
├── devices/
|
|
472
|
+
├── devices/
|
|
473
|
+
│ ├── catalog.ts # Static device catalog (commands, params, status fields)
|
|
474
|
+
│ └── cache.ts # Disk + in-memory cache for device list and status
|
|
475
|
+
├── lib/
|
|
476
|
+
│ └── devices.ts # Shared logic: listDevices, describeDevice, isDestructiveCommand
|
|
336
477
|
├── commands/
|
|
337
478
|
│ ├── config.ts
|
|
338
479
|
│ ├── devices.ts
|
|
339
480
|
│ ├── scenes.ts
|
|
340
481
|
│ ├── webhook.ts
|
|
482
|
+
│ ├── batch.ts # `switchbot batch run/validate`
|
|
483
|
+
│ ├── watch.ts # `switchbot watch <deviceId>`
|
|
484
|
+
│ ├── mcp.ts # `switchbot mcp serve` (MCP stdio server)
|
|
485
|
+
│ ├── cache.ts # `switchbot cache show/clear`
|
|
486
|
+
│ ├── history.ts # `switchbot history [replay]`
|
|
487
|
+
│ ├── events.ts # `switchbot events`
|
|
488
|
+
│ ├── quota.ts # `switchbot quota`
|
|
489
|
+
│ ├── explain.ts # `switchbot explain <deviceId>`
|
|
490
|
+
│ ├── plan.ts # `switchbot plan run <file>`
|
|
491
|
+
│ ├── doctor.ts # `switchbot doctor`
|
|
492
|
+
│ ├── schema.ts # `switchbot schema export`
|
|
493
|
+
│ ├── catalog.ts # `switchbot catalog search`
|
|
341
494
|
│ └── completion.ts # `switchbot completion bash|zsh|fish|powershell`
|
|
342
495
|
└── utils/
|
|
343
|
-
├── flags.ts # Global flag readers (isVerbose / isDryRun /
|
|
344
|
-
|
|
345
|
-
|
|
496
|
+
├── flags.ts # Global flag readers (isVerbose / isDryRun / getCacheMode / …)
|
|
497
|
+
├── output.ts # printTable / printKeyValue / printJson / handleError / buildErrorPayload
|
|
498
|
+
├── format.ts # renderRows / filterFields / output-format dispatch
|
|
499
|
+
├── audit.ts # JSONL audit log writer
|
|
500
|
+
└── quota.ts # Local daily-quota counter
|
|
501
|
+
tests/ # Vitest suite (592 tests, mocked axios, no network)
|
|
346
502
|
```
|
|
347
503
|
|
|
348
504
|
### Release flow
|
package/dist/api/client.d.ts
CHANGED
|
@@ -6,7 +6,13 @@ export declare class DryRunSignal extends Error {
|
|
|
6
6
|
constructor(method: string, url: string);
|
|
7
7
|
}
|
|
8
8
|
export declare function createClient(): AxiosInstance;
|
|
9
|
+
export interface ApiErrorMeta {
|
|
10
|
+
retryable?: boolean;
|
|
11
|
+
hint?: string;
|
|
12
|
+
}
|
|
9
13
|
export declare class ApiError extends Error {
|
|
10
14
|
readonly code: number;
|
|
11
|
-
|
|
15
|
+
readonly retryable: boolean;
|
|
16
|
+
readonly hint?: string;
|
|
17
|
+
constructor(message: string, code: number, meta?: ApiErrorMeta);
|
|
12
18
|
}
|
package/dist/api/client.js
CHANGED
|
@@ -2,7 +2,9 @@ import axios from 'axios';
|
|
|
2
2
|
import chalk from 'chalk';
|
|
3
3
|
import { buildAuthHeaders } from '../auth.js';
|
|
4
4
|
import { loadConfig } from '../config.js';
|
|
5
|
-
import { isVerbose, isDryRun, getTimeout } from '../utils/flags.js';
|
|
5
|
+
import { isVerbose, isDryRun, getTimeout, getRetryOn429, getBackoffStrategy, isQuotaDisabled, } from '../utils/flags.js';
|
|
6
|
+
import { nextRetryDelayMs, sleep } from '../utils/retry.js';
|
|
7
|
+
import { recordRequest } from '../utils/quota.js';
|
|
6
8
|
const API_ERROR_MESSAGES = {
|
|
7
9
|
151: 'Device type does not support this command',
|
|
8
10
|
152: 'Device ID does not exist',
|
|
@@ -26,6 +28,9 @@ export function createClient() {
|
|
|
26
28
|
const { token, secret } = loadConfig();
|
|
27
29
|
const verbose = isVerbose();
|
|
28
30
|
const dryRun = isDryRun();
|
|
31
|
+
const maxRetries = getRetryOn429();
|
|
32
|
+
const backoff = getBackoffStrategy();
|
|
33
|
+
const quotaEnabled = !isQuotaDisabled();
|
|
29
34
|
const client = axios.create({
|
|
30
35
|
baseURL: 'https://api.switch-bot.com',
|
|
31
36
|
timeout: getTimeout(),
|
|
@@ -43,9 +48,9 @@ export function createClient() {
|
|
|
43
48
|
}
|
|
44
49
|
}
|
|
45
50
|
if (dryRun && method !== 'GET') {
|
|
46
|
-
|
|
51
|
+
process.stderr.write(chalk.yellow(`[dry-run] Would ${method} ${url}\n`));
|
|
47
52
|
if (config.data !== undefined) {
|
|
48
|
-
|
|
53
|
+
process.stderr.write(chalk.yellow(`[dry-run] body: ${JSON.stringify(config.data)}\n`));
|
|
49
54
|
}
|
|
50
55
|
throw new DryRunSignal(method, url);
|
|
51
56
|
}
|
|
@@ -56,6 +61,11 @@ export function createClient() {
|
|
|
56
61
|
if (verbose) {
|
|
57
62
|
process.stderr.write(chalk.grey(`[verbose] ${response.status} ${response.statusText}\n`));
|
|
58
63
|
}
|
|
64
|
+
if (quotaEnabled && response.config) {
|
|
65
|
+
const method = (response.config.method ?? 'get').toUpperCase();
|
|
66
|
+
const url = `${response.config.baseURL ?? ''}${response.config.url ?? ''}`;
|
|
67
|
+
recordRequest(method, url);
|
|
68
|
+
}
|
|
59
69
|
const data = response.data;
|
|
60
70
|
if (data.statusCode !== undefined && data.statusCode !== 100) {
|
|
61
71
|
const msg = API_ERROR_MESSAGES[data.statusCode] ??
|
|
@@ -69,16 +79,38 @@ export function createClient() {
|
|
|
69
79
|
throw error;
|
|
70
80
|
if (axios.isAxiosError(error)) {
|
|
71
81
|
if (error.code === 'ECONNABORTED' || error.code === 'ETIMEDOUT') {
|
|
72
|
-
throw new ApiError(`Request timed out after ${getTimeout()}ms (override with --timeout <ms>)`, 0);
|
|
82
|
+
throw new ApiError(`Request timed out after ${getTimeout()}ms (override with --timeout <ms>)`, 0, { retryable: false });
|
|
73
83
|
}
|
|
74
84
|
const status = error.response?.status;
|
|
85
|
+
const config = error.config;
|
|
86
|
+
// 429 → transparent retry with Retry-After / exponential backoff.
|
|
87
|
+
// Skipped when: no config (shouldn't happen for real axios errors),
|
|
88
|
+
// retries disabled, or we've already used our budget.
|
|
89
|
+
if (status === 429 && config && maxRetries > 0) {
|
|
90
|
+
const attempt = config.__retryCount ?? 0;
|
|
91
|
+
if (attempt < maxRetries) {
|
|
92
|
+
config.__retryCount = attempt + 1;
|
|
93
|
+
const delay = nextRetryDelayMs(attempt, backoff, error.response?.headers?.['retry-after']);
|
|
94
|
+
if (verbose) {
|
|
95
|
+
process.stderr.write(chalk.grey(`[verbose] 429 received — retry ${attempt + 1}/${maxRetries} in ${delay}ms\n`));
|
|
96
|
+
}
|
|
97
|
+
return sleep(delay).then(() => client.request(config));
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
// Record exhausted/non-retryable HTTP responses too — they count
|
|
101
|
+
// against the daily quota.
|
|
102
|
+
if (quotaEnabled && error.response && config) {
|
|
103
|
+
const method = (config.method ?? 'get').toUpperCase();
|
|
104
|
+
const url = `${config.baseURL ?? ''}${config.url ?? ''}`;
|
|
105
|
+
recordRequest(method, url);
|
|
106
|
+
}
|
|
75
107
|
if (status === 401) {
|
|
76
|
-
throw new ApiError('Authentication failed: invalid token or daily 10,000-request quota exceeded', 401);
|
|
108
|
+
throw new ApiError('Authentication failed: invalid token or daily 10,000-request quota exceeded', 401, { retryable: false, hint: 'Run `switchbot config set-token <token> <secret>` to re-enter credentials, or `switchbot quota status` to check today\'s local count.' });
|
|
77
109
|
}
|
|
78
110
|
if (status === 429) {
|
|
79
|
-
throw new ApiError('Request rate too high: daily 10,000-request quota exceeded', 429);
|
|
111
|
+
throw new ApiError('Request rate too high: daily 10,000-request quota exceeded (retries exhausted)', 429, { retryable: true, hint: 'Use `switchbot quota status` to see today\'s usage; raise `--retry-on-429 <n>` for more retries.' });
|
|
80
112
|
}
|
|
81
|
-
throw new ApiError(`HTTP ${status ?? '?'}: ${error.message}`, status ?? 0);
|
|
113
|
+
throw new ApiError(`HTTP ${status ?? '?'}: ${error.message}`, status ?? 0, { retryable: status !== undefined && status >= 500 });
|
|
82
114
|
}
|
|
83
115
|
throw error;
|
|
84
116
|
});
|
|
@@ -86,10 +118,14 @@ export function createClient() {
|
|
|
86
118
|
}
|
|
87
119
|
export class ApiError extends Error {
|
|
88
120
|
code;
|
|
89
|
-
|
|
121
|
+
retryable;
|
|
122
|
+
hint;
|
|
123
|
+
constructor(message, code, meta = {}) {
|
|
90
124
|
super(message);
|
|
91
125
|
this.code = code;
|
|
92
126
|
this.name = 'ApiError';
|
|
127
|
+
this.retryable = meta.retryable ?? false;
|
|
128
|
+
this.hint = meta.hint;
|
|
93
129
|
}
|
|
94
130
|
}
|
|
95
131
|
//# sourceMappingURL=client.js.map
|
package/dist/api/client.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"client.js","sourceRoot":"","sources":["../../src/api/client.ts"],"names":[],"mappings":"AAAA,OAAO,
|
|
1
|
+
{"version":3,"file":"client.js","sourceRoot":"","sources":["../../src/api/client.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
import { printJson, isJsonMode, handleError } from '../utils/output.js';
|
|
2
|
+
import { fetchDeviceList, executeCommand, isDestructiveCommand, buildHubLocationMap, } from '../lib/devices.js';
|
|
3
|
+
import { parseFilter, applyFilter, FilterSyntaxError } from '../utils/filter.js';
|
|
4
|
+
import { isDryRun } from '../utils/flags.js';
|
|
5
|
+
import { DryRunSignal } from '../api/client.js';
|
|
6
|
+
const DEFAULT_CONCURRENCY = 5;
|
|
7
|
+
/** Run `task(x)` for every element with at most `concurrency` running at once. */
|
|
8
|
+
async function runPool(items, concurrency, task) {
|
|
9
|
+
const results = new Array(items.length);
|
|
10
|
+
let cursor = 0;
|
|
11
|
+
const workers = [];
|
|
12
|
+
const width = Math.max(1, Math.min(concurrency, items.length));
|
|
13
|
+
for (let w = 0; w < width; w++) {
|
|
14
|
+
workers.push((async () => {
|
|
15
|
+
while (cursor < items.length) {
|
|
16
|
+
const idx = cursor++;
|
|
17
|
+
results[idx] = await task(items[idx]);
|
|
18
|
+
// Tiny jitter between starts so we don't hammer the endpoint in a
|
|
19
|
+
// perfectly aligned burst. Keeps the default concurrency=5 polite.
|
|
20
|
+
await new Promise((r) => setTimeout(r, 20 + Math.random() * 40));
|
|
21
|
+
}
|
|
22
|
+
})());
|
|
23
|
+
}
|
|
24
|
+
await Promise.all(workers);
|
|
25
|
+
return results;
|
|
26
|
+
}
|
|
27
|
+
async function resolveTargetIds(options) {
|
|
28
|
+
const explicit = [];
|
|
29
|
+
if (options.ids) {
|
|
30
|
+
for (const id of options.ids.split(',').map((s) => s.trim()).filter(Boolean)) {
|
|
31
|
+
explicit.push(id);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
if (options.readStdin) {
|
|
35
|
+
const chunks = [];
|
|
36
|
+
for await (const chunk of process.stdin)
|
|
37
|
+
chunks.push(chunk);
|
|
38
|
+
const raw = Buffer.concat(chunks).toString('utf-8');
|
|
39
|
+
for (const line of raw.split(/\r?\n/)) {
|
|
40
|
+
const id = line.trim();
|
|
41
|
+
if (id)
|
|
42
|
+
explicit.push(id);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
const hasFilter = Boolean(options.filter);
|
|
46
|
+
if (explicit.length === 0 && !hasFilter) {
|
|
47
|
+
throw new Error('No target devices supplied — provide --ids, --filter, or pass "-" to read deviceIds from stdin.');
|
|
48
|
+
}
|
|
49
|
+
// Always fetch the device list so we can (a) apply --filter when present
|
|
50
|
+
// and (b) build a deviceId → deviceType map for destructive/validation
|
|
51
|
+
// checks regardless of how the ids were provided.
|
|
52
|
+
const body = await fetchDeviceList();
|
|
53
|
+
const hubLoc = buildHubLocationMap(body.deviceList);
|
|
54
|
+
const typeMap = new Map();
|
|
55
|
+
for (const d of body.deviceList)
|
|
56
|
+
if (d.deviceType)
|
|
57
|
+
typeMap.set(d.deviceId, d.deviceType);
|
|
58
|
+
for (const ir of body.infraredRemoteList)
|
|
59
|
+
typeMap.set(ir.deviceId, ir.remoteType);
|
|
60
|
+
let ids;
|
|
61
|
+
if (hasFilter) {
|
|
62
|
+
const clauses = parseFilter(options.filter);
|
|
63
|
+
const matched = applyFilter(clauses, body.deviceList, body.infraredRemoteList, hubLoc);
|
|
64
|
+
const filteredIds = new Set(matched.map((m) => m.deviceId));
|
|
65
|
+
ids =
|
|
66
|
+
explicit.length > 0 ? explicit.filter((id) => filteredIds.has(id)) : [...filteredIds];
|
|
67
|
+
}
|
|
68
|
+
else {
|
|
69
|
+
ids = explicit;
|
|
70
|
+
}
|
|
71
|
+
return { ids, typeMap };
|
|
72
|
+
}
|
|
73
|
+
export function registerBatchCommand(devices) {
|
|
74
|
+
devices
|
|
75
|
+
.command('batch')
|
|
76
|
+
.description('Send the same command to many devices in one run (filter- or stdin-driven)')
|
|
77
|
+
.argument('<command>', 'Command name, e.g. turnOn, turnOff, setBrightness')
|
|
78
|
+
.argument('[parameter]', 'Command parameter (same rules as `devices command`; omit for no-arg)')
|
|
79
|
+
.option('--filter <expr>', 'Target devices matching a filter, e.g. type=Bot,family=Home')
|
|
80
|
+
.option('--ids <csv>', 'Explicit comma-separated list of deviceIds')
|
|
81
|
+
.option('--concurrency <n>', 'Max parallel in-flight requests (default 5)', '5')
|
|
82
|
+
.option('--yes', 'Allow destructive commands (Smart Lock unlock, garage open, ...)')
|
|
83
|
+
.option('--type <commandType>', '"command" (default) or "customize" for user-defined IR buttons', 'command')
|
|
84
|
+
.option('--stdin', 'Read deviceIds from stdin, one per line (same as trailing "-")')
|
|
85
|
+
.addHelpText('after', `
|
|
86
|
+
Targets are resolved in this priority order:
|
|
87
|
+
1. --ids when present (explicit deviceIds)
|
|
88
|
+
2. stdin when --stdin / "-" (one deviceId per line)
|
|
89
|
+
3. --filter (matches the account's device list)
|
|
90
|
+
You can combine explicit ids with --filter to intersect them.
|
|
91
|
+
|
|
92
|
+
Filter grammar:
|
|
93
|
+
key=value exact match
|
|
94
|
+
key~=value case-insensitive substring match
|
|
95
|
+
clauses are comma-separated AND
|
|
96
|
+
|
|
97
|
+
Supported keys: type, family, room, category (category: physical | ir)
|
|
98
|
+
|
|
99
|
+
Output:
|
|
100
|
+
Human mode: one status line per device, summary at the end.
|
|
101
|
+
--json: {succeeded[], failed[{deviceId,error}], summary:{total,ok,failed,skipped,durationMs}}
|
|
102
|
+
|
|
103
|
+
Safety:
|
|
104
|
+
Destructive commands (Smart Lock unlock, Garage Door Opener turnOn/turnOff,
|
|
105
|
+
Keypad createKey/deleteKey) are blocked by default. Pass --yes to override.
|
|
106
|
+
--dry-run intercepts every POST and reports the intended calls without
|
|
107
|
+
hitting the API.
|
|
108
|
+
|
|
109
|
+
Examples:
|
|
110
|
+
$ switchbot devices batch turnOff --filter 'type~=Light,family=家里'
|
|
111
|
+
$ switchbot devices batch turnOn --ids ID1,ID2,ID3
|
|
112
|
+
$ switchbot devices list --format=id --filter 'type=Bot' | switchbot devices batch toggle -
|
|
113
|
+
$ switchbot devices batch unlock --filter 'type=Smart Lock' --yes
|
|
114
|
+
`)
|
|
115
|
+
.action(async (cmd, parameter, options, commandObj) => {
|
|
116
|
+
// Trailing "-" sentinel selects stdin mode.
|
|
117
|
+
const extra = commandObj.args ?? [];
|
|
118
|
+
const readStdin = Boolean(options.stdin) || extra.includes('-');
|
|
119
|
+
let resolved;
|
|
120
|
+
try {
|
|
121
|
+
resolved = await resolveTargetIds({
|
|
122
|
+
filter: options.filter,
|
|
123
|
+
ids: options.ids,
|
|
124
|
+
readStdin,
|
|
125
|
+
});
|
|
126
|
+
}
|
|
127
|
+
catch (error) {
|
|
128
|
+
if (error instanceof FilterSyntaxError) {
|
|
129
|
+
if (isJsonMode()) {
|
|
130
|
+
console.error(JSON.stringify({ error: { code: 2, kind: 'usage', message: error.message } }));
|
|
131
|
+
}
|
|
132
|
+
else {
|
|
133
|
+
console.error(`Error: ${error.message}`);
|
|
134
|
+
}
|
|
135
|
+
process.exit(2);
|
|
136
|
+
}
|
|
137
|
+
if (error instanceof Error && error.message.startsWith('No target devices')) {
|
|
138
|
+
if (isJsonMode()) {
|
|
139
|
+
console.error(JSON.stringify({ error: { code: 2, kind: 'usage', message: error.message } }));
|
|
140
|
+
}
|
|
141
|
+
else {
|
|
142
|
+
console.error(`Error: ${error.message}`);
|
|
143
|
+
}
|
|
144
|
+
process.exit(2);
|
|
145
|
+
}
|
|
146
|
+
handleError(error);
|
|
147
|
+
}
|
|
148
|
+
if (resolved.ids.length === 0) {
|
|
149
|
+
const out = {
|
|
150
|
+
succeeded: [],
|
|
151
|
+
failed: [],
|
|
152
|
+
summary: { total: 0, ok: 0, failed: 0, skipped: 0, durationMs: 0 },
|
|
153
|
+
};
|
|
154
|
+
if (isJsonMode())
|
|
155
|
+
printJson(out);
|
|
156
|
+
else
|
|
157
|
+
console.log('No devices matched — nothing to do.');
|
|
158
|
+
return;
|
|
159
|
+
}
|
|
160
|
+
const effectiveType = (options.type === 'customize' ? 'customize' : 'command');
|
|
161
|
+
// Pre-flight: identify destructive targets before spending API calls.
|
|
162
|
+
const blockedForDestructive = [];
|
|
163
|
+
for (const id of resolved.ids) {
|
|
164
|
+
const t = resolved.typeMap.get(id);
|
|
165
|
+
if (isDestructiveCommand(t, cmd, effectiveType) && !options.yes) {
|
|
166
|
+
blockedForDestructive.push({
|
|
167
|
+
deviceId: id,
|
|
168
|
+
reason: `destructive command "${cmd}" on ${t ?? 'unknown'} requires --yes`,
|
|
169
|
+
});
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
if (blockedForDestructive.length > 0 && !options.yes) {
|
|
173
|
+
if (isJsonMode()) {
|
|
174
|
+
const deviceIds = blockedForDestructive.map((b) => b.deviceId);
|
|
175
|
+
console.error(JSON.stringify({
|
|
176
|
+
error: {
|
|
177
|
+
code: 2,
|
|
178
|
+
kind: 'guard',
|
|
179
|
+
message: `Destructive command "${cmd}" requires --yes to run on ${blockedForDestructive.length} device(s).`,
|
|
180
|
+
hint: 'Re-issue the call with --yes to proceed.',
|
|
181
|
+
context: { command: cmd, deviceIds },
|
|
182
|
+
},
|
|
183
|
+
}));
|
|
184
|
+
}
|
|
185
|
+
else {
|
|
186
|
+
console.error(`Refusing to run destructive command "${cmd}" on ${blockedForDestructive.length} device(s) without --yes:`);
|
|
187
|
+
for (const b of blockedForDestructive)
|
|
188
|
+
console.error(` ${b.deviceId}`);
|
|
189
|
+
}
|
|
190
|
+
process.exit(2);
|
|
191
|
+
}
|
|
192
|
+
// parameter may be a JSON object string; mirror the single-command action.
|
|
193
|
+
let parsedParam = parameter ?? 'default';
|
|
194
|
+
if (parameter) {
|
|
195
|
+
try {
|
|
196
|
+
parsedParam = JSON.parse(parameter);
|
|
197
|
+
}
|
|
198
|
+
catch {
|
|
199
|
+
// keep as string
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
const concurrency = Math.max(1, Number.parseInt(options.concurrency, 10) || DEFAULT_CONCURRENCY);
|
|
203
|
+
const dryRun = isDryRun();
|
|
204
|
+
const startedAt = Date.now();
|
|
205
|
+
const outcomes = await runPool(resolved.ids, concurrency, async (id) => {
|
|
206
|
+
try {
|
|
207
|
+
const result = await executeCommand(id, cmd, parsedParam, effectiveType);
|
|
208
|
+
if (!isJsonMode()) {
|
|
209
|
+
console.log(`✓ ${id}: ${cmd}`);
|
|
210
|
+
}
|
|
211
|
+
return { ok: true, deviceId: id, result };
|
|
212
|
+
}
|
|
213
|
+
catch (err) {
|
|
214
|
+
// --dry-run uses DryRunSignal to short-circuit; surface that as a
|
|
215
|
+
// "skipped" outcome, not a failure.
|
|
216
|
+
if (err instanceof DryRunSignal) {
|
|
217
|
+
return { ok: 'dry-run', deviceId: id };
|
|
218
|
+
}
|
|
219
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
220
|
+
if (!isJsonMode()) {
|
|
221
|
+
console.error(`✗ ${id}: ${message}`);
|
|
222
|
+
}
|
|
223
|
+
return { ok: false, deviceId: id, error: message };
|
|
224
|
+
}
|
|
225
|
+
});
|
|
226
|
+
const succeeded = outcomes.filter((o) => o.ok === true);
|
|
227
|
+
const failed = outcomes.filter((o) => o.ok === false);
|
|
228
|
+
const dryRunned = outcomes.filter((o) => o.ok === 'dry-run');
|
|
229
|
+
const result = {
|
|
230
|
+
succeeded: succeeded.map((s) => ({ deviceId: s.deviceId, result: s.result })),
|
|
231
|
+
failed: failed.map((f) => ({ deviceId: f.deviceId, error: f.error })),
|
|
232
|
+
summary: {
|
|
233
|
+
total: resolved.ids.length,
|
|
234
|
+
ok: succeeded.length,
|
|
235
|
+
failed: failed.length,
|
|
236
|
+
skipped: dryRunned.length,
|
|
237
|
+
durationMs: Date.now() - startedAt,
|
|
238
|
+
...(dryRun ? { dryRun: true } : {}),
|
|
239
|
+
},
|
|
240
|
+
};
|
|
241
|
+
if (isJsonMode()) {
|
|
242
|
+
printJson(result);
|
|
243
|
+
}
|
|
244
|
+
else {
|
|
245
|
+
console.log(`\nSummary: ${result.summary.ok} ok, ${result.summary.failed} failed, ${result.summary.skipped} skipped (${result.summary.durationMs}ms)`);
|
|
246
|
+
}
|
|
247
|
+
// Non-zero exit when anything failed so scripts can react.
|
|
248
|
+
if (failed.length > 0)
|
|
249
|
+
process.exit(1);
|
|
250
|
+
});
|
|
251
|
+
}
|
|
252
|
+
//# sourceMappingURL=batch.js.map
|