@switchbot/openapi-cli 2.3.0 → 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.
@@ -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
  }
@@ -1,9 +1,10 @@
1
1
  import path from 'node:path';
2
2
  import os from 'node:os';
3
3
  import { intArg, stringArg } from '../utils/arg-parsers.js';
4
- import { printJson, isJsonMode, handleError } from '../utils/output.js';
5
- import { readAudit } from '../utils/audit.js';
4
+ import { printJson, isJsonMode, handleError, UsageError } from '../utils/output.js';
5
+ import { readAudit, verifyAudit } from '../utils/audit.js';
6
6
  import { executeCommand } from '../lib/devices.js';
7
+ import { queryDeviceHistory, queryDeviceHistoryStats, } from '../devices/history-query.js';
7
8
  const DEFAULT_AUDIT = path.join(os.homedir(), '.switchbot', 'audit.log');
8
9
  export function registerHistoryCommand(program) {
9
10
  const history = program
@@ -101,4 +102,125 @@ Examples:
101
102
  handleError(err);
102
103
  }
103
104
  });
105
+ history
106
+ .command('range')
107
+ .description('Query time-ranged device history from JSONL storage (populated by events mqtt-tail / MCP)')
108
+ .argument('<deviceId>', 'Device ID to query')
109
+ .option('--since <duration>', 'Relative window ending now, e.g. "30s", "15m", "1h", "7d" (mutually exclusive with --from/--to)', stringArg('--since'))
110
+ .option('--from <iso>', 'Range start (ISO-8601)', stringArg('--from'))
111
+ .option('--to <iso>', 'Range end (ISO-8601)', stringArg('--to'))
112
+ .option('--field <name>', 'Project a payload field (repeat to keep multiple)', (v, acc = []) => acc.concat(v), [])
113
+ .option('--limit <n>', 'Maximum records to return (default 1000)', intArg('--limit', { min: 1 }))
114
+ .addHelpText('after', `
115
+ History is the append-only JSONL mirror of the per-device ring buffer: every
116
+ 'events mqtt-tail' event and every MCP tool status-refresh is written to
117
+ ~/.switchbot/device-history/<deviceId>.jsonl (rotates at 50MB × 3 files).
118
+
119
+ Examples:
120
+ $ switchbot history range <id> --since 7d --json
121
+ $ switchbot history range <id> --since 1h --field temperature --field humidity
122
+ $ switchbot history range <id> --from 2026-04-18T00:00:00Z --to 2026-04-19T00:00:00Z
123
+ `)
124
+ .action(async (deviceId, options) => {
125
+ // Usage-level validation: keep synchronous and pre-query so handleError
126
+ // maps these to exit 2 (via UsageError) rather than runtime exit 1.
127
+ if (options.since && (options.from || options.to)) {
128
+ handleError(new UsageError('--since is mutually exclusive with --from/--to.'));
129
+ }
130
+ try {
131
+ const records = await queryDeviceHistory(deviceId, {
132
+ since: options.since,
133
+ from: options.from,
134
+ to: options.to,
135
+ fields: options.field ?? [],
136
+ limit: options.limit !== undefined ? Number(options.limit) : undefined,
137
+ });
138
+ if (isJsonMode()) {
139
+ printJson({ deviceId, count: records.length, records });
140
+ return;
141
+ }
142
+ if (records.length === 0) {
143
+ console.log(`(no history records for ${deviceId} in requested range)`);
144
+ return;
145
+ }
146
+ for (const r of records) {
147
+ const payloadStr = JSON.stringify(r.payload);
148
+ console.log(`${r.t} ${r.topic} ${payloadStr}`);
149
+ }
150
+ }
151
+ catch (err) {
152
+ // Convert history-query's plain Error range messages into UsageError so
153
+ // they exit 2 instead of 1.
154
+ if (err instanceof Error && /^(Invalid --|--from|--since)/i.test(err.message)) {
155
+ handleError(new UsageError(err.message));
156
+ }
157
+ handleError(err);
158
+ }
159
+ });
160
+ history
161
+ .command('stats')
162
+ .description('Show on-disk size + record counts for a device history')
163
+ .argument('<deviceId>', 'Device ID to inspect')
164
+ .action((deviceId) => {
165
+ try {
166
+ const stats = queryDeviceHistoryStats(deviceId);
167
+ if (isJsonMode()) {
168
+ printJson(stats);
169
+ return;
170
+ }
171
+ console.log(`Device: ${stats.deviceId}`);
172
+ console.log(`History dir: ${stats.historyDir}`);
173
+ console.log(`JSONL files: ${stats.fileCount} (${stats.jsonlFiles.join(', ') || '—'})`);
174
+ console.log(`Total size: ${stats.totalBytes.toLocaleString()} bytes`);
175
+ console.log(`Record count: ${stats.recordCount}`);
176
+ console.log(`Oldest: ${stats.oldest ?? '—'}`);
177
+ console.log(`Newest: ${stats.newest ?? '—'}`);
178
+ }
179
+ catch (err) {
180
+ handleError(err);
181
+ }
182
+ });
183
+ history
184
+ .command('verify')
185
+ .description('Check the audit log for malformed lines and schema-version drift')
186
+ .option('--file <path>', `Path to the audit log (default ${DEFAULT_AUDIT})`, stringArg('--file'))
187
+ .addHelpText('after', `
188
+ See docs/audit-log.md for the audit log format. Exit code:
189
+ 0 every line parses and carries the current auditVersion
190
+ 1 one or more lines are malformed OR the file is missing
191
+ 2 (usage) — not emitted by this subcommand
192
+
193
+ Examples:
194
+ $ switchbot history verify
195
+ $ switchbot history verify --file ./custom.log --json
196
+ `)
197
+ .action((options) => {
198
+ const file = options.file ?? DEFAULT_AUDIT;
199
+ const report = verifyAudit(file);
200
+ if (isJsonMode()) {
201
+ printJson(report);
202
+ }
203
+ else {
204
+ console.log(`Audit log: ${report.file}`);
205
+ console.log(`Parsed lines: ${report.parsedLines} / ${report.totalLines}`);
206
+ console.log(`Malformed: ${report.malformedLines}`);
207
+ console.log(`Unversioned: ${report.unversionedEntries}`);
208
+ const versions = Object.entries(report.versionCounts)
209
+ .map(([v, n]) => `${v}:${n}`)
210
+ .join(', ');
211
+ console.log(`Version counts: ${versions || '—'}`);
212
+ if (report.earliest)
213
+ console.log(`Earliest: ${report.earliest}`);
214
+ if (report.latest)
215
+ console.log(`Latest: ${report.latest}`);
216
+ if (report.problems.length > 0) {
217
+ console.log('\nProblems:');
218
+ for (const p of report.problems) {
219
+ console.log(` line ${p.line}: ${p.reason}${p.preview ? ` — "${p.preview}"` : ''}`);
220
+ }
221
+ }
222
+ }
223
+ const ok = report.malformedLines === 0 && report.problems.length === 0;
224
+ process.exit(ok ? 0 : 1);
225
+ });
104
226
  }
