@switchbot/openapi-cli 2.1.0 → 2.2.1

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.
@@ -1,8 +1,18 @@
1
1
  import http from 'node:http';
2
2
  import { printJson, isJsonMode, handleError, UsageError } from '../utils/output.js';
3
+ import { intArg, stringArg } from '../utils/arg-parsers.js';
3
4
  import { SwitchBotMqttClient } from '../mqtt/client.js';
4
5
  import { fetchMqttCredential } from '../mqtt/credential.js';
5
6
  import { tryLoadConfig } from '../config.js';
7
+ import { SinkDispatcher } from '../sinks/dispatcher.js';
8
+ import { StdoutSink } from '../sinks/stdout.js';
9
+ import { FileSink } from '../sinks/file.js';
10
+ import { WebhookSink } from '../sinks/webhook.js';
11
+ import { OpenClawSink } from '../sinks/openclaw.js';
12
+ import { TelegramSink } from '../sinks/telegram.js';
13
+ import { HomeAssistantSink } from '../sinks/homeassistant.js';
14
+ import { parseSinkEvent } from '../sinks/format.js';
15
+ import { deviceHistoryStore } from '../mcp/device-history.js';
6
16
  const DEFAULT_PORT = 3000;
7
17
  const DEFAULT_PATH = '/';
8
18
  const MAX_BODY_BYTES = 1_000_000;
