@switchbot/openapi-cli 1.3.2 → 2.0.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.
Files changed (122) hide show
  1. package/README.md +1 -0
  2. package/dist/api/client.js +22 -5
  3. package/dist/auth.js +0 -1
  4. package/dist/commands/batch.js +12 -6
  5. package/dist/commands/cache.js +0 -1
  6. package/dist/commands/capabilities.js +3 -3
  7. package/dist/commands/catalog.js +0 -1
  8. package/dist/commands/completion.js +0 -1
  9. package/dist/commands/config.js +0 -1
  10. package/dist/commands/device-meta.js +0 -1
  11. package/dist/commands/devices.js +2 -2
  12. package/dist/commands/doctor.js +0 -1
  13. package/dist/commands/events.js +0 -1
  14. package/dist/commands/expand.js +0 -1
  15. package/dist/commands/explain.js +0 -1
  16. package/dist/commands/history.js +0 -1
  17. package/dist/commands/mcp.js +334 -18
  18. package/dist/commands/plan.js +0 -1
  19. package/dist/commands/quota.js +0 -1
  20. package/dist/commands/scenes.js +0 -1
  21. package/dist/commands/schema.js +2 -9
  22. package/dist/commands/watch.js +0 -1
  23. package/dist/commands/webhook.js +0 -1
  24. package/dist/config.js +5 -5
  25. package/dist/devices/cache.js +0 -1
  26. package/dist/devices/catalog.js +0 -1
  27. package/dist/devices/device-meta.js +0 -1
  28. package/dist/index.js +0 -1
  29. package/dist/lib/devices.js +22 -18
  30. package/dist/lib/idempotency.js +72 -0
  31. package/dist/lib/request-context.js +12 -0
  32. package/dist/lib/scenes.js +0 -1
  33. package/dist/logger.js +16 -0
  34. package/dist/mcp/events-subscription.js +210 -0
  35. package/dist/mqtt/client.js +184 -0
  36. package/dist/mqtt/credential.js +12 -0
  37. package/dist/utils/audit.js +0 -1
  38. package/dist/utils/filter.js +0 -1
  39. package/dist/utils/flags.js +0 -1
  40. package/dist/utils/format.js +0 -1
  41. package/dist/utils/name-resolver.js +0 -1
  42. package/dist/utils/output.js +30 -6
  43. package/dist/utils/quota.js +0 -1
  44. package/dist/utils/retry.js +0 -1
  45. package/dist/utils/string.js +0 -1
  46. package/package.json +6 -2
  47. package/dist/api/client.d.ts +0 -18
  48. package/dist/api/client.js.map +0 -1
  49. package/dist/auth.d.ts +0 -1
  50. package/dist/auth.js.map +0 -1
  51. package/dist/commands/batch.d.ts +0 -2
  52. package/dist/commands/batch.js.map +0 -1
  53. package/dist/commands/cache.d.ts +0 -2
  54. package/dist/commands/cache.js.map +0 -1
  55. package/dist/commands/capabilities.d.ts +0 -2
  56. package/dist/commands/capabilities.js.map +0 -1
  57. package/dist/commands/catalog.d.ts +0 -2
  58. package/dist/commands/catalog.js.map +0 -1
  59. package/dist/commands/completion.d.ts +0 -2
  60. package/dist/commands/completion.js.map +0 -1
  61. package/dist/commands/config.d.ts +0 -2
  62. package/dist/commands/config.js.map +0 -1
  63. package/dist/commands/device-meta.d.ts +0 -2
  64. package/dist/commands/device-meta.js.map +0 -1
  65. package/dist/commands/devices.d.ts +0 -2
  66. package/dist/commands/devices.js.map +0 -1
  67. package/dist/commands/doctor.d.ts +0 -2
  68. package/dist/commands/doctor.js.map +0 -1
  69. package/dist/commands/events.d.ts +0 -15
  70. package/dist/commands/events.js.map +0 -1
  71. package/dist/commands/expand.d.ts +0 -2
  72. package/dist/commands/expand.js.map +0 -1
  73. package/dist/commands/explain.d.ts +0 -2
  74. package/dist/commands/explain.js.map +0 -1
  75. package/dist/commands/history.d.ts +0 -2
  76. package/dist/commands/history.js.map +0 -1
  77. package/dist/commands/mcp.d.ts +0 -4
  78. package/dist/commands/mcp.js.map +0 -1
  79. package/dist/commands/plan.d.ts +0 -38
  80. package/dist/commands/plan.js.map +0 -1
  81. package/dist/commands/quota.d.ts +0 -2
  82. package/dist/commands/quota.js.map +0 -1
  83. package/dist/commands/scenes.d.ts +0 -2
  84. package/dist/commands/scenes.js.map +0 -1
  85. package/dist/commands/schema.d.ts +0 -2
  86. package/dist/commands/schema.js.map +0 -1
  87. package/dist/commands/watch.d.ts +0 -2
  88. package/dist/commands/watch.js.map +0 -1
  89. package/dist/commands/webhook.d.ts +0 -2
  90. package/dist/commands/webhook.js.map +0 -1
  91. package/dist/config.d.ts +0 -18
  92. package/dist/config.js.map +0 -1
  93. package/dist/devices/cache.d.ts +0 -79
  94. package/dist/devices/cache.js.map +0 -1
  95. package/dist/devices/catalog.d.ts +0 -70
  96. package/dist/devices/catalog.js.map +0 -1
  97. package/dist/devices/device-meta.d.ts +0 -15
  98. package/dist/devices/device-meta.js.map +0 -1
  99. package/dist/index.d.ts +0 -2
  100. package/dist/index.js.map +0 -1
  101. package/dist/lib/devices.d.ts +0 -144
  102. package/dist/lib/devices.js.map +0 -1
  103. package/dist/lib/scenes.d.ts +0 -7
  104. package/dist/lib/scenes.js.map +0 -1
  105. package/dist/utils/audit.d.ts +0 -13
  106. package/dist/utils/audit.js.map +0 -1
  107. package/dist/utils/filter.d.ts +0 -45
  108. package/dist/utils/filter.js.map +0 -1
  109. package/dist/utils/flags.d.ts +0 -52
  110. package/dist/utils/flags.js.map +0 -1
  111. package/dist/utils/format.d.ts +0 -9
  112. package/dist/utils/format.js.map +0 -1
  113. package/dist/utils/name-resolver.d.ts +0 -17
  114. package/dist/utils/name-resolver.js.map +0 -1
  115. package/dist/utils/output.d.ts +0 -23
  116. package/dist/utils/output.js.map +0 -1
  117. package/dist/utils/quota.d.ts +0 -50
  118. package/dist/utils/quota.js.map +0 -1
  119. package/dist/utils/retry.d.ts +0 -23
  120. package/dist/utils/retry.js.map +0 -1
  121. package/dist/utils/string.d.ts +0 -2
  122. package/dist/utils/string.js.map +0 -1