@@ -10,6 +10,7 @@ import { findCatalogEntry } from '../devices/catalog.js';
10
10
  import { getCachedDevice } from '../devices/cache.js';
11
11
  import { EventSubscriptionManager } from '../mcp/events-subscription.js';
12
12
  import { deviceHistoryStore } from '../mcp/device-history.js';
13
+ import { queryDeviceHistory } from '../devices/history-query.js';
13
14
  import { todayUsage } from '../utils/quota.js';
14
15
  import { describeCache } from '../devices/cache.js';
15
16
  import { withRequestContext } from '../lib/request-context.js';
@@ -146,6 +147,47 @@ API docs: https://github.com/OpenWonderLabs/SwitchBotAPI`,
146
147
  structuredContent: result,
147
148
  };
148
149
  });
150
+ // ---- query_device_history --------------------------------------------------
151
+ server.registerTool('query_device_history', {
152
+ title: 'Query time-ranged device history',
153
+ description: 'Return records from the append-only JSONL history (~/.switchbot/device-history/<deviceId>.jsonl) ' +
154
+ 'filtered by a relative duration (since) or absolute ISO-8601 range (from/to). ' +
155
+ 'No API call — zero quota cost. Use for trend questions like "how many times did this switch turn on last week".',
156
+ inputSchema: {
157
+ deviceId: z.string().describe('Device ID to query'),
158
+ since: z.string().optional().describe('Relative window ending now, e.g. "30s", "15m", "1h", "7d". Mutually exclusive with from/to.'),
159
+ from: z.string().optional().describe('Range start (ISO-8601).'),
160
+ to: z.string().optional().describe('Range end (ISO-8601).'),
161
+ fields: z.array(z.string()).optional().describe('Project these payload fields; omit for the full payload.'),
162
+ limit: z.number().int().min(1).max(10000).optional().describe('Max records to return (default 1000).'),
163
+ },
164
+ outputSchema: {
165
+ deviceId: z.string(),
166
+ count: z.number().int(),
167
+ records: z.array(z.object({
168
+ t: z.string(),
169
+ topic: z.string(),
170
+ deviceType: z.string().optional(),
171
+ payload: z.unknown(),
172
+ })),
173
+ },
174
+ }, async ({ deviceId, since, from, to, fields, limit }) => {
175
+ if (since && (from || to)) {
176
+ return mcpError('usage', 2, '--since is mutually exclusive with --from/--to.');
177
+ }
178
+ try {
179
+ const records = await queryDeviceHistory(deviceId, { since, from, to, fields, limit });
180
+ const result = { deviceId, count: records.length, records };
181
+ return {
182
+ content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
183
+ structuredContent: result,
184
+ };
185
+ }
186
+ catch (err) {
187
+ const msg = err instanceof Error ? err.message : 'history query failed';
188
+ return mcpError('usage', 2, msg);
189
+ }
190
+ });
149
191
  // ---- send_command ---------------------------------------------------------