@@ -109,10 +119,10 @@ export function registerEventsCommand(program) {
109
119
  events
110
120
  .command('tail')
111
121
  .description('Run a local HTTP receiver and print incoming webhook events as JSONL')
112
- .option('--port <n>', `Local port to listen on (default ${DEFAULT_PORT})`, String(DEFAULT_PORT))
113
- .option('--path <p>', `HTTP path to match (default "${DEFAULT_PATH}"; use "*" for all paths)`, DEFAULT_PATH)
114
- .option('--filter <expr>', 'Filter events, e.g. "deviceId=ABC123" or "type=Bot" (comma-separated)')
115
- .option('--max <n>', 'Stop after N matching events (default: run until Ctrl-C)')
122
+ .option('--port <n>', `Local port to listen on (default ${DEFAULT_PORT})`, intArg('--port', { min: 1, max: 65535 }), String(DEFAULT_PORT))
123
+ .option('--path <p>', `HTTP path to match (default "${DEFAULT_PATH}"; use "*" for all paths)`, stringArg('--path'), DEFAULT_PATH)
124
+ .option('--filter <expr>', 'Filter events, e.g. "deviceId=ABC123" or "type=Bot" (comma-separated)', stringArg('--filter'))
125
+ .option('--max <n>', 'Stop after N matching events (default: run until Ctrl-C)', intArg('--max', { min: 1 }))
116
126
  .addHelpText('after', `
117
127
  SwitchBot posts events to a single webhook URL configured via:
118
128
  $ switchbot webhook setup https://<your-public-host>/<path>
@@ -190,8 +200,20 @@ Examples:
190
200
  events
191
201
  .command('mqtt-tail')
192
202
  .description('Subscribe to SwitchBot MQTT shadow events and stream them as JSONL')
193
- .option('--topic <pattern>', 'MQTT topic filter (default: SwitchBot shadow topic from credential)')
194
- .option('--max <n>', 'Stop after N events (default: run until Ctrl-C)')
203
+ .option('--topic <pattern>', 'MQTT topic filter (default: SwitchBot shadow topic from credential)', stringArg('--topic'))
204
+ .option('--max <n>', 'Stop after N events (default: run until Ctrl-C)', intArg('--max', { min: 1 }))
205
+ .option('--sink <type>', 'Output sink: stdout (default), file, webhook, openclaw, telegram, homeassistant (repeatable)', (val, prev) => [...prev, val], [])
206
+ .option('--sink-file <path>', 'File path for file sink', stringArg('--sink-file'))
207
+ .option('--webhook-url <url>', 'Webhook URL for webhook sink', stringArg('--webhook-url'))
208
+ .option('--openclaw-url <url>', 'OpenClaw gateway URL (default: http://localhost:18789)', stringArg('--openclaw-url'))
209
+ .option('--openclaw-token <token>', 'Bearer token for OpenClaw (or env OPENCLAW_TOKEN)', stringArg('--openclaw-token'))
210
+ .option('--openclaw-model <id>', 'OpenClaw agent model ID to route events to', stringArg('--openclaw-model'))
211
+ .option('--telegram-token <token>', 'Telegram bot token (or env TELEGRAM_TOKEN)', stringArg('--telegram-token'))
212
+ .option('--telegram-chat <id>', 'Telegram chat/channel ID to send messages to', stringArg('--telegram-chat'))
213
+ .option('--ha-url <url>', 'Home Assistant base URL (e.g. http://homeassistant.local:8123)', stringArg('--ha-url'))
214
+ .option('--ha-token <token>', 'HA long-lived access token (for REST event API)', stringArg('--ha-token'))
215
+ .option('--ha-webhook-id <id>', 'HA webhook ID (no auth; takes priority over --ha-token)', stringArg('--ha-webhook-id'))
216
+ .option('--ha-event-type <type>', 'HA event type for REST API (default: switchbot_event)', stringArg('--ha-event-type'))
195
217
  .addHelpText('after', `
196
218
  Connects to the SwitchBot MQTT service using your existing credentials
197
219
  (SWITCHBOT_TOKEN + SWITCHBOT_SECRET or ~/.switchbot/config.json).
@@ -200,10 +222,25 @@ No additional MQTT configuration required.
200
222
  Output (JSONL, one event per line):
201
223
  { "t": "<ISO>", "topic": "<mqtt-topic>", "payload": <parsed JSON or raw string> }
202
224
 
225
+ Sink types (--sink, repeatable):
226
+ stdout Print JSONL to stdout (default when no --sink given)
227
+ file Append JSONL to --sink-file <path>
228
+ webhook HTTP POST to --webhook-url <url>
229
+ openclaw POST to OpenClaw via --openclaw-url / --openclaw-token / --openclaw-model
230
+ telegram Send to Telegram via --telegram-token / --telegram-chat
231
+ homeassistant POST to HA via --ha-url + --ha-webhook-id (or --ha-token)
232
+
233
+ Device state is also persisted to ~/.switchbot/device-history/<deviceId>.json
234
+ regardless of sink configuration.
235
+
203
236
  Examples:
204
237
  $ switchbot events mqtt-tail
205
- $ switchbot events mqtt-tail --topic 'switchbot/#'
206
238
  $ switchbot events mqtt-tail --max 10 --json
239
+ $ switchbot events mqtt-tail --sink file --sink-file ~/.switchbot/events.jsonl
240
+ $ switchbot events mqtt-tail --sink openclaw --openclaw-token abc --openclaw-model home-agent
241
+ $ switchbot events mqtt-tail --sink telegram --telegram-token <token> --telegram-chat <chatId>
242
+ $ switchbot events mqtt-tail --sink homeassistant --ha-url http://ha.local:8123 --ha-webhook-id switchbot
243
+ $ switchbot events mqtt-tail --sink stdout --sink openclaw --openclaw-token abc --openclaw-model home
207
244
  `)
208
245
  .action(async (options) => {
209
246
  try {
@@ -211,20 +248,70 @@ Examples:
211
248
  if (maxEvents !== null && (!Number.isInteger(maxEvents) || maxEvents < 1)) {
212
249
  throw new UsageError(`Invalid --max "${options.max}". Must be a positive integer.`);
213
250
  }
214
- let creds;
215
251
  const loaded = tryLoadConfig();
216
252
  if (!loaded) {
217
253
  throw new UsageError('No credentials found. Run \'switchbot config set-token\' or set SWITCHBOT_TOKEN and SWITCHBOT_SECRET.');
218
254
  }
219
- creds = loaded;
255
+ const sinkTypes = options.sink;
256
+ let dispatcher = null;
257
+ if (sinkTypes.length > 0) {
258
+ const sinks = sinkTypes.map((type) => {
259
+ switch (type) {
260
+ case 'stdout':
261
+ return new StdoutSink();
262
+ case 'file': {
263
+ if (!options.sinkFile)
264
+ throw new UsageError('--sink file requires --sink-file <path>');
265
+ return new FileSink(options.sinkFile);
266
+ }
267
+ case 'webhook': {
268
+ if (!options.webhookUrl)
269
+ throw new UsageError('--sink webhook requires --webhook-url <url>');
270
+ return new WebhookSink(options.webhookUrl);
271
+ }
272
+ case 'openclaw': {
273
+ const token = options.openclawToken ?? process.env.OPENCLAW_TOKEN;
274
+ if (!token)
275
+ throw new UsageError('--sink openclaw requires --openclaw-token or env OPENCLAW_TOKEN');
276
+ if (!options.openclawModel)
277
+ throw new UsageError('--sink openclaw requires --openclaw-model <id>');
278
+ return new OpenClawSink({ url: options.openclawUrl, token, model: options.openclawModel });
279
+ }
280
+ case 'telegram': {
281
+ const token = options.telegramToken ?? process.env.TELEGRAM_TOKEN;
282
+ if (!token)
283
+ throw new UsageError('--sink telegram requires --telegram-token or env TELEGRAM_TOKEN');
284
+ if (!options.telegramChat)
285
+ throw new UsageError('--sink telegram requires --telegram-chat <id>');
286
+ return new TelegramSink({ token, chatId: options.telegramChat });
287
+ }
288
+ case 'homeassistant': {
289
+ if (!options.haUrl)
290
+ throw new UsageError('--sink homeassistant requires --ha-url <url>');
291
+ if (!options.haWebhookId && !options.haToken) {
292
+ throw new UsageError('--sink homeassistant requires --ha-webhook-id or --ha-token');
293
+ }
294
+ return new HomeAssistantSink({
295
+ url: options.haUrl,
296
+ token: options.haToken,
297
+ webhookId: options.haWebhookId,
298
+ eventType: options.haEventType,
299
+ });
300
+ }
301
+ default:
302
+ throw new UsageError(`Unknown --sink type "${type}". Supported: stdout, file, webhook, openclaw, telegram, homeassistant`);
303
+ }
304
+ });
305
+ dispatcher = new SinkDispatcher(sinks);
306
+ }
220
307
  if (!isJsonMode()) {
221
308
  console.error('Fetching MQTT credentials from SwitchBot service…');
222
309
  }
223
- const credential = await fetchMqttCredential(creds.token, creds.secret);
310
+ const credential = await fetchMqttCredential(loaded.token, loaded.secret);
224
311
  const topic = options.topic ?? credential.topics.status;
225
312
  let eventCount = 0;
226
313
  const ac = new AbortController();
227
- const client = new SwitchBotMqttClient(credential, () => fetchMqttCredential(creds.token, creds.secret));
314
+ const client = new SwitchBotMqttClient(credential, () => fetchMqttCredential(loaded.token, loaded.secret));
228
315
  const unsub = client.onMessage((msgTopic, payload) => {
229
316
  let parsed;
230
317
  try {
@@ -233,18 +320,43 @@ Examples:
233
320
  catch {
234
321
  parsed = payload.toString('utf-8');
235
322
  }
236
- const record = { t: new Date().toISOString(), topic: msgTopic, payload: parsed };
237
- if (isJsonMode()) {
238
- printJson(record);
323
+ const t = new Date().toISOString();
324
+ if (dispatcher) {
325
+ const { deviceId, deviceType, text } = parseSinkEvent(parsed);
326
+ const sinkEvent = { t, topic: msgTopic, deviceId, deviceType, payload: parsed, text };
327
+ deviceHistoryStore.record(deviceId, msgTopic, deviceType, parsed, t);
328
+ dispatcher.dispatch(sinkEvent).catch(() => { });
239
329
  }
240
330
  else {
241
- console.log(JSON.stringify(record));
331
+ // Default behavior: record history + print to stdout
332
+ const { deviceId, deviceType } = parseSinkEvent(parsed);
333
+ deviceHistoryStore.record(deviceId, msgTopic, deviceType, parsed, t);
334
+ const record = { t, topic: msgTopic, payload: parsed };
335
+ if (isJsonMode()) {
336
+ printJson(record);
337
+ }
338
+ else {
339
+ console.log(JSON.stringify(record));
340
+ }
242
341
  }
243
342
  eventCount++;
244
343
  if (maxEvents !== null && eventCount >= maxEvents) {
245
344
  ac.abort();
246
345
  }
247
346
  });
347
+ let mqttFailed = false;
348
+ const unsubState = client.onStateChange((state) => {
349
+ if (!isJsonMode()) {
350
+ console.error(`[${new Date().toLocaleTimeString()}] MQTT state: ${state}`);
351
+ }
352
+ if (state === 'failed') {
353
+ mqttFailed = true;
354
+ if (!isJsonMode()) {
355
+ console.error('MQTT connection failed permanently (credential expired or reconnect exhausted) — exiting.');
356
+ }
357
+ ac.abort();
358
+ }
359
+ });
248
360
  await client.connect();
249
361
  client.subscribe(topic);
250
362
  if (!isJsonMode()) {
@@ -255,12 +367,18 @@ Examples:
255
367
  process.removeListener('SIGINT', cleanup);
256
368
  process.removeListener('SIGTERM', cleanup);
257
369
  unsub();
370
+ unsubState();
371
+ dispatcher?.close().catch(() => { });
258
372
  client.disconnect().then(resolve).catch(resolve);
259
373
  };
260
374
  process.once('SIGINT', cleanup);
261
375
  process.once('SIGTERM', cleanup);
262
376
  ac.signal.addEventListener('abort', cleanup, { once: true });
263
377
  });
378
+ if (mqttFailed) {
379
+ // Surface as a runtime error so supervisors (pm2, systemd) can restart.
380
+ process.exit(1);
381
+ }
264
382
  }
265
383
  catch (error) {
266
384
  handleError(error);
@@ -1,7 +1,9 @@
1
+ import { intArg, stringArg } from '../utils/arg-parsers.js';
1
2
  import { handleError, isJsonMode, printJson, UsageError } from '../utils/output.js';
2
3
  import { getCachedDevice } from '../devices/cache.js';
3
4
  import { executeCommand, isDestructiveCommand, getDestructiveReason } from '../lib/devices.js';
4
5
  import { isDryRun } from '../utils/flags.js';
6
+ import { resolveDeviceId } from '../utils/name-resolver.js';
5
7
  import { DryRunSignal } from '../api/client.js';
6
8
  // ---- Mapping tables --------------------------------------------------------
7
9
  const AC_MODE_MAP = { auto: 1, cool: 2, dry: 3, fan: 4, heat: 5 };
@@ -85,16 +87,17 @@ export function registerExpandCommand(devices) {
85
87
  devices
86
88
  .command('expand')
87
89
  .description('Send a command with semantic flags instead of raw positional parameters')
88
- .argument('<deviceId>', 'Target device ID from "devices list"')
89
- .argument('<command>', 'Command name: setAll (AC), setPosition (Curtain/Blind Tilt), setMode (Relay Switch 2)')
90
- .option('--temp <celsius>', 'AC setAll: temperature in Celsius (16-30)')
91
- .option('--mode <mode>', 'AC: auto|cool|dry|fan|heat Curtain: default|performance|silent Relay: toggle|edge|detached|momentary')
92
- .option('--fan <speed>', 'AC setAll: fan speed auto|low|mid|high')
93
- .option('--power <state>', 'AC setAll: on|off')
94
- .option('--position <percent>', 'Curtain setPosition: 0-100 (0=open, 100=closed)')
95
- .option('--direction <dir>', 'Blind Tilt setPosition: up|down')
96
- .option('--angle <percent>', 'Blind Tilt setPosition: 0-100 (0=closed, 100=open)')
97
- .option('--channel <n>', 'Relay Switch 2 setMode: channel 1 or 2')
90
+ .argument('[deviceId]', 'Target device ID from "devices list" (or use --name)')
91
+ .argument('[command]', 'Command name: setAll (AC), setPosition (Curtain/Blind Tilt), setMode (Relay Switch 2)')
92
+ .option('--name <query>', 'Resolve device by fuzzy name instead of deviceId', stringArg('--name'))
93
+ .option('--temp <celsius>', 'AC setAll: temperature in Celsius (16-30)', intArg('--temp', { min: 16, max: 30 }))
94
+ .option('--mode <mode>', 'AC: auto|cool|dry|fan|heat Curtain: default|performance|silent Relay: toggle|edge|detached|momentary', stringArg('--mode'))
95
+ .option('--fan <speed>', 'AC setAll: fan speed auto|low|mid|high', stringArg('--fan'))
96
+ .option('--power <state>', 'AC setAll: on|off', stringArg('--power'))
97
+ .option('--position <percent>', 'Curtain setPosition: 0-100 (0=open, 100=closed)', intArg('--position', { min: 0, max: 100 }))
98
+ .option('--direction <dir>', 'Blind Tilt setPosition: up|down', stringArg('--direction'))
99
+ .option('--angle <percent>', 'Blind Tilt setPosition: 0-100 (0=closed, 100=open)', intArg('--angle', { min: 0, max: 100 }))
100
+ .option('--channel <n>', 'Relay Switch 2 setMode: channel 1 or 2', intArg('--channel', { min: 1, max: 2 }))
98
101
  .option('--yes', 'Confirm destructive commands')
99
102
  .addHelpText('after', `
100
103
  Translates semantic flags into the wire parameter format, then sends the command.
@@ -123,9 +126,24 @@ Examples:
123
126
  $ switchbot devices expand <blindId> setPosition --direction up --angle 50
124
127
  $ switchbot devices expand <relayId> setMode --channel 1 --mode edge
125
128
  $ switchbot devices expand <acId> setAll --temp 22 --mode heat --fan auto --power on --dry-run
129
+ $ switchbot devices expand --name "客厅空调" setAll --temp 26 --mode cool --fan low --power on
126
130
  `)
127
- .action(async (deviceId, command, options) => {
131
+ .action(async (deviceIdArg, commandArg, options) => {
132
+ let deviceId = '';
133
+ let command = '';
128
134
  try {
135
+ // When --name is provided, Commander assigns the first positional to deviceIdArg
136
+ // and leaves commandArg undefined. Detect and shift.
137
+ let effectiveDeviceIdArg = deviceIdArg;
138
+ let effectiveCommand = commandArg;
139
+ if (options.name && deviceIdArg && !commandArg) {
140
+ effectiveCommand = deviceIdArg;
141
+ effectiveDeviceIdArg = undefined;
142
+ }
143
+ deviceId = resolveDeviceId(effectiveDeviceIdArg, options.name);
144
+ if (!effectiveCommand)
145
+ throw new UsageError('A command argument is required (setAll, setPosition, setMode).');
146
+ command = effectiveCommand;
129
147
  const cached = getCachedDevice(deviceId);
130
148
  const deviceType = cached?.type ?? '';
131
149
  let parameter;
@@ -1,5 +1,6 @@
1
1
  import path from 'node:path';
2
2
  import os from 'node:os';
3
+ import { intArg, stringArg } from '../utils/arg-parsers.js';
3
4
  import { printJson, isJsonMode, handleError } from '../utils/output.js';
4
5
  import { readAudit } from '../utils/audit.js';
5
6
  import { executeCommand } from '../lib/devices.js';
@@ -21,8 +22,8 @@ Examples:
21
22
  history
22
23
  .command('show')
23
24
  .description('Print recent audit entries')
24
- .option('--file <path>', `Path to the audit log (default ${DEFAULT_AUDIT})`)
25
- .option('--limit <n>', 'Show only the last N entries')
25
+ .option('--file <path>', `Path to the audit log (default ${DEFAULT_AUDIT})`, stringArg('--file'))
26
+ .option('--limit <n>', 'Show only the last N entries', intArg('--limit', { min: 1 }))
26
27
  .action((options) => {
27
28
  const file = options.file ?? DEFAULT_AUDIT;
28
29
  const entries = readAudit(file);
@@ -52,7 +53,7 @@ Examples:
52
53
  .command('replay')
53
54
  .description('Re-run a recorded command by its 1-indexed position')
54
55
  .argument('<index>', 'Entry index (1 = oldest; as shown by "history show")')
55
- .option('--file <path>', `Path to the audit log (default ${DEFAULT_AUDIT})`)
56
+ .option('--file <path>', `Path to the audit log (default ${DEFAULT_AUDIT})`, stringArg('--file'))
56
57
  .addHelpText('after', `
57
58
  Dry-run-honouring: pass --dry-run on the parent command to preview without
58
59
  sending the actual call. Errors from the recorded entry are NOT replayed —
@@ -2,12 +2,14 @@ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
2
2
  import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
3
3
  import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
4
4
  import { z } from 'zod';
5
+ import { intArg, stringArg } from '../utils/arg-parsers.js';
5
6
  import { handleError, isJsonMode } from '../utils/output.js';
6
7
  import { fetchDeviceList, fetchDeviceStatus, executeCommand, describeDevice, validateCommand, isDestructiveCommand, getDestructiveReason, searchCatalog, DeviceNotFoundError, toMcpDescribeShape, toMcpDeviceListShape, toMcpIrDeviceShape, } from '../lib/devices.js';
7
8
  import { fetchScenes, executeScene } from '../lib/scenes.js';
8
9
  import { findCatalogEntry } from '../devices/catalog.js';
9
10
  import { getCachedDevice } from '../devices/cache.js';
10
11
  import { EventSubscriptionManager } from '../mcp/events-subscription.js';
12
+ import { deviceHistoryStore } from '../mcp/device-history.js';
11
13
  import { todayUsage } from '../utils/quota.js';
12
14
  import { describeCache } from '../devices/cache.js';
13
15
  import { withRequestContext } from '../lib/request-context.js';
@@ -110,6 +112,40 @@ API docs: https://github.com/OpenWonderLabs/SwitchBotAPI`,
110
112
  structuredContent: { status: body },
111
113
  };
112
114
  });
