@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.
@@ -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';
@@ -199,6 +200,10 @@ Examples:
199
200
  .description('Query the real-time status of a specific device')
200
201
  .argument('[deviceId]', 'Device ID from "devices list" (or use --name or --ids)')
201
202
  .option('--name <query>', 'Resolve device by fuzzy name instead of deviceId', stringArg('--name'))
203
+ .option('--name-strategy <s>', 'Name match strategy: exact|prefix|substring|fuzzy|first|require-unique (default: fuzzy)', stringArg('--name-strategy'))
204
+ .option('--name-type <type>', 'Narrow --name by device type (e.g. "Bot", "Color Bulb")', stringArg('--name-type'))
205
+ .option('--name-category <cat>', 'Narrow --name by category: physical|ir', enumArg('--name-category', ['physical', 'ir']))
206
+ .option('--name-room <room>', 'Narrow --name by room name (substring match)', stringArg('--name-room'))
202
207
  .option('--ids <list>', 'Comma-separated device IDs for batch status (incompatible with --name)', stringArg('--ids'))
203
208
  .addHelpText('after', `
204
209
  Status fields vary by device type. To discover them without a live call:
@@ -260,7 +265,12 @@ Examples:
260
265
  }
261
266
  return;
262
267
  }
263
- const deviceId = resolveDeviceId(deviceIdArg, options.name);
268
+ const deviceId = resolveDeviceId(deviceIdArg, options.name, {
269
+ strategy: options.nameStrategy ?? 'fuzzy',
270
+ type: options.nameType,
271
+ category: options.nameCategory,
272
+ room: options.nameRoom,
273
+ });
264
274
  const body = await fetchDeviceStatus(deviceId);
265
275
  const fetchedAt = new Date().toISOString();
266
276
  const fmt = resolveFormat();
@@ -291,6 +301,10 @@ Examples:
291
301
  .argument('[cmd]', 'Command name, e.g. turnOn, turnOff, setColor, setBrightness, setAll, startClean')
292
302
  .argument('[parameter]', 'Command parameter. Omit for commands like turnOn/turnOff (defaults to "default"). Format depends on the command (see below).')
293
303
  .option('--name <query>', 'Resolve device by fuzzy name instead of deviceId', stringArg('--name'))
304
+ .option('--name-strategy <s>', 'Name match strategy: exact|prefix|substring|fuzzy|first|require-unique (default for command: require-unique)', stringArg('--name-strategy'))
305
+ .option('--name-type <type>', 'Narrow --name by device type (e.g. "Bot", "Color Bulb")', stringArg('--name-type'))
306
+ .option('--name-category <cat>', 'Narrow --name by category: physical|ir', enumArg('--name-category', ['physical', 'ir']))
307
+ .option('--name-room <room>', 'Narrow --name by room name (substring match)', stringArg('--name-room'))
294
308
  .option('--type <commandType>', 'Command type: "command" for built-in commands (default), "customize" for user-defined IR buttons', enumArg('--type', COMMAND_TYPES), 'command')
295
309
  .option('--yes', 'Confirm a destructive command (Smart Lock unlock, Garage open, …). --dry-run is always allowed without --yes.')
296
310
  .option('--idempotency-key <key>', 'Idempotency key for deduplication (60s window; same key replays cached result)', stringArg('--idempotency-key'))
@@ -341,19 +355,22 @@ Examples:
341
355
  `)
