@switchbot/openapi-cli 2.2.1 → 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 +2 -0
- package/dist/commands/devices.js +43 -8
- package/dist/commands/expand.js +1 -77
- package/dist/devices/param-validator.js +263 -0
- package/dist/lib/devices.js +26 -14
- package/dist/utils/arg-parsers.js +4 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -278,6 +278,8 @@ Generic parameter shapes (which one applies is decided by the device — see the
|
|
|
278
278
|
| `<json object>` | `'{"action":"sweep","param":{"fanLevel":2,"times":1}}'` |
|
|
279
279
|
| Custom IR button | `devices command <id> MyButton --type customize` |
|
|
280
280
|
|
|
281
|
+
Parameters for `setAll` (Air Conditioner), `setPosition` (Curtain / Blind Tilt), and `setMode` (Relay Switch) are validated client-side before the request — malformed shapes, out-of-range values, and JSON for CSV fields all fail fast with exit 2. Command names are also case-normalized against the catalog (e.g. `turnon` is auto-corrected to `turnOn` with a stderr warning); unknown names still exit 2 with the supported-commands list.
|
|
282
|
+
|
|
281
283
|
For the complete per-device command reference, see the [SwitchBot API docs](https://github.com/OpenWonderLabs/SwitchBotAPI#send-device-control-commands).
|
|
282
284
|
|
|
283
285
|
#### `devices expand` — named flags for packed parameters
|
package/dist/commands/devices.js
CHANGED
|
@@ -6,6 +6,7 @@ import { getCachedDevice } from '../devices/cache.js';
|
|
|
6
6
|
import { loadDeviceMeta } from '../devices/device-meta.js';
|
|
7
7
|
import { resolveDeviceId } from '../utils/name-resolver.js';
|
|
8
8
|
import { fetchDeviceList, fetchDeviceStatus, executeCommand, describeDevice, validateCommand, isDestructiveCommand, getDestructiveReason, buildHubLocationMap, DeviceNotFoundError, } from '../lib/devices.js';
|
|
9
|
+
import { validateParameter } from '../devices/param-validator.js';
|
|
9
10
|
import { registerBatchCommand } from './batch.js';
|
|
10
11
|
import { registerWatchCommand } from './watch.js';
|
|
11
12
|
import { registerExplainCommand } from './explain.js';
|
|
@@ -341,19 +342,22 @@ Examples:
|
|
|
341
342
|
`)
|
|
342
343
|
.action(async (deviceIdArg, cmdArg, parameter, options) => {
|
|
343
344
|
try {
|
|
344
|
-
// BUG-FIX: When --name is provided, Commander
|
|
345
|
-
//
|
|
345
|
+
// BUG-FIX: When --name is provided, Commander fills positionals left-to-right
|
|
346
|
+
// starting at [deviceId]. Shift them back to their semantic slots.
|
|
346
347
|
let cmd;
|
|
347
348
|
let effectiveDeviceIdArg;
|
|
348
349
|
if (options.name) {
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
}
|
|
352
|
-
if (!deviceIdArg && !cmdArg) {
|
|
350
|
+
// `--name "x" <cmd> [parameter]` → Commander binds deviceIdArg=<cmd>, cmdArg=[parameter].
|
|
351
|
+
if (!deviceIdArg) {
|
|
353
352
|
throw new UsageError('Command name is required (e.g. turnOn, turnOff, setAll).');
|
|
354
353
|
}
|
|
355
|
-
|
|
356
|
-
|
|
354
|
+
cmd = deviceIdArg;
|
|
355
|
+
if (cmdArg !== undefined) {
|
|
356
|
+
if (parameter !== undefined) {
|
|
357
|
+
throw new UsageError('Too many positional arguments after --name. Expected: --name <query> <cmd> [parameter].');
|
|
358
|
+
}
|
|
359
|
+
parameter = cmdArg;
|
|
360
|
+
}
|
|
357
361
|
effectiveDeviceIdArg = undefined;
|
|
358
362
|
}
|
|
359
363
|
else {
|
|
@@ -391,6 +395,37 @@ Examples:
|
|
|
391
395
|
}
|
|
392
396
|
process.exit(2);
|
|
393
397
|
}
|
|
398
|
+
// Case-only mismatch: emit a warning and continue with the canonical name.
|
|
399
|
+
if (validation.caseNormalizedFrom && validation.normalized) {
|
|
400
|
+
console.error(`Note: '${validation.caseNormalizedFrom}' normalized to '${validation.normalized}' (case mismatch). Use exact casing to silence this warning.`);
|
|
401
|
+
cmd = validation.normalized;
|
|
402
|
+
}
|
|
403
|
+
else if (validation.normalized) {
|
|
404
|
+
cmd = validation.normalized;
|
|
405
|
+
}
|
|
406
|
+
// Raw-parameter validation (runs for known (deviceType, command) pairs only).
|
|
407
|
+
const cachedForParam = getCachedDevice(deviceId);
|
|
408
|
+
if (cachedForParam && options.type === 'command') {
|
|
409
|
+
const paramCheck = validateParameter(cachedForParam.type, cmd, parameter);
|
|
410
|
+
if (!paramCheck.ok) {
|
|
411
|
+
if (isJsonMode()) {
|
|
412
|
+
console.error(JSON.stringify({
|
|
413
|
+
error: {
|
|
414
|
+
code: 2,
|
|
415
|
+
kind: 'usage',
|
|
416
|
+
message: paramCheck.error,
|
|
417
|
+
context: { command: cmd, deviceType: cachedForParam.type, deviceId },
|
|
418
|
+
},
|
|
419
|
+
}));
|
|
420
|
+
}
|
|
421
|
+
else {
|
|
422
|
+
console.error(`Error: ${paramCheck.error}`);
|
|
423
|
+
}
|
|
424
|
+
process.exit(2);
|
|
425
|
+
}
|
|
426
|
+
if (paramCheck.normalized !== undefined)
|
|
427
|
+
parameter = paramCheck.normalized;
|
|
428
|
+
}
|
|
394
429
|
const cachedForGuard = getCachedDevice(deviceId);
|
|
395
430
|
if (!options.yes &&
|
|
396
431
|
!isDryRun() &&
|
package/dist/commands/expand.js
CHANGED
|
@@ -5,83 +5,7 @@ import { executeCommand, isDestructiveCommand, getDestructiveReason } from '../l
|
|
|
5
5
|
import { isDryRun } from '../utils/flags.js';
|
|
6
6
|
import { resolveDeviceId } from '../utils/name-resolver.js';
|
|
7
7
|
import { DryRunSignal } from '../api/client.js';
|
|
8
|
-
|
|
9
|
-
const AC_MODE_MAP = { auto: 1, cool: 2, dry: 3, fan: 4, heat: 5 };
|
|
10
|
-
const AC_FAN_MAP = { auto: 1, low: 2, mid: 3, high: 4 };
|
|
11
|
-
const CURTAIN_MODE_MAP = { default: 'ff', performance: '0', silent: '1' };
|
|
12
|
-
const RELAY_MODE_MAP = { toggle: 0, edge: 1, detached: 2, momentary: 3 };
|
|
13
|
-
const BLIND_DIRECTION = new Set(['up', 'down']);
|
|
14
|
-
// ---- Translators -----------------------------------------------------------
|
|
15
|
-
function buildAcSetAll(opts) {
|
|
16
|
-
if (!opts.temp)
|
|
17
|
-
throw new UsageError('--temp is required for setAll (e.g. --temp 26)');
|
|
18
|
-
if (!opts.mode)
|
|
19
|
-
throw new UsageError('--mode is required for setAll (auto|cool|dry|fan|heat)');
|
|
20
|
-
if (!opts.fan)
|
|
21
|
-
throw new UsageError('--fan is required for setAll (auto|low|mid|high)');
|
|
22
|
-
if (!opts.power)
|
|
23
|
-
throw new UsageError('--power is required for setAll (on|off)');
|
|
24
|
-
const temp = parseInt(opts.temp, 10);
|
|
25
|
-
if (!Number.isFinite(temp) || temp < 16 || temp > 30) {
|
|
26
|
-
throw new UsageError(`--temp must be an integer between 16 and 30 (got "${opts.temp}")`);
|
|
27
|
-
}
|
|
28
|
-
const modeInt = AC_MODE_MAP[opts.mode.toLowerCase()];
|
|
29
|
-
if (modeInt === undefined) {
|
|
30
|
-
throw new UsageError(`--mode must be one of: auto, cool, dry, fan, heat (got "${opts.mode}")`);
|
|
31
|
-
}
|
|
32
|
-
const fanInt = AC_FAN_MAP[opts.fan.toLowerCase()];
|
|
33
|
-
if (fanInt === undefined) {
|
|
34
|
-
throw new UsageError(`--fan must be one of: auto, low, mid, high (got "${opts.fan}")`);
|
|
35
|
-
}
|
|
36
|
-
const power = opts.power.toLowerCase();
|
|
37
|
-
if (power !== 'on' && power !== 'off') {
|
|
38
|
-
throw new UsageError(`--power must be "on" or "off" (got "${opts.power}")`);
|
|
39
|
-
}
|
|
40
|
-
return `${temp},${modeInt},${fanInt},${power}`;
|
|
41
|
-
}
|
|
42
|
-
function buildCurtainSetPosition(opts) {
|
|
43
|
-
if (!opts.position)
|
|
44
|
-
throw new UsageError('--position is required (0-100)');
|
|
45
|
-
const pos = parseInt(opts.position, 10);
|
|
46
|
-
if (!Number.isFinite(pos) || pos < 0 || pos > 100) {
|
|
47
|
-
throw new UsageError(`--position must be an integer between 0 and 100 (got "${opts.position}")`);
|
|
48
|
-
}
|
|
49
|
-
const modeStr = opts.mode ? CURTAIN_MODE_MAP[opts.mode.toLowerCase()] : 'ff';
|
|
50
|
-
if (modeStr === undefined) {
|
|
51
|
-
throw new UsageError(`--mode must be one of: default, performance, silent (got "${opts.mode}")`);
|
|
52
|
-
}
|
|
53
|
-
return `0,${modeStr},${pos}`;
|
|
54
|
-
}
|
|
55
|
-
function buildBlindTiltSetPosition(opts) {
|
|
56
|
-
if (!opts.direction)
|
|
57
|
-
throw new UsageError('--direction is required (up|down)');
|
|
58
|
-
if (!opts.angle)
|
|
59
|
-
throw new UsageError('--angle is required (0-100)');
|
|
60
|
-
const dir = opts.direction.toLowerCase();
|
|
61
|
-
if (!BLIND_DIRECTION.has(dir)) {
|
|
62
|
-
throw new UsageError(`--direction must be "up" or "down" (got "${opts.direction}")`);
|
|
63
|
-
}
|
|
64
|
-
const angle = parseInt(opts.angle, 10);
|
|
65
|
-
if (!Number.isFinite(angle) || angle < 0 || angle > 100) {
|
|
66
|
-
throw new UsageError(`--angle must be an integer between 0 and 100 (got "${opts.angle}")`);
|
|
67
|
-
}
|
|
68
|
-
return `${dir};${angle}`;
|
|
69
|
-
}
|
|
70
|
-
function buildRelaySetMode(opts) {
|
|
71
|
-
if (!opts.channel)
|
|
72
|
-
throw new UsageError('--channel is required (1 or 2)');
|
|
73
|
-
if (!opts.mode)
|
|
74
|
-
throw new UsageError('--mode is required (toggle|edge|detached|momentary)');
|
|
75
|
-
const ch = parseInt(opts.channel, 10);
|
|
76
|
-
if (ch !== 1 && ch !== 2) {
|
|
77
|
-
throw new UsageError(`--channel must be 1 or 2 (got "${opts.channel}")`);
|
|
78
|
-
}
|
|
79
|
-
const modeInt = RELAY_MODE_MAP[opts.mode.toLowerCase()];
|
|
80
|
-
if (modeInt === undefined) {
|
|
81
|
-
throw new UsageError(`--mode must be one of: toggle, edge, detached, momentary (got "${opts.mode}")`);
|
|
82
|
-
}
|
|
83
|
-
return `${ch};${modeInt}`;
|
|
84
|
-
}
|
|
8
|
+
import { buildAcSetAll, buildCurtainSetPosition, buildBlindTiltSetPosition, buildRelaySetMode, } from '../devices/param-validator.js';
|
|
85
9
|
// ---- Registration ----------------------------------------------------------
|
|
86
10
|
export function registerExpandCommand(devices) {
|
|
87
11
|
devices
|
|
@@ -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/lib/devices.js
CHANGED
|
@@ -129,8 +129,10 @@ export async function executeCommand(deviceId, cmd, parameter, commandType, clie
|
|
|
129
129
|
/**
|
|
130
130
|
* Validate a command against the locally-cached device → catalog mapping.
|
|
131
131
|
* Returns `{ ok: true }` when validation passes or is skipped (unknown device,
|
|
132
|
-
* custom IR button, etc.)
|
|
133
|
-
*
|
|
132
|
+
* custom IR button, etc.). On a case-only mismatch the canonical command name
|
|
133
|
+
* is returned via `normalized` along with a `caseNormalizedFrom` field so the
|
|
134
|
+
* caller can emit a warning and continue with the canonical name.
|
|
135
|
+
* Returns `{ ok: false, error }` only when the caller should refuse the call.
|
|
134
136
|
*/
|
|
135
137
|
export function validateCommand(deviceId, cmd, parameter, commandType) {
|
|
136
138
|
if (commandType === 'customize')
|
|
@@ -144,24 +146,34 @@ export function validateCommand(deviceId, cmd, parameter, commandType) {
|
|
|
144
146
|
const builtinCommands = match.commands.filter((c) => c.commandType !== 'customize');
|
|
145
147
|
if (builtinCommands.length === 0)
|
|
146
148
|
return { ok: true };
|
|
147
|
-
|
|
149
|
+
let spec = builtinCommands.find((c) => c.command === cmd);
|
|
150
|
+
let caseNormalizedFrom;
|
|
151
|
+
let normalizedCmd = cmd;
|
|
148
152
|
if (!spec) {
|
|
149
153
|
const unique = [...new Set(builtinCommands.map((c) => c.command))];
|
|
150
154
|
const caseMatch = unique.find((c) => c.toLowerCase() === cmd.toLowerCase());
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
155
|
+
if (caseMatch) {
|
|
156
|
+
// Case-only mismatch: normalize and continue.
|
|
157
|
+
caseNormalizedFrom = cmd;
|
|
158
|
+
normalizedCmd = caseMatch;
|
|
159
|
+
spec = builtinCommands.find((c) => c.command === caseMatch);
|
|
160
|
+
}
|
|
161
|
+
else {
|
|
162
|
+
const hint = `Supported commands: ${unique.join(', ')}`;
|
|
163
|
+
return {
|
|
164
|
+
ok: false,
|
|
165
|
+
error: new CommandValidationError(`"${cmd}" is not a supported command for ${cached.name} (${cached.type}).`, 'unknown-command', hint),
|
|
166
|
+
};
|
|
167
|
+
}
|
|
158
168
|
}
|
|
169
|
+
if (!spec)
|
|
170
|
+
return { ok: true, normalized: normalizedCmd, caseNormalizedFrom };
|
|
159
171
|
const noParamExpected = spec.parameter === '—';
|
|
160
172
|
const userProvidedParam = parameter !== undefined && parameter !== 'default';
|
|
161
173
|
if (noParamExpected && userProvidedParam) {
|
|
162
174
|
return {
|
|
163
175
|
ok: false,
|
|
164
|
-
error: new CommandValidationError(`"${
|
|
176
|
+
error: new CommandValidationError(`"${normalizedCmd}" takes no parameter, but one was provided: "${parameter}".`, 'unexpected-parameter', `Try: switchbot devices command ${deviceId} ${normalizedCmd}`),
|
|
165
177
|
};
|
|
166
178
|
}
|
|
167
179
|
// Warn when a parameter is required but the user omitted it
|
|
@@ -170,12 +182,12 @@ export function validateCommand(deviceId, cmd, parameter, commandType) {
|
|
|
170
182
|
const example = spec.exampleParams?.[0];
|
|
171
183
|
return {
|
|
172
184
|
ok: false,
|
|
173
|
-
error: new CommandValidationError(`"${
|
|
174
|
-
? `Example: switchbot devices command <deviceId> ${
|
|
185
|
+
error: new CommandValidationError(`"${normalizedCmd}" requires a parameter (${spec.parameter}).`, 'missing-parameter', example
|
|
186
|
+
? `Example: switchbot devices command <deviceId> ${normalizedCmd} "${example}"`
|
|
175
187
|
: `See: switchbot devices commands ${cached.type}`),
|
|
176
188
|
};
|
|
177
189
|
}
|
|
178
|
-
return { ok: true };
|
|
190
|
+
return { ok: true, normalized: normalizedCmd, caseNormalizedFrom };
|
|
179
191
|
}
|
|
180
192
|
/**
|
|
181
193
|
* Inspect catalog annotations to decide whether a command is destructive,
|
|
@@ -9,7 +9,10 @@ import { parseDurationToMs } from './flags.js';
|
|
|
9
9
|
*/
|
|
10
10
|
export function intArg(flagName, opts) {
|
|
11
11
|
return (value) => {
|
|
12
|
-
|
|
12
|
+
// Flag-like tokens (`--something`, `-x`) are rejected up-front.
|
|
13
|
+
// Pure negative integers (`-1`, `-42`) fall through to min/max so the
|
|
14
|
+
// error classifies as a range error rather than "requires a numeric value".
|
|
15
|
+
if (value.startsWith('-') && !/^-\d+$/.test(value)) {
|
|
13
16
|
throw new InvalidArgumentError(`${flagName} requires a numeric value, got "${value}". ` +
|
|
14
17
|
`Did you forget a value? Use ${flagName}=<n> if the value really starts with "-".`);
|
|
15
18
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@switchbot/openapi-cli",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.3.0",
|
|
4
4
|
"description": "SwitchBot smart home CLI — control devices, run scenes, stream real-time events, and integrate AI agents via MCP. Full API v1.1 coverage.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"switchbot",
|