115
+ // ---- get_device_history ----------------------------------------------------
116
+ server.registerTool('get_device_history', {
117
+ title: 'Get locally-persisted device state history',
118
+ description: 'Return device state history recorded from MQTT events (persisted to ~/.switchbot/device-history/). ' +
119
+ 'No API call — zero quota cost. Use when you need recent historical readings or want to avoid a live API call. ' +
120
+ 'Omit deviceId to list all devices with stored history.',
121
+ inputSchema: {
122
+ deviceId: z.string().optional().describe('Device MAC address (deviceId). Omit to list all devices with history.'),
123
+ limit: z.number().int().min(1).max(100).optional().describe('Max history entries to return (default 20, max 100)'),
124
+ },
125
+ outputSchema: {
126
+ deviceId: z.string().optional(),
127
+ latest: z.unknown().optional(),
128
+ history: z.array(z.unknown()).optional(),
129
+ devices: z.array(z.object({ deviceId: z.string(), latest: z.unknown() })).optional(),
130
+ },
131
+ }, async ({ deviceId, limit }) => {
132
+ if (deviceId) {
133
+ const latest = deviceHistoryStore.getLatest(deviceId);
134
+ const history = deviceHistoryStore.getHistory(deviceId, limit ?? 20);
135
+ const result = { deviceId, latest, history };
136
+ return {
137
+ content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
138
+ structuredContent: result,
139
+ };
140
+ }
141
+ const ids = deviceHistoryStore.listDevices();
142
+ const devices = ids.map((id) => ({ deviceId: id, latest: deviceHistoryStore.getLatest(id) }));
143
+ const result = { devices };
144
+ return {
145
+ content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
146
+ structuredContent: result,
147
+ };
148
+ });
113
149
  // ---- send_command ---------------------------------------------------------
