@switchbot/openapi-cli 2.6.3 → 2.7.2
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 -2
- package/dist/api/client.js +13 -12
- package/dist/commands/agent-bootstrap.js +21 -15
- package/dist/commands/batch.js +28 -35
- package/dist/commands/capabilities.js +29 -21
- package/dist/commands/catalog.js +12 -3
- package/dist/commands/config.js +32 -38
- package/dist/commands/devices.js +124 -83
- package/dist/commands/doctor.js +355 -19
- package/dist/commands/events.js +112 -23
- package/dist/commands/expand.js +7 -15
- package/dist/commands/explain.js +10 -6
- package/dist/commands/history.js +12 -18
- package/dist/commands/identity.js +59 -0
- package/dist/commands/mcp.js +168 -73
- package/dist/commands/plan.js +1 -1
- package/dist/commands/schema.js +22 -12
- package/dist/commands/watch.js +15 -2
- package/dist/config.js +65 -21
- package/dist/devices/catalog.js +125 -12
- package/dist/devices/resources.js +270 -0
- package/dist/index.js +25 -6
- package/dist/lib/devices.js +22 -7
- package/dist/schema/field-aliases.js +131 -0
- package/dist/utils/filter.js +17 -4
- package/dist/utils/help-json.js +54 -0
- package/dist/utils/output.js +37 -0
- package/package.json +1 -1
package/dist/commands/watch.js
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
|
-
import { printJson, isJsonMode, handleError, UsageError } from '../utils/output.js';
|
|
1
|
+
import { printJson, isJsonMode, handleError, UsageError, emitStreamHeader } from '../utils/output.js';
|
|
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
5
|
import { intArg, durationArg, stringArg } from '../utils/arg-parsers.js';
|
|
6
6
|
import { createClient } from '../api/client.js';
|
|
7
7
|
import { resolveDeviceId } from '../utils/name-resolver.js';
|
|
8
|
+
import { resolveFieldList, listAllCanonical } from '../schema/field-aliases.js';
|
|
8
9
|
const DEFAULT_INTERVAL_MS = 30_000;
|
|
9
10
|
const MIN_INTERVAL_MS = 1_000;
|
|
10
11
|
function diff(prev, next, fields) {
|
|
@@ -101,7 +102,15 @@ Examples:
|
|
|
101
102
|
maxTicks = Math.floor(n);
|
|
102
103
|
}
|
|
103
104
|
const forMs = options.for ? parseDurationToMs(options.for) : null;
|
|
104
|
-
const
|
|
105
|
+
const rawFields = getFields() ?? null;
|
|
106
|
+
// Resolve aliases upfront against the static canonical registry.
|
|
107
|
+
// Validating here lets UsageError exit the command before any
|
|
108
|
+
// polling starts, and keeps mid-loop error handling free of
|
|
109
|
+
// "misuse" concerns. Unknown fields that are not registered as
|
|
110
|
+
// aliases but happen to match an API key pass through unchanged.
|
|
111
|
+
const fields = rawFields
|
|
112
|
+
? resolveFieldList(rawFields, listAllCanonical())
|
|
113
|
+
: null;
|
|
105
114
|
const ac = new AbortController();
|
|
106
115
|
const onSig = () => ac.abort();
|
|
107
116
|
process.on('SIGINT', onSig);
|
|
@@ -109,6 +118,10 @@ Examples:
|
|
|
109
118
|
const forTimer = forMs !== null && forMs > 0
|
|
110
119
|
? setTimeout(() => ac.abort(), forMs)
|
|
111
120
|
: null;
|
|
121
|
+
// P7: streaming JSON contract — first line under --json is the
|
|
122
|
+
// stream header so consumers can route by eventKind/cadence.
|
|
123
|
+
if (isJsonMode())
|
|
124
|
+
emitStreamHeader({ eventKind: 'tick', cadence: 'poll' });
|
|
112
125
|
try {
|
|
113
126
|
const prev = new Map();
|
|
114
127
|
const client = createClient();
|
package/dist/config.js
CHANGED
|
@@ -3,6 +3,7 @@ import path from 'node:path';
|
|
|
3
3
|
import os from 'node:os';
|
|
4
4
|
import { getConfigPath } from './utils/flags.js';
|
|
5
5
|
import { getActiveProfile } from './lib/request-context.js';
|
|
6
|
+
import { emitJsonError, isJsonMode } from './utils/output.js';
|
|
6
7
|
function sanitizeOptionalString(v) {
|
|
7
8
|
if (typeof v !== 'string')
|
|
8
9
|
return undefined;
|
|
@@ -51,20 +52,36 @@ export function loadConfig() {
|
|
|
51
52
|
const hint = profile
|
|
52
53
|
? `No credentials configured for profile "${profile}". Run: switchbot --profile ${profile} config set-token <token> <secret>`
|
|
53
54
|
: 'No credentials configured. Run: switchbot config set-token <token> <secret>';
|
|
54
|
-
|
|
55
|
+
const msg = `${hint}\nOr set SWITCHBOT_TOKEN and SWITCHBOT_SECRET environment variables.`;
|
|
56
|
+
if (isJsonMode()) {
|
|
57
|
+
emitJsonError({ code: 1, kind: 'runtime', message: hint });
|
|
58
|
+
}
|
|
59
|
+
else {
|
|
60
|
+
console.error(msg);
|
|
61
|
+
}
|
|
55
62
|
process.exit(1);
|
|
56
63
|
}
|
|
57
64
|
try {
|
|
58
65
|
const raw = fs.readFileSync(file, 'utf-8');
|
|
59
66
|
const cfg = JSON.parse(raw);
|
|
60
67
|
if (!cfg.token || !cfg.secret) {
|
|
61
|
-
|
|
68
|
+
if (isJsonMode()) {
|
|
69
|
+
emitJsonError({ code: 1, kind: 'runtime', message: 'Invalid config format. Please re-run: switchbot config set-token' });
|
|
70
|
+
}
|
|
71
|
+
else {
|
|
72
|
+
console.error('Invalid config format. Please re-run: switchbot config set-token');
|
|
73
|
+
}
|
|
62
74
|
process.exit(1);
|
|
63
75
|
}
|
|
64
76
|
return cfg;
|
|
65
77
|
}
|
|
66
78
|
catch {
|
|
67
|
-
|
|
79
|
+
if (isJsonMode()) {
|
|
80
|
+
emitJsonError({ code: 1, kind: 'runtime', message: 'Failed to read config file. Please re-run: switchbot config set-token' });
|
|
81
|
+
}
|
|
82
|
+
else {
|
|
83
|
+
console.error('Failed to read config file. Please re-run: switchbot config set-token');
|
|
84
|
+
}
|
|
68
85
|
process.exit(1);
|
|
69
86
|
}
|
|
70
87
|
}
|
|
@@ -156,36 +173,63 @@ export function readProfileMeta(profile) {
|
|
|
156
173
|
}
|
|
157
174
|
}
|
|
158
175
|
export function showConfig() {
|
|
176
|
+
const summary = getConfigSummary();
|
|
177
|
+
if (summary.source === 'env') {
|
|
178
|
+
console.log('Credential source: environment variables');
|
|
179
|
+
console.log(`token : ${summary.token ?? ''}`);
|
|
180
|
+
console.log(`secret: ${summary.secret ?? ''}`);
|
|
181
|
+
return;
|
|
182
|
+
}
|
|
183
|
+
if (summary.source === 'none') {
|
|
184
|
+
console.log('No credentials configured');
|
|
185
|
+
return;
|
|
186
|
+
}
|
|
187
|
+
if (summary.source === 'invalid') {
|
|
188
|
+
console.error('Failed to read config file');
|
|
189
|
+
return;
|
|
190
|
+
}
|
|
191
|
+
console.log(`Credential source: ${summary.path}`);
|
|
192
|
+
if (summary.label)
|
|
193
|
+
console.log(`label : ${summary.label}`);
|
|
194
|
+
if (summary.description)
|
|
195
|
+
console.log(`desc : ${summary.description}`);
|
|
196
|
+
console.log(`token : ${summary.token ?? ''}`);
|
|
197
|
+
console.log(`secret: ${summary.secret ?? ''}`);
|
|
198
|
+
if (summary.dailyCap)
|
|
199
|
+
console.log(`limits: dailyCap=${summary.dailyCap}`);
|
|
200
|
+
if (summary.defaultFlags?.length)
|
|
201
|
+
console.log(`defaults: ${summary.defaultFlags.join(' ')}`);
|
|
202
|
+
}
|
|
203
|
+
export function getConfigSummary() {
|
|
159
204
|
const envToken = process.env.SWITCHBOT_TOKEN;
|
|
160
205
|
const envSecret = process.env.SWITCHBOT_SECRET;
|
|
161
206
|
if (envToken && envSecret) {
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
207
|
+
return {
|
|
208
|
+
source: 'env',
|
|
209
|
+
token: maskCredential(envToken),
|
|
210
|
+
secret: maskSecret(envSecret),
|
|
211
|
+
};
|
|
166
212
|
}
|
|
167
213
|
const file = configFilePath();
|
|
168
214
|
if (!fs.existsSync(file)) {
|
|
169
|
-
|
|
170
|
-
return;
|
|
215
|
+
return { source: 'none' };
|
|
171
216
|
}
|
|
172
217
|
try {
|
|
173
218
|
const raw = fs.readFileSync(file, 'utf-8');
|
|
174
219
|
const cfg = JSON.parse(raw);
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
console.log(`defaults: ${cfg.defaults.flags.join(' ')}`);
|
|
220
|
+
return {
|
|
221
|
+
source: 'file',
|
|
222
|
+
path: file,
|
|
223
|
+
label: cfg.label,
|
|
224
|
+
description: cfg.description,
|
|
225
|
+
token: maskCredential(cfg.token),
|
|
226
|
+
secret: maskSecret(cfg.secret),
|
|
227
|
+
dailyCap: cfg.limits?.dailyCap,
|
|
228
|
+
defaultFlags: cfg.defaults?.flags,
|
|
229
|
+
};
|
|
186
230
|
}
|
|
187
231
|
catch {
|
|
188
|
-
|
|
232
|
+
return { source: 'invalid', path: file };
|
|
189
233
|
}
|
|
190
234
|
}
|
|
191
235
|
function maskCredential(token) {
|
package/dist/devices/catalog.js
CHANGED
|
@@ -7,14 +7,99 @@
|
|
|
7
7
|
* - CommandSpec.idempotent: repeat-safe — calling it N times ends in the
|
|
8
8
|
* same state as calling it once (turnOn, setBrightness 50). Agents can
|
|
9
9
|
* retry these freely. Counter-examples: toggle, press, volumeAdd.
|
|
10
|
-
* - CommandSpec.
|
|
11
|
-
*
|
|
12
|
-
*
|
|
10
|
+
* - CommandSpec.safetyTier: explicit action safety classification. See
|
|
11
|
+
* SafetyTier for the 5-tier enum. Built-in entries set this on the
|
|
12
|
+
* destructive tier; other tiers are derived (see deriveSafetyTier).
|
|
13
|
+
* - CommandSpec.destructive (deprecated, v3.0 removal): legacy boolean
|
|
14
|
+
* that maps to safetyTier === 'destructive'. Still accepted in
|
|
15
|
+
* ~/.switchbot/catalog.json overlays and derived into safetyTier.
|
|
13
16
|
* - DeviceCatalogEntry.role: functional grouping for filter/search
|
|
14
17
|
* ("all lighting", "all security"). Does not affect API behavior.
|
|
15
18
|
* - DeviceCatalogEntry.readOnly: the device has no control commands; it
|
|
16
19
|
* can only be queried via 'devices status'.
|
|
17
20
|
*/
|
|
21
|
+
/**
|
|
22
|
+
* Catalog shape version. Bump when any of the exported interfaces
|
|
23
|
+
* (CommandSpec / DeviceCatalogEntry / SafetyTier) gain/lose/rename a
|
|
24
|
+
* load-bearing field. The agent-bootstrap payload's schemaVersion must
|
|
25
|
+
* stay pinned to this value; `doctor` fails the `catalog-schema` check
|
|
26
|
+
* when they drift.
|
|
27
|
+
*/
|
|
28
|
+
export const CATALOG_SCHEMA_VERSION = '1.0';
|
|
29
|
+
/**
|
|
30
|
+
* Human-readable descriptions for common status fields. Populated from
|
|
31
|
+
* the SwitchBot API v1.1 docs. Used by deriveStatusQueries() so every
|
|
32
|
+
* query has a meaningful description even when the entry itself only
|
|
33
|
+
* declares the field name.
|
|
34
|
+
*/
|
|
35
|
+
const STATUS_FIELD_DESCRIPTIONS = {
|
|
36
|
+
power: 'Power state (on/off)',
|
|
37
|
+
battery: 'Battery percentage (0-100)',
|
|
38
|
+
version: 'Firmware version string',
|
|
39
|
+
temperature: 'Ambient temperature (°C)',
|
|
40
|
+
humidity: 'Ambient humidity (% RH)',
|
|
41
|
+
CO2: 'CO2 concentration (ppm)',
|
|
42
|
+
brightness: 'Current brightness (0-100)',
|
|
43
|
+
color: 'Current RGB color (r:g:b)',
|
|
44
|
+
colorTemperature: 'Color temperature in Kelvin',
|
|
45
|
+
mode: 'Operating mode',
|
|
46
|
+
deviceMode: 'Hardware mode (Bot-specific)',
|
|
47
|
+
lockState: 'Lock state (locked/unlocked)',
|
|
48
|
+
doorState: 'Door contact state (open/closed)',
|
|
49
|
+
calibrate: 'Calibration status',
|
|
50
|
+
moving: 'Motion in progress (boolean)',
|
|
51
|
+
slidePosition: 'Slide position (0-100)',
|
|
52
|
+
group: 'Multi-device group membership',
|
|
53
|
+
direction: 'Tilt direction',
|
|
54
|
+
voltage: 'Line voltage',
|
|
55
|
+
electricCurrent: 'Instantaneous current draw',
|
|
56
|
+
electricityOfDay: 'kWh consumed today',
|
|
57
|
+
usedElectricity: 'Cumulative kWh',
|
|
58
|
+
useTime: 'Total runtime (seconds)',
|
|
59
|
+
weight: 'Load / weight reading',
|
|
60
|
+
switchStatus: 'Relay state (integer encoded)',
|
|
61
|
+
switch1Status: 'Channel 1 relay state',
|
|
62
|
+
switch2Status: 'Channel 2 relay state',
|
|
63
|
+
workingStatus: 'Device working status (vacuum/purifier)',
|
|
64
|
+
onlineStatus: 'Online / offline (string)',
|
|
65
|
+
online: 'Online / offline (boolean or int)',
|
|
66
|
+
taskType: 'Current task identifier',
|
|
67
|
+
nightStatus: 'Night-mode status',
|
|
68
|
+
oscillation: 'Horizontal oscillation on/off',
|
|
69
|
+
verticalOscillation: 'Vertical oscillation on/off',
|
|
70
|
+
chargingStatus: 'Charging (boolean)',
|
|
71
|
+
fanSpeed: 'Current fan speed level',
|
|
72
|
+
nebulizationEfficiency: 'Humidifier mist level',
|
|
73
|
+
childLock: 'Child-lock engaged',
|
|
74
|
+
sound: 'Beep / audio feedback enabled',
|
|
75
|
+
lackWater: 'Water tank low (boolean)',
|
|
76
|
+
filterElement: 'Filter life remaining',
|
|
77
|
+
auto: 'Auto mode enabled',
|
|
78
|
+
targetTemperature: 'Thermostat target temperature',
|
|
79
|
+
moveDetected: 'Motion detected (boolean)',
|
|
80
|
+
openState: 'Contact sensor open/closed',
|
|
81
|
+
status: 'Device-specific status word',
|
|
82
|
+
lightLevel: 'Ambient light level',
|
|
83
|
+
};
|
|
84
|
+
/**
|
|
85
|
+
* P11: derive the read-only query list for an entry. If the entry has
|
|
86
|
+
* explicit `statusQueries`, return them as-is; otherwise synthesize one
|
|
87
|
+
* ReadOnlyQuerySpec per `statusFields` entry, all keyed to the `status`
|
|
88
|
+
* endpoint. IR-category entries have no status channel so return [].
|
|
89
|
+
*/
|
|
90
|
+
export function deriveStatusQueries(entry) {
|
|
91
|
+
if (entry.statusQueries && entry.statusQueries.length > 0)
|
|
92
|
+
return entry.statusQueries;
|
|
93
|
+
if (entry.category === 'ir')
|
|
94
|
+
return [];
|
|
95
|
+
const fields = entry.statusFields ?? [];
|
|
96
|
+
return fields.map((f) => ({
|
|
97
|
+
field: f,
|
|
98
|
+
description: STATUS_FIELD_DESCRIPTIONS[f] ?? `${f} (see API docs)`,
|
|
99
|
+
endpoint: 'status',
|
|
100
|
+
safetyTier: 'read',
|
|
101
|
+
}));
|
|
102
|
+
}
|
|
18
103
|
// ---- Command fragments (reused across entries) -------------------------
|
|
19
104
|
const onOff = [
|
|
20
105
|
{ command: 'turnOn', parameter: '—', description: 'Power on', idempotent: true },
|
|
@@ -64,7 +149,7 @@ export const DEVICE_CATALOG = [
|
|
|
64
149
|
aliases: ['Smart Lock Pro'],
|
|
65
150
|
commands: [
|
|
66
151
|
{ command: 'lock', parameter: '—', description: 'Lock the door', idempotent: true },
|
|
67
|
-
{ command: 'unlock', parameter: '—', description: 'Unlock the door', idempotent: true,
|
|
152
|
+
{ command: 'unlock', parameter: '—', description: 'Unlock the door', idempotent: true, safetyTier: 'destructive', safetyReason: 'Physically unlocks the door — anyone nearby can open it.' },
|
|
68
153
|
{ command: 'deadbolt', parameter: '—', description: 'Pro only: engage deadbolt', idempotent: true },
|
|
69
154
|
],
|
|
70
155
|
statusFields: ['battery', 'version', 'lockState', 'doorState', 'calibrate'],
|
|
@@ -76,7 +161,7 @@ export const DEVICE_CATALOG = [
|
|
|
76
161
|
role: 'security',
|
|
77
162
|
commands: [
|
|
78
163
|
{ command: 'lock', parameter: '—', description: 'Lock the door', idempotent: true },
|
|
79
|
-
{ command: 'unlock', parameter: '—', description: 'Unlock the door', idempotent: true,
|
|
164
|
+
{ command: 'unlock', parameter: '—', description: 'Unlock the door', idempotent: true, safetyTier: 'destructive', safetyReason: 'Physically unlocks the door — anyone nearby can open it.' },
|
|
80
165
|
],
|
|
81
166
|
statusFields: ['battery', 'version', 'lockState', 'doorState', 'calibrate'],
|
|
82
167
|
},
|
|
@@ -87,7 +172,7 @@ export const DEVICE_CATALOG = [
|
|
|
87
172
|
role: 'security',
|
|
88
173
|
commands: [
|
|
89
174
|
{ command: 'lock', parameter: '—', description: 'Lock the door', idempotent: true },
|
|
90
|
-
{ command: 'unlock', parameter: '—', description: 'Unlock the door', idempotent: true,
|
|
175
|
+
{ command: 'unlock', parameter: '—', description: 'Unlock the door', idempotent: true, safetyTier: 'destructive', safetyReason: 'Physically unlocks the door — anyone nearby can open it.' },
|
|
91
176
|
{ command: 'deadbolt', parameter: '—', description: 'Engage deadbolt', idempotent: true },
|
|
92
177
|
],
|
|
93
178
|
statusFields: ['battery', 'version', 'lockState', 'doorState', 'calibrate'],
|
|
@@ -219,7 +304,7 @@ export const DEVICE_CATALOG = [
|
|
|
219
304
|
category: 'physical',
|
|
220
305
|
description: 'Entry-level robot vacuum with start/stop/dock and four suction power levels.',
|
|
221
306
|
role: 'cleaning',
|
|
222
|
-
aliases: ['Robot Vacuum Cleaner S1 Plus', 'K10+'],
|
|
307
|
+
aliases: ['Robot Vacuum', 'Robot Vacuum Cleaner S1 Plus', 'K10+'],
|
|
223
308
|
commands: [
|
|
224
309
|
{ command: 'start', parameter: '—', description: 'Start cleaning', idempotent: true },
|
|
225
310
|
{ command: 'stop', parameter: '—', description: 'Stop cleaning', idempotent: true },
|
|
@@ -306,8 +391,8 @@ export const DEVICE_CATALOG = [
|
|
|
306
391
|
description: 'Cloud-connected garage door controller; turnOn opens and turnOff closes the door.',
|
|
307
392
|
role: 'security',
|
|
308
393
|
commands: [
|
|
309
|
-
{ command: 'turnOn', parameter: '—', description: 'Open the garage door', idempotent: true,
|
|
310
|
-
{ command: 'turnOff', parameter: '—', description: 'Close the garage door', idempotent: true,
|
|
394
|
+
{ command: 'turnOn', parameter: '—', description: 'Open the garage door', idempotent: true, safetyTier: 'destructive', safetyReason: 'Opens the garage door — anyone nearby can enter the space.' },
|
|
395
|
+
{ command: 'turnOff', parameter: '—', description: 'Close the garage door', idempotent: true, safetyTier: 'destructive', safetyReason: 'Closes the garage door — verify no person or obstacle is in the way.' },
|
|
311
396
|
],
|
|
312
397
|
statusFields: ['switchStatus', 'version', 'online'],
|
|
313
398
|
},
|
|
@@ -329,8 +414,8 @@ export const DEVICE_CATALOG = [
|
|
|
329
414
|
role: 'security',
|
|
330
415
|
aliases: ['Keypad Touch'],
|
|
331
416
|
commands: [
|
|
332
|
-
{ command: 'createKey', parameter: '\'{"name":"...","type":"permanent|timeLimit|disposable|urgent","password":"6-12 digits","startTime":<s>,"endTime":<s>}\'', description: 'Create a passcode (async; result via webhook)', idempotent: false,
|
|
333
|
-
{ command: 'deleteKey', parameter: '\'{"id":<passcode_id>}\'', description: 'Delete a passcode (async; result via webhook)', idempotent: true,
|
|
417
|
+
{ command: 'createKey', parameter: '\'{"name":"...","type":"permanent|timeLimit|disposable|urgent","password":"6-12 digits","startTime":<s>,"endTime":<s>}\'', description: 'Create a passcode (async; result via webhook)', idempotent: false, safetyTier: 'destructive', safetyReason: 'Provisions a new access credential — anyone with this passcode can unlock the door.' },
|
|
418
|
+
{ command: 'deleteKey', parameter: '\'{"id":<passcode_id>}\'', description: 'Delete a passcode (async; result via webhook)', idempotent: true, safetyTier: 'destructive', safetyReason: 'Permanently removes a passcode — the holder immediately loses door access.' },
|
|
334
419
|
],
|
|
335
420
|
statusFields: ['version'],
|
|
336
421
|
},
|
|
@@ -538,13 +623,41 @@ export function findCatalogEntry(query) {
|
|
|
538
623
|
return matches[0];
|
|
539
624
|
return matches;
|
|
540
625
|
}
|
|
626
|
+
/**
|
|
627
|
+
* Derive the safety tier for a catalog command, honouring an explicit
|
|
628
|
+
* `safetyTier` when present and falling back to heuristic inference.
|
|
629
|
+
*
|
|
630
|
+
* The inference order is:
|
|
631
|
+
* 1. Explicit `spec.safetyTier`.
|
|
632
|
+
* 2. Legacy `spec.destructive: true` → `'destructive'` (overlay compat).
|
|
633
|
+
* 3. IR context (customize command OR entry.category === 'ir')
|
|
634
|
+
* → `'ir-fire-forget'`.
|
|
635
|
+
* 4. Default → `'mutation'`.
|
|
636
|
+
*/
|
|
637
|
+
export function deriveSafetyTier(spec, entry) {
|
|
638
|
+
if (spec.safetyTier)
|
|
639
|
+
return spec.safetyTier;
|
|
640
|
+
if (spec.destructive)
|
|
641
|
+
return 'destructive';
|
|
642
|
+
if (spec.commandType === 'customize')
|
|
643
|
+
return 'ir-fire-forget';
|
|
644
|
+
if (entry?.category === 'ir')
|
|
645
|
+
return 'ir-fire-forget';
|
|
646
|
+
return 'mutation';
|
|
647
|
+
}
|
|
648
|
+
/** Read the safety reason for a command, with fallback to the legacy field. */
|
|
649
|
+
export function getCommandSafetyReason(spec) {
|
|
650
|
+
return spec.safetyReason ?? spec.destructiveReason ?? null;
|
|
651
|
+
}
|
|
541
652
|
/**
|
|
542
653
|
* Pick up to 3 non-destructive, idempotent commands an agent can safely invoke
|
|
543
654
|
* to explore or exercise a device. Used by `devices describe --json` to hint
|
|
544
655
|
* at concrete next steps.
|
|
545
656
|
*/
|
|
546
657
|
export function suggestedActions(entry) {
|
|
547
|
-
const safe = entry.commands.filter((c) => c.idempotent === true &&
|
|
658
|
+
const safe = entry.commands.filter((c) => c.idempotent === true &&
|
|
659
|
+
deriveSafetyTier(c, entry) !== 'destructive' &&
|
|
660
|
+
c.commandType !== 'customize');
|
|
548
661
|
const picks = [];
|
|
549
662
|
const seen = new Set();
|
|
550
663
|
for (const c of safe) {
|
|
@@ -0,0 +1,270 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Declarative metadata for non-device resources exposed by the SwitchBot API:
|
|
3
|
+
* scenes, webhooks, and keypad credentials ("keys").
|
|
4
|
+
*
|
|
5
|
+
* Consumed by `capabilities --json` and `schema export` so AI agents can
|
|
6
|
+
* discover these surfaces the same way they discover device commands.
|
|
7
|
+
*
|
|
8
|
+
* Scope:
|
|
9
|
+
* - Descriptive metadata only (no runtime execution — CLI/MCP handlers stay
|
|
10
|
+
* source-of-truth for behavior).
|
|
11
|
+
* - Webhook event list is derived from the device catalog and is advisory —
|
|
12
|
+
* not every SwitchBot device actually pushes every listed event; refer to
|
|
13
|
+
* the SwitchBot webhook docs for authoritative shapes.
|
|
14
|
+
*/
|
|
15
|
+
const COMMON_WEBHOOK_FIELDS = [
|
|
16
|
+
{ name: 'deviceType', type: 'string', description: 'SwitchBot device type string', example: 'WoMeter' },
|
|
17
|
+
{ name: 'deviceMac', type: 'string', description: 'Bluetooth MAC address (uppercase, colon-separated)', example: 'AA:BB:CC:11:22:33' },
|
|
18
|
+
{ name: 'timeOfSample', type: 'timestamp', description: 'Millisecond Unix timestamp when the sample was taken', example: 1700000000000 },
|
|
19
|
+
];
|
|
20
|
+
export const RESOURCE_CATALOG = {
|
|
21
|
+
scenes: {
|
|
22
|
+
description: 'Manual scenes (IFTTT-style rules) authored in the SwitchBot app. Execution is fire-and-forget from the cloud — side-effects happen on the user\'s devices.',
|
|
23
|
+
operations: [
|
|
24
|
+
{
|
|
25
|
+
verb: 'list',
|
|
26
|
+
method: 'GET',
|
|
27
|
+
endpoint: '/v1.1/scenes',
|
|
28
|
+
params: [],
|
|
29
|
+
safetyTier: 'read',
|
|
30
|
+
},
|
|
31
|
+
{
|
|
32
|
+
verb: 'execute',
|
|
33
|
+
method: 'POST',
|
|
34
|
+
endpoint: '/v1.1/scenes/{sceneId}/execute',
|
|
35
|
+
params: [{ name: 'sceneId', required: true, type: 'string' }],
|
|
36
|
+
safetyTier: 'mutation',
|
|
37
|
+
},
|
|
38
|
+
{
|
|
39
|
+
verb: 'describe',
|
|
40
|
+
method: 'GET',
|
|
41
|
+
endpoint: '/v1.1/scenes/{sceneId}',
|
|
42
|
+
params: [{ name: 'sceneId', required: true, type: 'string' }],
|
|
43
|
+
safetyTier: 'read',
|
|
44
|
+
},
|
|
45
|
+
],
|
|
46
|
+
},
|
|
47
|
+
webhooks: {
|
|
48
|
+
endpoints: [
|
|
49
|
+
{
|
|
50
|
+
verb: 'setup',
|
|
51
|
+
method: 'POST',
|
|
52
|
+
path: '/v1.1/webhook/setupWebhook',
|
|
53
|
+
safetyTier: 'mutation',
|
|
54
|
+
requiredParams: ['url'],
|
|
55
|
+
},
|
|
56
|
+
{
|
|
57
|
+
verb: 'query',
|
|
58
|
+
method: 'POST',
|
|
59
|
+
path: '/v1.1/webhook/queryWebhook',
|
|
60
|
+
safetyTier: 'read',
|
|
61
|
+
requiredParams: ['action'],
|
|
62
|
+
},
|
|
63
|
+
{
|
|
64
|
+
verb: 'update',
|
|
65
|
+
method: 'POST',
|
|
66
|
+
path: '/v1.1/webhook/updateWebhook',
|
|
67
|
+
safetyTier: 'mutation',
|
|
68
|
+
requiredParams: ['url', 'enable'],
|
|
69
|
+
},
|
|
70
|
+
{
|
|
71
|
+
verb: 'delete',
|
|
72
|
+
method: 'POST',
|
|
73
|
+
path: '/v1.1/webhook/deleteWebhook',
|
|
74
|
+
safetyTier: 'destructive',
|
|
75
|
+
requiredParams: ['url'],
|
|
76
|
+
},
|
|
77
|
+
],
|
|
78
|
+
events: [
|
|
79
|
+
{
|
|
80
|
+
eventType: 'WoMeter',
|
|
81
|
+
devicePattern: 'Meter / Meter Plus / Indoor-Outdoor Meter',
|
|
82
|
+
fields: [
|
|
83
|
+
...COMMON_WEBHOOK_FIELDS,
|
|
84
|
+
{ name: 'temperature', type: 'number', description: 'Ambient temperature in Celsius', example: 22.5 },
|
|
85
|
+
{ name: 'humidity', type: 'number', description: 'Relative humidity (%)', example: 45 },
|
|
86
|
+
{ name: 'battery', type: 'number', description: 'Battery remaining (%)', example: 88 },
|
|
87
|
+
],
|
|
88
|
+
},
|
|
89
|
+
{
|
|
90
|
+
eventType: 'WoCO2Sensor',
|
|
91
|
+
devicePattern: 'CO2 Monitor',
|
|
92
|
+
fields: [
|
|
93
|
+
...COMMON_WEBHOOK_FIELDS,
|
|
94
|
+
{ name: 'CO2', type: 'number', description: 'CO2 concentration in ppm', example: 520 },
|
|
95
|
+
{ name: 'temperature', type: 'number', description: 'Ambient temperature in Celsius' },
|
|
96
|
+
{ name: 'humidity', type: 'number', description: 'Relative humidity (%)' },
|
|
97
|
+
],
|
|
98
|
+
},
|
|
99
|
+
{
|
|
100
|
+
eventType: 'WoPresence',
|
|
101
|
+
devicePattern: 'Motion Sensor / Video Doorbell motion',
|
|
102
|
+
fields: [
|
|
103
|
+
...COMMON_WEBHOOK_FIELDS,
|
|
104
|
+
{ name: 'detectionState', type: 'string', description: 'Detection result word', example: 'DETECTED' },
|
|
105
|
+
],
|
|
106
|
+
},
|
|
107
|
+
{
|
|
108
|
+
eventType: 'WoContact',
|
|
109
|
+
devicePattern: 'Contact Sensor',
|
|
110
|
+
fields: [
|
|
111
|
+
...COMMON_WEBHOOK_FIELDS,
|
|
112
|
+
{ name: 'openState', type: 'string', description: 'Door/window state', example: 'open' },
|
|
113
|
+
{ name: 'moveDetected', type: 'boolean', description: 'Motion detected during this sample' },
|
|
114
|
+
],
|
|
115
|
+
},
|
|
116
|
+
{
|
|
117
|
+
eventType: 'WoLock',
|
|
118
|
+
devicePattern: 'Smart Lock / Smart Lock Lite / Smart Lock Pro',
|
|
119
|
+
fields: [
|
|
120
|
+
...COMMON_WEBHOOK_FIELDS,
|
|
121
|
+
{ name: 'lockState', type: 'string', description: 'Lock state: locked, unlocked, jammed', example: 'locked' },
|
|
122
|
+
{ name: 'battery', type: 'number', description: 'Battery remaining (%)' },
|
|
123
|
+
],
|
|
124
|
+
},
|
|
125
|
+
{
|
|
126
|
+
eventType: 'WoPlug',
|
|
127
|
+
devicePattern: 'Plug Mini / Plug / Relay Switch',
|
|
128
|
+
fields: [
|
|
129
|
+
...COMMON_WEBHOOK_FIELDS,
|
|
130
|
+
{ name: 'power', type: 'string', description: 'Power state (on/off)', example: 'on' },
|
|
131
|
+
{ name: 'voltage', type: 'number', description: 'Instantaneous voltage (V)' },
|
|
132
|
+
{ name: 'electricCurrent', type: 'number', description: 'Instantaneous current (A)' },
|
|
133
|
+
],
|
|
134
|
+
},
|
|
135
|
+
{
|
|
136
|
+
eventType: 'WoBot',
|
|
137
|
+
devicePattern: 'Bot',
|
|
138
|
+
fields: [
|
|
139
|
+
...COMMON_WEBHOOK_FIELDS,
|
|
140
|
+
{ name: 'power', type: 'string', description: 'Power state (on/off)' },
|
|
141
|
+
{ name: 'battery', type: 'number', description: 'Battery remaining (%)' },
|
|
142
|
+
],
|
|
143
|
+
},
|
|
144
|
+
{
|
|
145
|
+
eventType: 'WoCurtain',
|
|
146
|
+
devicePattern: 'Curtain / Blind Tilt / Roller Shade',
|
|
147
|
+
fields: [
|
|
148
|
+
...COMMON_WEBHOOK_FIELDS,
|
|
149
|
+
{ name: 'slidePosition', type: 'number', description: 'Current slide position (0–100)' },
|
|
150
|
+
{ name: 'calibrate', type: 'boolean', description: 'True if device is calibrated' },
|
|
151
|
+
],
|
|
152
|
+
},
|
|
153
|
+
{
|
|
154
|
+
eventType: 'WoDoorbell',
|
|
155
|
+
devicePattern: 'Video Doorbell button press',
|
|
156
|
+
fields: [
|
|
157
|
+
...COMMON_WEBHOOK_FIELDS,
|
|
158
|
+
{ name: 'buttonName', type: 'string', description: 'Identifier of the pressed button' },
|
|
159
|
+
{ name: 'pressedAt', type: 'timestamp', description: 'Press timestamp in milliseconds' },
|
|
160
|
+
],
|
|
161
|
+
},
|
|
162
|
+
{
|
|
163
|
+
eventType: 'WoKeypad',
|
|
164
|
+
devicePattern: 'Keypad scan / createKey result / deleteKey result',
|
|
165
|
+
fields: [
|
|
166
|
+
...COMMON_WEBHOOK_FIELDS,
|
|
167
|
+
{ name: 'eventType', type: 'string', description: 'Sub-event (createKey / deleteKey / invalidCode)' },
|
|
168
|
+
{ name: 'commandId', type: 'string', description: 'Correlation id returned by the original command' },
|
|
169
|
+
{ name: 'result', type: 'string', description: 'Outcome (success / failed / timeout)' },
|
|
170
|
+
],
|
|
171
|
+
},
|
|
172
|
+
{
|
|
173
|
+
eventType: 'WoColorBulb',
|
|
174
|
+
devicePattern: 'Color Bulb',
|
|
175
|
+
fields: [
|
|
176
|
+
...COMMON_WEBHOOK_FIELDS,
|
|
177
|
+
{ name: 'power', type: 'string', description: 'Power state (on/off)' },
|
|
178
|
+
{ name: 'brightness', type: 'number', description: 'Brightness (0–100)' },
|
|
179
|
+
{ name: 'color', type: 'string', description: 'RGB triplet "r:g:b"' },
|
|
180
|
+
{ name: 'colorTemperature', type: 'number', description: 'Color temperature in Kelvin' },
|
|
181
|
+
],
|
|
182
|
+
},
|
|
183
|
+
{
|
|
184
|
+
eventType: 'WoStrip',
|
|
185
|
+
devicePattern: 'Strip Light',
|
|
186
|
+
fields: [
|
|
187
|
+
...COMMON_WEBHOOK_FIELDS,
|
|
188
|
+
{ name: 'power', type: 'string', description: 'Power state (on/off)' },
|
|
189
|
+
{ name: 'brightness', type: 'number', description: 'Brightness (0–100)' },
|
|
190
|
+
{ name: 'color', type: 'string', description: 'RGB triplet "r:g:b"' },
|
|
191
|
+
],
|
|
192
|
+
},
|
|
193
|
+
{
|
|
194
|
+
eventType: 'WoSweeper',
|
|
195
|
+
devicePattern: 'Robot Vacuum',
|
|
196
|
+
fields: [
|
|
197
|
+
...COMMON_WEBHOOK_FIELDS,
|
|
198
|
+
{ name: 'workingStatus', type: 'string', description: 'Cleaning state' },
|
|
199
|
+
{ name: 'battery', type: 'number', description: 'Battery remaining (%)' },
|
|
200
|
+
{ name: 'taskType', type: 'string', description: 'Current task (standby / clean / charge)' },
|
|
201
|
+
],
|
|
202
|
+
},
|
|
203
|
+
{
|
|
204
|
+
eventType: 'WoWaterLeakDetect',
|
|
205
|
+
devicePattern: 'Water Leak Detector',
|
|
206
|
+
fields: [
|
|
207
|
+
...COMMON_WEBHOOK_FIELDS,
|
|
208
|
+
{ name: 'waterLeakDetect', type: 'number', description: 'Leak flag (0 = dry, 1 = leak detected)' },
|
|
209
|
+
{ name: 'battery', type: 'number', description: 'Battery remaining (%)' },
|
|
210
|
+
],
|
|
211
|
+
},
|
|
212
|
+
{
|
|
213
|
+
eventType: 'WoHub',
|
|
214
|
+
devicePattern: 'Hub 2 / Hub 3 (ambient sensors)',
|
|
215
|
+
fields: [
|
|
216
|
+
...COMMON_WEBHOOK_FIELDS,
|
|
217
|
+
{ name: 'temperature', type: 'number', description: 'Ambient temperature in Celsius' },
|
|
218
|
+
{ name: 'humidity', type: 'number', description: 'Relative humidity (%)' },
|
|
219
|
+
{ name: 'lightLevel', type: 'number', description: 'Illuminance level' },
|
|
220
|
+
],
|
|
221
|
+
},
|
|
222
|
+
],
|
|
223
|
+
constraints: {
|
|
224
|
+
maxUrlLength: 2048,
|
|
225
|
+
maxWebhooksPerAccount: 1,
|
|
226
|
+
},
|
|
227
|
+
},
|
|
228
|
+
keys: [
|
|
229
|
+
{
|
|
230
|
+
keyType: 'permanent',
|
|
231
|
+
description: 'Passcode that never expires — valid until manually deleted.',
|
|
232
|
+
requiredParams: ['name', 'password'],
|
|
233
|
+
optionalParams: [],
|
|
234
|
+
supportedDevices: ['Keypad', 'Keypad Touch'],
|
|
235
|
+
safetyTier: 'destructive',
|
|
236
|
+
},
|
|
237
|
+
{
|
|
238
|
+
keyType: 'timeLimit',
|
|
239
|
+
description: 'Passcode valid only between startTime and endTime (Unix seconds).',
|
|
240
|
+
requiredParams: ['name', 'password', 'startTime', 'endTime'],
|
|
241
|
+
optionalParams: [],
|
|
242
|
+
supportedDevices: ['Keypad', 'Keypad Touch'],
|
|
243
|
+
safetyTier: 'destructive',
|
|
244
|
+
},
|
|
245
|
+
{
|
|
246
|
+
keyType: 'disposable',
|
|
247
|
+
description: 'Passcode that can be used once and then auto-expires.',
|
|
248
|
+
requiredParams: ['name', 'password'],
|
|
249
|
+
optionalParams: ['startTime', 'endTime'],
|
|
250
|
+
supportedDevices: ['Keypad', 'Keypad Touch'],
|
|
251
|
+
safetyTier: 'destructive',
|
|
252
|
+
},
|
|
253
|
+
{
|
|
254
|
+
keyType: 'urgent',
|
|
255
|
+
description: 'Emergency passcode (typically tied to panic / audit workflow).',
|
|
256
|
+
requiredParams: ['name', 'password'],
|
|
257
|
+
optionalParams: [],
|
|
258
|
+
supportedDevices: ['Keypad', 'Keypad Touch'],
|
|
259
|
+
safetyTier: 'destructive',
|
|
260
|
+
},
|
|
261
|
+
],
|
|
262
|
+
};
|
|
263
|
+
/** Convenience: return the list of known webhook event types. */
|
|
264
|
+
export function listWebhookEventTypes() {
|
|
265
|
+
return RESOURCE_CATALOG.webhooks.events.map((e) => e.eventType);
|
|
266
|
+
}
|
|
267
|
+
/** Convenience: return the list of supported keypad key types. */
|
|
268
|
+
export function listKeyTypes() {
|
|
269
|
+
return RESOURCE_CATALOG.keys.map((k) => k.keyType);
|
|
270
|
+
}
|