150
192
  server.registerTool('send_command', {
151
193
  title: 'Send a control command to a device',
@@ -167,14 +209,26 @@ API docs: https://github.com/OpenWonderLabs/SwitchBotAPI`,
167
209
  .optional()
168
210
  .default(false)
169
211
  .describe('Required true for destructive commands (unlock, garage open, createKey, ...)'),
212
+ idempotencyKey: z
213
+ .string()
214
+ .optional()
215
+ .describe('Deduplication key — repeat calls with the same key within 60s replay the first result (adds replayed:true). Same key + different (command, parameter) within 60s returns an idempotency_conflict guard error.'),
170
216
  },
171
217
  outputSchema: {
172
218
  ok: z.literal(true),
173
219
  command: z.string(),
174
220
  deviceId: z.string(),
175
221
  result: z.unknown().describe('API response body from SwitchBot'),
222
+ verification: z
223
+ .object({
224
+ verifiable: z.boolean(),
225
+ reason: z.string(),
226
+ suggestedFollowup: z.string(),
227
+ })
228
+ .optional()
229
+ .describe('Present when the target is an IR device. IR is unidirectional — agents should treat the success as "signal sent" not "state changed".'),
176
230
  },
177
- }, async ({ deviceId, command, parameter, commandType, confirm }) => {
231
+ }, async ({ deviceId, command, parameter, commandType, confirm, idempotencyKey }) => {
178
232
  const effectiveType = commandType ?? 'command';
179
233
  // Resolve the device's catalog type via cache or a fresh lookup so we
180
234
  // can evaluate destructive/validation without an extra round-trip if
@@ -217,8 +271,33 @@ API docs: https://github.com/OpenWonderLabs/SwitchBotAPI`,
217
271
  if (!validation.ok) {
218
272
  return mcpError('usage', 2, validation.error.message, { hint: validation.error.hint, context: { validationKind: validation.error.kind } });
219
273
  }
220
- const result = await executeCommand(deviceId, command, parameter, effectiveType);
274
+ let result;
275
+ try {
276
+ result = await executeCommand(deviceId, command, parameter, effectiveType, undefined, {
277
+ idempotencyKey,
278
+ });
279
+ }
280
+ catch (err) {
281
+ if (err instanceof Error && err.name === 'IdempotencyConflictError') {
282
+ return mcpError('guard', 2, err.message, {
283
+ hint: 'Use a fresh idempotencyKey, or wait for the prior key to expire (60s TTL).',
284
+ context: {
285
+ existingShape: err.existingShape,
286
+ newShape: err.newShape,
287
+ },
288
+ });
289
+ }
290
+ throw err;
291
+ }
292
+ const isIr = getCachedDevice(deviceId)?.category === 'ir';
221
293
  const structured = { ok: true, command, deviceId, result };
294
+ if (isIr) {
295
+ structured.verification = {
296
+ verifiable: false,
297
+ reason: 'IR transmission is unidirectional; no receipt acknowledgment is possible.',
298
+ suggestedFollowup: 'Confirm visible change manually or via a paired state sensor.',
299
+ };
300
+ }
222
301
  return {
223
302
  content: [{ type: 'text', text: JSON.stringify(structured, null, 2) }],
224
303
  structuredContent: structured,
@@ -11,17 +11,19 @@ is a best-effort mirror of the SwitchBot 10,000/day limit — it does not
11
11
  include requests made outside this CLI (mobile app, other scripts).
12
12
 
13
13
  Subcommands:
14
- status Show today's usage and the last 7 days
14
+ status Show today's usage and the last 7 days (alias: show)
15
15
  reset Delete the local counter file
16
16
 
17
17
  Examples:
18
18
  $ switchbot quota status
19
+ $ switchbot quota show # alias of 'status'
19
20
  $ switchbot quota status --json
20
21
  $ switchbot quota reset
21
22
  `);
22
23
  quota
23
24
  .command('status')
24
- .description("Show today's usage and the last 7 days")
25
+ .alias('show')
26
+ .description("Show today's usage and the last 7 days (alias: show)")
25
27
  .action(() => {
26
28
  const usage = todayUsage();
27
29
  const history = loadQuota();