@switchbot/openapi-cli 2.0.1 → 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.
@@ -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,11 +8,11 @@ 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';
14
- import { profileFilePath } from '../config.js';
15
- import { getMqttConfig } from '../mqtt/credential.js';
15
+ import { profileFilePath, tryLoadConfig } from '../config.js';
16
16
  import fs from 'node:fs';
17
17
  function mcpError(kind, code, message, options) {
18
18
  const obj = { code, kind, message };
@@ -111,6 +111,40 @@ API docs: https://github.com/OpenWonderLabs/SwitchBotAPI`,
111
111
  structuredContent: { status: body },
112
112
  };
113
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
+ });
114
148
  // ---- send_command ---------------------------------------------------------
115
149
  server.registerTool('send_command', {
116
150
  title: 'Send a control command to a device',
@@ -350,7 +384,7 @@ API docs: https://github.com/OpenWonderLabs/SwitchBotAPI`,
350
384
  mqtt: z.object({
351
385
  state: z.string(),
352
386
  subscribers: z.number(),
353
- }).optional().describe('MQTT connection state (HTTP mode only)'),
387
+ }).optional().describe('MQTT connection state (present when REST credentials are configured; auto-provisioned via POST /v1.1/iot/credential)'),
354
388
  },
355
389
  }, async () => {
356
390
  const deviceList = await fetchDeviceList();
@@ -398,7 +432,7 @@ API docs: https://github.com/OpenWonderLabs/SwitchBotAPI`,
398
432
  server.registerResource('events', 'switchbot://events', {
399
433
  title: 'SwitchBot real-time shadow events',
400
434
  description: 'Recent device shadow-update events received via MQTT. Returns a JSON snapshot of the ring buffer. ' +
401
- 'State is "disabled" when MQTT credentials are not configured (set SWITCHBOT_MQTT_HOST / USERNAME / PASSWORD).',
435
+ 'State is "disabled" when REST credentials (SWITCHBOT_TOKEN + SWITCHBOT_SECRET) are not configured.',
402
436
  mimeType: 'application/json',
403
437
  }, (_uri) => {
404
438
  const state = eventManager.getState();
@@ -419,7 +453,7 @@ export function registerMcpCommand(program) {
419
453
  .command('mcp')
420
454
  .description('Run as a Model Context Protocol server so AI agents can call SwitchBot tools')
421
455
  .addHelpText('after', `
422
- The MCP server exposes seven tools over stdio:
456
+ The MCP server exposes eight tools:
423
457
  - list_devices fetch all physical + IR devices
424
458
  - get_device_status live status for a physical device
425
459
  - send_command control a device (destructive commands need confirm:true)
@@ -427,6 +461,12 @@ The MCP server exposes seven tools over stdio:
427
461
  - run_scene execute a manual scene
428
462
  - search_catalog offline catalog search by type/alias
429
463
  - describe_device metadata + commands + (optionally) live status for one device
464
+ - account_overview single cold-start snapshot: devices + scenes + quota + cache + MQTT state
465
+
466
+ Resource (read-only):
467
+ - switchbot://events snapshot of recent MQTT shadow events from the ring buffer
468
+ Auto-provisioned from SWITCHBOT_TOKEN + SWITCHBOT_SECRET;
469
+ returns {state:"disabled"} when credentials are not configured.
430
470
 
431
471
  Example Claude Desktop config (~/Library/Application Support/Claude/claude_desktop_config.json):
432
472
 
@@ -487,17 +527,17 @@ Inspect locally:
487
527
  const { createServer } = await import('node:http');
488
528
  const rateLimitMap = new Map();
489
529
  // Initialize shared EventSubscriptionManager for event streaming.
490
- // If MQTT creds are present, connect in the background so the HTTP server
491
- // starts immediately; /ready reflects the real state.
530
+ // Credentials are auto-provisioned from the SwitchBot API using the
531
+ // account's token+secret no extra MQTT env vars needed.
492
532
  const eventManager = new EventSubscriptionManager();
493
- const mqttConfig = getMqttConfig();
494
- if (mqttConfig) {
495
- eventManager.initialize(mqttConfig).catch((err) => {
533
+ const mqttCreds = tryLoadConfig();
534
+ if (mqttCreds) {
535
+ eventManager.initialize(mqttCreds.token, mqttCreds.secret).catch((err) => {
496
536
  console.error('MQTT initialization failed:', err instanceof Error ? err.message : String(err));
497
537
  });
498
538
  }
499
539
  else {
500
- console.error('MQTT disabled: set SWITCHBOT_MQTT_HOST, SWITCHBOT_MQTT_USERNAME, SWITCHBOT_MQTT_PASSWORD to enable real-time events.');
540
+ console.error('MQTT disabled: credentials not configured.');
501
541
  }
502
542
  // Helper: constant-time token comparison
503
543
  const tokenMatch = (provided) => {
@@ -691,7 +731,14 @@ process_uptime_seconds ${Math.floor(process.uptime())}
691
731
  });
692
732
  return;
693
733
  }
694
- const server = createSwitchBotMcpServer();
734
+ const eventManager = new EventSubscriptionManager();
735
+ const mqttCreds = tryLoadConfig();
736
+ if (mqttCreds) {
737
+ eventManager.initialize(mqttCreds.token, mqttCreds.secret).catch((err) => {
738
+ console.error('MQTT initialization failed:', err instanceof Error ? err.message : String(err));
739
+ });
740
+ }
741
+ const server = createSwitchBotMcpServer({ eventManager });
695
742
  const transport = new StdioServerTransport();
696
743
  await server.connect(transport);
697
744
  }
@@ -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/config.js CHANGED
@@ -62,6 +62,29 @@ export function loadConfig() {
62
62
  process.exit(1);
63
63
  }
64
64
  }
65
+ /**
66
+ * Like loadConfig but returns null instead of exiting. Use this in code paths
67
+ * that want graceful degradation (e.g. optional MQTT init in `mcp serve`).
68
+ */
69
+ export function tryLoadConfig() {
70
+ const envToken = process.env.SWITCHBOT_TOKEN;
71
+ const envSecret = process.env.SWITCHBOT_SECRET;
72
+ if (envToken && envSecret)
73
+ return { token: envToken, secret: envSecret };
74
+ const file = configFilePath();
75
+ if (!fs.existsSync(file))
76
+ return null;
77
+ try {
78
+ const raw = fs.readFileSync(file, 'utf-8');
79
+ const cfg = JSON.parse(raw);
80
+ if (!cfg.token || !cfg.secret)
81
+ return null;
82
+ return cfg;
83
+ }
84
+ catch {
85
+ return null;
86
+ }
87
+ }
65
88
  export function saveConfig(token, secret) {
66
89
  const file = configFilePath();
67
90
  const dir = path.dirname(file);
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);
@@ -68,9 +69,9 @@ Exit codes:
68
69
  2 usage error (bad flag, unknown subcommand, invalid argument, unknown device type)
69
70
 
70
71
  Environment:
71
- SWITCHBOT_TOKEN credential token (takes priority over config file)
72
- SWITCHBOT_SECRET credential secret (takes priority over config file)
73
- NO_COLOR disable ANSI colors (auto-respected via chalk)
72
+ SWITCHBOT_TOKEN credential token (takes priority over config file)
73
+ SWITCHBOT_SECRET credential secret (takes priority over config file)
74
+ NO_COLOR disable ANSI colors (auto-respected via chalk)
74
75
 
75
76
  Examples:
76
77
  $ switchbot config set-token <token> <secret>
@@ -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();
@@ -1,9 +1,11 @@
1
1
  import { SwitchBotMqttClient } from '../mqtt/client.js';
2
+ import { fetchMqttCredential } from '../mqtt/credential.js';
2
3
  import { parseFilter, applyFilter } from '../utils/filter.js';
3
4
  import { fetchDeviceList } from '../lib/devices.js';
4
5
  import { getCachedDevice } from '../devices/cache.js';
5
6
  import { createClient } from '../api/client.js';
6
7
  import { log } from '../logger.js';
8
+ import { deviceHistoryStore } from './device-history.js';
7
9
  export class EventSubscriptionManager {
8
10
  mqttClient = null;
9
11
  subscribers = new Map();
@@ -18,33 +20,34 @@ export class EventSubscriptionManager {
18
20
  this.mqttClient = mqttClient || null;
19
21
  this.getClient = getClient;
20
22
  }
21
- async initialize(mqttConfig) {
23
+ async initialize(token, secret) {
22
24
  if (!this.mqttClient) {
23
- const client = new SwitchBotMqttClient(mqttConfig, async () => {
24
- // Auth refresh callback - would need credential resolution here
25
- return {
26
- username: mqttConfig.username,
27
- password: mqttConfig.password,
28
- };
29
- });
25
+ const credential = await fetchMqttCredential(token, secret);
26
+ const client = new SwitchBotMqttClient(credential, () => fetchMqttCredential(token, secret));
30
27
  client.onStateChange((state) => {
31
28
  if (state === 'connected') {
32
29
  this.emit({
33
30
  kind: 'events.reconnected',
34
31
  timestamp: Date.now(),
35
32
  });
36
- client.subscribe('$aws/things/+/shadow/update/accepted');
33
+ client.subscribe(credential.topics.status);
37
34
  }
38
35
  });
39
36
  client.onMessage((topic, payload) => {
40
37
  try {
41
38
  const data = JSON.parse(payload.toString());
42
- const deviceId = this.extractDeviceId(topic);
43
- 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);
44
47
  this.addEvent({
45
48
  kind: 'shadow.updated',
46
49
  deviceId,
47
- payload: data.state,
50
+ payload: payloadData,
48
51
  timestamp: Date.now(),
49
52
  });
50
53
  }
@@ -1,41 +1,53 @@
1
1
  import { connect } from 'mqtt';
2
2
  export class SwitchBotMqttClient {
3
3
  client = null;
4
- config;
4
+ credential;
5
5
  state = 'connecting';
6
- authRefreshNeeded = false;
6
+ credentialExpired = false;
7
7
  reconnectAttempts = 0;
8
8
  maxReconnectAttempts = 10;
9
+ disconnecting = false;
9
10
  handlers = new Set();
10
11
  messageHandlers = new Set();
11
- authRefreshCallback;
12
- stableTimer = null;
13
- lastConnectionAttempt = 0;
14
- constructor(config, onAuthRefreshNeeded) {
15
- this.config = config;
16
- this.authRefreshCallback = onAuthRefreshNeeded;
12
+ credentialRefreshCallback;
13
+ constructor(credential, onCredentialExpired) {
14
+ this.credential = credential;
15
+ this.credentialRefreshCallback = onCredentialExpired;
17
16
  }
18
17
  async connect() {
19
- if (this.client && this.state === 'connected') {
18
+ if (this.client && this.state === 'connected')
20
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;
21
27
  }
22
28
  this.setState('connecting');
23
- this.authRefreshNeeded = false;
29
+ this.credentialExpired = false;
24
30
  this.reconnectAttempts = 0;
25
31
  try {
32
+ const { tls, brokerUrl, clientId } = this.credential;
33
+ // tls.ca/cert/keyBase64 are PEM strings despite the misleading field name
26
34
  const options = {
27
- username: this.config.username,
28
- password: this.config.password,
35
+ clientId,
36
+ ca: tls.caBase64,
37
+ cert: tls.certBase64,
38
+ key: tls.keyBase64,
39
+ rejectUnauthorized: true,
29
40
  clean: true,
30
- reconnectPeriod: 0, // Manual reconnect control
31
- connectTimeout: 10000,
32
- rejectUnauthorized: this.config.rejectUnauthorized ?? true,
41
+ reconnectPeriod: 0,
42
+ connectTimeout: 30000,
43
+ keepalive: 60,
44
+ reschedulePings: true,
33
45
  };
34
- this.client = connect(`mqtts://${this.config.host}:${this.config.port}`, options);
46
+ this.client = connect(brokerUrl, options);
35
47
  this.client.on('connect', () => {
36
48
  this.reconnectAttempts = 0;
37
49
  this.setState('connected');
38
- this.authRefreshNeeded = false;
50
+ this.credentialExpired = false;
39
51
  });
40
52
  this.client.on('message', (topic, payload) => {
41
53
  for (const handler of this.messageHandlers) {
@@ -43,18 +55,17 @@ export class SwitchBotMqttClient {
43
55
  }
44
56
  });
45
57
  this.client.on('error', (err) => {
46
- // Check for auth-related errors
47
- if ((err instanceof Error &&
48
- (err.message.includes('401') ||
49
- err.message.includes('Unauthorized') ||
50
- err.message.includes('EACCES'))) ||
51
- err.code === 'EACCES') {
52
- this.authRefreshNeeded = true;
58
+ if (err instanceof Error &&
59
+ (err.message.includes('certificate') ||
60
+ err.message.includes('ECONNRESET') ||
61
+ err.message.includes('handshake'))) {
62
+ this.credentialExpired = true;
53
63
  }
54
64
  });
55
65
  this.client.on('close', () => {
56
- this.clearStableTimer();
57
- if (this.authRefreshNeeded) {
66
+ if (this.disconnecting)
67
+ return;
68
+ if (this.credentialExpired) {
58
69
  this.setState('failed');
59
70
  }
60
71
  else if (this.reconnectAttempts < this.maxReconnectAttempts) {
@@ -64,7 +75,6 @@ export class SwitchBotMqttClient {
64
75
  this.setState('failed');
65
76
  }
66
77
  });
67
- // Wait for connection with timeout
68
78
  await new Promise((resolve, reject) => {
69
79
  const timeout = setTimeout(() => {
70
80
  reject(new Error('MQTT connection timeout'));
@@ -97,26 +107,22 @@ export class SwitchBotMqttClient {
97
107
  async attemptReconnect() {
98
108
  this.reconnectAttempts++;
99
109
  this.setState('reconnecting');
100
- if (this.authRefreshNeeded && this.authRefreshCallback) {
110
+ if (this.credentialExpired && this.credentialRefreshCallback) {
101
111
  try {
102
- const refreshed = await this.authRefreshCallback();
103
- this.config.username = refreshed.username;
104
- this.config.password = refreshed.password;
105
- this.authRefreshNeeded = false;
112
+ this.credential = await this.credentialRefreshCallback();
113
+ this.credentialExpired = false;
106
114
  }
107
- catch (err) {
108
- // Auth refresh failed, mark as failed
115
+ catch {
109
116
  this.setState('failed');
110
117
  return;
111
118
  }
112
119
  }
113
- // Exponential backoff: 1s, 2s, 4s, 8s, 16s, 30s...
114
120
  const delay = Math.min(30000, 1000 * Math.pow(2, this.reconnectAttempts - 1));
115
121
  await new Promise((r) => setTimeout(r, delay));
116
122
  try {
117
123
  await this.connect();
118
124
  }
119
- catch (err) {
125
+ catch {
120
126
  if (this.reconnectAttempts < this.maxReconnectAttempts) {
121
127
  await this.attemptReconnect();
122
128
  }
@@ -133,12 +139,6 @@ export class SwitchBotMqttClient {
133
139
  }
134
140
  }
135
141
  }
136
- clearStableTimer() {
137
- if (this.stableTimer) {
138
- clearTimeout(this.stableTimer);
139
- this.stableTimer = null;
140
- }
141
- }
142
142
  subscribe(topic) {
143
143
  if (this.client && this.state === 'connected') {
144
144
  this.client.subscribe(topic, (err) => {
@@ -167,18 +167,14 @@ export class SwitchBotMqttClient {
167
167
  return this.state === 'connected' && this.client?.connected === true;
168
168
  }
169
169
  async disconnect() {
170
- this.clearStableTimer();
170
+ this.disconnecting = true;
171
171
  if (this.client) {
172
172
  await new Promise((resolve) => {
173
- this.client?.end(false, () => {
174
- resolve();
175
- });
173
+ this.client?.end(false, () => resolve());
176
174
  });
177
175
  this.client = null;
178
- this.setState('failed');
179
176
  }
180
- }
181
- setAuthRefreshCallback(callback) {
182
- this.authRefreshCallback = callback;
177
+ this.disconnecting = false;
178
+ this.setState('failed');
183
179
  }
184
180
  }