@switchbot/openapi-cli 2.2.0 → 2.3.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 +5 -0
- package/dist/commands/batch.js +7 -5
- package/dist/commands/cache.js +3 -1
- package/dist/commands/catalog.js +3 -1
- package/dist/commands/completion.js +139 -12
- package/dist/commands/config.js +4 -3
- package/dist/commands/device-meta.js +3 -2
- package/dist/commands/devices.js +56 -15
- package/dist/commands/events.js +30 -17
- package/dist/commands/expand.js +11 -86
- package/dist/commands/history.js +4 -3
- package/dist/commands/mcp.js +6 -5
- package/dist/commands/schema.js +6 -3
- package/dist/commands/watch.js +4 -3
- package/dist/commands/webhook.js +2 -1
- package/dist/config.js +7 -2
- package/dist/devices/param-validator.js +263 -0
- package/dist/index.js +48 -19
- package/dist/lib/devices.js +26 -14
- package/dist/utils/arg-parsers.js +65 -0
- package/package.json +1 -1
package/dist/commands/expand.js
CHANGED
|
@@ -1,86 +1,11 @@
|
|
|
1
|
+
import { intArg, stringArg } from '../utils/arg-parsers.js';
|
|
1
2
|
import { handleError, isJsonMode, printJson, UsageError } from '../utils/output.js';
|
|
2
3
|
import { getCachedDevice } from '../devices/cache.js';
|
|
3
4
|
import { executeCommand, isDestructiveCommand, getDestructiveReason } from '../lib/devices.js';
|
|
4
5
|
import { isDryRun } from '../utils/flags.js';
|
|
5
6
|
import { resolveDeviceId } from '../utils/name-resolver.js';
|
|
6
7
|
import { DryRunSignal } from '../api/client.js';
|
|
7
|
-
|
|
8
|
-
const AC_MODE_MAP = { auto: 1, cool: 2, dry: 3, fan: 4, heat: 5 };
|
|
9
|
-
const AC_FAN_MAP = { auto: 1, low: 2, mid: 3, high: 4 };
|
|
10
|
-
const CURTAIN_MODE_MAP = { default: 'ff', performance: '0', silent: '1' };
|
|
11
|
-
const RELAY_MODE_MAP = { toggle: 0, edge: 1, detached: 2, momentary: 3 };
|
|
12
|
-
const BLIND_DIRECTION = new Set(['up', 'down']);
|
|
13
|
-
// ---- Translators -----------------------------------------------------------
|
|
14
|
-
function buildAcSetAll(opts) {
|
|
15
|
-
if (!opts.temp)
|
|
16
|
-
throw new UsageError('--temp is required for setAll (e.g. --temp 26)');
|
|
17
|
-
if (!opts.mode)
|
|
18
|
-
throw new UsageError('--mode is required for setAll (auto|cool|dry|fan|heat)');
|
|
19
|
-
if (!opts.fan)
|
|
20
|
-
throw new UsageError('--fan is required for setAll (auto|low|mid|high)');
|
|
21
|
-
if (!opts.power)
|
|
22
|
-
throw new UsageError('--power is required for setAll (on|off)');
|
|
23
|
-
const temp = parseInt(opts.temp, 10);
|
|
24
|
-
if (!Number.isFinite(temp) || temp < 16 || temp > 30) {
|
|
25
|
-
throw new UsageError(`--temp must be an integer between 16 and 30 (got "${opts.temp}")`);
|
|
26
|
-
}
|
|
27
|
-
const modeInt = AC_MODE_MAP[opts.mode.toLowerCase()];
|
|
28
|
-
if (modeInt === undefined) {
|
|
29
|
-
throw new UsageError(`--mode must be one of: auto, cool, dry, fan, heat (got "${opts.mode}")`);
|
|
30
|
-
}
|
|
31
|
-
const fanInt = AC_FAN_MAP[opts.fan.toLowerCase()];
|
|
32
|
-
if (fanInt === undefined) {
|
|
33
|
-
throw new UsageError(`--fan must be one of: auto, low, mid, high (got "${opts.fan}")`);
|
|
34
|
-
}
|
|
35
|
-
const power = opts.power.toLowerCase();
|
|
36
|
-
if (power !== 'on' && power !== 'off') {
|
|
37
|
-
throw new UsageError(`--power must be "on" or "off" (got "${opts.power}")`);
|
|
38
|
-
}
|
|
39
|
-
return `${temp},${modeInt},${fanInt},${power}`;
|
|
40
|
-
}
|
|
41
|
-
function buildCurtainSetPosition(opts) {
|
|
42
|
-
if (!opts.position)
|
|
43
|
-
throw new UsageError('--position is required (0-100)');
|
|
44
|
-
const pos = parseInt(opts.position, 10);
|
|
45
|
-
if (!Number.isFinite(pos) || pos < 0 || pos > 100) {
|
|
46
|
-
throw new UsageError(`--position must be an integer between 0 and 100 (got "${opts.position}")`);
|
|
47
|
-
}
|
|
48
|
-
const modeStr = opts.mode ? CURTAIN_MODE_MAP[opts.mode.toLowerCase()] : 'ff';
|
|
49
|
-
if (modeStr === undefined) {
|
|
50
|
-
throw new UsageError(`--mode must be one of: default, performance, silent (got "${opts.mode}")`);
|
|
51
|
-
}
|
|
52
|
-
return `0,${modeStr},${pos}`;
|
|
53
|
-
}
|
|
54
|
-
function buildBlindTiltSetPosition(opts) {
|
|
55
|
-
if (!opts.direction)
|
|
56
|
-
throw new UsageError('--direction is required (up|down)');
|
|
57
|
-
if (!opts.angle)
|
|
58
|
-
throw new UsageError('--angle is required (0-100)');
|
|
59
|
-
const dir = opts.direction.toLowerCase();
|
|
60
|
-
if (!BLIND_DIRECTION.has(dir)) {
|
|
61
|
-
throw new UsageError(`--direction must be "up" or "down" (got "${opts.direction}")`);
|
|
62
|
-
}
|
|
63
|
-
const angle = parseInt(opts.angle, 10);
|
|
64
|
-
if (!Number.isFinite(angle) || angle < 0 || angle > 100) {
|
|
65
|
-
throw new UsageError(`--angle must be an integer between 0 and 100 (got "${opts.angle}")`);
|
|
66
|
-
}
|
|
67
|
-
return `${dir};${angle}`;
|
|
68
|
-
}
|
|
69
|
-
function buildRelaySetMode(opts) {
|
|
70
|
-
if (!opts.channel)
|
|
71
|
-
throw new UsageError('--channel is required (1 or 2)');
|
|
72
|
-
if (!opts.mode)
|
|
73
|
-
throw new UsageError('--mode is required (toggle|edge|detached|momentary)');
|
|
74
|
-
const ch = parseInt(opts.channel, 10);
|
|
75
|
-
if (ch !== 1 && ch !== 2) {
|
|
76
|
-
throw new UsageError(`--channel must be 1 or 2 (got "${opts.channel}")`);
|
|
77
|
-
}
|
|
78
|
-
const modeInt = RELAY_MODE_MAP[opts.mode.toLowerCase()];
|
|
79
|
-
if (modeInt === undefined) {
|
|
80
|
-
throw new UsageError(`--mode must be one of: toggle, edge, detached, momentary (got "${opts.mode}")`);
|
|
81
|
-
}
|
|
82
|
-
return `${ch};${modeInt}`;
|
|
83
|
-
}
|
|
8
|
+
import { buildAcSetAll, buildCurtainSetPosition, buildBlindTiltSetPosition, buildRelaySetMode, } from '../devices/param-validator.js';
|
|
84
9
|
// ---- Registration ----------------------------------------------------------
|
|
85
10
|
export function registerExpandCommand(devices) {
|
|
86
11
|
devices
|
|
@@ -88,15 +13,15 @@ export function registerExpandCommand(devices) {
|
|
|
88
13
|
.description('Send a command with semantic flags instead of raw positional parameters')
|
|
89
14
|
.argument('[deviceId]', 'Target device ID from "devices list" (or use --name)')
|
|
90
15
|
.argument('[command]', 'Command name: setAll (AC), setPosition (Curtain/Blind Tilt), setMode (Relay Switch 2)')
|
|
91
|
-
.option('--name <query>', 'Resolve device by fuzzy name instead of deviceId')
|
|
92
|
-
.option('--temp <celsius>', 'AC setAll: temperature in Celsius (16-30)')
|
|
93
|
-
.option('--mode <mode>', 'AC: auto|cool|dry|fan|heat Curtain: default|performance|silent Relay: toggle|edge|detached|momentary')
|
|
94
|
-
.option('--fan <speed>', 'AC setAll: fan speed auto|low|mid|high')
|
|
95
|
-
.option('--power <state>', 'AC setAll: on|off')
|
|
96
|
-
.option('--position <percent>', 'Curtain setPosition: 0-100 (0=open, 100=closed)')
|
|
97
|
-
.option('--direction <dir>', 'Blind Tilt setPosition: up|down')
|
|
98
|
-
.option('--angle <percent>', 'Blind Tilt setPosition: 0-100 (0=closed, 100=open)')
|
|
99
|
-
.option('--channel <n>', 'Relay Switch 2 setMode: channel 1 or 2')
|
|
16
|
+
.option('--name <query>', 'Resolve device by fuzzy name instead of deviceId', stringArg('--name'))
|
|
17
|
+
.option('--temp <celsius>', 'AC setAll: temperature in Celsius (16-30)', intArg('--temp', { min: 16, max: 30 }))
|
|
18
|
+
.option('--mode <mode>', 'AC: auto|cool|dry|fan|heat Curtain: default|performance|silent Relay: toggle|edge|detached|momentary', stringArg('--mode'))
|
|
19
|
+
.option('--fan <speed>', 'AC setAll: fan speed auto|low|mid|high', stringArg('--fan'))
|
|
20
|
+
.option('--power <state>', 'AC setAll: on|off', stringArg('--power'))
|
|
21
|
+
.option('--position <percent>', 'Curtain setPosition: 0-100 (0=open, 100=closed)', intArg('--position', { min: 0, max: 100 }))
|
|
22
|
+
.option('--direction <dir>', 'Blind Tilt setPosition: up|down', stringArg('--direction'))
|
|
23
|
+
.option('--angle <percent>', 'Blind Tilt setPosition: 0-100 (0=closed, 100=open)', intArg('--angle', { min: 0, max: 100 }))
|
|
24
|
+
.option('--channel <n>', 'Relay Switch 2 setMode: channel 1 or 2', intArg('--channel', { min: 1, max: 2 }))
|
|
100
25
|
.option('--yes', 'Confirm destructive commands')
|
|
101
26
|
.addHelpText('after', `
|
|
102
27
|
Translates semantic flags into the wire parameter format, then sends the command.
|
package/dist/commands/history.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import path from 'node:path';
|
|
2
2
|
import os from 'node:os';
|
|
3
|
+
import { intArg, stringArg } from '../utils/arg-parsers.js';
|
|
3
4
|
import { printJson, isJsonMode, handleError } from '../utils/output.js';
|
|
4
5
|
import { readAudit } from '../utils/audit.js';
|
|
5
6
|
import { executeCommand } from '../lib/devices.js';
|
|
@@ -21,8 +22,8 @@ Examples:
|
|
|
21
22
|
history
|
|
22
23
|
.command('show')
|
|
23
24
|
.description('Print recent audit entries')
|
|
24
|
-
.option('--file <path>', `Path to the audit log (default ${DEFAULT_AUDIT})
|
|
25
|
-
.option('--limit <n>', 'Show only the last N entries')
|
|
25
|
+
.option('--file <path>', `Path to the audit log (default ${DEFAULT_AUDIT})`, stringArg('--file'))
|
|
26
|
+
.option('--limit <n>', 'Show only the last N entries', intArg('--limit', { min: 1 }))
|
|
26
27
|
.action((options) => {
|
|
27
28
|
const file = options.file ?? DEFAULT_AUDIT;
|
|
28
29
|
const entries = readAudit(file);
|
|
@@ -52,7 +53,7 @@ Examples:
|
|
|
52
53
|
.command('replay')
|
|
53
54
|
.description('Re-run a recorded command by its 1-indexed position')
|
|
54
55
|
.argument('<index>', 'Entry index (1 = oldest; as shown by "history show")')
|
|
55
|
-
.option('--file <path>', `Path to the audit log (default ${DEFAULT_AUDIT})
|
|
56
|
+
.option('--file <path>', `Path to the audit log (default ${DEFAULT_AUDIT})`, stringArg('--file'))
|
|
56
57
|
.addHelpText('after', `
|
|
57
58
|
Dry-run-honouring: pass --dry-run on the parent command to preview without
|
|
58
59
|
sending the actual call. Errors from the recorded entry are NOT replayed —
|
package/dist/commands/mcp.js
CHANGED
|
@@ -2,6 +2,7 @@ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
|
2
2
|
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
|
3
3
|
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
|
|
4
4
|
import { z } from 'zod';
|
|
5
|
+
import { intArg, stringArg } from '../utils/arg-parsers.js';
|
|
5
6
|
import { handleError, isJsonMode } from '../utils/output.js';
|
|
6
7
|
import { fetchDeviceList, fetchDeviceStatus, executeCommand, describeDevice, validateCommand, isDestructiveCommand, getDestructiveReason, searchCatalog, DeviceNotFoundError, toMcpDescribeShape, toMcpDeviceListShape, toMcpIrDeviceShape, } from '../lib/devices.js';
|
|
7
8
|
import { fetchScenes, executeScene } from '../lib/scenes.js';
|
|
@@ -489,11 +490,11 @@ Inspect locally:
|
|
|
489
490
|
mcp
|
|
490
491
|
.command('serve')
|
|
491
492
|
.description('Start the MCP server on stdio (default) or HTTP (--port)')
|
|
492
|
-
.option('--port <n>', 'Listen on HTTP instead of stdio (Streamable HTTP transport)')
|
|
493
|
-
.option('--bind <host>', 'IP address to bind (default 127.0.0.1; use 0.0.0.0 to accept external connections)', '127.0.0.1')
|
|
494
|
-
.option('--auth-token <token>', 'Bearer token for HTTP requests (required for --bind 0.0.0.0; falls back to SWITCHBOT_MCP_TOKEN env var)')
|
|
495
|
-
.option('--cors-origin <url>', 'Allowed CORS origin(s) for HTTP (repeatable)')
|
|
496
|
-
.option('--rate-limit <n>', 'Max requests per minute per profile (default 60)', '60')
|
|
493
|
+
.option('--port <n>', 'Listen on HTTP instead of stdio (Streamable HTTP transport)', intArg('--port', { min: 1, max: 65535 }))
|
|
494
|
+
.option('--bind <host>', 'IP address to bind (default 127.0.0.1; use 0.0.0.0 to accept external connections)', stringArg('--bind'), '127.0.0.1')
|
|
495
|
+
.option('--auth-token <token>', 'Bearer token for HTTP requests (required for --bind 0.0.0.0; falls back to SWITCHBOT_MCP_TOKEN env var)', stringArg('--auth-token'))
|
|
496
|
+
.option('--cors-origin <url>', 'Allowed CORS origin(s) for HTTP (repeatable)', stringArg('--cors-origin'))
|
|
497
|
+
.option('--rate-limit <n>', 'Max requests per minute per profile (default 60)', intArg('--rate-limit', { min: 1 }), '60')
|
|
497
498
|
.action(async (options) => {
|
|
498
499
|
try {
|
|
499
500
|
if (options.port) {
|
package/dist/commands/schema.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { enumArg, stringArg } from '../utils/arg-parsers.js';
|
|
1
2
|
import { printJson } from '../utils/output.js';
|
|
2
3
|
import { getEffectiveCatalog } from '../devices/catalog.js';
|
|
3
4
|
function toSchemaEntry(e) {
|
|
@@ -24,15 +25,17 @@ function toSchemaCommand(c) {
|
|
|
24
25
|
};
|
|
25
26
|
}
|
|
26
27
|
export function registerSchemaCommand(program) {
|
|
28
|
+
const ROLES = ['lighting', 'security', 'sensor', 'climate', 'media', 'cleaning', 'curtain', 'fan', 'power', 'hub', 'other'];
|
|
29
|
+
const CATEGORIES = ['physical', 'ir'];
|
|
27
30
|
const schema = program
|
|
28
31
|
.command('schema')
|
|
29
32
|
.description('Export the device catalog as structured JSON (for agent prompts / tooling)');
|
|
30
33
|
schema
|
|
31
34
|
.command('export')
|
|
32
35
|
.description('Print the full catalog as structured JSON (one object per type)')
|
|
33
|
-
.option('--type <type>', 'Restrict to a single device type (e.g. "Strip Light")')
|
|
34
|
-
.option('--role <role>', 'Restrict to a functional role: lighting, security, sensor, climate, media, cleaning, curtain, fan, power, hub, other')
|
|
35
|
-
.option('--category <cat>', 'Restrict to "physical" or "ir"')
|
|
36
|
+
.option('--type <type>', 'Restrict to a single device type (e.g. "Strip Light")', stringArg('--type'))
|
|
37
|
+
.option('--role <role>', 'Restrict to a functional role: lighting, security, sensor, climate, media, cleaning, curtain, fan, power, hub, other', enumArg('--role', ROLES))
|
|
38
|
+
.option('--category <cat>', 'Restrict to "physical" or "ir"', enumArg('--category', CATEGORIES))
|
|
36
39
|
.addHelpText('after', `
|
|
37
40
|
Output is always JSON (this command ignores --format). The output is a
|
|
38
41
|
catalog export — not a formal JSON Schema standard document — suitable for
|
package/dist/commands/watch.js
CHANGED
|
@@ -2,6 +2,7 @@ import { printJson, isJsonMode, handleError, UsageError } from '../utils/output.
|
|
|
2
2
|
import { fetchDeviceStatus } from '../lib/devices.js';
|
|
3
3
|
import { getCachedDevice } from '../devices/cache.js';
|
|
4
4
|
import { parseDurationToMs, getFields } from '../utils/flags.js';
|
|
5
|
+
import { intArg, durationArg, stringArg } from '../utils/arg-parsers.js';
|
|
5
6
|
import { createClient } from '../api/client.js';
|
|
6
7
|
import { resolveDeviceId } from '../utils/name-resolver.js';
|
|
7
8
|
const DEFAULT_INTERVAL_MS = 30_000;
|
|
@@ -57,9 +58,9 @@ export function registerWatchCommand(devices) {
|
|
|
57
58
|
.command('watch')
|
|
58
59
|
.description('Poll device status on an interval and emit field-level changes (JSONL)')
|
|
59
60
|
.argument('[deviceId...]', 'One or more deviceIds to watch (or use --name for one device)')
|
|
60
|
-
.option('--name <query>', 'Resolve one device by fuzzy name (combined with any positional IDs)')
|
|
61
|
-
.option('--interval <dur>', `Polling interval: "30s", "1m", "500ms", ... (default 30s, min ${MIN_INTERVAL_MS / 1000}s)`, '30s')
|
|
62
|
-
.option('--max <n>', 'Stop after N ticks (default: run until Ctrl-C)')
|
|
61
|
+
.option('--name <query>', 'Resolve one device by fuzzy name (combined with any positional IDs)', stringArg('--name'))
|
|
62
|
+
.option('--interval <dur>', `Polling interval: "30s", "1m", "500ms", ... (default 30s, min ${MIN_INTERVAL_MS / 1000}s)`, durationArg('--interval'), '30s')
|
|
63
|
+
.option('--max <n>', 'Stop after N ticks (default: run until Ctrl-C)', intArg('--max', { min: 1 }))
|
|
63
64
|
.option('--include-unchanged', 'Emit a tick even when no field changed')
|
|
64
65
|
.addHelpText('after', `
|
|
65
66
|
Each poll emits one JSON line per deviceId with the shape:
|
package/dist/commands/webhook.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { stringArg } from '../utils/arg-parsers.js';
|
|
1
2
|
import { createClient } from '../api/client.js';
|
|
2
3
|
import { printKeyValue, printJson, isJsonMode, handleError, UsageError } from '../utils/output.js';
|
|
3
4
|
import chalk from 'chalk';
|
|
@@ -54,7 +55,7 @@ Example:
|
|
|
54
55
|
webhook
|
|
55
56
|
.command('query')
|
|
56
57
|
.description('Query webhook configuration')
|
|
57
|
-
.option('--details <url>', 'Query detailed configuration (enable/deviceList/timestamps) for a specific URL')
|
|
58
|
+
.option('--details <url>', 'Query detailed configuration (enable/deviceList/timestamps) for a specific URL', stringArg('--details'))
|
|
58
59
|
.addHelpText('after', `
|
|
59
60
|
Without --details, lists all configured webhook URLs.
|
|
60
61
|
With --details, prints enable/deviceList/createTime/lastUpdateTime for the given URL.
|
package/dist/config.js
CHANGED
|
@@ -99,7 +99,7 @@ export function showConfig() {
|
|
|
99
99
|
const envSecret = process.env.SWITCHBOT_SECRET;
|
|
100
100
|
if (envToken && envSecret) {
|
|
101
101
|
console.log('Credential source: environment variables');
|
|
102
|
-
console.log(`token : ${envToken}`);
|
|
102
|
+
console.log(`token : ${maskCredential(envToken)}`);
|
|
103
103
|
console.log(`secret: ${maskSecret(envSecret)}`);
|
|
104
104
|
return;
|
|
105
105
|
}
|
|
@@ -112,13 +112,18 @@ export function showConfig() {
|
|
|
112
112
|
const raw = fs.readFileSync(file, 'utf-8');
|
|
113
113
|
const cfg = JSON.parse(raw);
|
|
114
114
|
console.log(`Credential source: ${file}`);
|
|
115
|
-
console.log(`token : ${cfg.token}`);
|
|
115
|
+
console.log(`token : ${maskCredential(cfg.token)}`);
|
|
116
116
|
console.log(`secret: ${maskSecret(cfg.secret)}`);
|
|
117
117
|
}
|
|
118
118
|
catch {
|
|
119
119
|
console.error('Failed to read config file');
|
|
120
120
|
}
|
|
121
121
|
}
|
|
122
|
+
function maskCredential(token) {
|
|
123
|
+
if (token.length <= 8)
|
|
124
|
+
return '*'.repeat(Math.max(4, token.length));
|
|
125
|
+
return token.slice(0, 4) + '*'.repeat(token.length - 8) + token.slice(-4);
|
|
126
|
+
}
|
|
122
127
|
function maskSecret(secret) {
|
|
123
128
|
if (secret.length <= 4)
|
|
124
129
|
return '****';
|
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
import { UsageError } from '../utils/output.js';
|
|
2
|
+
export const AC_MODE_MAP = { auto: 1, cool: 2, dry: 3, fan: 4, heat: 5 };
|
|
3
|
+
export const AC_FAN_MAP = { auto: 1, low: 2, mid: 3, high: 4 };
|
|
4
|
+
export const CURTAIN_MODE_MAP = { default: 'ff', performance: '0', silent: '1' };
|
|
5
|
+
export const RELAY_MODE_MAP = { toggle: 0, edge: 1, detached: 2, momentary: 3 };
|
|
6
|
+
const BLIND_DIRECTION = new Set(['up', 'down']);
|
|
7
|
+
// ---- Semantic-flag builders (used by `devices expand`) --------------------
|
|
8
|
+
export function buildAcSetAll(opts) {
|
|
9
|
+
if (!opts.temp)
|
|
10
|
+
throw new UsageError('--temp is required for setAll (e.g. --temp 26)');
|
|
11
|
+
if (!opts.mode)
|
|
12
|
+
throw new UsageError('--mode is required for setAll (auto|cool|dry|fan|heat)');
|
|
13
|
+
if (!opts.fan)
|
|
14
|
+
throw new UsageError('--fan is required for setAll (auto|low|mid|high)');
|
|
15
|
+
if (!opts.power)
|
|
16
|
+
throw new UsageError('--power is required for setAll (on|off)');
|
|
17
|
+
const temp = parseInt(opts.temp, 10);
|
|
18
|
+
if (!Number.isFinite(temp) || temp < 16 || temp > 30) {
|
|
19
|
+
throw new UsageError(`--temp must be an integer between 16 and 30 (got "${opts.temp}")`);
|
|
20
|
+
}
|
|
21
|
+
const modeInt = AC_MODE_MAP[opts.mode.toLowerCase()];
|
|
22
|
+
if (modeInt === undefined) {
|
|
23
|
+
throw new UsageError(`--mode must be one of: auto, cool, dry, fan, heat (got "${opts.mode}")`);
|
|
24
|
+
}
|
|
25
|
+
const fanInt = AC_FAN_MAP[opts.fan.toLowerCase()];
|
|
26
|
+
if (fanInt === undefined) {
|
|
27
|
+
throw new UsageError(`--fan must be one of: auto, low, mid, high (got "${opts.fan}")`);
|
|
28
|
+
}
|
|
29
|
+
const power = opts.power.toLowerCase();
|
|
30
|
+
if (power !== 'on' && power !== 'off') {
|
|
31
|
+
throw new UsageError(`--power must be "on" or "off" (got "${opts.power}")`);
|
|
32
|
+
}
|
|
33
|
+
return `${temp},${modeInt},${fanInt},${power}`;
|
|
34
|
+
}
|
|
35
|
+
export function buildCurtainSetPosition(opts) {
|
|
36
|
+
if (!opts.position)
|
|
37
|
+
throw new UsageError('--position is required (0-100)');
|
|
38
|
+
const pos = parseInt(opts.position, 10);
|
|
39
|
+
if (!Number.isFinite(pos) || pos < 0 || pos > 100) {
|
|
40
|
+
throw new UsageError(`--position must be an integer between 0 and 100 (got "${opts.position}")`);
|
|
41
|
+
}
|
|
42
|
+
const modeStr = opts.mode ? CURTAIN_MODE_MAP[opts.mode.toLowerCase()] : 'ff';
|
|
43
|
+
if (modeStr === undefined) {
|
|
44
|
+
throw new UsageError(`--mode must be one of: default, performance, silent (got "${opts.mode}")`);
|
|
45
|
+
}
|
|
46
|
+
return `0,${modeStr},${pos}`;
|
|
47
|
+
}
|
|
48
|
+
export function buildBlindTiltSetPosition(opts) {
|
|
49
|
+
if (!opts.direction)
|
|
50
|
+
throw new UsageError('--direction is required (up|down)');
|
|
51
|
+
if (!opts.angle)
|
|
52
|
+
throw new UsageError('--angle is required (0-100)');
|
|
53
|
+
const dir = opts.direction.toLowerCase();
|
|
54
|
+
if (!BLIND_DIRECTION.has(dir)) {
|
|
55
|
+
throw new UsageError(`--direction must be "up" or "down" (got "${opts.direction}")`);
|
|
56
|
+
}
|
|
57
|
+
const angle = parseInt(opts.angle, 10);
|
|
58
|
+
if (!Number.isFinite(angle) || angle < 0 || angle > 100) {
|
|
59
|
+
throw new UsageError(`--angle must be an integer between 0 and 100 (got "${opts.angle}")`);
|
|
60
|
+
}
|
|
61
|
+
return `${dir};${angle}`;
|
|
62
|
+
}
|
|
63
|
+
export function buildRelaySetMode(opts) {
|
|
64
|
+
if (!opts.channel)
|
|
65
|
+
throw new UsageError('--channel is required (1 or 2)');
|
|
66
|
+
if (!opts.mode)
|
|
67
|
+
throw new UsageError('--mode is required (toggle|edge|detached|momentary)');
|
|
68
|
+
const ch = parseInt(opts.channel, 10);
|
|
69
|
+
if (ch !== 1 && ch !== 2) {
|
|
70
|
+
throw new UsageError(`--channel must be 1 or 2 (got "${opts.channel}")`);
|
|
71
|
+
}
|
|
72
|
+
const modeInt = RELAY_MODE_MAP[opts.mode.toLowerCase()];
|
|
73
|
+
if (modeInt === undefined) {
|
|
74
|
+
throw new UsageError(`--mode must be one of: toggle, edge, detached, momentary (got "${opts.mode}")`);
|
|
75
|
+
}
|
|
76
|
+
return `${ch};${modeInt}`;
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* Validate a raw wire-format parameter string for (deviceType, command)
|
|
80
|
+
* combos where the shape is well-defined. Unknown combos pass through so
|
|
81
|
+
* `devices command` remains a usable escape hatch for types/commands the
|
|
82
|
+
* CLI hasn't modelled yet.
|
|
83
|
+
*
|
|
84
|
+
* On passthrough, `normalized` is left undefined so the caller keeps the
|
|
85
|
+
* original parameter value (preserving the `undefined → "default"` default
|
|
86
|
+
* for no-arg commands).
|
|
87
|
+
*/
|
|
88
|
+
export function validateParameter(deviceType, command, raw) {
|
|
89
|
+
if (!deviceType)
|
|
90
|
+
return { ok: true };
|
|
91
|
+
if (deviceType === 'Air Conditioner' && command === 'setAll') {
|
|
92
|
+
return validateAcSetAll(raw);
|
|
93
|
+
}
|
|
94
|
+
if (deviceType.startsWith('Curtain') && command === 'setPosition') {
|
|
95
|
+
return validateCurtainSetPosition(raw);
|
|
96
|
+
}
|
|
97
|
+
if (deviceType.startsWith('Blind Tilt') && command === 'setPosition') {
|
|
98
|
+
return validateBlindTiltSetPosition(raw);
|
|
99
|
+
}
|
|
100
|
+
if (deviceType.startsWith('Relay Switch') && command === 'setMode') {
|
|
101
|
+
return validateRelaySetMode(raw);
|
|
102
|
+
}
|
|
103
|
+
return { ok: true };
|
|
104
|
+
}
|
|
105
|
+
function validateAcSetAll(raw) {
|
|
106
|
+
if (raw === undefined || raw === '' || raw === 'default') {
|
|
107
|
+
return {
|
|
108
|
+
ok: false,
|
|
109
|
+
error: `setAll requires a parameter "<temp>,<mode>,<fan>,<on|off>". Example: "26,2,2,on".`,
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
if (raw.startsWith('{') || raw.startsWith('[')) {
|
|
113
|
+
return {
|
|
114
|
+
ok: false,
|
|
115
|
+
error: `setAll parameter must be a CSV string like "26,2,2,on", not JSON (got ${JSON.stringify(raw)}).`,
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
const parts = raw.split(',');
|
|
119
|
+
if (parts.length !== 4) {
|
|
120
|
+
return {
|
|
121
|
+
ok: false,
|
|
122
|
+
error: `setAll expects 4 comma-separated fields "<temp>,<mode>,<fan>,<on|off>", got ${parts.length} (${JSON.stringify(raw)}). Example: "26,2,2,on".`,
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
const [tempStr, modeStr, fanStr, powerStr] = parts.map((s) => s.trim());
|
|
126
|
+
const temp = Number(tempStr);
|
|
127
|
+
if (!Number.isInteger(temp) || temp < 16 || temp > 30) {
|
|
128
|
+
return {
|
|
129
|
+
ok: false,
|
|
130
|
+
error: `setAll field 1 (temp) must be an integer 16-30, got "${tempStr}". Example: "26,2,2,on".`,
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
const mode = Number(modeStr);
|
|
134
|
+
if (!Number.isInteger(mode) || mode < 1 || mode > 5) {
|
|
135
|
+
return {
|
|
136
|
+
ok: false,
|
|
137
|
+
error: `setAll field 2 (mode) must be 1-5 (1=auto 2=cool 3=dry 4=fan 5=heat), got "${modeStr}". Example: "26,2,2,on".`,
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
const fan = Number(fanStr);
|
|
141
|
+
if (!Number.isInteger(fan) || fan < 1 || fan > 4) {
|
|
142
|
+
return {
|
|
143
|
+
ok: false,
|
|
144
|
+
error: `setAll field 3 (fan) must be 1-4 (1=auto 2=low 3=mid 4=high), got "${fanStr}". Example: "26,2,2,on".`,
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
const power = powerStr.toLowerCase();
|
|
148
|
+
if (power !== 'on' && power !== 'off') {
|
|
149
|
+
return {
|
|
150
|
+
ok: false,
|
|
151
|
+
error: `setAll field 4 (power) must be "on" or "off", got "${powerStr}". Example: "26,2,2,on".`,
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
return { ok: true, normalized: `${temp},${mode},${fan},${power}` };
|
|
155
|
+
}
|
|
156
|
+
function validateCurtainSetPosition(raw) {
|
|
157
|
+
if (raw === undefined || raw === '' || raw === 'default') {
|
|
158
|
+
return {
|
|
159
|
+
ok: false,
|
|
160
|
+
error: `setPosition requires a parameter. Expected: "<0-100>" or "<index>,<ff|0|1>,<0-100>". Example: "50" or "0,ff,50".`,
|
|
161
|
+
};
|
|
162
|
+
}
|
|
163
|
+
if (!raw.includes(',')) {
|
|
164
|
+
const pos = Number(raw);
|
|
165
|
+
if (!Number.isInteger(pos) || pos < 0 || pos > 100) {
|
|
166
|
+
return {
|
|
167
|
+
ok: false,
|
|
168
|
+
error: `setPosition must be an integer 0-100, got "${raw}". Example: "50".`,
|
|
169
|
+
};
|
|
170
|
+
}
|
|
171
|
+
return { ok: true, normalized: String(pos) };
|
|
172
|
+
}
|
|
173
|
+
const parts = raw.split(',').map((s) => s.trim());
|
|
174
|
+
if (parts.length !== 3) {
|
|
175
|
+
return {
|
|
176
|
+
ok: false,
|
|
177
|
+
error: `setPosition tuple form expects 3 comma-separated fields "<index>,<ff|0|1>,<0-100>", got ${parts.length} (${JSON.stringify(raw)}).`,
|
|
178
|
+
};
|
|
179
|
+
}
|
|
180
|
+
const [idxStr, modeStr, posStr] = parts;
|
|
181
|
+
const idx = Number(idxStr);
|
|
182
|
+
if (!Number.isInteger(idx) || idx < 0) {
|
|
183
|
+
return {
|
|
184
|
+
ok: false,
|
|
185
|
+
error: `setPosition field 1 (index) must be a non-negative integer, got "${idxStr}".`,
|
|
186
|
+
};
|
|
187
|
+
}
|
|
188
|
+
const modeLower = modeStr.toLowerCase();
|
|
189
|
+
if (!['ff', '0', '1'].includes(modeLower)) {
|
|
190
|
+
return {
|
|
191
|
+
ok: false,
|
|
192
|
+
error: `setPosition field 2 (mode) must be "ff", "0", or "1", got "${modeStr}". (ff=default, 0=performance, 1=silent)`,
|
|
193
|
+
};
|
|
194
|
+
}
|
|
195
|
+
const pos = Number(posStr);
|
|
196
|
+
if (!Number.isInteger(pos) || pos < 0 || pos > 100) {
|
|
197
|
+
return {
|
|
198
|
+
ok: false,
|
|
199
|
+
error: `setPosition field 3 (position) must be an integer 0-100, got "${posStr}".`,
|
|
200
|
+
};
|
|
201
|
+
}
|
|
202
|
+
return { ok: true, normalized: `${idx},${modeLower},${pos}` };
|
|
203
|
+
}
|
|
204
|
+
function validateBlindTiltSetPosition(raw) {
|
|
205
|
+
if (raw === undefined || raw === '' || raw === 'default') {
|
|
206
|
+
return {
|
|
207
|
+
ok: false,
|
|
208
|
+
error: `Blind Tilt setPosition requires a parameter. Expected: "<up|down>;<0-100>". Example: "up;50".`,
|
|
209
|
+
};
|
|
210
|
+
}
|
|
211
|
+
const parts = raw.split(';');
|
|
212
|
+
if (parts.length !== 2) {
|
|
213
|
+
return {
|
|
214
|
+
ok: false,
|
|
215
|
+
error: `Blind Tilt setPosition expects "<up|down>;<angle>", got ${JSON.stringify(raw)}. Example: "up;50".`,
|
|
216
|
+
};
|
|
217
|
+
}
|
|
218
|
+
const dir = parts[0].toLowerCase();
|
|
219
|
+
if (!BLIND_DIRECTION.has(dir)) {
|
|
220
|
+
return {
|
|
221
|
+
ok: false,
|
|
222
|
+
error: `Blind Tilt setPosition direction must be "up" or "down", got "${parts[0]}".`,
|
|
223
|
+
};
|
|
224
|
+
}
|
|
225
|
+
const angle = Number(parts[1]);
|
|
226
|
+
if (!Number.isInteger(angle) || angle < 0 || angle > 100) {
|
|
227
|
+
return {
|
|
228
|
+
ok: false,
|
|
229
|
+
error: `Blind Tilt setPosition angle must be an integer 0-100, got "${parts[1]}".`,
|
|
230
|
+
};
|
|
231
|
+
}
|
|
232
|
+
return { ok: true, normalized: `${dir};${angle}` };
|
|
233
|
+
}
|
|
234
|
+
function validateRelaySetMode(raw) {
|
|
235
|
+
if (raw === undefined || raw === '' || raw === 'default') {
|
|
236
|
+
return {
|
|
237
|
+
ok: false,
|
|
238
|
+
error: `Relay Switch setMode requires a parameter. Expected: "<1|2>;<0|1|2|3>". Example: "1;1" (channel 1, edge mode).`,
|
|
239
|
+
};
|
|
240
|
+
}
|
|
241
|
+
const parts = raw.split(';');
|
|
242
|
+
if (parts.length !== 2) {
|
|
243
|
+
return {
|
|
244
|
+
ok: false,
|
|
245
|
+
error: `Relay Switch setMode expects "<channel>;<mode>", got ${JSON.stringify(raw)}. Example: "1;1".`,
|
|
246
|
+
};
|
|
247
|
+
}
|
|
248
|
+
const ch = Number(parts[0]);
|
|
249
|
+
if (ch !== 1 && ch !== 2) {
|
|
250
|
+
return {
|
|
251
|
+
ok: false,
|
|
252
|
+
error: `Relay Switch setMode channel must be 1 or 2, got "${parts[0]}".`,
|
|
253
|
+
};
|
|
254
|
+
}
|
|
255
|
+
const mode = Number(parts[1]);
|
|
256
|
+
if (!Number.isInteger(mode) || mode < 0 || mode > 3) {
|
|
257
|
+
return {
|
|
258
|
+
ok: false,
|
|
259
|
+
error: `Relay Switch setMode mode must be 0-3 (0=toggle 1=edge 2=detached 3=momentary), got "${parts[1]}".`,
|
|
260
|
+
};
|
|
261
|
+
}
|
|
262
|
+
return { ok: true, normalized: `${ch};${mode}` };
|
|
263
|
+
}
|
package/dist/index.js
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import { Command, CommanderError } from 'commander';
|
|
2
|
+
import { Command, CommanderError, InvalidArgumentError } from 'commander';
|
|
3
3
|
import { createRequire } from 'node:module';
|
|
4
|
+
import { intArg, stringArg, enumArg } from './utils/arg-parsers.js';
|
|
5
|
+
import { parseDurationToMs } from './utils/flags.js';
|
|
4
6
|
import { registerConfigCommand } from './commands/config.js';
|
|
5
7
|
import { registerDevicesCommand } from './commands/devices.js';
|
|
6
8
|
import { registerScenesCommand } from './commands/scenes.js';
|
|
@@ -19,26 +21,44 @@ import { registerCapabilitiesCommand } from './commands/capabilities.js';
|
|
|
19
21
|
const require = createRequire(import.meta.url);
|
|
20
22
|
const { version: pkgVersion } = require('../package.json');
|
|
21
23
|
const program = new Command();
|
|
24
|
+
// Top-level subcommand names. Used by stringArg to produce clearer errors when
|
|
25
|
+
// a value is omitted and the next argv token turns out to be a subcommand name.
|
|
26
|
+
const TOP_LEVEL_COMMANDS = [
|
|
27
|
+
'config', 'devices', 'scenes', 'webhook', 'completion', 'mcp',
|
|
28
|
+
'quota', 'catalog', 'cache', 'events', 'doctor', 'schema',
|
|
29
|
+
'history', 'plan', 'capabilities',
|
|
30
|
+
];
|
|
31
|
+
const cacheModeArg = (value) => {
|
|
32
|
+
if (value.startsWith('-')) {
|
|
33
|
+
throw new InvalidArgumentError(`--cache requires a mode value, got "${value}". ` +
|
|
34
|
+
`Valid: "off", "auto", or a duration like "5m", "1h". Use --cache=<mode> if needed.`);
|
|
35
|
+
}
|
|
36
|
+
if (value === 'off' || value === 'auto')
|
|
37
|
+
return value;
|
|
38
|
+
if (parseDurationToMs(value) !== null)
|
|
39
|
+
return value;
|
|
40
|
+
throw new InvalidArgumentError(`--cache must be "off", "auto", or a duration like "30s"/"5m"/"1h" (got "${value}")`);
|
|
41
|
+
};
|
|
22
42
|
program
|
|
23
43
|
.name('switchbot')
|
|
24
44
|
.description('Command-line tool for SwitchBot API v1.1')
|
|
25
45
|
.version(pkgVersion)
|
|
26
46
|
.option('--json', 'Output raw JSON response (disables tables; useful for pipes/scripts)')
|
|
27
|
-
.option('--format <type>', 'Output format: table (default), json, jsonl, tsv, yaml, id')
|
|
28
|
-
.option('--fields <csv>', 'Comma-separated list of columns to include (e.g. --fields=id,name,type)')
|
|
47
|
+
.option('--format <type>', 'Output format: table (default), json, jsonl, tsv, yaml, id', enumArg('--format', ['table', 'json', 'jsonl', 'tsv', 'yaml', 'id']))
|
|
48
|
+
.option('--fields <csv>', 'Comma-separated list of columns to include (e.g. --fields=id,name,type)', stringArg('--fields', { disallow: TOP_LEVEL_COMMANDS }))
|
|
29
49
|
.option('-v, --verbose', 'Log HTTP request/response details to stderr')
|
|
30
50
|
.option('--dry-run', 'Print mutating requests without sending them (GETs still execute)')
|
|
31
|
-
.option('--timeout <ms>', 'HTTP request timeout in milliseconds (default: 30000)')
|
|
32
|
-
.option('--retry-on-429 <n>', 'Max 429 retries before surfacing the error (default: 3)')
|
|
33
|
-
.option('--backoff <strategy>', 'Backoff strategy for retries: "linear" or "exponential" (default)')
|
|
51
|
+
.option('--timeout <ms>', 'HTTP request timeout in milliseconds (default: 30000)', intArg('--timeout', { min: 1 }))
|
|
52
|
+
.option('--retry-on-429 <n>', 'Max 429 retries before surfacing the error (default: 3)', intArg('--retry-on-429', { min: 0 }))
|
|
53
|
+
.option('--backoff <strategy>', 'Backoff strategy for retries: "linear" or "exponential" (default)', enumArg('--backoff', ['linear', 'exponential']))
|
|
34
54
|
.option('--no-retry', 'Disable 429 retries entirely (equivalent to --retry-on-429 0)')
|
|
35
55
|
.option('--no-quota', 'Disable the local ~/.switchbot/quota.json counter for this run')
|
|
36
|
-
.option('--cache <mode>', 'Cache mode: "off" | "auto" (default: list 1h, status off) | duration like 5m, 1h, 30s (enables both stores)')
|
|
56
|
+
.option('--cache <mode>', 'Cache mode: "off" | "auto" (default: list 1h, status off) | duration like 5m, 1h, 30s (enables both stores)', cacheModeArg)
|
|
37
57
|
.option('--no-cache', 'Disable cache reads (equivalent to --cache off)')
|
|
38
|
-
.option('--config <path>', 'Override credential file location (default: ~/.switchbot/config.json)')
|
|
39
|
-
.option('--profile <name>', 'Use a named profile: ~/.switchbot/profiles/<name>.json')
|
|
58
|
+
.option('--config <path>', 'Override credential file location (default: ~/.switchbot/config.json)', stringArg('--config', { disallow: TOP_LEVEL_COMMANDS }))
|
|
59
|
+
.option('--profile <name>', 'Use a named profile: ~/.switchbot/profiles/<name>.json', stringArg('--profile', { disallow: TOP_LEVEL_COMMANDS }))
|
|
40
60
|
.option('--audit-log', 'Append every mutating command to JSONL audit log (default path: ~/.switchbot/audit.log)')
|
|
41
|
-
.option('--audit-log-path <path>', 'Custom audit log file path; use together with --audit-log')
|
|
61
|
+
.option('--audit-log-path <path>', 'Custom audit log file path; use together with --audit-log', stringArg('--audit-log-path', { disallow: TOP_LEVEL_COMMANDS }))
|
|
42
62
|
.showHelpAfterError('(run with --help to see usage)')
|
|
43
63
|
.showSuggestionAfterError();
|
|
44
64
|
registerConfigCommand(program);
|
|
@@ -95,24 +115,33 @@ Discovery:
|
|
|
95
115
|
|
|
96
116
|
Docs: https://github.com/OpenWonderLabs/SwitchBotAPI
|
|
97
117
|
`);
|
|
98
|
-
// Map commander usage errors (unknown option, missing argument,
|
|
99
|
-
|
|
100
|
-
|
|
118
|
+
// Map commander usage errors (unknown option, missing argument, argParser
|
|
119
|
+
// InvalidArgumentError, etc.) to exit code 2. Commander's exitOverride is
|
|
120
|
+
// per-command: subcommand errors won't bubble to the root override, so walk
|
|
121
|
+
// every registered command and apply the same handler.
|
|
122
|
+
const usageExitHandler = (err) => {
|
|
101
123
|
if (err.code === 'commander.helpDisplayed' || err.code === 'commander.version') {
|
|
102
124
|
process.exit(0);
|
|
103
125
|
}
|
|
104
|
-
// Everything else from commander (unknown option, missing argument,
|
|
105
|
-
// invalid choice, conflicting options, unknown command) is a usage error.
|
|
106
126
|
process.exit(2);
|
|
107
|
-
}
|
|
127
|
+
};
|
|
128
|
+
function applyExitOverride(cmd) {
|
|
129
|
+
cmd.exitOverride(usageExitHandler);
|
|
130
|
+
cmd.commands.forEach(applyExitOverride);
|
|
131
|
+
}
|
|
132
|
+
applyExitOverride(program);
|
|
108
133
|
try {
|
|
109
134
|
await program.parseAsync();
|
|
110
135
|
}
|
|
111
136
|
catch (err) {
|
|
112
|
-
//
|
|
113
|
-
//
|
|
137
|
+
// Subcommand-level CommanderErrors (e.g. InvalidArgumentError from an
|
|
138
|
+
// argParser on a subcommand option) don't always hit the root exitOverride.
|
|
139
|
+
// Mirror the root mapping so all usage errors surface as exit 2.
|
|
114
140
|
if (err instanceof CommanderError) {
|
|
115
|
-
|
|
141
|
+
if (err.code === 'commander.helpDisplayed' || err.code === 'commander.version') {
|
|
142
|
+
process.exit(0);
|
|
143
|
+
}
|
|
144
|
+
process.exit(2);
|
|
116
145
|
}
|
|
117
146
|
throw err;
|
|
118
147
|
}
|