342
356
  .action(async (deviceIdArg, cmdArg, parameter, options) => {
343
357
  try {
344
- // BUG-FIX: When --name is provided, Commander misassigns the first positional
345
- // to [deviceId] instead of [cmd]. Detect and shift positionals accordingly.
358
+ // BUG-FIX: When --name is provided, Commander fills positionals left-to-right
359
+ // starting at [deviceId]. Shift them back to their semantic slots.
346
360
  let cmd;
347
361
  let effectiveDeviceIdArg;
348
362
  if (options.name) {
349
- if (deviceIdArg && cmdArg) {
350
- throw new UsageError('Provide either a deviceId argument or --name, not both.');
351
- }
352
- if (!deviceIdArg && !cmdArg) {
363
+ // `--name "x" <cmd> [parameter]` → Commander binds deviceIdArg=<cmd>, cmdArg=[parameter].
364
+ if (!deviceIdArg) {
353
365
  throw new UsageError('Command name is required (e.g. turnOn, turnOff, setAll).');
354
366
  }
355
- // --name "x" turnOn → deviceIdArg="turnOn", cmdArg=undefined → shift
356
- cmd = (deviceIdArg ?? cmdArg);
367
+ cmd = deviceIdArg;
368
+ if (cmdArg !== undefined) {
369
+ if (parameter !== undefined) {
370
+ throw new UsageError('Too many positional arguments after --name. Expected: --name <query> <cmd> [parameter].');
371
+ }
372
+ parameter = cmdArg;
373
+ }
357
374
  effectiveDeviceIdArg = undefined;
358
375
  }
359
376
  else {
@@ -363,7 +380,13 @@ Examples:
363
380
  cmd = cmdArg;
364
381
  effectiveDeviceIdArg = deviceIdArg;
365
382
  }
366
- const deviceId = resolveDeviceId(effectiveDeviceIdArg, options.name);
383
+ const deviceId = resolveDeviceId(effectiveDeviceIdArg, options.name, {
384
+ // Mutating command → default require-unique (never silently pick between ambiguous matches).
385
+ strategy: options.nameStrategy ?? 'require-unique',
386
+ type: options.nameType,
387
+ category: options.nameCategory,
388
+ room: options.nameRoom,
389
+ });
367
390
  if (!getCachedDevice(deviceId)) {
368
391
  console.error(`Note: device ${deviceId} is not in the local cache — run 'switchbot devices list' first to enable command validation.`);
369
392
  }
@@ -391,6 +414,37 @@ Examples:
391
414
  }
392
415
  process.exit(2);
393
416
  }
417
+ // Case-only mismatch: emit a warning and continue with the canonical name.
418
+ if (validation.caseNormalizedFrom && validation.normalized) {
419
+ console.error(`Note: '${validation.caseNormalizedFrom}' normalized to '${validation.normalized}' (case mismatch). Use exact casing to silence this warning.`);
420
+ cmd = validation.normalized;
421
+ }
422
+ else if (validation.normalized) {
423
+ cmd = validation.normalized;
424
+ }
425
+ // Raw-parameter validation (runs for known (deviceType, command) pairs only).
426
+ const cachedForParam = getCachedDevice(deviceId);
427
+ if (cachedForParam && options.type === 'command') {
428
+ const paramCheck = validateParameter(cachedForParam.type, cmd, parameter);
429
+ if (!paramCheck.ok) {
430
+ if (isJsonMode()) {
431
+ console.error(JSON.stringify({
432
+ error: {
433
+ code: 2,
434
+ kind: 'usage',
435
+ message: paramCheck.error,
436
+ context: { command: cmd, deviceType: cachedForParam.type, deviceId },
437
+ },
438
+ }));
439
+ }
440
+ else {
441
+ console.error(`Error: ${paramCheck.error}`);
442
+ }
443
+ process.exit(2);
444
+ }
445
+ if (paramCheck.normalized !== undefined)
446
+ parameter = paramCheck.normalized;
447
+ }
394
448
  const cachedForGuard = getCachedDevice(deviceId);
395
449
  if (!options.yes &&
396
450
  !isDryRun() &&
@@ -434,10 +488,19 @@ Examples:
434
488
  }
435
489
  const body = await executeCommand(deviceId, cmd, parsedParam, options.type, undefined, { idempotencyKey: options.idempotencyKey });
436
490
  const isIr = getCachedDevice(deviceId)?.category === 'ir';
491
+ const verification = isIr
492
+ ? {
493
+ verifiable: false,
494
+ reason: 'IR transmission is unidirectional; no receipt acknowledgment is possible.',
495
+ suggestedFollowup: 'Confirm visible change manually or via a paired state sensor.',
496
+ }
497
+ : null;
437
498
  if (isJsonMode()) {
438
499
  const result = { ok: true, command: cmd, deviceId };
439
- if (isIr)
500
+ if (isIr) {
440
501
  result.subKind = 'ir-no-feedback';
502
+ result.verification = verification;
503
+ }
441
504
  if (body && typeof body === 'object' && Object.keys(body).length > 0) {
442
505
  Object.assign(result, body);
443
506
  }
@@ -446,6 +509,7 @@ Examples:
446
509
  }
447
510
  if (isIr) {
448
511
  console.log(`→ IR signal sent: ${cmd} (no feedback — fire-and-forget)`);
512
+ console.error('⚠ IR (unverifiable) — no receipt acknowledgment. Confirm state manually.');
449
513
  }
450
514
  else {
451
515
  console.log(`✓ Command sent: ${cmd}`);
@@ -546,6 +610,10 @@ Examples:
546
610
  .description('Describe a device by ID: metadata + supported commands + status fields (1 API call)')
547
611
  .argument('[deviceId]', 'Target device ID (or use --name)')
548
612
  .option('--name <query>', 'Resolve device by fuzzy name instead of deviceId', stringArg('--name'))
613
+ .option('--name-strategy <s>', 'Name match strategy: exact|prefix|substring|fuzzy|first|require-unique (default: fuzzy)', stringArg('--name-strategy'))
614
+ .option('--name-type <type>', 'Narrow --name by device type', stringArg('--name-type'))
615
+ .option('--name-category <cat>', 'Narrow --name by category: physical|ir', enumArg('--name-category', ['physical', 'ir']))
616
+ .option('--name-room <room>', 'Narrow --name by room name (substring match)', stringArg('--name-room'))
549
617
  .option('--live', 'Also fetch live status values and merge them into capabilities (costs 1 extra API call)')
550
618
  .addHelpText('after', `
551
619
  Makes a GET /v1.1/devices call to look up the device's type, then prints its
@@ -577,7 +645,12 @@ Examples:
577
645
  `)
578
646
  .action(async (deviceIdArg, options) => {
579
647
  try {
580
- const deviceId = resolveDeviceId(deviceIdArg, options.name);
648
+ const deviceId = resolveDeviceId(deviceIdArg, options.name, {
649
+ strategy: options.nameStrategy ?? 'fuzzy',
650
+ type: options.nameType,
651
+ category: options.nameCategory,
652
+ room: options.nameRoom,
653
+ });
581
654
  const result = await describeDevice(deviceId, options);
582
655
  const { device, isPhysical, typeName, controlType, catalog, capabilities, source, suggestedActions: picks } = result;
583
656
  if (isJsonMode()) {
@@ -3,8 +3,9 @@ import os from 'node:os';
3
3
  import path from 'node:path';
4
4
  import { printJson, isJsonMode } from '../utils/output.js';
5
5
  import { getEffectiveCatalog } from '../devices/catalog.js';
6
- import { configFilePath, listProfiles } from '../config.js';
6
+ import { configFilePath, listProfiles, readProfileMeta } from '../config.js';
7
7
  import { describeCache } from '../devices/cache.js';
8
+ export const DOCTOR_SCHEMA_VERSION = 1;
8
9
  async function checkCredentials() {
9
10
  const envOk = Boolean(process.env.SWITCHBOT_TOKEN && process.env.SWITCHBOT_SECRET);
10
11
  if (envOk)
@@ -39,21 +40,97 @@ function checkProfiles() {
39
40
  return { name: 'profiles', status: 'ok', detail: 'no profile dir (default profile only)' };
40
41
  }
41
42
  const profiles = listProfiles();
43
+ if (profiles.length === 0) {
44
+ return { name: 'profiles', status: 'ok', detail: 'profile dir empty' };
45
+ }
46
+ const labelled = profiles.map((p) => {
47
+ const meta = readProfileMeta(p);
48
+ if (meta?.label)
49
+ return `${p} (${meta.label})`;
50
+ return p;
51
+ });
42
52
  return {
43
53
  name: 'profiles',
44
54
  status: 'ok',
45
- detail: profiles.length ? `found ${profiles.length}: ${profiles.join(', ')}` : 'profile dir empty',
55
+ detail: `found ${profiles.length}: ${labelled.join(', ')}`,
46
56
  };
47
57
  }
48
- function checkClockSkew() {
49
- const now = Date.now();
50
- const drift = now - Math.floor(now / 1000) * 1000;
51
- // HMAC signing uses ms timestamps we can't detect remote skew without a
52
- // round-trip, but we can flag if the local clock has NTP issues via the
53
- // classic "jumps back" pattern. Best-effort: just report local time.
54
- const iso = new Date().toISOString();
55
- return { name: 'clock', status: 'ok', detail: `local time ${iso} (drift check needs API round-trip)` };
56
- void drift;
58
+ async function checkClockSkew() {
59
+ // Real probe: HEAD the SwitchBot API endpoint and compare the server's Date
60
+ // header against local time. No auth required for the Date header — the API
61
+ // returns 401 but still stamps the response. Gracefully degrades to
62
+ // probeSource:'none' if offline / no network reachable.
63
+ //
64
+ // Under vitest, only run the probe if fetch has been stubbed (detected via
65
+ // vi.fn marker) otherwise skip network I/O to keep unrelated tests fast.
66
+ const underVitest = Boolean(process.env.VITEST);
67
+ const fetchFn = globalThis.fetch;
68
+ const fetchIsMocked = Boolean(fetchFn && typeof fetchFn === 'function' && 'mock' in fetchFn);
69
+ if (underVitest && !fetchIsMocked) {
70
+ return {
71
+ name: 'clock',
72
+ status: 'warn',
73
+ detail: { probeSource: 'none', skewMs: null, message: 'skipped: test environment' },
74
+ };
75
+ }
76
+ const localBefore = Date.now();
77
+ const ctrl = new AbortController();
78
+ const timer = setTimeout(() => ctrl.abort(), 2500);
79
+ try {
80
+ const res = await fetch('https://api.switch-bot.com/v1.1/devices', {
81
+ method: 'HEAD',
82
+ signal: ctrl.signal,
83
+ });
84
+ const localAfter = Date.now();
85
+ const dateHeader = res.headers.get('date');
86
+ if (!dateHeader) {
87
+ return {
88
+ name: 'clock',
89
+ status: 'warn',
90
+ detail: { probeSource: 'api', skewMs: null, message: 'server returned no Date header' },
91
+ };
92
+ }
93
+ const serverMs = Date.parse(dateHeader);
94
+ if (!Number.isFinite(serverMs)) {
95
+ return {
96
+ name: 'clock',
97
+ status: 'warn',
98
+ detail: { probeSource: 'api', skewMs: null, message: `unparseable Date header: ${dateHeader}` },
99
+ };
100
+ }
101
+ // Split the round-trip in half to estimate the local instant that matches
102
+ // the server's Date header. HTTP Date resolution is 1s, so treat anything
103
+ // under 2000ms as ok, 2000–60000ms as warn, beyond that as fail (HMAC
104
+ // auth rejects requests with skew > 5 minutes anyway).
105
+ const midpoint = (localBefore + localAfter) / 2;
106
+ const skewMs = Math.round(midpoint - serverMs);
107
+ const absSkew = Math.abs(skewMs);
108
+ const status = absSkew < 2000 ? 'ok' : absSkew < 60_000 ? 'warn' : 'fail';
109
+ return {
110
+ name: 'clock',
111
+ status,
112
+ detail: {
113
+ probeSource: 'api',
114
+ skewMs,
115
+ localIso: new Date(midpoint).toISOString(),
116
+ serverIso: new Date(serverMs).toISOString(),
117
+ },
118
+ };
119
+ }
120
+ catch (err) {
121
+ return {
122
+ name: 'clock',
123
+ status: 'warn',
124
+ detail: {
125
+ probeSource: 'none',
126
+ skewMs: null,
127
+ message: `probe failed: ${err instanceof Error ? err.message : String(err)}`,
128
+ },
129
+ };
130
+ }
131
+ finally {
132
+ clearTimeout(timer);
133
+ }
57
134
  }
58
135
  function checkCatalog() {
59
136
  const catalog = getEffectiveCatalog();
@@ -153,7 +230,7 @@ Examples:
153
230
  checkCatalog(),
154
231
  checkCache(),
155
232
  checkQuotaFile(),
156
- checkClockSkew(),
233
+ await checkClockSkew(),
157
234
  checkMqtt(),
158
235
  ];
159
236
  const summary = {
@@ -162,13 +239,27 @@ Examples:
162
239
  fail: checks.filter((c) => c.status === 'fail').length,
163
240
  };
164
241
  const overallFail = summary.fail > 0;
242
+ const overall = overallFail ? 'fail' : summary.warn > 0 ? 'warn' : 'ok';
165
243
  if (isJsonMode()) {
166
- printJson({ overall: overallFail ? 'fail' : summary.warn > 0 ? 'warn' : 'ok', summary, checks });
244
+ // Stable contract (locked as doctor.schemaVersion=1):
245
+ // { ok: boolean, overall: 'ok'|'warn'|'fail', generatedAt, schemaVersion,
246
+ // summary: { ok, warn, fail }, checks: [{ name, status, detail }] }
247
+ // `ok` is an alias of (overall === 'ok') — agents prefer the boolean,
248
+ // humans prefer the string; both are provided.
249
+ printJson({
250
+ ok: overall === 'ok',
251
+ overall,
252
+ generatedAt: new Date().toISOString(),
253
+ schemaVersion: DOCTOR_SCHEMA_VERSION,
254
+ summary,
255
+ checks,
256
+ });
167
257
  }
168
258
  else {
169
259
  for (const c of checks) {
170
260
  const icon = c.status === 'ok' ? '✓' : c.status === 'warn' ? '!' : '✗';
171
- console.log(`${icon} ${c.name.padEnd(12)} ${c.detail}`);
261
+ const detailStr = typeof c.detail === 'string' ? c.detail : JSON.stringify(c.detail);
262
+ console.log(`${icon} ${c.name.padEnd(12)} ${detailStr}`);
172
263
  }
173
264
  console.log('');
174
265
  console.log(`${summary.ok} ok, ${summary.warn} warn, ${summary.fail} fail`);
@@ -1,4 +1,5 @@
1
1
  import http from 'node:http';
2
+ import crypto from 'node:crypto';
2
3
  import { printJson, isJsonMode, handleError, UsageError } from '../utils/output.js';
3
4
  import { intArg, stringArg } from '../utils/arg-parsers.js';
4
5
  import { SwitchBotMqttClient } from '../mqtt/client.js';
@@ -16,6 +17,17 @@ import { deviceHistoryStore } from '../mcp/device-history.js';
16
17
  const DEFAULT_PORT = 3000;
17
18
  const DEFAULT_PATH = '/';
18
19
  const MAX_BODY_BYTES = 1_000_000;
20
+ function extractEventId(parsed) {
21
+ if (!parsed || typeof parsed !== 'object')
22
+ return null;
23
+ const p = parsed;
24
+ if (typeof p.eventId === 'string' && p.eventId.length > 0)
25
+ return p.eventId;
26
+ const ctx = p.context;
27
+ if (ctx && typeof ctx.eventId === 'string' && ctx.eventId.length > 0)
28
+ return ctx.eventId;
29
+ return null;
30
+ }
19
31
  function matchFilter(body, filter) {
20
32
  if (!filter)
21
33
  return true;
@@ -220,7 +232,19 @@ Connects to the SwitchBot MQTT service using your existing credentials
220
232
  No additional MQTT configuration required.
221
233
 
222
234
  Output (JSONL, one event per line):
223
- { "t": "<ISO>", "topic": "<mqtt-topic>", "payload": <parsed JSON or raw string> }
235
+ { "t": "<ISO>", "eventId": "<uuid>", "topic": "<mqtt-topic>", "payload": <parsed JSON or raw string> }
236
+
237
+ Control records (interleaved, no "payload" field — use type-prefix to filter):
238
+ { "type": "__connect", "at": "<ISO>", "eventId": "<uuid>" } first successful connect
239
+ { "type": "__reconnect", "at": "<ISO>", "eventId": "<uuid>" } connect after a disconnect
240
+ { "type": "__disconnect", "at": "<ISO>", "eventId": "<uuid>" } reconnecting or failed
241
+
242
+ Reconnect policy: the MQTT client retries with exponential backoff
243
+ (1s → 30s capped, forever) while the credential is still valid; if the
244
+ credential is rejected or 5 consecutive reconnects fail, state goes to
245
+ 'failed' and the command exits non-zero so supervisors can restart it.
246
+ QoS is 0 (at-most-once); agents requiring at-least-once delivery should
247
+ fan-out via --sink file and deduplicate by eventId on the consumer side.
224
248
 
225
249
  Sink types (--sink, repeatable):
226
250
  stdout Print JSONL to stdout (default when no --sink given)
@@ -321,9 +345,14 @@ Examples:
321
345
  parsed = payload.toString('utf-8');
322
346
  }
323
347
  const t = new Date().toISOString();
348
+ // Every event carries an eventId so downstream sinks / replay tools
349
+ // can dedupe. If the broker supplied one (some providers do via a
350
+ // header), prefer that; otherwise synth a UUID locally.
351
+ const existingId = extractEventId(parsed);
352
+ const eventId = existingId ?? crypto.randomUUID();
324
353
  if (dispatcher) {
325
354
  const { deviceId, deviceType, text } = parseSinkEvent(parsed);
326
- const sinkEvent = { t, topic: msgTopic, deviceId, deviceType, payload: parsed, text };
355
+ const sinkEvent = { t, topic: msgTopic, deviceId, deviceType, payload: parsed, text, eventId };
327
356
  deviceHistoryStore.record(deviceId, msgTopic, deviceType, parsed, t);
328
357
  dispatcher.dispatch(sinkEvent).catch(() => { });
329
358
  }
@@ -331,7 +360,7 @@ Examples:
331
360
  // Default behavior: record history + print to stdout
332
361
  const { deviceId, deviceType } = parseSinkEvent(parsed);
333
362
  deviceHistoryStore.record(deviceId, msgTopic, deviceType, parsed, t);
334
- const record = { t, topic: msgTopic, payload: parsed };
363
+ const record = { t, eventId, topic: msgTopic, payload: parsed };
335
364
  if (isJsonMode()) {
336
365
  printJson(record);
337
366
  }
@@ -345,12 +374,32 @@ Examples:
345
374
  }
346
375
  });
347
376
  let mqttFailed = false;
377
+ let hasConnectedBefore = false;
378
+ const emitControl = (kind) => {
379
+ const ctl = { type: kind, at: new Date().toISOString(), eventId: crypto.randomUUID() };
380
+ // Control events always go to stdout as JSONL so consumers that
381
+ // filter real events by presence of `payload` can skip them.
382
+ if (isJsonMode()) {
383
+ printJson(ctl);
384
+ }
385
+ else {
386
+ console.log(JSON.stringify(ctl));
387
+ }
388
+ };
348
389
  const unsubState = client.onStateChange((state) => {
349
390
  if (!isJsonMode()) {
350
391
  console.error(`[${new Date().toLocaleTimeString()}] MQTT state: ${state}`);
351
392
  }
352
- if (state === 'failed') {
393
+ if (state === 'connected') {
394
+ emitControl(hasConnectedBefore ? '__reconnect' : '__connect');
395
+ hasConnectedBefore = true;
396
+ }
397
+ else if (state === 'reconnecting') {
398
+ emitControl('__disconnect');
399
+ }
400
+ else if (state === 'failed') {
353
401
  mqttFailed = true;
402
+ emitControl('__disconnect');
354
403
  if (!isJsonMode()) {
355
404
  console.error('MQTT connection failed permanently (credential expired or reconnect exhausted) — exiting.');
356
405
  }
@@ -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
- // ---- Mapping tables --------------------------------------------------------
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