@@ -357,4 +357,3 @@ Workflow:
357
357
  process.exit(1);
358
358
  });
359
359
  }
360
- //# sourceMappingURL=plan.js.map
@@ -74,4 +74,3 @@ Examples:
74
74
  }
75
75
  });
76
76
  }
77
- //# sourceMappingURL=quota.js.map
@@ -60,4 +60,3 @@ Example:
60
60
  }
61
61
  });
62
62
  }
63
- //# sourceMappingURL=scenes.js.map
@@ -1,4 +1,4 @@
1
- import { printJson, isJsonMode } from '../utils/output.js';
1
+ import { printJson } from '../utils/output.js';
2
2
  import { getEffectiveCatalog } from '../devices/catalog.js';
3
3
  function toSchemaEntry(e) {
4
4
  return {
@@ -65,13 +65,6 @@ Examples:
65
65
  generatedAt: new Date().toISOString(),
66
66
  types: filtered.map(toSchemaEntry),
67
67
  };
68
- // Always JSON — schema export without JSON would be a category error.
69
- if (isJsonMode()) {
70
- printJson(payload);
71
- }
72
- else {
73
- console.log(JSON.stringify(payload, null, 2));
74
- }
68
+ printJson(payload);
75
69
  });
76
70
  }
77
- //# sourceMappingURL=schema.js.map
@@ -160,4 +160,3 @@ Examples:
160
160
  }