114
150
  server.registerTool('send_command', {
115
151
  title: 'Send a control command to a device',
@@ -454,11 +490,11 @@ Inspect locally:
454
490
  mcp
455
491
  .command('serve')
456
492
  .description('Start the MCP server on stdio (default) or HTTP (--port)')
457
- .option('--port <n>', 'Listen on HTTP instead of stdio (Streamable HTTP transport)')
458
- .option('--bind <host>', 'IP address to bind (default 127.0.0.1; use 0.0.0.0 to accept external connections)', '127.0.0.1')
459
- .option('--auth-token <token>', 'Bearer token for HTTP requests (required for --bind 0.0.0.0; falls back to SWITCHBOT_MCP_TOKEN env var)')
460
- .option('--cors-origin <url>', 'Allowed CORS origin(s) for HTTP (repeatable)')
461
- .option('--rate-limit <n>', 'Max requests per minute per profile (default 60)', '60')
493
+ .option('--port <n>', 'Listen on HTTP instead of stdio (Streamable HTTP transport)', intArg('--port', { min: 1, max: 65535 }))
494
+ .option('--bind <host>', 'IP address to bind (default 127.0.0.1; use 0.0.0.0 to accept external connections)', stringArg('--bind'), '127.0.0.1')
495
+ .option('--auth-token <token>', 'Bearer token for HTTP requests (required for --bind 0.0.0.0; falls back to SWITCHBOT_MCP_TOKEN env var)', stringArg('--auth-token'))
496
+ .option('--cors-origin <url>', 'Allowed CORS origin(s) for HTTP (repeatable)', stringArg('--cors-origin'))
497
+ .option('--rate-limit <n>', 'Max requests per minute per profile (default 60)', intArg('--rate-limit', { min: 1 }), '60')
462
498
  .action(async (options) => {
463
499
  try {
464
500
  if (options.port) {
@@ -210,10 +210,18 @@ Workflow:
210
210
  process.exit(2);
211
211
  }
212
212
  if (isJsonMode()) {
213
- printJson({ valid: true, steps: result.plan.steps.length });
213
+ const out = { valid: true, steps: result.plan.steps.length };
214
+ if (result.plan.steps.length === 0)
215
+ out.warning = 'plan has no steps — nothing will execute';
216
+ printJson(out);
214
217
  }
215
218
  else {
216
- console.log(`✓ plan valid (${result.plan.steps.length} step${result.plan.steps.length === 1 ? '' : 's'})`);
219
+ if (result.plan.steps.length === 0) {
220
+ console.log('✓ plan valid — but 0 steps: nothing will execute');
221
+ }
222
+ else {
223
+ console.log(`✓ plan valid (${result.plan.steps.length} step${result.plan.steps.length === 1 ? '' : 's'})`);
224
+ }
217
225
  }
218
226
  });
219
227
  plan
@@ -27,7 +27,7 @@ Examples:
27
27
  printJson(scenes);
28
28
  return;
29
29
  }
30
- renderRows(['sceneId', 'sceneName'], scenes.map((s) => [s.sceneId, s.sceneName]), fmt, resolveFields());
30
+ renderRows(['sceneId', 'sceneName'], scenes.map((s) => [s.sceneId, s.sceneName]), fmt, resolveFields(), { id: 'sceneId', name: 'sceneName' });
31
31
  if (fmt === 'table' && scenes.length === 0) {
32
32
  console.log('No scenes found');
33
33
  }
@@ -1,3 +1,4 @@
1
+ import { enumArg, stringArg } from '../utils/arg-parsers.js';
1
2
  import { printJson } from '../utils/output.js';
2
3
  import { getEffectiveCatalog } from '../devices/catalog.js';
3
4
  function toSchemaEntry(e) {
@@ -24,15 +25,17 @@ function toSchemaCommand(c) {
24
25
  };
25
26
  }
26
27
  export function registerSchemaCommand(program) {
28
+ const ROLES = ['lighting', 'security', 'sensor', 'climate', 'media', 'cleaning', 'curtain', 'fan', 'power', 'hub', 'other'];
29
+ const CATEGORIES = ['physical', 'ir'];
27
30
  const schema = program
28
31
  .command('schema')
29
32
  .description('Export the device catalog as structured JSON (for agent prompts / tooling)');
30
33
  schema
31
34
  .command('export')
32
35
  .description('Print the full catalog as structured JSON (one object per type)')
33
- .option('--type <type>', 'Restrict to a single device type (e.g. "Strip Light")')
34
- .option('--role <role>', 'Restrict to a functional role: lighting, security, sensor, climate, media, cleaning, curtain, fan, power, hub, other')
35
- .option('--category <cat>', 'Restrict to "physical" or "ir"')
36
+ .option('--type <type>', 'Restrict to a single device type (e.g. "Strip Light")', stringArg('--type'))
37
+ .option('--role <role>', 'Restrict to a functional role: lighting, security, sensor, climate, media, cleaning, curtain, fan, power, hub, other', enumArg('--role', ROLES))
38
+ .option('--category <cat>', 'Restrict to "physical" or "ir"', enumArg('--category', CATEGORIES))
36
39
  .addHelpText('after', `
37
40
  Output is always JSON (this command ignores --format). The output is a
38
41
  catalog export — not a formal JSON Schema standard document — suitable for
@@ -2,7 +2,9 @@ import { printJson, isJsonMode, handleError, UsageError } from '../utils/output.
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
+ import { intArg, durationArg, stringArg } from '../utils/arg-parsers.js';
5
6
  import { createClient } from '../api/client.js';
7
+ import { resolveDeviceId } from '../utils/name-resolver.js';
6
8
  const DEFAULT_INTERVAL_MS = 30_000;
7
9
  const MIN_INTERVAL_MS = 1_000;
8
10
  function diff(prev, next, fields) {
@@ -55,9 +57,10 @@ export function registerWatchCommand(devices) {
55
57
  devices
56
58
  .command('watch')
57
59
  .description('Poll device status on an interval and emit field-level changes (JSONL)')
58
- .argument('<deviceId...>', 'One or more deviceIds to watch')
59
- .option('--interval <dur>', `Polling interval: "30s", "1m", "500ms", ... (default 30s, min ${MIN_INTERVAL_MS / 1000}s)`, '30s')
60
- .option('--max <n>', 'Stop after N ticks (default: run until Ctrl-C)')
60
+ .argument('[deviceId...]', 'One or more deviceIds to watch (or use --name for one device)')
61
+ .option('--name <query>', 'Resolve one device by fuzzy name (combined with any positional IDs)', stringArg('--name'))
62
+ .option('--interval <dur>', `Polling interval: "30s", "1m", "500ms", ... (default 30s, min ${MIN_INTERVAL_MS / 1000}s)`, durationArg('--interval'), '30s')
63
+ .option('--max <n>', 'Stop after N ticks (default: run until Ctrl-C)', intArg('--max', { min: 1 }))
61
64
  .option('--include-unchanged', 'Emit a tick even when no field changed')
62
65
  .addHelpText('after', `
63
66
  Each poll emits one JSON line per deviceId with the shape:
@@ -71,9 +74,18 @@ Examples:
71
74
  $ switchbot devices watch ABC123 --fields battery,power --interval 1m
72
75
  $ switchbot devices watch ABC123 DEF456 --interval 30s --max 10
73
76
  $ switchbot devices watch ABC123 --json | jq 'select(.changed.power)'
77
+ $ switchbot devices watch --name "客厅空调" --interval 10s
74
78
  `)
75
79
  .action(async (deviceIds, options) => {
76
80
  try {
81
+ const allIds = [...deviceIds];
82
+ if (options.name) {
83
+ const resolved = resolveDeviceId(undefined, options.name);
84
+ if (!allIds.includes(resolved))
85
+ allIds.push(resolved);
86
+ }
87
+ if (allIds.length === 0)
88
+ throw new UsageError('Provide at least one deviceId argument or --name.');
77
89
  const parsed = parseDurationToMs(options.interval);
78
90
  if (parsed === null || parsed < MIN_INTERVAL_MS) {
79
91
  throw new UsageError(`Invalid --interval "${options.interval}". Minimum is ${MIN_INTERVAL_MS / 1000}s.`);
@@ -101,7 +113,7 @@ Examples:
101
113
  const t = new Date().toISOString();
102
114
  // Poll all devices in parallel; one failure per device doesn't stop
103
115
  // the others.
104
- await Promise.all(deviceIds.map(async (id) => {
116
+ await Promise.all(allIds.map(async (id) => {
105
117
  const cached = getCachedDevice(id);
106
118
  try {
107
119
  const body = await fetchDeviceStatus(id, client);
@@ -1,3 +1,4 @@
1
+ import { stringArg } from '../utils/arg-parsers.js';
1
2
  import { createClient } from '../api/client.js';
2
3
  import { printKeyValue, printJson, isJsonMode, handleError, UsageError } from '../utils/output.js';
3
4
  import chalk from 'chalk';
@@ -54,7 +55,7 @@ Example:
54
55
  webhook
55
56
  .command('query')
56
57
  .description('Query webhook configuration')
57
- .option('--details <url>', 'Query detailed configuration (enable/deviceList/timestamps) for a specific URL')
58
+ .option('--details <url>', 'Query detailed configuration (enable/deviceList/timestamps) for a specific URL', stringArg('--details'))
58
59
  .addHelpText('after', `
59
60
  Without --details, lists all configured webhook URLs.
60
61
  With --details, prints enable/deviceList/createTime/lastUpdateTime for the given URL.
package/dist/config.js CHANGED
@@ -99,7 +99,7 @@ export function showConfig() {
99
99
  const envSecret = process.env.SWITCHBOT_SECRET;
100
100
  if (envToken && envSecret) {
101
101
  console.log('Credential source: environment variables');
102
- console.log(`token : ${envToken}`);
102
+ console.log(`token : ${maskCredential(envToken)}`);
103
103
  console.log(`secret: ${maskSecret(envSecret)}`);
104
104
  return;
105
105
  }
@@ -112,13 +112,18 @@ export function showConfig() {
112
112
  const raw = fs.readFileSync(file, 'utf-8');
113
113
  const cfg = JSON.parse(raw);
114
114
  console.log(`Credential source: ${file}`);
115
- console.log(`token : ${cfg.token}`);
115
+ console.log(`token : ${maskCredential(cfg.token)}`);
116
116
  console.log(`secret: ${maskSecret(cfg.secret)}`);
117
117
  }
118
118
  catch {
119
119
  console.error('Failed to read config file');
120
120
  }
121
121
  }
122
+ function maskCredential(token) {
123
+ if (token.length <= 8)
124
+ return '*'.repeat(Math.max(4, token.length));
125
+ return token.slice(0, 4) + '*'.repeat(token.length - 8) + token.slice(-4);
126
+ }
122
127
  function maskSecret(secret) {
123
128
  if (secret.length <= 4)
124
129
  return '****';