@switchbot/openapi-cli 2.5.0 → 2.6.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 +69 -7
- package/dist/api/client.js +35 -3
- package/dist/commands/agent-bootstrap.js +3 -0
- package/dist/commands/batch.js +64 -24
- package/dist/commands/cache.js +17 -1
- package/dist/commands/capabilities.js +36 -0
- package/dist/commands/catalog.js +60 -2
- package/dist/commands/config.js +6 -6
- package/dist/commands/device-meta.js +18 -1
- package/dist/commands/devices.js +148 -68
- package/dist/commands/events.js +63 -32
- package/dist/commands/expand.js +7 -5
- package/dist/commands/history.js +4 -7
- package/dist/commands/mcp.js +54 -8
- package/dist/commands/plan.js +6 -1
- package/dist/commands/scenes.js +9 -0
- package/dist/commands/watch.js +7 -0
- package/dist/devices/cache.js +61 -26
- package/dist/devices/param-validator.js +170 -0
- package/dist/utils/arg-parsers.js +2 -1
- package/dist/utils/filter.js +120 -39
- package/dist/utils/flags.js +27 -1
- package/dist/utils/format.js +2 -2
- package/dist/utils/name-resolver.js +6 -3
- package/dist/utils/output.js +64 -4
- package/package.json +1 -1
|
@@ -100,8 +100,178 @@ export function validateParameter(deviceType, command, raw) {
|
|
|
100
100
|
if (deviceType.startsWith('Relay Switch') && command === 'setMode') {
|
|
101
101
|
return validateRelaySetMode(raw);
|
|
102
102
|
}
|
|
103
|
+
if (command === 'setBrightness' && isBrightnessDevice(deviceType)) {
|
|
104
|
+
return validateSetBrightness(raw);
|
|
105
|
+
}
|
|
106
|
+
if (command === 'setColor' && isColorDevice(deviceType)) {
|
|
107
|
+
return validateSetColor(raw);
|
|
108
|
+
}
|
|
109
|
+
if (command === 'setColorTemperature' && isColorDevice(deviceType)) {
|
|
110
|
+
return validateSetColorTemperature(raw);
|
|
111
|
+
}
|
|
103
112
|
return { ok: true };
|
|
104
113
|
}
|
|
114
|
+
function isBrightnessDevice(deviceType) {
|
|
115
|
+
return (deviceType === 'Color Bulb' ||
|
|
116
|
+
deviceType === 'Strip Light' ||
|
|
117
|
+
deviceType === 'Strip Light 3' ||
|
|
118
|
+
deviceType === 'Ceiling Light' ||
|
|
119
|
+
deviceType === 'Ceiling Light Pro' ||
|
|
120
|
+
deviceType === 'Floor Lamp' ||
|
|
121
|
+
deviceType === 'Light Strip' ||
|
|
122
|
+
deviceType === 'Dimmer' ||
|
|
123
|
+
deviceType === 'Fill Light');
|
|
124
|
+
}
|
|
125
|
+
function isColorDevice(deviceType) {
|
|
126
|
+
return (deviceType === 'Color Bulb' ||
|
|
127
|
+
deviceType === 'Strip Light' ||
|
|
128
|
+
deviceType === 'Strip Light 3' ||
|
|
129
|
+
deviceType === 'Ceiling Light' ||
|
|
130
|
+
deviceType === 'Ceiling Light Pro' ||
|
|
131
|
+
deviceType === 'Floor Lamp' ||
|
|
132
|
+
deviceType === 'Light Strip' ||
|
|
133
|
+
deviceType === 'Fill Light');
|
|
134
|
+
}
|
|
135
|
+
function validateSetBrightness(raw) {
|
|
136
|
+
if (raw === undefined || raw === '' || raw === 'default') {
|
|
137
|
+
return {
|
|
138
|
+
ok: false,
|
|
139
|
+
error: `setBrightness requires an integer 1-100 (percent). Example: "50".`,
|
|
140
|
+
};
|
|
141
|
+
}
|
|
142
|
+
const trimmed = raw.trim();
|
|
143
|
+
if (!/^-?\d+$/.test(trimmed)) {
|
|
144
|
+
return {
|
|
145
|
+
ok: false,
|
|
146
|
+
error: `setBrightness must be an integer 1-100, got ${JSON.stringify(raw)}. ${hintBrightnessRetry()}`,
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
const n = Number(trimmed);
|
|
150
|
+
if (!Number.isInteger(n) || n < 1 || n > 100) {
|
|
151
|
+
return {
|
|
152
|
+
ok: false,
|
|
153
|
+
error: `setBrightness must be an integer 1-100, got "${raw}". ${hintBrightnessRetry()}`,
|
|
154
|
+
};
|
|
155
|
+
}
|
|
156
|
+
return { ok: true, normalized: String(n) };
|
|
157
|
+
}
|
|
158
|
+
function hintBrightnessRetry() {
|
|
159
|
+
return `Ask the user whether they meant a percentage (1-100). Example: "50".`;
|
|
160
|
+
}
|
|
161
|
+
// B-12: setColor accepts R:G:B, R,G,B, #RRGGBB, #RGB, or a small CSS named color
|
|
162
|
+
// palette. All forms are normalized to `R:G:B` (the only wire shape SwitchBot
|
|
163
|
+
// accepts) so the caller can POST the result unchanged.
|
|
164
|
+
const NAMED_COLORS = {
|
|
165
|
+
red: [255, 0, 0],
|
|
166
|
+
green: [0, 128, 0],
|
|
167
|
+
lime: [0, 255, 0],
|
|
168
|
+
blue: [0, 0, 255],
|
|
169
|
+
yellow: [255, 255, 0],
|
|
170
|
+
cyan: [0, 255, 255],
|
|
171
|
+
magenta: [255, 0, 255],
|
|
172
|
+
white: [255, 255, 255],
|
|
173
|
+
black: [0, 0, 0],
|
|
174
|
+
orange: [255, 165, 0],
|
|
175
|
+
purple: [128, 0, 128],
|
|
176
|
+
pink: [255, 192, 203],
|
|
177
|
+
brown: [165, 42, 42],
|
|
178
|
+
grey: [128, 128, 128],
|
|
179
|
+
gray: [128, 128, 128],
|
|
180
|
+
warm: [255, 180, 100],
|
|
181
|
+
};
|
|
182
|
+
function validateSetColor(raw) {
|
|
183
|
+
if (raw === undefined || raw === '' || raw === 'default') {
|
|
184
|
+
return {
|
|
185
|
+
ok: false,
|
|
186
|
+
error: `setColor requires a color. Expected one of: "R:G:B" (e.g. "255:0:0"), "#RRGGBB" (e.g. "#FF0000"), "#RGB", "R,G,B", or a named color (${Object.keys(NAMED_COLORS).slice(0, 8).join(', ')}, ...).`,
|
|
187
|
+
};
|
|
188
|
+
}
|
|
189
|
+
const trimmed = raw.trim();
|
|
190
|
+
// Named color.
|
|
191
|
+
const named = NAMED_COLORS[trimmed.toLowerCase()];
|
|
192
|
+
if (named) {
|
|
193
|
+
return { ok: true, normalized: `${named[0]}:${named[1]}:${named[2]}` };
|
|
194
|
+
}
|
|
195
|
+
// Hex #RRGGBB or #RGB.
|
|
196
|
+
if (trimmed.startsWith('#')) {
|
|
197
|
+
const hex = trimmed.slice(1);
|
|
198
|
+
if (/^[0-9a-fA-F]{6}$/.test(hex)) {
|
|
199
|
+
const r = parseInt(hex.slice(0, 2), 16);
|
|
200
|
+
const g = parseInt(hex.slice(2, 4), 16);
|
|
201
|
+
const b = parseInt(hex.slice(4, 6), 16);
|
|
202
|
+
return { ok: true, normalized: `${r}:${g}:${b}` };
|
|
203
|
+
}
|
|
204
|
+
if (/^[0-9a-fA-F]{3}$/.test(hex)) {
|
|
205
|
+
const r = parseInt(hex[0] + hex[0], 16);
|
|
206
|
+
const g = parseInt(hex[1] + hex[1], 16);
|
|
207
|
+
const b = parseInt(hex[2] + hex[2], 16);
|
|
208
|
+
return { ok: true, normalized: `${r}:${g}:${b}` };
|
|
209
|
+
}
|
|
210
|
+
return {
|
|
211
|
+
ok: false,
|
|
212
|
+
error: `setColor "${raw}" is not valid hex. ${hintColorRetry()}`,
|
|
213
|
+
};
|
|
214
|
+
}
|
|
215
|
+
// R:G:B or R,G,B — pick whichever separator appears.
|
|
216
|
+
const sep = trimmed.includes(':') ? ':' : trimmed.includes(',') ? ',' : null;
|
|
217
|
+
if (!sep) {
|
|
218
|
+
return {
|
|
219
|
+
ok: false,
|
|
220
|
+
error: `setColor "${raw}" is not a recognized format. ${hintColorRetry()}`,
|
|
221
|
+
};
|
|
222
|
+
}
|
|
223
|
+
const parts = trimmed.split(sep).map((s) => s.trim());
|
|
224
|
+
if (parts.length !== 3) {
|
|
225
|
+
return {
|
|
226
|
+
ok: false,
|
|
227
|
+
error: `setColor expects 3 components (R${sep}G${sep}B), got ${parts.length} (${JSON.stringify(raw)}). ${hintColorRetry()}`,
|
|
228
|
+
};
|
|
229
|
+
}
|
|
230
|
+
const nums = [];
|
|
231
|
+
for (const p of parts) {
|
|
232
|
+
if (!/^-?\d+$/.test(p)) {
|
|
233
|
+
return {
|
|
234
|
+
ok: false,
|
|
235
|
+
error: `setColor component "${p}" is not an integer. ${hintColorRetry()}`,
|
|
236
|
+
};
|
|
237
|
+
}
|
|
238
|
+
const n = Number(p);
|
|
239
|
+
if (!Number.isInteger(n) || n < 0 || n > 255) {
|
|
240
|
+
return {
|
|
241
|
+
ok: false,
|
|
242
|
+
error: `setColor components must be integers 0-255, got "${p}". ${hintColorRetry()}`,
|
|
243
|
+
};
|
|
244
|
+
}
|
|
245
|
+
nums.push(n);
|
|
246
|
+
}
|
|
247
|
+
return { ok: true, normalized: `${nums[0]}:${nums[1]}:${nums[2]}` };
|
|
248
|
+
}
|
|
249
|
+
function hintColorRetry() {
|
|
250
|
+
return `Expected "R:G:B" (e.g. "255:0:0"), "#RRGGBB", "#RGB", "R,G,B", or a named color.`;
|
|
251
|
+
}
|
|
252
|
+
function validateSetColorTemperature(raw) {
|
|
253
|
+
if (raw === undefined || raw === '' || raw === 'default') {
|
|
254
|
+
return {
|
|
255
|
+
ok: false,
|
|
256
|
+
error: `setColorTemperature requires an integer Kelvin value 2700-6500. Example: "4000".`,
|
|
257
|
+
};
|
|
258
|
+
}
|
|
259
|
+
const trimmed = raw.trim();
|
|
260
|
+
if (!/^-?\d+$/.test(trimmed)) {
|
|
261
|
+
return {
|
|
262
|
+
ok: false,
|
|
263
|
+
error: `setColorTemperature must be an integer 2700-6500, got ${JSON.stringify(raw)}.`,
|
|
264
|
+
};
|
|
265
|
+
}
|
|
266
|
+
const n = Number(trimmed);
|
|
267
|
+
if (!Number.isInteger(n) || n < 2700 || n > 6500) {
|
|
268
|
+
return {
|
|
269
|
+
ok: false,
|
|
270
|
+
error: `setColorTemperature must be an integer 2700-6500, got "${raw}".`,
|
|
271
|
+
};
|
|
272
|
+
}
|
|
273
|
+
return { ok: true, normalized: String(n) };
|
|
274
|
+
}
|
|
105
275
|
function validateAcSetAll(raw) {
|
|
106
276
|
if (raw === undefined || raw === '' || raw === 'default') {
|
|
107
277
|
return {
|
|
@@ -37,7 +37,8 @@ export function durationArg(flagName) {
|
|
|
37
37
|
}
|
|
38
38
|
const ms = parseDurationToMs(value);
|
|
39
39
|
if (ms === null) {
|
|
40
|
-
throw new InvalidArgumentError(`${flagName} must look like "30s", "1m", "500ms", "1h"
|
|
40
|
+
throw new InvalidArgumentError(`${flagName} must look like "30s", "1m", "500ms", "1h", "7d", "2w" ` +
|
|
41
|
+
`(supported units: ms, s, m, h, d, w — got "${value}")`);
|
|
41
42
|
}
|
|
42
43
|
return value;
|
|
43
44
|
};
|
package/dist/utils/filter.js
CHANGED
|
@@ -4,42 +4,127 @@ export class FilterSyntaxError extends Error {
|
|
|
4
4
|
this.name = 'FilterSyntaxError';
|
|
5
5
|
}
|
|
6
6
|
}
|
|
7
|
-
const VALID_KEYS = ['type', 'family', 'room', 'category'];
|
|
8
7
|
/**
|
|
9
|
-
* Parse a filter expression
|
|
8
|
+
* Parse a comma-separated filter expression into discrete clauses.
|
|
10
9
|
*
|
|
11
|
-
* Grammar:
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
10
|
+
* Grammar (per clause, recognition order):
|
|
11
|
+
* 1. key=/pattern/ → regex (case-insensitive); invalid regex throws.
|
|
12
|
+
* 2. key!=value → 'neq' op (negated substring; exact-negated for keys
|
|
13
|
+
* listed in matchClause's `exactKeys` option).
|
|
14
|
+
* 3. key~value → substring (case-insensitive).
|
|
15
|
+
* 4. key=value → 'eq' op (substring; caller decides whether to treat
|
|
16
|
+
* as exact for specific keys via matchClause's
|
|
17
|
+
* `exactKeys` option).
|
|
17
18
|
*
|
|
18
|
-
*
|
|
19
|
+
* `allowedKeys` is command-specific: `devices list` uses
|
|
20
|
+
* {type,name,category,room}; `devices batch` uses {type,family,room,category};
|
|
21
|
+
* `events tail` uses {deviceId,type}.
|
|
19
22
|
*/
|
|
20
|
-
export function
|
|
23
|
+
export function parseFilterExpr(expr, allowedKeys) {
|
|
21
24
|
if (!expr)
|
|
22
25
|
return [];
|
|
23
26
|
const parts = expr.split(',').map((p) => p.trim()).filter((p) => p.length > 0);
|
|
24
27
|
const clauses = [];
|
|
25
28
|
for (const part of parts) {
|
|
26
|
-
const
|
|
27
|
-
|
|
28
|
-
|
|
29
|
+
const regexMatch = /^([^=~!]+)=\/(.*)\/$/.exec(part);
|
|
30
|
+
const neqIdx = part.indexOf('!=');
|
|
31
|
+
const tildeIdx = part.indexOf('~');
|
|
32
|
+
const eqIdx = part.indexOf('=');
|
|
33
|
+
let key;
|
|
34
|
+
let op;
|
|
35
|
+
let raw;
|
|
36
|
+
let regex;
|
|
37
|
+
if (regexMatch) {
|
|
38
|
+
key = regexMatch[1].trim();
|
|
39
|
+
op = 'regex';
|
|
40
|
+
raw = regexMatch[2];
|
|
41
|
+
try {
|
|
42
|
+
regex = new RegExp(raw, 'i');
|
|
43
|
+
}
|
|
44
|
+
catch (err) {
|
|
45
|
+
throw new FilterSyntaxError(`Invalid regex in --filter "${part}": ${err.message}`);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
else if (neqIdx !== -1 && (tildeIdx === -1 || neqIdx < tildeIdx)) {
|
|
49
|
+
key = part.slice(0, neqIdx).trim();
|
|
50
|
+
op = 'neq';
|
|
51
|
+
raw = part.slice(neqIdx + 2).trim();
|
|
52
|
+
}
|
|
53
|
+
else if (tildeIdx !== -1 && (eqIdx === -1 || tildeIdx < eqIdx)) {
|
|
54
|
+
key = part.slice(0, tildeIdx).trim();
|
|
55
|
+
op = 'sub';
|
|
56
|
+
raw = part.slice(tildeIdx + 1).trim();
|
|
57
|
+
if (raw.startsWith('=')) {
|
|
58
|
+
throw new FilterSyntaxError(`Invalid filter clause "${part}" — "~=" is no longer supported. Use "${key}~${raw.slice(1)}" instead.`);
|
|
59
|
+
}
|
|
29
60
|
}
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
throw new FilterSyntaxError(`Unknown filter key "${key}" — supported: ${VALID_KEYS.join(', ')}`);
|
|
61
|
+
else if (eqIdx !== -1) {
|
|
62
|
+
key = part.slice(0, eqIdx).trim();
|
|
63
|
+
op = 'eq';
|
|
64
|
+
raw = part.slice(eqIdx + 1).trim();
|
|
35
65
|
}
|
|
36
|
-
|
|
66
|
+
else {
|
|
67
|
+
throw new FilterSyntaxError(`Invalid filter clause "${part}" — expected "<key>=<value>", "<key>!=<value>", "<key>~<value>", or "<key>=/<regex>/"`);
|
|
68
|
+
}
|
|
69
|
+
if (!key) {
|
|
70
|
+
throw new FilterSyntaxError(`Empty key in filter clause "${part}"`);
|
|
71
|
+
}
|
|
72
|
+
if (!raw) {
|
|
37
73
|
throw new FilterSyntaxError(`Empty value for filter clause "${part}"`);
|
|
38
74
|
}
|
|
39
|
-
|
|
75
|
+
if (!allowedKeys.includes(key)) {
|
|
76
|
+
throw new FilterSyntaxError(`Unknown filter key "${key}" — supported: ${allowedKeys.join(', ')}`);
|
|
77
|
+
}
|
|
78
|
+
clauses.push({ key, op, raw, regex });
|
|
40
79
|
}
|
|
41
80
|
return clauses;
|
|
42
81
|
}
|
|
82
|
+
/**
|
|
83
|
+
* Match a single candidate string against a clause.
|
|
84
|
+
*
|
|
85
|
+
* - `regex` → RegExp.test against the candidate (case-insensitive by construction).
|
|
86
|
+
* - `sub` → case-insensitive substring.
|
|
87
|
+
* - `eq` → case-insensitive substring, except for keys listed in
|
|
88
|
+
* `exactKeys`, which get case-insensitive exact comparison.
|
|
89
|
+
* Default `exactKeys` is `['category']` to preserve the existing
|
|
90
|
+
* list/batch behavior for that key.
|
|
91
|
+
* - `neq` → logical inverse of `eq` (negated substring; exact-negated for
|
|
92
|
+
* `exactKeys`). `undefined` candidates remain non-matching so a
|
|
93
|
+
* `neq` clause does NOT accidentally match missing data.
|
|
94
|
+
*/
|
|
95
|
+
export function matchClause(candidate, clause, options) {
|
|
96
|
+
if (candidate === undefined) {
|
|
97
|
+
// Missing field: `neq` treats absence as "definitely not X"; everything
|
|
98
|
+
// else treats it as "no evidence — don't match".
|
|
99
|
+
return clause.op === 'neq';
|
|
100
|
+
}
|
|
101
|
+
if (clause.op === 'regex') {
|
|
102
|
+
return clause.regex.test(candidate);
|
|
103
|
+
}
|
|
104
|
+
const cLower = candidate.toLowerCase();
|
|
105
|
+
const vLower = clause.raw.toLowerCase();
|
|
106
|
+
if (clause.op === 'sub') {
|
|
107
|
+
return cLower.includes(vLower);
|
|
108
|
+
}
|
|
109
|
+
const exactKeys = options?.exactKeys ?? ['category'];
|
|
110
|
+
const exact = exactKeys.includes(clause.key);
|
|
111
|
+
if (clause.op === 'neq') {
|
|
112
|
+
return exact ? cLower !== vLower : !cLower.includes(vLower);
|
|
113
|
+
}
|
|
114
|
+
if (exact) {
|
|
115
|
+
return cLower === vLower;
|
|
116
|
+
}
|
|
117
|
+
return cLower.includes(vLower);
|
|
118
|
+
}
|
|
119
|
+
const BATCH_KEYS = ['type', 'family', 'room', 'category'];
|
|
120
|
+
/**
|
|
121
|
+
* Back-compat narrow signature: parses with the batch key set. Callers that
|
|
122
|
+
* need a different key set (list, events tail) should call parseFilterExpr
|
|
123
|
+
* directly.
|
|
124
|
+
*/
|
|
125
|
+
export function parseFilter(expr) {
|
|
126
|
+
return parseFilterExpr(expr, BATCH_KEYS);
|
|
127
|
+
}
|
|
43
128
|
/** Normalize a physical / IR device entry to the shape the filter matcher expects. */
|
|
44
129
|
function toFilterable(d, isPhysical, hubLocation) {
|
|
45
130
|
if (isPhysical) {
|
|
@@ -62,27 +147,23 @@ function toFilterable(d, isPhysical, hubLocation) {
|
|
|
62
147
|
category: 'ir',
|
|
63
148
|
};
|
|
64
149
|
}
|
|
65
|
-
function
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
return candidate.toLowerCase().includes(clause.value.toLowerCase());
|
|
150
|
+
function candidateFor(d, key) {
|
|
151
|
+
switch (key) {
|
|
152
|
+
case 'type':
|
|
153
|
+
return d.type;
|
|
154
|
+
case 'family':
|
|
155
|
+
return d.family;
|
|
156
|
+
case 'room':
|
|
157
|
+
return d.room;
|
|
158
|
+
case 'category':
|
|
159
|
+
return d.category;
|
|
160
|
+
default:
|
|
161
|
+
return undefined;
|
|
162
|
+
}
|
|
79
163
|
}
|
|
80
164
|
/**
|
|
81
165
|
* Apply the parsed clauses to a mixed list of physical devices + IR remotes.
|
|
82
|
-
* Returns the
|
|
83
|
-
*
|
|
84
|
-
* `hubLocation` (optional) allows family/room filters to match IR remotes by
|
|
85
|
-
* the Hub-inherited location.
|
|
166
|
+
* Returns the filterable entries that satisfy every clause.
|
|
86
167
|
*/
|
|
87
168
|
export function applyFilter(clauses, deviceList, infraredRemoteList, hubLocation) {
|
|
88
169
|
const candidates = [
|
|
@@ -91,5 +172,5 @@ export function applyFilter(clauses, deviceList, infraredRemoteList, hubLocation
|
|
|
91
172
|
];
|
|
92
173
|
if (clauses.length === 0)
|
|
93
174
|
return candidates;
|
|
94
|
-
return candidates.filter((c) => clauses.every((clause) =>
|
|
175
|
+
return candidates.filter((c) => clauses.every((clause) => matchClause(candidateFor(c, clause.key), clause)));
|
|
95
176
|
}
|
package/dist/utils/flags.js
CHANGED
|
@@ -8,6 +8,14 @@ function getFlagValue(...flagNames) {
|
|
|
8
8
|
if (idx !== -1 && idx + 1 < process.argv.length) {
|
|
9
9
|
return process.argv[idx + 1];
|
|
10
10
|
}
|
|
11
|
+
// Also accept the `--flag=value` token form. Commander.js recognizes it at
|
|
12
|
+
// the option layer but global-flag scans like this one used to miss it,
|
|
13
|
+
// so `--format=json` silently fell back to the default (table).
|
|
14
|
+
const prefix = `${flag}=`;
|
|
15
|
+
const combined = process.argv.find((arg) => arg.startsWith(prefix));
|
|
16
|
+
if (combined !== undefined) {
|
|
17
|
+
return combined.slice(prefix.length);
|
|
18
|
+
}
|
|
11
19
|
}
|
|
12
20
|
return undefined;
|
|
13
21
|
}
|
|
@@ -82,6 +90,22 @@ export function getBackoffStrategy() {
|
|
|
82
90
|
return 'linear';
|
|
83
91
|
return 'exponential';
|
|
84
92
|
}
|
|
93
|
+
/**
|
|
94
|
+
* Max retries on 5xx / gateway-timeout responses for idempotent (GET) reads.
|
|
95
|
+
* Default 2. `--no-retry` disables retries entirely. POSTs are not retried
|
|
96
|
+
* automatically — use --idempotency-key and let the server dedupe.
|
|
97
|
+
*/
|
|
98
|
+
export function getRetryOn5xx() {
|
|
99
|
+
if (process.argv.includes('--no-retry'))
|
|
100
|
+
return 0;
|
|
101
|
+
const v = getFlagValue('--retry-on-5xx');
|
|
102
|
+
if (v === undefined)
|
|
103
|
+
return 2;
|
|
104
|
+
const n = Number(v);
|
|
105
|
+
if (!Number.isFinite(n) || n < 0)
|
|
106
|
+
return 2;
|
|
107
|
+
return Math.floor(n);
|
|
108
|
+
}
|
|
85
109
|
/**
|
|
86
110
|
* Whether local quota counting is disabled. Quota counting is best-effort
|
|
87
111
|
* (see src/utils/quota.ts) — this lets scripts opt out entirely when even
|
|
@@ -92,7 +116,7 @@ export function isQuotaDisabled() {
|
|
|
92
116
|
}
|
|
93
117
|
const DEFAULT_LIST_TTL_MS = 60 * 60 * 1000;
|
|
94
118
|
function parseDurationToMs(v) {
|
|
95
|
-
const m = /^(\d+)(ms|s|m|h)?$/.exec(v.trim().toLowerCase());
|
|
119
|
+
const m = /^(\d+)(ms|s|m|h|d|w)?$/.exec(v.trim().toLowerCase());
|
|
96
120
|
if (!m)
|
|
97
121
|
return null;
|
|
98
122
|
const n = Number(m[1]);
|
|
@@ -104,6 +128,8 @@ function parseDurationToMs(v) {
|
|
|
104
128
|
case 's': return n * 1000;
|
|
105
129
|
case 'm': return n * 60 * 1000;
|
|
106
130
|
case 'h': return n * 60 * 60 * 1000;
|
|
131
|
+
case 'd': return n * 24 * 60 * 60 * 1000;
|
|
132
|
+
case 'w': return n * 7 * 24 * 60 * 60 * 1000;
|
|
107
133
|
default: return null;
|
|
108
134
|
}
|
|
109
135
|
}
|
package/dist/utils/format.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { printTable, printJson, isJsonMode, UsageError } from './output.js';
|
|
1
|
+
import { printTable, printJson, isJsonMode, UsageError, emitJsonError } from './output.js';
|
|
2
2
|
import { getFormat, getFields } from './flags.js';
|
|
3
3
|
import { dump as yamlDump } from 'js-yaml';
|
|
4
4
|
export function parseFormat(flag) {
|
|
@@ -16,7 +16,7 @@ export function parseFormat(flag) {
|
|
|
16
16
|
default: {
|
|
17
17
|
const msg = `Unknown --format "${flag}". Expected: table, json, jsonl, tsv, yaml, id, markdown.`;
|
|
18
18
|
if (isJsonMode()) {
|
|
19
|
-
|
|
19
|
+
emitJsonError({ code: 2, kind: 'usage', message: msg });
|
|
20
20
|
}
|
|
21
21
|
else {
|
|
22
22
|
console.error(msg);
|
|
@@ -2,7 +2,7 @@ import { loadCache } from '../devices/cache.js';
|
|
|
2
2
|
import { loadDeviceMeta } from '../devices/device-meta.js';
|
|
3
3
|
import { levenshtein, normalizeDeviceName } from './string.js';
|
|
4
4
|
import { UsageError, StructuredUsageError } from './output.js';
|
|
5
|
-
const ALL_STRATEGIES = [
|
|
5
|
+
export const ALL_STRATEGIES = [
|
|
6
6
|
'exact', 'prefix', 'substring', 'fuzzy', 'first', 'require-unique',
|
|
7
7
|
];
|
|
8
8
|
export function isValidStrategy(s) {
|
|
@@ -117,9 +117,12 @@ export function resolveDeviceId(deviceId, nameQuery, opts = {}) {
|
|
|
117
117
|
narrow.push('--category');
|
|
118
118
|
if (!opts.room)
|
|
119
119
|
narrow.push('--room');
|
|
120
|
+
const strategyHint = opts.strategy === 'fuzzy'
|
|
121
|
+
? `pass --name-strategy=first to pick the best match`
|
|
122
|
+
: `pass --name-strategy=fuzzy or --name-strategy=first to pick the best match`;
|
|
120
123
|
const hint = narrow.length > 0
|
|
121
|
-
? `Narrow with ${narrow.join(' / ')}
|
|
122
|
-
: `
|
|
124
|
+
? `Narrow with ${narrow.join(' / ')}, refine the name, use the deviceId directly, or ${strategyHint}.`
|
|
125
|
+
: `Refine the name, use the deviceId directly, or ${strategyHint}.`;
|
|
123
126
|
throw new StructuredUsageError(`"${nameQuery}" is ambiguous — ${candidates.length} devices match.`, {
|
|
124
127
|
error: 'ambiguous_name_match',
|
|
125
128
|
query: nameQuery,
|
package/dist/utils/output.js
CHANGED
|
@@ -9,6 +9,25 @@ export function isJsonMode() {
|
|
|
9
9
|
export function printJson(data) {
|
|
10
10
|
console.log(JSON.stringify({ schemaVersion: SCHEMA_VERSION, data }, null, 2));
|
|
11
11
|
}
|
|
12
|
+
/**
|
|
13
|
+
* Emit a structured JSON error envelope on stdout.
|
|
14
|
+
*
|
|
15
|
+
* Bug #SYS-1: Under `--json`, both success and error payloads must share
|
|
16
|
+
* the same output channel (stdout) so a single `cli --json ... | jq` pipe
|
|
17
|
+
* can decode either shape. Use this helper everywhere that previously
|
|
18
|
+
* called `console.error(JSON.stringify({ error: ... }))` in --json mode.
|
|
19
|
+
*
|
|
20
|
+
* The envelope is always `{ schemaVersion, error }` — callers pass only the
|
|
21
|
+
* error payload. Also emits a brief human-readable line on stderr when a
|
|
22
|
+
* TTY is attached, so interactive runs still see the failure.
|
|
23
|
+
*/
|
|
24
|
+
export function emitJsonError(errorPayload) {
|
|
25
|
+
console.log(JSON.stringify({ schemaVersion: SCHEMA_VERSION, error: errorPayload }));
|
|
26
|
+
if (process.stderr.isTTY) {
|
|
27
|
+
const msg = typeof errorPayload.message === 'string' ? errorPayload.message : 'Error';
|
|
28
|
+
console.error(chalk.red(msg));
|
|
29
|
+
}
|
|
30
|
+
}
|
|
12
31
|
function escapeMarkdownCell(s) {
|
|
13
32
|
// Pipes break markdown table layout; backslash-escape them. Collapse
|
|
14
33
|
// newlines into <br> so each row stays on one line.
|
|
@@ -25,6 +44,9 @@ function formatCell(cell, style) {
|
|
|
25
44
|
return String(cell);
|
|
26
45
|
}
|
|
27
46
|
function renderMarkdownTable(headers, rows) {
|
|
47
|
+
if (rows.length === 0) {
|
|
48
|
+
return '_(empty)_';
|
|
49
|
+
}
|
|
28
50
|
const head = `| ${headers.map(escapeMarkdownCell).join(' | ')} |`;
|
|
29
51
|
const sep = `| ${headers.map(() => '---').join(' | ')} |`;
|
|
30
52
|
const body = rows.map((r) => `| ${r
|
|
@@ -119,11 +141,12 @@ export class StructuredUsageError extends Error {
|
|
|
119
141
|
function classifyApiError(code) {
|
|
120
142
|
switch (code) {
|
|
121
143
|
case 151:
|
|
122
|
-
case 160:
|
|
144
|
+
case 160:
|
|
145
|
+
case 3005: return 'command-not-supported';
|
|
123
146
|
case 152: return 'device-not-found';
|
|
124
147
|
case 161:
|
|
125
148
|
case 171: return 'device-offline';
|
|
126
|
-
case 190: return 'device-
|
|
149
|
+
case 190: return 'device-internal-error';
|
|
127
150
|
case 401: return 'auth-failed';
|
|
128
151
|
case 429: return 'quota-exceeded';
|
|
129
152
|
default: return 'unknown-api-error';
|
|
@@ -212,11 +235,46 @@ export function handleError(error) {
|
|
|
212
235
|
}
|
|
213
236
|
const payload = buildErrorPayload(error);
|
|
214
237
|
if (isJsonMode()) {
|
|
215
|
-
|
|
238
|
+
// Bug #SYS-1: Under --json, route the structured envelope to stdout so
|
|
239
|
+
// `cli --json ... | jq` pipelines can decode the error shape exactly
|
|
240
|
+
// the same way they decode success. Previously it went to stderr, which
|
|
241
|
+
// silently broke every error-path pipeline. TTY users still get a
|
|
242
|
+
// terse human-readable line on stderr so interactive runs don't look
|
|
243
|
+
// like the process simply exited.
|
|
244
|
+
console.log(JSON.stringify({ schemaVersion: SCHEMA_VERSION, error: payload }));
|
|
245
|
+
if (process.stderr.isTTY) {
|
|
246
|
+
console.error(chalk.red(payload.message));
|
|
247
|
+
}
|
|
216
248
|
process.exit(payload.code === 2 ? 2 : 1);
|
|
217
249
|
}
|
|
218
250
|
if (payload.kind === 'usage') {
|
|
219
251
|
console.error(payload.message);
|
|
252
|
+
const ctx = payload.context;
|
|
253
|
+
if (ctx && Array.isArray(ctx.candidates) && ctx.candidates.length > 0) {
|
|
254
|
+
const names = ctx.candidates
|
|
255
|
+
.map((c) => {
|
|
256
|
+
if (typeof c === 'string')
|
|
257
|
+
return c;
|
|
258
|
+
if (c && typeof c === 'object') {
|
|
259
|
+
const o = c;
|
|
260
|
+
const name = typeof o.name === 'string'
|
|
261
|
+
? o.name
|
|
262
|
+
: typeof o.sceneName === 'string' ? o.sceneName : undefined;
|
|
263
|
+
const id = typeof o.deviceId === 'string'
|
|
264
|
+
? o.deviceId
|
|
265
|
+
: typeof o.sceneId === 'string' ? o.sceneId : typeof o.id === 'string' ? o.id : undefined;
|
|
266
|
+
if (name && id)
|
|
267
|
+
return `${name} (${id})`;
|
|
268
|
+
return name ?? id ?? JSON.stringify(c);
|
|
269
|
+
}
|
|
270
|
+
return String(c);
|
|
271
|
+
})
|
|
272
|
+
.slice(0, 6);
|
|
273
|
+
console.error(`Did you mean: ${names.join(', ')}?`);
|
|
274
|
+
}
|
|
275
|
+
if (ctx && typeof ctx.hint === 'string') {
|
|
276
|
+
console.error(ctx.hint);
|
|
277
|
+
}
|
|
220
278
|
process.exit(2);
|
|
221
279
|
}
|
|
222
280
|
if (payload.kind === 'guard') {
|
|
@@ -247,11 +305,13 @@ function errorHint(code) {
|
|
|
247
305
|
case 171:
|
|
248
306
|
return 'The Hub itself is offline — check its power and Wi-Fi.';
|
|
249
307
|
case 190:
|
|
250
|
-
return
|
|
308
|
+
return 'SwitchBot API code 190 is a generic internal error. Common causes: invalid deviceId, unsupported command/parameter, or the endpoint does not apply (e.g., "webhook query" with no webhook configured). Verify with --verbose.';
|
|
251
309
|
case 401:
|
|
252
310
|
return "Re-run 'switchbot config set-token <token> <secret>', or verify SWITCHBOT_TOKEN / SWITCHBOT_SECRET.";
|
|
253
311
|
case 429:
|
|
254
312
|
return 'Daily quota is 10,000 requests/account — retry after midnight UTC.';
|
|
313
|
+
case 3005:
|
|
314
|
+
return "SwitchBot rejected the command as invalid for this specific device model. For IR remotes, this often means the command works only on --type customize (user-learned buttons). Try 'switchbot devices commands <type>' or check the device's capabilities.";
|
|
255
315
|
default:
|
|
256
316
|
return null;
|
|
257
317
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@switchbot/openapi-cli",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.6.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",
|