@switchbot/openapi-cli 2.1.0 → 2.2.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,6 +3,15 @@ import { printJson, isJsonMode, handleError, UsageError } from '../utils/output.
3
3
  import { SwitchBotMqttClient } from '../mqtt/client.js';
4
4
  import { fetchMqttCredential } from '../mqtt/credential.js';
5
5
  import { tryLoadConfig } from '../config.js';
6
+ import { SinkDispatcher } from '../sinks/dispatcher.js';
7
+ import { StdoutSink } from '../sinks/stdout.js';
8
+ import { FileSink } from '../sinks/file.js';
9
+ import { WebhookSink } from '../sinks/webhook.js';
10
+ import { OpenClawSink } from '../sinks/openclaw.js';
11
+ import { TelegramSink } from '../sinks/telegram.js';
12
+ import { HomeAssistantSink } from '../sinks/homeassistant.js';
13
+ import { parseSinkEvent } from '../sinks/format.js';
14
+ import { deviceHistoryStore } from '../mcp/device-history.js';
6
15
  const DEFAULT_PORT = 3000;
7
16
  const DEFAULT_PATH = '/';
8
17
  const MAX_BODY_BYTES = 1_000_000;
@@ -192,6 +201,18 @@ Examples:
192
201
  .description('Subscribe to SwitchBot MQTT shadow events and stream them as JSONL')
193
202
  .option('--topic <pattern>', 'MQTT topic filter (default: SwitchBot shadow topic from credential)')
194
203
  .option('--max <n>', 'Stop after N events (default: run until Ctrl-C)')
204
+ .option('--sink <type>', 'Output sink: stdout (default), file, webhook, openclaw, telegram, homeassistant (repeatable)', (val, prev) => [...prev, val], [])
205
+ .option('--sink-file <path>', 'File path for file sink')
206
+ .option('--webhook-url <url>', 'Webhook URL for webhook sink')
207
+ .option('--openclaw-url <url>', 'OpenClaw gateway URL (default: http://localhost:18789)')
208
+ .option('--openclaw-token <token>', 'Bearer token for OpenClaw (or env OPENCLAW_TOKEN)')
209
+ .option('--openclaw-model <id>', 'OpenClaw agent model ID to route events to')
210
+ .option('--telegram-token <token>', 'Telegram bot token (or env TELEGRAM_TOKEN)')
211
+ .option('--telegram-chat <id>', 'Telegram chat/channel ID to send messages to')
212
+ .option('--ha-url <url>', 'Home Assistant base URL (e.g. http://homeassistant.local:8123)')
213
+ .option('--ha-token <token>', 'HA long-lived access token (for REST event API)')
214
+ .option('--ha-webhook-id <id>', 'HA webhook ID (no auth; takes priority over --ha-token)')
215
+ .option('--ha-event-type <type>', 'HA event type for REST API (default: switchbot_event)')
195
216
  .addHelpText('after', `
196
217
  Connects to the SwitchBot MQTT service using your existing credentials
197
218
  (SWITCHBOT_TOKEN + SWITCHBOT_SECRET or ~/.switchbot/config.json).
@@ -200,10 +221,25 @@ No additional MQTT configuration required.
200
221
  Output (JSONL, one event per line):
201
222
  { "t": "<ISO>", "topic": "<mqtt-topic>", "payload": <parsed JSON or raw string> }
202
223
 
224
+ Sink types (--sink, repeatable):
225
+ stdout Print JSONL to stdout (default when no --sink given)
226
+ file Append JSONL to --sink-file <path>
227
+ webhook HTTP POST to --webhook-url <url>
228
+ openclaw POST to OpenClaw via --openclaw-url / --openclaw-token / --openclaw-model
229
+ telegram Send to Telegram via --telegram-token / --telegram-chat
230
+ homeassistant POST to HA via --ha-url + --ha-webhook-id (or --ha-token)
231
+
232
+ Device state is also persisted to ~/.switchbot/device-history/<deviceId>.json
233
+ regardless of sink configuration.
234
+
203
235
  Examples:
204
236
  $ switchbot events mqtt-tail
205
- $ switchbot events mqtt-tail --topic 'switchbot/#'
206
237
  $ switchbot events mqtt-tail --max 10 --json
238
+ $ switchbot events mqtt-tail --sink file --sink-file ~/.switchbot/events.jsonl
239
+ $ switchbot events mqtt-tail --sink openclaw --openclaw-token abc --openclaw-model home-agent
240
+ $ switchbot events mqtt-tail --sink telegram --telegram-token <token> --telegram-chat <chatId>
241
+ $ switchbot events mqtt-tail --sink homeassistant --ha-url http://ha.local:8123 --ha-webhook-id switchbot
242
+ $ switchbot events mqtt-tail --sink stdout --sink openclaw --openclaw-token abc --openclaw-model home
207
243
  `)