161
161
  });
162
162
  }
163
- //# sourceMappingURL=watch.js.map
@@ -179,4 +179,3 @@ Example:
179
179
  }
180
180
  });
181
181
  }
182
- //# sourceMappingURL=webhook.js.map
package/dist/config.js CHANGED
@@ -1,11 +1,12 @@
1
1
  import fs from 'node:fs';
2
2
  import path from 'node:path';
3
3
  import os from 'node:os';
4
- import { getConfigPath, getProfile } from './utils/flags.js';
4
+ import { getConfigPath } from './utils/flags.js';
5
+ import { getActiveProfile } from './lib/request-context.js';
5
6
  /**
6
7
  * Credential file resolution priority:
7
8
  * 1. --config <path> (absolute override — wins over everything)
8
- * 2. --profile <name> → ~/.switchbot/profiles/<name>.json
9
+ * 2. active profile (ALS request context, else --profile flag) → ~/.switchbot/profiles/<name>.json
9
10
  * 3. default → ~/.switchbot/config.json
10
11
  *
11
12
  * Env SWITCHBOT_TOKEN+SWITCHBOT_SECRET still take priority inside loadConfig.
@@ -14,7 +15,7 @@ export function configFilePath() {
14
15
  const override = getConfigPath();
15
16
  if (override)
16
17
  return path.resolve(override);
17
- const profile = getProfile();
18
+ const profile = getActiveProfile();
18
19
  if (profile) {
19
20
  return path.join(os.homedir(), '.switchbot', 'profiles', `${profile}.json`);
20
21
  }
@@ -40,7 +41,7 @@ export function loadConfig() {
40
41
  }
41
42
  const file = configFilePath();
42
43
  if (!fs.existsSync(file)) {
43
- const profile = getProfile();
44
+ const profile = getActiveProfile();
44
45
  const hint = profile
45
46
  ? `No credentials configured for profile "${profile}". Run: switchbot --profile ${profile} config set-token <token> <secret>`
46
47
  : 'No credentials configured. Run: switchbot config set-token <token> <secret>';
@@ -100,4 +101,3 @@ function maskSecret(secret) {
100
101
  return '****';
101
102
  return secret.slice(0, 2) + '*'.repeat(secret.length - 4) + secret.slice(-2);
102
103
  }
103
- //# sourceMappingURL=config.js.map
@@ -256,4 +256,3 @@ export function describeCache(now = Date.now()) {
256
256
  }
257
257
  return { list, status };
258
258
  }
259
- //# sourceMappingURL=cache.js.map
@@ -658,4 +658,3 @@ export function getEffectiveCatalog() {
658
658
  }
659
659
  return Array.from(byType.values());
660
660
  }
661
- //# sourceMappingURL=catalog.js.map
@@ -54,4 +54,3 @@ export function clearDeviceMeta(deviceId) {
54
54
  delete meta.devices[deviceId];
55
55
  saveDeviceMeta(meta);
56
56
  }
57
- //# sourceMappingURL=device-meta.js.map
package/dist/index.js CHANGED
@@ -115,4 +115,3 @@ catch (err) {
115
115
  }
116
116
  throw err;
117
117
  }
118
- //# sourceMappingURL=index.js.map
@@ -1,4 +1,5 @@
1
1
  import { createClient } from '../api/client.js';
2
+ import { idempotencyCache } from './idempotency.js';
2
3
  import { findCatalogEntry, suggestedActions, getEffectiveCatalog, } from '../devices/catalog.js';
3
4
  import { getCachedDevice, updateCacheFromDeviceList, loadCache, isListCacheFresh, getCachedStatus, setCachedStatus, } from '../devices/cache.js';
4
5
  import { getCacheMode } from '../utils/flags.js';
@@ -85,7 +86,7 @@ export async function fetchDeviceStatus(deviceId, client) {
85
86
  * (JSON-object when applicable), not a raw CLI string — callers should parse
86
87
  * upstream if needed.
87
88
  */
