@switchbot/openapi-cli 2.2.1 → 2.4.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/api/client.js +33 -1
- package/dist/commands/agent-bootstrap.js +125 -0
- package/dist/commands/batch.js +108 -14
- package/dist/commands/capabilities.js +200 -62
- package/dist/commands/config.js +132 -9
- package/dist/commands/devices.js +85 -12
- package/dist/commands/doctor.js +105 -14
- package/dist/commands/events.js +53 -4
- package/dist/commands/expand.js +1 -77
- package/dist/commands/history.js +124 -2
- package/dist/commands/mcp.js +81 -2
- package/dist/commands/quota.js +4 -2
- package/dist/commands/schema.js +95 -5
- package/dist/config.js +71 -2
- package/dist/devices/history-query.js +181 -0
- package/dist/devices/param-validator.js +263 -0
- package/dist/index.js +12 -2
- package/dist/lib/devices.js +34 -15
- package/dist/lib/idempotency.js +56 -22
- package/dist/mcp/device-history.js +75 -7
- package/dist/utils/arg-parsers.js +4 -1
- package/dist/utils/audit.js +66 -1
- package/dist/utils/flags.js +18 -0
- package/dist/utils/name-resolver.js +76 -28
- package/dist/utils/output.js +115 -19
- package/dist/utils/quota.js +14 -0
- package/dist/utils/redact.js +68 -0
- package/package.json +1 -1
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import os from 'node:os';
|
|
4
|
+
import readline from 'node:readline';
|
|
5
|
+
const DEFAULT_LIMIT = 1000;
|
|
6
|
+
function historyDir() {
|
|
7
|
+
return path.join(os.homedir(), '.switchbot', 'device-history');
|
|
8
|
+
}
|
|
9
|
+
/**
|
|
10
|
+
* Parse a duration shortcut like "7d", "12h", "30m", "45s" into milliseconds.
|
|
11
|
+
* Returns null on malformed input (caller throws UsageError).
|
|
12
|
+
*/
|
|
13
|
+
export function parseDurationToMs(spec) {
|
|
14
|
+
const m = spec.trim().match(/^(\d+)(ms|s|m|h|d)$/i);
|
|
15
|
+
if (!m)
|
|
16
|
+
return null;
|
|
17
|
+
const n = Number(m[1]);
|
|
18
|
+
const unit = m[2].toLowerCase();
|
|
19
|
+
const factor = unit === 'ms' ? 1 : unit === 's' ? 1_000 : unit === 'm' ? 60_000 : unit === 'h' ? 3_600_000 : 86_400_000;
|
|
20
|
+
return n * factor;
|
|
21
|
+
}
|
|
22
|
+
/** Parse ISO-8601 (with Z or offset) or Date-parseable string → ms, else null. */
|
|
23
|
+
export function parseInstantToMs(spec) {
|
|
24
|
+
const ms = Date.parse(spec);
|
|
25
|
+
return Number.isFinite(ms) ? ms : null;
|
|
26
|
+
}
|
|
27
|
+
function resolveRange(opts) {
|
|
28
|
+
let fromMs = Number.NEGATIVE_INFINITY;
|
|
29
|
+
let toMs = Number.POSITIVE_INFINITY;
|
|
30
|
+
if (opts.since && (opts.from || opts.to)) {
|
|
31
|
+
throw new Error('--since is mutually exclusive with --from/--to.');
|
|
32
|
+
}
|
|
33
|
+
if (opts.since) {
|
|
34
|
+
const durMs = parseDurationToMs(opts.since);
|
|
35
|
+
if (durMs === null) {
|
|
36
|
+
throw new Error(`Invalid --since value "${opts.since}". Expected e.g. "30s", "15m", "1h", "7d".`);
|
|
37
|
+
}
|
|
38
|
+
fromMs = Date.now() - durMs;
|
|
39
|
+
}
|
|
40
|
+
else {
|
|
41
|
+
if (opts.from) {
|
|
42
|
+
const parsed = parseInstantToMs(opts.from);
|
|
43
|
+
if (parsed === null)
|
|
44
|
+
throw new Error(`Invalid --from value "${opts.from}". Expected ISO-8601 timestamp.`);
|
|
45
|
+
fromMs = parsed;
|
|
46
|
+
}
|
|
47
|
+
if (opts.to) {
|
|
48
|
+
const parsed = parseInstantToMs(opts.to);
|
|
49
|
+
if (parsed === null)
|
|
50
|
+
throw new Error(`Invalid --to value "${opts.to}". Expected ISO-8601 timestamp.`);
|
|
51
|
+
toMs = parsed;
|
|
52
|
+
}
|
|
53
|
+
if (fromMs > toMs)
|
|
54
|
+
throw new Error('--from must be <= --to.');
|
|
55
|
+
}
|
|
56
|
+
return { fromMs, toMs };
|
|
57
|
+
}
|
|
58
|
+
/** Return jsonl candidate files for a device, oldest-first. */
|
|
59
|
+
export function jsonlFilesForDevice(deviceId, baseDir = historyDir()) {
|
|
60
|
+
const out = [];
|
|
61
|
+
if (!fs.existsSync(baseDir))
|
|
62
|
+
return out;
|
|
63
|
+
// Oldest-first so range walks can bail early once a line overshoots `toMs`.
|
|
64
|
+
for (let i = 3; i >= 1; i--) {
|
|
65
|
+
const p = path.join(baseDir, `${deviceId}.jsonl.${i}`);
|
|
66
|
+
if (fs.existsSync(p))
|
|
67
|
+
out.push(p);
|
|
68
|
+
}
|
|
69
|
+
const current = path.join(baseDir, `${deviceId}.jsonl`);
|
|
70
|
+
if (fs.existsSync(current))
|
|
71
|
+
out.push(current);
|
|
72
|
+
return out;
|
|
73
|
+
}
|
|
74
|
+
function projectFields(record, fields) {
|
|
75
|
+
if (fields.length === 0)
|
|
76
|
+
return record;
|
|
77
|
+
const projected = {};
|
|
78
|
+
const payload = (record.payload ?? {});
|
|
79
|
+
for (const f of fields) {
|
|
80
|
+
if (f in payload)
|
|
81
|
+
projected[f] = payload[f];
|
|
82
|
+
}
|
|
83
|
+
return { t: record.t, topic: record.topic, deviceType: record.deviceType, payload: projected };
|
|
84
|
+
}
|
|
85
|
+
/**
|
|
86
|
+
* Stream-read the JSONL rotation files for `deviceId` and return records
|
|
87
|
+
* within [fromMs, toMs]. Parse failures are silently dropped (best-effort).
|
|
88
|
+
*
|
|
89
|
+
* Files whose mtime < fromMs are skipped whole (coarse but sound: the newest
|
|
90
|
+
* record in the file is <= mtime, so nothing in it can match).
|
|
91
|
+
*/
|
|
92
|
+
export async function queryDeviceHistory(deviceId, opts = {}) {
|
|
93
|
+
const { fromMs, toMs } = resolveRange(opts);
|
|
94
|
+
const limit = Math.max(0, opts.limit ?? DEFAULT_LIMIT);
|
|
95
|
+
const fields = opts.fields ?? [];
|
|
96
|
+
const files = jsonlFilesForDevice(deviceId);
|
|
97
|
+
const out = [];
|
|
98
|
+
for (const file of files) {
|
|
99
|
+
try {
|
|
100
|
+
const stat = fs.statSync(file);
|
|
101
|
+
if (stat.mtimeMs < fromMs)
|
|
102
|
+
continue;
|
|
103
|
+
}
|
|
104
|
+
catch {
|
|
105
|
+
continue;
|
|
106
|
+
}
|
|
107
|
+
const stream = fs.createReadStream(file, { encoding: 'utf-8' });
|
|
108
|
+
const rl = readline.createInterface({ input: stream, crlfDelay: Infinity });
|
|
109
|
+
for await (const line of rl) {
|
|
110
|
+
if (!line)
|
|
111
|
+
continue;
|
|
112
|
+
let rec;
|
|
113
|
+
try {
|
|
114
|
+
rec = JSON.parse(line);
|
|
115
|
+
}
|
|
116
|
+
catch {
|
|
117
|
+
continue;
|
|
118
|
+
}
|
|
119
|
+
const tMs = Date.parse(rec.t);
|
|
120
|
+
if (!Number.isFinite(tMs))
|
|
121
|
+
continue;
|
|
122
|
+
if (tMs < fromMs || tMs > toMs)
|
|
123
|
+
continue;
|
|
124
|
+
out.push(projectFields(rec, fields));
|
|
125
|
+
if (out.length >= limit) {
|
|
126
|
+
rl.close();
|
|
127
|
+
stream.destroy();
|
|
128
|
+
return out;
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
return out;
|
|
133
|
+
}
|
|
134
|
+
export function queryDeviceHistoryStats(deviceId) {
|
|
135
|
+
const dir = historyDir();
|
|
136
|
+
const files = jsonlFilesForDevice(deviceId);
|
|
137
|
+
let totalBytes = 0;
|
|
138
|
+
let oldest = null;
|
|
139
|
+
let newest = null;
|
|
140
|
+
let count = 0;
|
|
141
|
+
for (const file of files) {
|
|
142
|
+
try {
|
|
143
|
+
totalBytes += fs.statSync(file).size;
|
|
144
|
+
}
|
|
145
|
+
catch { /* */ }
|
|
146
|
+
}
|
|
147
|
+
// Walk the oldest file's head + current file's tail for oldest/newest + count.
|
|
148
|
+
// Counting is O(records) here, acceptable for "stats" which isn't a hot path.
|
|
149
|
+
for (const file of files) {
|
|
150
|
+
try {
|
|
151
|
+
const lines = fs.readFileSync(file, 'utf-8').split('\n');
|
|
152
|
+
for (const line of lines) {
|
|
153
|
+
if (!line)
|
|
154
|
+
continue;
|
|
155
|
+
count += 1;
|
|
156
|
+
try {
|
|
157
|
+
const rec = JSON.parse(line);
|
|
158
|
+
const tMs = Date.parse(rec.t);
|
|
159
|
+
if (Number.isFinite(tMs)) {
|
|
160
|
+
if (oldest === null || tMs < oldest)
|
|
161
|
+
oldest = tMs;
|
|
162
|
+
if (newest === null || tMs > newest)
|
|
163
|
+
newest = tMs;
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
catch { /* */ }
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
catch { /* */ }
|
|
170
|
+
}
|
|
171
|
+
return {
|
|
172
|
+
deviceId,
|
|
173
|
+
fileCount: files.length,
|
|
174
|
+
totalBytes,
|
|
175
|
+
recordCount: count,
|
|
176
|
+
oldest: oldest !== null ? new Date(oldest).toISOString() : undefined,
|
|
177
|
+
newest: newest !== null ? new Date(newest).toISOString() : undefined,
|
|
178
|
+
jsonlFiles: files.map((f) => path.basename(f)),
|
|
179
|
+
historyDir: dir,
|
|
180
|
+
};
|
|
181
|
+
}
|
|
@@ -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
|
@@ -18,6 +18,7 @@ import { registerSchemaCommand } from './commands/schema.js';
|
|
|
18
18
|
import { registerHistoryCommand } from './commands/history.js';
|
|
19
19
|
import { registerPlanCommand } from './commands/plan.js';
|
|
20
20
|
import { registerCapabilitiesCommand } from './commands/capabilities.js';
|
|
21
|
+
import { registerAgentBootstrapCommand } from './commands/agent-bootstrap.js';
|
|
21
22
|
const require = createRequire(import.meta.url);
|
|
22
23
|
const { version: pkgVersion } = require('../package.json');
|
|
23
24
|
const program = new Command();
|
|
@@ -26,7 +27,7 @@ const program = new Command();
|
|
|
26
27
|
const TOP_LEVEL_COMMANDS = [
|
|
27
28
|
'config', 'devices', 'scenes', 'webhook', 'completion', 'mcp',
|
|
28
29
|
'quota', 'catalog', 'cache', 'events', 'doctor', 'schema',
|
|
29
|
-
'history', 'plan', 'capabilities',
|
|
30
|
+
'history', 'plan', 'capabilities', 'agent-bootstrap',
|
|
30
31
|
];
|
|
31
32
|
const cacheModeArg = (value) => {
|
|
32
33
|
if (value.startsWith('-')) {
|
|
@@ -44,8 +45,9 @@ program
|
|
|
44
45
|
.description('Command-line tool for SwitchBot API v1.1')
|
|
45
46
|
.version(pkgVersion)
|
|
46
47
|
.option('--json', 'Output raw JSON response (disables tables; useful for pipes/scripts)')
|
|
47
|
-
.option('--format <type>', 'Output format: table (default), json, jsonl, tsv, yaml, id', enumArg('--format', ['table', 'json', 'jsonl', 'tsv', 'yaml', 'id']))
|
|
48
|
+
.option('--format <type>', 'Output format: table (default), json, jsonl, tsv, yaml, id, markdown', enumArg('--format', ['table', 'json', 'jsonl', 'tsv', 'yaml', 'id', 'markdown']))
|
|
48
49
|
.option('--fields <csv>', 'Comma-separated list of columns to include (e.g. --fields=id,name,type)', stringArg('--fields', { disallow: TOP_LEVEL_COMMANDS }))
|
|
50
|
+
.option('--table-style <style>', 'Table rendering style: unicode (default on TTY), ascii (default on pipes), simple, markdown', enumArg('--table-style', ['unicode', 'ascii', 'simple', 'markdown']))
|
|
49
51
|
.option('-v, --verbose', 'Log HTTP request/response details to stderr')
|
|
50
52
|
.option('--dry-run', 'Print mutating requests without sending them (GETs still execute)')
|
|
51
53
|
.option('--timeout <ms>', 'HTTP request timeout in milliseconds (default: 30000)', intArg('--timeout', { min: 1 }))
|
|
@@ -76,6 +78,7 @@ registerSchemaCommand(program);
|
|
|
76
78
|
registerHistoryCommand(program);
|
|
77
79
|
registerPlanCommand(program);
|
|
78
80
|
registerCapabilitiesCommand(program);
|
|
81
|
+
registerAgentBootstrapCommand(program);
|
|
79
82
|
program.addHelpText('after', `
|
|
80
83
|
Credentials:
|
|
81
84
|
Provide SwitchBot API v1.1 credentials via either:
|
|
@@ -130,6 +133,13 @@ function applyExitOverride(cmd) {
|
|
|
130
133
|
cmd.commands.forEach(applyExitOverride);
|
|
131
134
|
}
|
|
132
135
|
applyExitOverride(program);
|
|
136
|
+
// Enable "did you mean" suggestions across every subcommand, not just the root.
|
|
137
|
+
// Without this, `switchbot devices lst` fails without suggesting `list`.
|
|
138
|
+
function enableSuggestions(cmd) {
|
|
139
|
+
cmd.showSuggestionAfterError(true);
|
|
140
|
+
cmd.commands.forEach(enableSuggestions);
|
|
141
|
+
}
|
|
142
|
+
enableSuggestions(program);
|
|
133
143
|
try {
|
|
134
144
|
await program.parseAsync();
|
|
135
145
|
}
|
package/dist/lib/devices.js
CHANGED
|
@@ -124,13 +124,22 @@ export async function executeCommand(deviceId, cmd, parameter, commandType, clie
|
|
|
124
124
|
throw err;
|
|
125
125
|
}
|
|
126
126
|
};
|
|
127
|
-
|
|
127
|
+
const { result, replayed } = await idempotencyCache.run(options?.idempotencyKey, execute, { command: cmd, parameter });
|
|
128
|
+
if (!replayed)
|
|
129
|
+
return result;
|
|
130
|
+
// Cached hit — attach replayed marker without mutating the original.
|
|
131
|
+
if (result && typeof result === 'object') {
|
|
132
|
+
return { ...result, replayed: true };
|
|
133
|
+
}
|
|
134
|
+
return { value: result, replayed: true };
|
|
128
135
|
}
|
|
129
136
|
/**
|
|
130
137
|
* Validate a command against the locally-cached device → catalog mapping.
|
|
131
138
|
* Returns `{ ok: true }` when validation passes or is skipped (unknown device,
|
|
132
|
-
* custom IR button, etc.)
|
|
133
|
-
*
|
|
139
|
+
* custom IR button, etc.). On a case-only mismatch the canonical command name
|
|
140
|
+
* is returned via `normalized` along with a `caseNormalizedFrom` field so the
|
|
141
|
+
* caller can emit a warning and continue with the canonical name.
|
|
142
|
+
* Returns `{ ok: false, error }` only when the caller should refuse the call.
|
|
134
143
|
*/
|
|
135
144
|
export function validateCommand(deviceId, cmd, parameter, commandType) {
|
|
136
145
|
if (commandType === 'customize')
|
|
@@ -144,24 +153,34 @@ export function validateCommand(deviceId, cmd, parameter, commandType) {
|
|
|
144
153
|
const builtinCommands = match.commands.filter((c) => c.commandType !== 'customize');
|
|
145
154
|
if (builtinCommands.length === 0)
|
|
146
155
|
return { ok: true };
|
|
147
|
-
|
|
156
|
+
let spec = builtinCommands.find((c) => c.command === cmd);
|
|
157
|
+
let caseNormalizedFrom;
|
|
158
|
+
let normalizedCmd = cmd;
|
|
148
159
|
if (!spec) {
|
|
149
160
|
const unique = [...new Set(builtinCommands.map((c) => c.command))];
|
|
150
161
|
const caseMatch = unique.find((c) => c.toLowerCase() === cmd.toLowerCase());
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
162
|
+
if (caseMatch) {
|
|
163
|
+
// Case-only mismatch: normalize and continue.
|
|
164
|
+
caseNormalizedFrom = cmd;
|
|
165
|
+
normalizedCmd = caseMatch;
|
|
166
|
+
spec = builtinCommands.find((c) => c.command === caseMatch);
|
|
167
|
+
}
|
|
168
|
+
else {
|
|
169
|
+
const hint = `Supported commands: ${unique.join(', ')}`;
|
|
170
|
+
return {
|
|
171
|
+
ok: false,
|
|
172
|
+
error: new CommandValidationError(`"${cmd}" is not a supported command for ${cached.name} (${cached.type}).`, 'unknown-command', hint),
|
|
173
|
+
};
|
|
174
|
+
}
|
|
158
175
|
}
|
|
176
|
+
if (!spec)
|
|
177
|
+
return { ok: true, normalized: normalizedCmd, caseNormalizedFrom };
|
|
159
178
|
const noParamExpected = spec.parameter === '—';
|
|
160
179
|
const userProvidedParam = parameter !== undefined && parameter !== 'default';
|
|
161
180
|
if (noParamExpected && userProvidedParam) {
|
|
162
181
|
return {
|
|
163
182
|
ok: false,
|
|
164
|
-
error: new CommandValidationError(`"${
|
|
183
|
+
error: new CommandValidationError(`"${normalizedCmd}" takes no parameter, but one was provided: "${parameter}".`, 'unexpected-parameter', `Try: switchbot devices command ${deviceId} ${normalizedCmd}`),
|
|
165
184
|
};
|
|
166
185
|
}
|
|
167
186
|
// Warn when a parameter is required but the user omitted it
|
|
@@ -170,12 +189,12 @@ export function validateCommand(deviceId, cmd, parameter, commandType) {
|
|
|
170
189
|
const example = spec.exampleParams?.[0];
|
|
171
190
|
return {
|
|
172
191
|
ok: false,
|
|
173
|
-
error: new CommandValidationError(`"${
|
|
174
|
-
? `Example: switchbot devices command <deviceId> ${
|
|
192
|
+
error: new CommandValidationError(`"${normalizedCmd}" requires a parameter (${spec.parameter}).`, 'missing-parameter', example
|
|
193
|
+
? `Example: switchbot devices command <deviceId> ${normalizedCmd} "${example}"`
|
|
175
194
|
: `See: switchbot devices commands ${cached.type}`),
|
|
176
195
|
};
|
|
177
196
|
}
|
|
178
|
-
return { ok: true };
|
|
197
|
+
return { ok: true, normalized: normalizedCmd, caseNormalizedFrom };
|
|
179
198
|
}
|
|
180
199
|
/**
|
|
181
200
|
* Inspect catalog annotations to decide whether a command is destructive,
|