208
244
  .action(async (options) => {
209
245
  try {
@@ -211,20 +247,70 @@ Examples:
211
247
  if (maxEvents !== null && (!Number.isInteger(maxEvents) || maxEvents < 1)) {
212
248
  throw new UsageError(`Invalid --max "${options.max}". Must be a positive integer.`);
213
249
  }
214
- let creds;
215
250
  const loaded = tryLoadConfig();
216
251
  if (!loaded) {
217
252
  throw new UsageError('No credentials found. Run \'switchbot config set-token\' or set SWITCHBOT_TOKEN and SWITCHBOT_SECRET.');
218
253
  }
219
- creds = loaded;
254
+ const sinkTypes = options.sink;
255
+ let dispatcher = null;
256
+ if (sinkTypes.length > 0) {
257
+ const sinks = sinkTypes.map((type) => {
258
+ switch (type) {
259
+ case 'stdout':
260
+ return new StdoutSink();
261
+ case 'file': {
262
+ if (!options.sinkFile)
263
+ throw new UsageError('--sink file requires --sink-file <path>');
264
+ return new FileSink(options.sinkFile);
265
+ }
266
+ case 'webhook': {
267
+ if (!options.webhookUrl)
268
+ throw new UsageError('--sink webhook requires --webhook-url <url>');
269
+ return new WebhookSink(options.webhookUrl);
270
+ }
271
+ case 'openclaw': {
272
+ const token = options.openclawToken ?? process.env.OPENCLAW_TOKEN;
273
+ if (!token)
274
+ throw new UsageError('--sink openclaw requires --openclaw-token or env OPENCLAW_TOKEN');
275
+ if (!options.openclawModel)
276
+ throw new UsageError('--sink openclaw requires --openclaw-model <id>');
277
+ return new OpenClawSink({ url: options.openclawUrl, token, model: options.openclawModel });
278
+ }
279
+ case 'telegram': {
280
+ const token = options.telegramToken ?? process.env.TELEGRAM_TOKEN;
281
+ if (!token)
282
+ throw new UsageError('--sink telegram requires --telegram-token or env TELEGRAM_TOKEN');
283
+ if (!options.telegramChat)
284
+ throw new UsageError('--sink telegram requires --telegram-chat <id>');
285
+ return new TelegramSink({ token, chatId: options.telegramChat });
286
+ }
287
+ case 'homeassistant': {
288
+ if (!options.haUrl)
289
+ throw new UsageError('--sink homeassistant requires --ha-url <url>');
290
+ if (!options.haWebhookId && !options.haToken) {
291
+ throw new UsageError('--sink homeassistant requires --ha-webhook-id or --ha-token');
292
+ }
293
+ return new HomeAssistantSink({
294
+ url: options.haUrl,
295
+ token: options.haToken,
296
+ webhookId: options.haWebhookId,
297
+ eventType: options.haEventType,
298
+ });
299
+ }
300
+ default:
301
+ throw new UsageError(`Unknown --sink type "${type}". Supported: stdout, file, webhook, openclaw, telegram, homeassistant`);
302
+ }
303
+ });
304
+ dispatcher = new SinkDispatcher(sinks);
305
+ }
220
306
  if (!isJsonMode()) {
221
307
  console.error('Fetching MQTT credentials from SwitchBot service…');
222
308
  }
223
- const credential = await fetchMqttCredential(creds.token, creds.secret);
309
+ const credential = await fetchMqttCredential(loaded.token, loaded.secret);
224
310
  const topic = options.topic ?? credential.topics.status;
225
311
  let eventCount = 0;
226
312
  const ac = new AbortController();
227
- const client = new SwitchBotMqttClient(credential, () => fetchMqttCredential(creds.token, creds.secret));
313
+ const client = new SwitchBotMqttClient(credential, () => fetchMqttCredential(loaded.token, loaded.secret));
228
314
  const unsub = client.onMessage((msgTopic, payload) => {
229
315
  let parsed;
230
316
  try {
@@ -233,18 +319,35 @@ Examples:
233
319
  catch {
234
320
  parsed = payload.toString('utf-8');
235
321
  }
236
- const record = { t: new Date().toISOString(), topic: msgTopic, payload: parsed };
237
- if (isJsonMode()) {
238
- printJson(record);
322
+ const t = new Date().toISOString();
323
+ if (dispatcher) {
324
+ const { deviceId, deviceType, text } = parseSinkEvent(parsed);
325
+ const sinkEvent = { t, topic: msgTopic, deviceId, deviceType, payload: parsed, text };
326
+ deviceHistoryStore.record(deviceId, msgTopic, deviceType, parsed, t);
327
+ dispatcher.dispatch(sinkEvent).catch(() => { });
239
328
  }
240
329
  else {
241
- console.log(JSON.stringify(record));
330
+ // Default behavior: record history + print to stdout
331
+ const { deviceId, deviceType } = parseSinkEvent(parsed);
332
+ deviceHistoryStore.record(deviceId, msgTopic, deviceType, parsed, t);
333
+ const record = { t, topic: msgTopic, payload: parsed };
334
+ if (isJsonMode()) {
335
+ printJson(record);
336
+ }
337
+ else {
338
+ console.log(JSON.stringify(record));
339
+ }
242
340
  }
243
341
  eventCount++;
244
342
  if (maxEvents !== null && eventCount >= maxEvents) {
245
343
  ac.abort();
246
344
  }
247
345
  });
346
+ const unsubState = client.onStateChange((state) => {
347
+ if (!isJsonMode()) {
348
+ console.error(`[${new Date().toLocaleTimeString()}] MQTT state: ${state}`);
349
+ }
350
+ });
248
351
  await client.connect();
249
352
  client.subscribe(topic);
250
353
  if (!isJsonMode()) {
@@ -255,6 +358,8 @@ Examples:
255
358
  process.removeListener('SIGINT', cleanup);
256
359
  process.removeListener('SIGTERM', cleanup);
257
360
  unsub();
361
+ unsubState();
362
+ dispatcher?.close().catch(() => { });
258
363
  client.disconnect().then(resolve).catch(resolve);
259
364
  };
260
365
  process.once('SIGINT', cleanup);
@@ -2,6 +2,7 @@ import { handleError, isJsonMode, printJson, UsageError } from '../utils/output.
2
2
  import { getCachedDevice } from '../devices/cache.js';
3
3
  import { executeCommand, isDestructiveCommand, getDestructiveReason } from '../lib/devices.js';
4
4
  import { isDryRun } from '../utils/flags.js';
5
+ import { resolveDeviceId } from '../utils/name-resolver.js';
5
6
  import { DryRunSignal } from '../api/client.js';
6
7
  // ---- Mapping tables --------------------------------------------------------
7
8
  const AC_MODE_MAP = { auto: 1, cool: 2, dry: 3, fan: 4, heat: 5 };
@@ -85,8 +86,9 @@ export function registerExpandCommand(devices) {
85
86
  devices
86
87
  .command('expand')
87
88
  .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)')
89
+ .argument('[deviceId]', 'Target device ID from "devices list" (or use --name)')
90
+ .argument('[command]', 'Command name: setAll (AC), setPosition (Curtain/Blind Tilt), setMode (Relay Switch 2)')
91
+ .option('--name <query>', 'Resolve device by fuzzy name instead of deviceId')
90
92
  .option('--temp <celsius>', 'AC setAll: temperature in Celsius (16-30)')
91
93
  .option('--mode <mode>', 'AC: auto|cool|dry|fan|heat Curtain: default|performance|silent Relay: toggle|edge|detached|momentary')
92
94
  .option('--fan <speed>', 'AC setAll: fan speed auto|low|mid|high')
@@ -123,9 +125,24 @@ Examples:
123
125
  $ switchbot devices expand <blindId> setPosition --direction up --angle 50
124
126
  $ switchbot devices expand <relayId> setMode --channel 1 --mode edge
125
127
  $ switchbot devices expand <acId> setAll --temp 22 --mode heat --fan auto --power on --dry-run
128
+ $ switchbot devices expand --name "客厅空调" setAll --temp 26 --mode cool --fan low --power on
126
129
  `)
127
- .action(async (deviceId, command, options) => {
130
+ .action(async (deviceIdArg, commandArg, options) => {
131
+ let deviceId = '';
132
+ let command = '';
128
133
  try {
134
+ // When --name is provided, Commander assigns the first positional to deviceIdArg
135
+ // and leaves commandArg undefined. Detect and shift.
136
+ let effectiveDeviceIdArg = deviceIdArg;
137
+ let effectiveCommand = commandArg;
138
+ if (options.name && deviceIdArg && !commandArg) {
139
+ effectiveCommand = deviceIdArg;
140
+ effectiveDeviceIdArg = undefined;
141
+ }
142
+ deviceId = resolveDeviceId(effectiveDeviceIdArg, options.name);
143
+ if (!effectiveCommand)
144
+ throw new UsageError('A command argument is required (setAll, setPosition, setMode).');
145
+ command = effectiveCommand;
129
146
  const cached = getCachedDevice(deviceId);
130
147
  const deviceType = cached?.type ?? '';
131
148
  let parameter;
@@ -8,6 +8,7 @@ import { fetchScenes, executeScene } from '../lib/scenes.js';
8
8
  import { findCatalogEntry } from '../devices/catalog.js';
9
9
  import { getCachedDevice } from '../devices/cache.js';
10
10
  import { EventSubscriptionManager } from '../mcp/events-subscription.js';
11
+ import { deviceHistoryStore } from '../mcp/device-history.js';
11
12
  import { todayUsage } from '../utils/quota.js';
12
13
  import { describeCache } from '../devices/cache.js';
13
14
  import { withRequestContext } from '../lib/request-context.js';
@@ -110,6 +111,40 @@ API docs: https://github.com/OpenWonderLabs/SwitchBotAPI`,
110
111
  structuredContent: { status: body },
111
112
  };
112
113
  });
114
+ // ---- get_device_history ----------------------------------------------------
115
+ server.registerTool('get_device_history', {
116
+ title: 'Get locally-persisted device state history',
117
+ description: 'Return device state history recorded from MQTT events (persisted to ~/.switchbot/device-history/). ' +
118
+ 'No API call — zero quota cost. Use when you need recent historical readings or want to avoid a live API call. ' +
119
+ 'Omit deviceId to list all devices with stored history.',
120
+ inputSchema: {
121
+ deviceId: z.string().optional().describe('Device MAC address (deviceId). Omit to list all devices with history.'),
122
+ limit: z.number().int().min(1).max(100).optional().describe('Max history entries to return (default 20, max 100)'),
123
+ },
124
+ outputSchema: {
125
+ deviceId: z.string().optional(),
126
+ latest: z.unknown().optional(),
127
+ history: z.array(z.unknown()).optional(),
128
+ devices: z.array(z.object({ deviceId: z.string(), latest: z.unknown() })).optional(),
129
+ },
130
+ }, async ({ deviceId, limit }) => {
131
+ if (deviceId) {
132
+ const latest = deviceHistoryStore.getLatest(deviceId);
133
+ const history = deviceHistoryStore.getHistory(deviceId, limit ?? 20);
134
+ const result = { deviceId, latest, history };
135
+ return {
136
+ content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
137
+ structuredContent: result,
138
+ };
139
+ }
140
+ const ids = deviceHistoryStore.listDevices();
141
+ const devices = ids.map((id) => ({ deviceId: id, latest: deviceHistoryStore.getLatest(id) }));
142
+ const result = { devices };
143
+ return {
144
+ content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
145
+ structuredContent: result,
146
+ };
147
+ });
113
148
  // ---- send_command ---------------------------------------------------------
114
149
  server.registerTool('send_command', {
115
150
  title: 'Send a control command to a device',
@@ -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
  }
@@ -3,6 +3,7 @@ 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 { createClient } from '../api/client.js';
6
+ import { resolveDeviceId } from '../utils/name-resolver.js';
6
7
  const DEFAULT_INTERVAL_MS = 30_000;
7
8
  const MIN_INTERVAL_MS = 1_000;
8
9
  function diff(prev, next, fields) {
@@ -55,7 +56,8 @@ export function registerWatchCommand(devices) {
55
56
  devices
56
57
  .command('watch')
57
58
  .description('Poll device status on an interval and emit field-level changes (JSONL)')
58
- .argument('<deviceId...>', 'One or more deviceIds to watch')
59
+ .argument('[deviceId...]', 'One or more deviceIds to watch (or use --name for one device)')
60
+ .option('--name <query>', 'Resolve one device by fuzzy name (combined with any positional IDs)')
59
61
  .option('--interval <dur>', `Polling interval: "30s", "1m", "500ms", ... (default 30s, min ${MIN_INTERVAL_MS / 1000}s)`, '30s')
60
62
  .option('--max <n>', 'Stop after N ticks (default: run until Ctrl-C)')
61
63
  .option('--include-unchanged', 'Emit a tick even when no field changed')
@@ -71,9 +73,18 @@ Examples:
71
73
  $ switchbot devices watch ABC123 --fields battery,power --interval 1m
72
74
  $ switchbot devices watch ABC123 DEF456 --interval 30s --max 10
73
75
  $ switchbot devices watch ABC123 --json | jq 'select(.changed.power)'
76
+ $ switchbot devices watch --name "客厅空调" --interval 10s
74
77
  `)
75
78
  .action(async (deviceIds, options) => {
76
79
  try {
80
+ const allIds = [...deviceIds];
81
+ if (options.name) {
82
+ const resolved = resolveDeviceId(undefined, options.name);
83
+ if (!allIds.includes(resolved))
84
+ allIds.push(resolved);
85
+ }
86
+ if (allIds.length === 0)
87
+ throw new UsageError('Provide at least one deviceId argument or --name.');
77
88
  const parsed = parseDurationToMs(options.interval);
78
89
  if (parsed === null || parsed < MIN_INTERVAL_MS) {
79
90
  throw new UsageError(`Invalid --interval "${options.interval}". Minimum is ${MIN_INTERVAL_MS / 1000}s.`);
@@ -101,7 +112,7 @@ Examples:
101
112
  const t = new Date().toISOString();
102
113
  // Poll all devices in parallel; one failure per device doesn't stop
103
114
  // the others.
104
- await Promise.all(deviceIds.map(async (id) => {
115
+ await Promise.all(allIds.map(async (id) => {
105
116
  const cached = getCachedDevice(id);
106
117
  try {
107
118
  const body = await fetchDeviceStatus(id, client);
package/dist/index.js CHANGED
@@ -37,7 +37,8 @@ program
37
37
  .option('--no-cache', 'Disable cache reads (equivalent to --cache off)')
38
38
  .option('--config <path>', 'Override credential file location (default: ~/.switchbot/config.json)')
39
39
  .option('--profile <name>', 'Use a named profile: ~/.switchbot/profiles/<name>.json')
40
- .option('--audit-log [path]', 'Append every mutating command to JSONL audit log (default ~/.switchbot/audit.log)')
40
+ .option('--audit-log', 'Append every mutating command to JSONL audit log (default path: ~/.switchbot/audit.log)')
41
+ .option('--audit-log-path <path>', 'Custom audit log file path; use together with --audit-log')
41
42
  .showHelpAfterError('(run with --help to see usage)')
42
43
  .showSuggestionAfterError();
43
44
  registerConfigCommand(program);
@@ -147,9 +147,13 @@ export function validateCommand(deviceId, cmd, parameter, commandType) {
147
147
  const spec = builtinCommands.find((c) => c.command === cmd);
148
148
  if (!spec) {
149
149
  const unique = [...new Set(builtinCommands.map((c) => c.command))];
150
+ const caseMatch = unique.find((c) => c.toLowerCase() === cmd.toLowerCase());
151
+ const hint = caseMatch
152
+ ? `Did you mean "${caseMatch}"? Supported commands: ${unique.join(', ')}`
153
+ : `Supported commands: ${unique.join(', ')}`;
150
154
  return {
151
155
  ok: false,
152
- error: new CommandValidationError(`"${cmd}" is not a supported command for ${cached.name} (${cached.type}).`, 'unknown-command', `Supported commands: ${unique.join(', ')}`),
156
+ error: new CommandValidationError(`"${cmd}" is not a supported command for ${cached.name} (${cached.type}).`, 'unknown-command', hint),
153
157
  };
154
158
  }
155
159
  const noParamExpected = spec.parameter === '—';
@@ -160,6 +164,17 @@ export function validateCommand(deviceId, cmd, parameter, commandType) {
160
164
  error: new CommandValidationError(`"${cmd}" takes no parameter, but one was provided: "${parameter}".`, 'unexpected-parameter', `Try: switchbot devices command ${deviceId} ${cmd}`),
161
165
  };
162
166
  }
167
+ // Warn when a parameter is required but the user omitted it
168
+ const paramRequired = !noParamExpected && spec.parameter !== 'default';
169
+ if (paramRequired && !userProvidedParam) {
170
+ const example = spec.exampleParams?.[0];
171
+ return {
172
+ ok: false,
173
+ error: new CommandValidationError(`"${cmd}" requires a parameter (${spec.parameter}).`, 'missing-parameter', example
174
+ ? `Example: switchbot devices command <deviceId> ${cmd} "${example}"`
175
+ : `See: switchbot devices commands ${cached.type}`),
176
+ };
177
+ }
163
178
  return { ok: true };
164
179
  }
165
180
  /**
@@ -0,0 +1,66 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import os from 'node:os';
4
+ const MAX_HISTORY = 100;
5
+ function historyDir() {
6
+ return path.join(os.homedir(), '.switchbot', 'device-history');
7
+ }
8
+ export class DeviceHistoryStore {
9
+ dir;
10
+ constructor() {
11
+ this.dir = historyDir();
12
+ }
13
+ record(deviceId, topic, deviceType, payload, t) {
14
+ try {
15
+ if (!fs.existsSync(this.dir))
16
+ fs.mkdirSync(this.dir, { recursive: true });
17
+ const file = path.join(this.dir, `${deviceId}.json`);
18
+ const existing = fs.existsSync(file)
19
+ ? JSON.parse(fs.readFileSync(file, 'utf-8'))
20
+ : { latest: null, history: [] };
21
+ const entry = { t: t ?? new Date().toISOString(), topic, deviceType, payload };
22
+ existing.latest = entry;
23
+ existing.history = [entry, ...existing.history].slice(0, MAX_HISTORY);
24
+ fs.writeFileSync(file, JSON.stringify(existing, null, 2), { mode: 0o600 });
25
+ }
26
+ catch {
27
+ // best-effort — history loss is non-fatal
28
+ }
29
+ }
30
+ getLatest(deviceId) {
31
+ try {
32
+ const file = path.join(this.dir, `${deviceId}.json`);
33
+ if (!fs.existsSync(file))
34
+ return null;
35
+ return JSON.parse(fs.readFileSync(file, 'utf-8')).latest;
36
+ }
37
+ catch {
38
+ return null;
39
+ }
40
+ }
41
+ getHistory(deviceId, limit = 20) {
42
+ try {
43
+ const file = path.join(this.dir, `${deviceId}.json`);
44
+ if (!fs.existsSync(file))
45
+ return [];
46
+ const data = JSON.parse(fs.readFileSync(file, 'utf-8'));
47
+ return data.history.slice(0, Math.min(limit, MAX_HISTORY));
48
+ }
49
+ catch {
50
+ return [];
51
+ }
52
+ }
53
+ listDevices() {
54
+ try {
55
+ if (!fs.existsSync(this.dir))
56
+ return [];
57
+ return fs.readdirSync(this.dir)
58
+ .filter((f) => f.endsWith('.json'))
59
+ .map((f) => f.slice(0, -5));
60
+ }
61
+ catch {
62
+ return [];
63
+ }
64
+ }
65
+ }
66
+ export const deviceHistoryStore = new DeviceHistoryStore();
@@ -5,6 +5,7 @@ import { fetchDeviceList } from '../lib/devices.js';
5
5
  import { getCachedDevice } from '../devices/cache.js';
6
6
  import { createClient } from '../api/client.js';
7
7
  import { log } from '../logger.js';
8
+ import { deviceHistoryStore } from './device-history.js';
8
9
  export class EventSubscriptionManager {
9
10
  mqttClient = null;
10
11
  subscribers = new Map();
@@ -35,12 +36,18 @@ export class EventSubscriptionManager {
35
36
  client.onMessage((topic, payload) => {
36
37
  try {
37
38
  const data = JSON.parse(payload.toString());
38
- const deviceId = this.extractDeviceId(topic);
39
- if (deviceId && data.state) {
39
+ // Support SwitchBot direct format: { eventType, context: { deviceMac, deviceType, ... } }
40
+ // and AWS IoT shadow format: $aws/things/<id>/shadow/... with data.state
41
+ const context = data.context;
42
+ const deviceId = context?.deviceMac ?? this.extractDeviceId(topic);
43
+ const payloadData = context ?? data.state;
44
+ const deviceType = String(context?.deviceType ?? 'Unknown');
45
+ if (deviceId && payloadData) {
46
+ deviceHistoryStore.record(deviceId, topic, deviceType, payloadData);
40
47
  this.addEvent({
41
48
  kind: 'shadow.updated',
42
49
  deviceId,
43
- payload: data.state,
50
+ payload: payloadData,
44
51
  timestamp: Date.now(),
45
52
  });
46
53
  }
@@ -17,6 +17,14 @@ export class SwitchBotMqttClient {
17
17
  async connect() {
18
18
  if (this.client && this.state === 'connected')
19
19
  return;
20
+ // Remove stale listeners before replacing the client instance, otherwise
21
+ // the old client's close event fires after the new connection is established
22
+ // (AWS IoT drops the old session), triggering a spurious reconnect loop.
23
+ if (this.client) {
24
+ this.client.removeAllListeners();
25
+ this.client.end(true);
26
+ this.client = null;
27
+ }
20
28
  this.setState('connecting');
21
29
  this.credentialExpired = false;
22
30
  this.reconnectAttempts = 0;
@@ -2,8 +2,9 @@ import crypto from 'node:crypto';
2
2
  import { buildAuthHeaders } from '../auth.js';
3
3
  const CREDENTIAL_ENDPOINT = 'https://api.switchbot.net/v1.1/iot/credential';
4
4
  export async function fetchMqttCredential(token, secret) {
5
- // Derive a stable instance ID per token so the server can track this client.
6
- const instanceId = crypto.createHash('sha256').update(token).digest('hex').slice(0, 16);
5
+ // Use a random instanceId so each CLI session gets its own clientId, avoiding
6
+ // conflicts with the SwitchBot cloud service that shares the same account credentials.
7
+ const instanceId = crypto.randomUUID().replace(/-/g, '').slice(0, 16);
7
8
  const headers = buildAuthHeaders(token, secret);
8
9
  const res = await fetch(CREDENTIAL_ENDPOINT, {
9
10
  method: 'POST',
@@ -0,0 +1,12 @@
1
+ export class SinkDispatcher {
2
+ sinks;
3
+ constructor(sinks) {
4
+ this.sinks = sinks;
5
+ }
6
+ async dispatch(event) {
7
+ await Promise.allSettled(this.sinks.map((s) => s.write(event)));
8
+ }
9
+ async close() {
10
+ await Promise.allSettled(this.sinks.map((s) => s.close?.()));
11
+ }
12
+ }
@@ -0,0 +1,19 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ export class FileSink {
4
+ filePath;
5
+ constructor(filePath) {
6
+ this.filePath = path.resolve(filePath);
7
+ const dir = path.dirname(this.filePath);
8
+ if (!fs.existsSync(dir))
9
+ fs.mkdirSync(dir, { recursive: true });
10
+ }
11
+ async write(event) {
12
+ try {
13
+ fs.appendFileSync(this.filePath, JSON.stringify(event) + '\n', { encoding: 'utf-8' });
14
+ }
15
+ catch {
16
+ // best-effort
17
+ }
18
+ }
19
+ }
@@ -0,0 +1,56 @@
1
+ const ICONS = {
2
+ 'Bot': '🤖',
3
+ 'Curtain': '🪟',
4
+ 'Hub': '📡',
5
+ 'Hub 2': '📡',
6
+ 'Hub 3': '📡',
7
+ 'Hub Mini': '📡',
8
+ 'Smart Lock': '🔒',
9
+ 'Smart Lock Pro': '🔒',
10
+ 'Plug': '🔌',
11
+ 'Plug Mini (US)': '🔌',
12
+ 'Plug Mini (JP)': '🔌',
13
+ 'Color Bulb': '💡',
14
+ 'Strip Light': '💡',
15
+ 'Contact Sensor': '🚪',
16
+ 'Motion Sensor': '👁',
17
+ 'Meter': '🌡',
18
+ 'MeterPro': '🌡',
19
+ 'Climate Panel': '🌡',
20
+ 'WoMeter': '🌡',
21
+ 'WoIOSensor': '🌡',
22
+ };
23
+ function icon(deviceType) {
24
+ return ICONS[deviceType] ?? '📱';
25
+ }
26
+ export function formatEventText(context) {
27
+ const type = context.deviceType ?? 'Unknown';
28
+ const pfx = `${icon(type)} ${type}`;
29
+ const parts = [];
30
+ if (context.temperature !== undefined)
31
+ parts.push(`${context.temperature}°C`);
32
+ if (context.humidity !== undefined)
33
+ parts.push(`${context.humidity}%`);
34
+ if (parts.length)
35
+ return `${pfx}: ${parts.join(' / ')}`;
36
+ if (context.power !== undefined)
37
+ return `${pfx}: ${context.power}`;
38
+ if (context.lockState !== undefined)
39
+ return `${pfx}: ${context.lockState}`;
40
+ if (context.openState !== undefined)
41
+ return `${pfx}: ${context.openState}`;
42
+ if (context.detectionState !== undefined)
43
+ return `${pfx}: ${context.detectionState}`;
44
+ if (context.brightness !== undefined)
45
+ return `${pfx}: ${context.brightness}`;
46
+ return `${pfx}: state change`;
47
+ }
48
+ export function parseSinkEvent(payload) {
49
+ const p = payload;
50
+ const context = (p?.context ?? {});
51
+ return {
52
+ deviceId: String(context.deviceMac ?? 'unknown'),
53
+ deviceType: String(context.deviceType ?? 'Unknown'),
54
+ text: formatEventText(context),
55
+ };
56
+ }