88
- export async function executeCommand(deviceId, cmd, parameter, commandType, client) {
89
+ export async function executeCommand(deviceId, cmd, parameter, commandType, client, options) {
89
90
  const c = client ?? createClient();
90
91
  const body = {
91
92
  command: cmd,
@@ -101,25 +102,29 @@ export async function executeCommand(deviceId, cmd, parameter, commandType, clie
101
102
  commandType,
102
103
  dryRun: isDryRun(),
103
104
  };
104
- try {
105
- const res = await c.post(`/v1.1/devices/${deviceId}/commands`, body);
106
- writeAudit({ ...baseAudit, result: 'ok' });
107
- return res.data.body;
108
- }
109
- catch (err) {
110
- // Dry-run intercepts throw DryRunSignal — still log the intent.
111
- if (err instanceof Error && err.name === 'DryRunSignal') {
105
+ // Wrap in idempotency cache if key is provided
106
+ const execute = async () => {
107
+ try {
108
+ const res = await c.post(`/v1.1/devices/${deviceId}/commands`, body);
112
109
  writeAudit({ ...baseAudit, result: 'ok' });
110
+ return res.data.body;
113
111
  }
114
- else {
115
- writeAudit({
116
- ...baseAudit,
117
- result: 'error',
118
- error: err instanceof Error ? err.message : String(err),
119
- });
112
+ catch (err) {
113
+ // Dry-run intercepts throw DryRunSignal — still log the intent.
114
+ if (err instanceof Error && err.name === 'DryRunSignal') {
115
+ writeAudit({ ...baseAudit, result: 'ok' });
116
+ }
117
+ else {
118
+ writeAudit({
119
+ ...baseAudit,
120
+ result: 'error',
121
+ error: err instanceof Error ? err.message : String(err),
122
+ });
123
+ }
124
+ throw err;
120
125
  }
121
- throw err;
122
- }
126
+ };
127
+ return idempotencyCache.run(options?.idempotencyKey, execute);
123
128
  }
124
129
  /**
125
130
  * Validate a command against the locally-cached device → catalog mapping.
@@ -326,4 +331,3 @@ export function toMcpIrDeviceShape(d) {
326
331
  controlType: d.controlType,
327
332
  };
328
333
  }
329
- //# sourceMappingURL=devices.js.map
@@ -0,0 +1,72 @@
1
+ /**
2
+ * In-memory LRU cache for idempotent request deduplication.
3
+ * Caches the outcome of a keyed operation for 60 seconds;
4
+ * duplicate keys within the window return the cached result without re-executing.
5
+ * Process-local only — not shared across replicas.
6
+ */
7
+ const DEFAULT_TTL_MS = 60000; // 60 seconds
8
+ const DEFAULT_MAX_ENTRIES = 1024;
9
+ export class IdempotencyCache {
10
+ cache = new Map();
11
+ ttlMs;
12
+ maxEntries;
13
+ constructor(ttlMs, maxEntries) {
14
+ this.ttlMs = ttlMs ?? DEFAULT_TTL_MS;
15
+ this.maxEntries = maxEntries ?? DEFAULT_MAX_ENTRIES;
16
+ }
17
+ /**
18
+ * Execute fn if the key is not cached, or return the cached result if it is.
19
+ * On new execution, caches the result for ttlMs.
20
+ */
21
+ async run(key, fn) {
22
+ // No key = always execute (not cached)
23
+ if (!key) {
24
+ return fn();
25
+ }
26
+ const now = Date.now();
27
+ const cached = this.cache.get(key);
28
+ // Cached and not expired
29
+ if (cached && cached.expiresAt > now) {
30
+ return cached.result;
31
+ }
32
+ // Expired or uncached: execute
33
+ const result = await fn();
34
+ // Prune if over capacity (LRU: remove oldest entries)
35
+ if (this.cache.size >= this.maxEntries) {
36
+ const toRemove = Math.ceil(this.maxEntries * 0.1); // Remove 10%
37
+ let removed = 0;
38
+ for (const [k, v] of this.cache.entries()) {
39
+ if (removed >= toRemove)
40
+ break;
41
+ // Remove expired entries first, then oldest
42
+ if (v.expiresAt <= now) {
43
+ this.cache.delete(k);
44
+ removed++;
45
+ }
46
+ }
47
+ // If still over capacity, remove oldest insertion (Map is insertion-ordered)
48
+ if (this.cache.size >= this.maxEntries) {
49
+ const firstKey = this.cache.keys().next().value;
50
+ if (firstKey)
51
+ this.cache.delete(firstKey);
52
+ }
53
+ }
54
+ // Cache the result
55
+ this.cache.set(key, { result, expiresAt: now + this.ttlMs });
56
+ return result;
57
+ }
58
+ /**
59
+ * Clear all cached entries (mainly for testing).
60
+ */
61
+ clear() {
62
+ this.cache.clear();
63
+ }
64
+ /**
65
+ * Return the number of cached entries.
66
+ */
67
+ size() {
68
+ return this.cache.size;
69
+ }
70
+ }
71
+ // Global shared instance for the process
72
+ export const idempotencyCache = new IdempotencyCache();
@@ -0,0 +1,12 @@
1
+ import { AsyncLocalStorage } from 'node:async_hooks';
2
+ import { getProfile } from '../utils/flags.js';
3
+ export const requestContext = new AsyncLocalStorage();
4
+ export function withRequestContext(ctx, fn) {
5
+ return requestContext.run(ctx, fn);
6
+ }
7
+ export function getActiveProfile() {
8
+ const ctx = requestContext.getStore();
9
+ if (ctx?.profile !== undefined)
10
+ return ctx.profile;
11
+ return getProfile();
12
+ }
@@ -8,4 +8,3 @@ export async function executeScene(sceneId, client) {
8
8
  const c = client ?? createClient();
9
9
  await c.post(`/v1.1/scenes/${sceneId}/execute`);
10
10
  }
11
- //# sourceMappingURL=scenes.js.map
package/dist/logger.js ADDED
@@ -0,0 +1,16 @@
1
+ import pino from 'pino';
2
+ const logLevel = process.env.LOG_LEVEL || 'warn';
3
+ const logFormat = process.env.LOG_FORMAT || 'json';
4
+ const pinoConfig = {
5
+ level: logLevel,
6
+ transport: logFormat === 'pretty'
7
+ ? { target: 'pino-pretty' }
8
+ : undefined,
9
+ };
10
+ export const log = pino(pinoConfig);
11
+ export function setLogLevel(level) {
12
+ log.level = level;
13
+ }
14
+ export function getLogLevel() {
15
+ return log.level;
16
+ }
@@ -0,0 +1,210 @@
1
+ import { SwitchBotMqttClient } from '../mqtt/client.js';
2
+ import { parseFilter, applyFilter } from '../utils/filter.js';
3
+ import { fetchDeviceList } from '../lib/devices.js';
4
+ import { getCachedDevice } from '../devices/cache.js';
5
+ import { createClient } from '../api/client.js';
6
+ import { log } from '../logger.js';
7
+ export class EventSubscriptionManager {
8
+ mqttClient = null;
9
+ subscribers = new Map();
10
+ ringBuffer = [];
11
+ ringSize = 1000;
12
+ typeMap = new Map();
13
+ refreshTypeMapTimer = null;
14
+ idleCleanupTimer = null;
15
+ getClient;
16
+ lastRefreshAttempt = 0;
17
+ constructor(mqttClient, getClient) {
18
+ this.mqttClient = mqttClient || null;
19
+ this.getClient = getClient;
20
+ }
21
+ async initialize(mqttConfig) {
22
+ 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
+ });
30
+ client.onStateChange((state) => {
31
+ if (state === 'connected') {
32
+ this.emit({
33
+ kind: 'events.reconnected',
34
+ timestamp: Date.now(),
35
+ });
36
+ client.subscribe('$aws/things/+/shadow/update/accepted');
37
+ }
38
+ });
39
+ client.onMessage((topic, payload) => {
40
+ try {
41
+ const data = JSON.parse(payload.toString());
42
+ const deviceId = this.extractDeviceId(topic);
43
+ if (deviceId && data.state) {
44
+ this.addEvent({
45
+ kind: 'shadow.updated',
46
+ deviceId,
47
+ payload: data.state,
48
+ timestamp: Date.now(),
49
+ });
50
+ }
51
+ }
52
+ catch (err) {
53
+ log.debug({ err, topic }, 'failed to parse shadow payload');
54
+ }
55
+ });
56
+ await client.connect();
57
+ this.mqttClient = client;
58
+ }
59
+ this.scheduleIdleCleanup();
60
+ }
61
+ subscribe(id, handler, filter) {
62
+ // Validate filter syntax if provided
63
+ if (filter) {
64
+ parseFilter(filter);
65
+ }
66
+ const subscriber = {
67
+ id,
68
+ handler,
69
+ filter,
70
+ lastActivity: Date.now(),
71
+ };
72
+ this.subscribers.set(id, subscriber);
73
+ // Send recent events that match the filter
74
+ for (const event of this.ringBuffer) {
75
+ if (this.matchesFilter(event, filter)) {
76
+ handler(event);
77
+ }
78
+ }
79
+ return () => {
80
+ this.subscribers.delete(id);
81
+ };
82
+ }
83
+ addEvent(event) {
84
+ this.ringBuffer.push(event);
85
+ // Check for overflow
86
+ if (this.ringBuffer.length > this.ringSize) {
87
+ const droppedCount = this.ringBuffer.length - this.ringSize;
88
+ const oldestTimestamp = this.ringBuffer[0]?.timestamp || Date.now();
89
+ // Emit overflow notice to all subscribers
90
+ this.emit({
91
+ kind: 'events.dropped',
92
+ count: droppedCount,
93
+ sinceTs: oldestTimestamp,
94
+ });
95
+ // Trim buffer
96
+ this.ringBuffer = this.ringBuffer.slice(-this.ringSize);
97
+ }
98
+ // Broadcast to matching subscribers
99
+ this.emit(event);
100
+ }
101
+ emit(event) {
102
+ for (const subscriber of this.subscribers.values()) {
103
+ if (this.matchesFilter(event, subscriber.filter)) {
104
+ subscriber.lastActivity = Date.now();
105
+ subscriber.handler(event);
106
+ }
107
+ }
108
+ }
109
+ matchesFilter(event, filter) {
110
+ if (!filter)
111
+ return true;
112
+ // Only filter shadow events
113
+ if (event.kind !== 'shadow.updated')
114
+ return true;
115
+ try {
116
+ // Parse filter and match against device metadata
117
+ const clauses = parseFilter(filter);
118
+ const deviceId = event.deviceId;
119
+ // Get device info from cache
120
+ const cached = getCachedDevice(deviceId);
121
+ if (!cached) {
122
+ // Lazily refresh type map if device unknown
123
+ this.scheduleTypeMapRefresh();
124
+ return false; // Conservative: drop if unknown
125
+ }
126
+ // Build a Device-compatible shape for applyFilter
127
+ const device = {
128
+ deviceId,
129
+ deviceType: this.typeMap.get(deviceId) || cached.type,
130
+ deviceName: cached.name,
131
+ familyName: cached.familyName,
132
+ roomName: cached.roomName,
133
+ enableCloudService: true,
134
+ hubDeviceId: '',
135
+ };
136
+ // Use applyFilter with single device in list
137
+ const matched = applyFilter(clauses, [device], [], new Map());
138
+ return matched.length > 0;
139
+ }
140
+ catch {
141
+ return false; // Invalid filter matches nothing
142
+ }
143
+ }
144
+ scheduleTypeMapRefresh() {
145
+ if (this.refreshTypeMapTimer || Date.now() - this.lastRefreshAttempt < 5000) {
146
+ return; // Already scheduled or too recent
147
+ }
148
+ this.refreshTypeMapTimer = setTimeout(async () => {
149
+ this.refreshTypeMapTimer = null;
150
+ this.lastRefreshAttempt = Date.now();
151
+ try {
152
+ const client = this.getClient?.() || createClient();
153
+ const body = await fetchDeviceList(client);
154
+ for (const d of body.deviceList) {
155
+ if (d.deviceType)
156
+ this.typeMap.set(d.deviceId, d.deviceType);
157
+ }
158
+ for (const ir of body.infraredRemoteList) {
159
+ this.typeMap.set(ir.deviceId, ir.remoteType);
160
+ }
161
+ }
162
+ catch {
163
+ // Silently fail type map refresh
164
+ }
165
+ }, 100);
166
+ }
167
+ scheduleIdleCleanup() {
168
+ if (this.idleCleanupTimer)
169
+ return;
170
+ this.idleCleanupTimer = setInterval(() => {
171
+ const now = Date.now();
172
+ const idleThreshold = 10 * 60 * 1000; // 10 minutes
173
+ for (const [id, subscriber] of this.subscribers.entries()) {
174
+ if (now - subscriber.lastActivity > idleThreshold) {
175
+ this.subscribers.delete(id);
176
+ }
177
+ }
178
+ }, 60000); // Check every minute
179
+ }
180
+ extractDeviceId(topic) {
181
+ // Topic format: $aws/things/<deviceId>/shadow/update/accepted
182
+ const match = topic.match(/\$aws\/things\/([^/]+)\/shadow/);
183
+ return match ? match[1] : null;
184
+ }
185
+ getState() {
186
+ if (!this.mqttClient)
187
+ return 'disabled';
188
+ return this.mqttClient.getState();
189
+ }
190
+ getSubscriberCount() {
191
+ return this.subscribers.size;
192
+ }
193
+ getRecentEvents(limit = 100) {
194
+ return this.ringBuffer.slice(-limit);
195
+ }
196
+ async shutdown() {
197
+ if (this.refreshTypeMapTimer) {
198
+ clearTimeout(this.refreshTypeMapTimer);
199
+ }
200
+ if (this.idleCleanupTimer) {
201
+ clearInterval(this.idleCleanupTimer);
202
+ }
203
+ if (this.mqttClient) {
204
+ await this.mqttClient.disconnect();
205
+ this.mqttClient = null;
206
+ }
207
+ this.subscribers.clear();
208
+ this.ringBuffer = [];
209
+ }
210
+ }
@@ -0,0 +1,184 @@
1
+ import { connect } from 'mqtt';
2
+ export class SwitchBotMqttClient {
3
+ client = null;
4
+ config;
5
+ state = 'connecting';
6
+ authRefreshNeeded = false;
7
+ reconnectAttempts = 0;
8
+ maxReconnectAttempts = 10;
9
+ handlers = new Set();
10
+ messageHandlers = new Set();
11
+ authRefreshCallback;
12
+ stableTimer = null;
13
+ lastConnectionAttempt = 0;
14
+ constructor(config, onAuthRefreshNeeded) {
15
+ this.config = config;
16
+ this.authRefreshCallback = onAuthRefreshNeeded;
17
+ }
18
+ async connect() {
19
+ if (this.client && this.state === 'connected') {
20
+ return;
21
+ }
22
+ this.setState('connecting');
23
+ this.authRefreshNeeded = false;
24
+ this.reconnectAttempts = 0;
25
+ try {
26
+ const options = {
27
+ username: this.config.username,
28
+ password: this.config.password,
29
+ clean: true,
30
+ reconnectPeriod: 0, // Manual reconnect control
31
+ connectTimeout: 10000,
32
+ rejectUnauthorized: this.config.rejectUnauthorized ?? true,
33
+ };
34
+ this.client = connect(`mqtts://${this.config.host}:${this.config.port}`, options);
35
+ this.client.on('connect', () => {
36
+ this.reconnectAttempts = 0;
37
+ this.setState('connected');
38
+ this.authRefreshNeeded = false;
39
+ });
40
+ this.client.on('message', (topic, payload) => {
41
+ for (const handler of this.messageHandlers) {
42
+ handler(topic, payload);
43
+ }
44
+ });
45
+ 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;
53
+ }
54
+ });
55
+ this.client.on('close', () => {
56
+ this.clearStableTimer();
57
+ if (this.authRefreshNeeded) {
58
+ this.setState('failed');
59
+ }
60
+ else if (this.reconnectAttempts < this.maxReconnectAttempts) {
61
+ this.attemptReconnect();
62
+ }
63
+ else {
64
+ this.setState('failed');
65
+ }
66
+ });
67
+ // Wait for connection with timeout
68
+ await new Promise((resolve, reject) => {
69
+ const timeout = setTimeout(() => {
70
+ reject(new Error('MQTT connection timeout'));
71
+ }, 15000);
72
+ const onConnect = () => {
73
+ clearTimeout(timeout);
74
+ this.client?.removeListener('error', onError);
75
+ resolve();
76
+ };
77
+ const onError = (err) => {
78
+ clearTimeout(timeout);
79
+ this.client?.removeListener('connect', onConnect);
80
+ reject(err);
81
+ };
82
+ if (this.client?.connected) {
83
+ clearTimeout(timeout);
84
+ resolve();
85
+ }
86
+ else {
87
+ this.client?.once('connect', onConnect);
88
+ this.client?.once('error', onError);
89
+ }
90
+ });
91
+ }
92
+ catch (err) {
93
+ this.setState('failed');
94
+ throw err;
95
+ }
96
+ }
97
+ async attemptReconnect() {
98
+ this.reconnectAttempts++;
99
+ this.setState('reconnecting');
100
+ if (this.authRefreshNeeded && this.authRefreshCallback) {
101
+ try {
102
+ const refreshed = await this.authRefreshCallback();
103
+ this.config.username = refreshed.username;
104
+ this.config.password = refreshed.password;
105
+ this.authRefreshNeeded = false;
106
+ }
107
+ catch (err) {
108
+ // Auth refresh failed, mark as failed
109
+ this.setState('failed');
110
+ return;
111
+ }
112
+ }
113
+ // Exponential backoff: 1s, 2s, 4s, 8s, 16s, 30s...
114
+ const delay = Math.min(30000, 1000 * Math.pow(2, this.reconnectAttempts - 1));
115
+ await new Promise((r) => setTimeout(r, delay));
116
+ try {
117
+ await this.connect();
118
+ }
119
+ catch (err) {
120
+ if (this.reconnectAttempts < this.maxReconnectAttempts) {
121
+ await this.attemptReconnect();
122
+ }
123
+ else {
124
+ this.setState('failed');
125
+ }
126
+ }
127
+ }
128
+ setState(newState) {
129
+ if (this.state !== newState) {
130
+ this.state = newState;
131
+ for (const handler of this.handlers) {
132
+ handler(newState);
133
+ }
134
+ }
135
+ }
136
+ clearStableTimer() {
137
+ if (this.stableTimer) {
138
+ clearTimeout(this.stableTimer);
139
+ this.stableTimer = null;
140
+ }
141
+ }
142
+ subscribe(topic) {
143
+ if (this.client && this.state === 'connected') {
144
+ this.client.subscribe(topic, (err) => {
145
+ if (err) {
146
+ console.error(`Failed to subscribe to ${topic}:`, err);
147
+ }
148
+ });
149
+ }
150
+ }
151
+ onStateChange(handler) {
152
+ this.handlers.add(handler);
153
+ return () => {
154
+ this.handlers.delete(handler);
155
+ };
156
+ }
157
+ onMessage(handler) {
158
+ this.messageHandlers.add(handler);
159
+ return () => {
160
+ this.messageHandlers.delete(handler);
161
+ };
162
+ }
163
+ getState() {
164
+ return this.state;
165
+ }
166
+ isConnected() {
167
+ return this.state === 'connected' && this.client?.connected === true;
168
+ }
169
+ async disconnect() {
170
+ this.clearStableTimer();
171
+ if (this.client) {
172
+ await new Promise((resolve) => {
173
+ this.client?.end(false, () => {
174
+ resolve();
175
+ });
176
+ });
177
+ this.client = null;
178
+ this.setState('failed');
179
+ }
180
+ }
181
+ setAuthRefreshCallback(callback) {
182
+ this.authRefreshCallback = callback;
183
+ }
184
+ }