@switchbot/openapi-cli 1.3.2 → 2.0.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.
- package/README.md +30 -1
- package/dist/api/client.js +22 -5
- package/dist/auth.js +0 -1
- package/dist/commands/batch.js +12 -6
- package/dist/commands/cache.js +0 -1
- package/dist/commands/capabilities.js +5 -3
- package/dist/commands/catalog.js +0 -1
- package/dist/commands/completion.js +0 -1
- package/dist/commands/config.js +0 -1
- package/dist/commands/device-meta.js +0 -1
- package/dist/commands/devices.js +2 -2
- package/dist/commands/doctor.js +0 -1
- package/dist/commands/events.js +0 -1
- package/dist/commands/expand.js +0 -1
- package/dist/commands/explain.js +0 -1
- package/dist/commands/history.js +0 -1
- package/dist/commands/mcp.js +334 -18
- package/dist/commands/plan.js +0 -1
- package/dist/commands/quota.js +0 -1
- package/dist/commands/scenes.js +0 -1
- package/dist/commands/schema.js +2 -9
- package/dist/commands/watch.js +0 -1
- package/dist/commands/webhook.js +0 -1
- package/dist/config.js +5 -5
- package/dist/devices/cache.js +0 -1
- package/dist/devices/catalog.js +0 -1
- package/dist/devices/device-meta.js +0 -1
- package/dist/index.js +0 -1
- package/dist/lib/devices.js +22 -18
- package/dist/lib/idempotency.js +72 -0
- package/dist/lib/request-context.js +12 -0
- package/dist/lib/scenes.js +0 -1
- package/dist/logger.js +16 -0
- package/dist/mcp/events-subscription.js +210 -0
- package/dist/mqtt/client.js +184 -0
- package/dist/mqtt/credential.js +12 -0
- package/dist/utils/audit.js +0 -1
- package/dist/utils/filter.js +0 -1
- package/dist/utils/flags.js +0 -1
- package/dist/utils/format.js +0 -1
- package/dist/utils/name-resolver.js +0 -1
- package/dist/utils/output.js +30 -6
- package/dist/utils/quota.js +0 -1
- package/dist/utils/retry.js +0 -1
- package/dist/utils/string.js +0 -1
- package/package.json +6 -2
- package/dist/api/client.d.ts +0 -18
- package/dist/api/client.js.map +0 -1
- package/dist/auth.d.ts +0 -1
- package/dist/auth.js.map +0 -1
- package/dist/commands/batch.d.ts +0 -2
- package/dist/commands/batch.js.map +0 -1
- package/dist/commands/cache.d.ts +0 -2
- package/dist/commands/cache.js.map +0 -1
- package/dist/commands/capabilities.d.ts +0 -2
- package/dist/commands/capabilities.js.map +0 -1
- package/dist/commands/catalog.d.ts +0 -2
- package/dist/commands/catalog.js.map +0 -1
- package/dist/commands/completion.d.ts +0 -2
- package/dist/commands/completion.js.map +0 -1
- package/dist/commands/config.d.ts +0 -2
- package/dist/commands/config.js.map +0 -1
- package/dist/commands/device-meta.d.ts +0 -2
- package/dist/commands/device-meta.js.map +0 -1
- package/dist/commands/devices.d.ts +0 -2
- package/dist/commands/devices.js.map +0 -1
- package/dist/commands/doctor.d.ts +0 -2
- package/dist/commands/doctor.js.map +0 -1
- package/dist/commands/events.d.ts +0 -15
- package/dist/commands/events.js.map +0 -1
- package/dist/commands/expand.d.ts +0 -2
- package/dist/commands/expand.js.map +0 -1
- package/dist/commands/explain.d.ts +0 -2
- package/dist/commands/explain.js.map +0 -1
- package/dist/commands/history.d.ts +0 -2
- package/dist/commands/history.js.map +0 -1
- package/dist/commands/mcp.d.ts +0 -4
- package/dist/commands/mcp.js.map +0 -1
- package/dist/commands/plan.d.ts +0 -38
- package/dist/commands/plan.js.map +0 -1
- package/dist/commands/quota.d.ts +0 -2
- package/dist/commands/quota.js.map +0 -1
- package/dist/commands/scenes.d.ts +0 -2
- package/dist/commands/scenes.js.map +0 -1
- package/dist/commands/schema.d.ts +0 -2
- package/dist/commands/schema.js.map +0 -1
- package/dist/commands/watch.d.ts +0 -2
- package/dist/commands/watch.js.map +0 -1
- package/dist/commands/webhook.d.ts +0 -2
- package/dist/commands/webhook.js.map +0 -1
- package/dist/config.d.ts +0 -18
- package/dist/config.js.map +0 -1
- package/dist/devices/cache.d.ts +0 -79
- package/dist/devices/cache.js.map +0 -1
- package/dist/devices/catalog.d.ts +0 -70
- package/dist/devices/catalog.js.map +0 -1
- package/dist/devices/device-meta.d.ts +0 -15
- package/dist/devices/device-meta.js.map +0 -1
- package/dist/index.d.ts +0 -2
- package/dist/index.js.map +0 -1
- package/dist/lib/devices.d.ts +0 -144
- package/dist/lib/devices.js.map +0 -1
- package/dist/lib/scenes.d.ts +0 -7
- package/dist/lib/scenes.js.map +0 -1
- package/dist/utils/audit.d.ts +0 -13
- package/dist/utils/audit.js.map +0 -1
- package/dist/utils/filter.d.ts +0 -45
- package/dist/utils/filter.js.map +0 -1
- package/dist/utils/flags.d.ts +0 -52
- package/dist/utils/flags.js.map +0 -1
- package/dist/utils/format.d.ts +0 -9
- package/dist/utils/format.js.map +0 -1
- package/dist/utils/name-resolver.d.ts +0 -17
- package/dist/utils/name-resolver.js.map +0 -1
- package/dist/utils/output.d.ts +0 -23
- package/dist/utils/output.js.map +0 -1
- package/dist/utils/quota.d.ts +0 -50
- package/dist/utils/quota.js.map +0 -1
- package/dist/utils/retry.d.ts +0 -23
- package/dist/utils/retry.js.map +0 -1
- package/dist/utils/string.d.ts +0 -2
- package/dist/utils/string.js.map +0 -1
|
@@ -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
|
+
}
|
package/dist/lib/scenes.js
CHANGED
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
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
export function getMqttConfig() {
|
|
2
|
+
const host = process.env.SWITCHBOT_MQTT_HOST;
|
|
3
|
+
const username = process.env.SWITCHBOT_MQTT_USERNAME;
|
|
4
|
+
const password = process.env.SWITCHBOT_MQTT_PASSWORD;
|
|
5
|
+
if (!host || !username || !password)
|
|
6
|
+
return null;
|
|
7
|
+
const rawPort = process.env.SWITCHBOT_MQTT_PORT;
|
|
8
|
+
const port = rawPort ? Number(rawPort) : 8883;
|
|
9
|
+
if (!Number.isFinite(port) || port <= 0 || port > 65535)
|
|
10
|
+
return null;
|
|
11
|
+
return { host, port, username, password };
|
|
12
|
+
}
|
package/dist/utils/audit.js
CHANGED
package/dist/utils/filter.js
CHANGED
package/dist/utils/flags.js
CHANGED
package/dist/utils/format.js
CHANGED
package/dist/utils/output.js
CHANGED
|
@@ -2,11 +2,12 @@ import Table from 'cli-table3';
|
|
|
2
2
|
import chalk from 'chalk';
|
|
3
3
|
import { ApiError, DryRunSignal } from '../api/client.js';
|
|
4
4
|
import { getFormat } from './flags.js';
|
|
5
|
+
export const SCHEMA_VERSION = '1.1';
|
|
5
6
|
export function isJsonMode() {
|
|
6
7
|
return process.argv.includes('--json') || getFormat() === 'json';
|
|
7
8
|
}
|
|
8
9
|
export function printJson(data) {
|
|
9
|
-
console.log(JSON.stringify(data, null, 2));
|
|
10
|
+
console.log(JSON.stringify({ schemaVersion: SCHEMA_VERSION, data }, null, 2));
|
|
10
11
|
}
|
|
11
12
|
export function printTable(headers, rows) {
|
|
12
13
|
const table = new Table({
|
|
@@ -67,26 +68,50 @@ function classifyApiError(code) {
|
|
|
67
68
|
}
|
|
68
69
|
export function buildErrorPayload(error) {
|
|
69
70
|
if (error instanceof StructuredUsageError) {
|
|
70
|
-
const payload = {
|
|
71
|
+
const payload = {
|
|
72
|
+
code: 2,
|
|
73
|
+
kind: 'usage',
|
|
74
|
+
message: error.message,
|
|
75
|
+
errorClass: 'usage',
|
|
76
|
+
transient: false
|
|
77
|
+
};
|
|
71
78
|
if (error.context)
|
|
72
79
|
payload.context = error.context;
|
|
73
80
|
return payload;
|
|
74
81
|
}
|
|
75
82
|
if (error instanceof UsageError) {
|
|
76
|
-
return { code: 2, kind: 'usage', message: error.message };
|
|
83
|
+
return { code: 2, kind: 'usage', message: error.message, errorClass: 'usage', transient: false };
|
|
77
84
|
}
|
|
78
85
|
const code = error instanceof ApiError ? error.code : 1;
|
|
79
86
|
const kind = error instanceof ApiError ? 'api' : 'runtime';
|
|
80
87
|
const message = error instanceof Error ? error.message : 'An unknown error occurred';
|
|
81
88
|
const hint = error instanceof ApiError ? (error.hint ?? errorHint(error.code)) : null;
|
|
82
89
|
const retryable = error instanceof ApiError ? error.retryable : false;
|
|
83
|
-
const
|
|
90
|
+
const retryAfterMs = error instanceof ApiError ? error.retryAfterMs : undefined;
|
|
91
|
+
const transient = error instanceof ApiError ? error.transient : false;
|
|
92
|
+
// Classify error
|
|
93
|
+
let errorClass = 'api';
|
|
94
|
+
if (kind === 'runtime') {
|
|
95
|
+
errorClass = 'api';
|
|
96
|
+
}
|
|
97
|
+
else if (transient && code >= 500) {
|
|
98
|
+
errorClass = 'api';
|
|
99
|
+
}
|
|
100
|
+
else if (code === 0) {
|
|
101
|
+
errorClass = 'network';
|
|
102
|
+
}
|
|
103
|
+
else if (code >= 400) {
|
|
104
|
+
errorClass = 'api';
|
|
105
|
+
}
|
|
106
|
+
const payload = { code, kind, message, errorClass, transient };
|
|
84
107
|
if (error instanceof ApiError)
|
|
85
108
|
payload.subKind = classifyApiError(error.code);
|
|
86
109
|
if (hint)
|
|
87
110
|
payload.hint = hint;
|
|
88
111
|
if (retryable)
|
|
89
112
|
payload.retryable = true;
|
|
113
|
+
if (retryAfterMs !== undefined)
|
|
114
|
+
payload.retryAfterMs = retryAfterMs;
|
|
90
115
|
return payload;
|
|
91
116
|
}
|
|
92
117
|
export function handleError(error) {
|
|
@@ -95,7 +120,7 @@ export function handleError(error) {
|
|
|
95
120
|
}
|
|
96
121
|
const payload = buildErrorPayload(error);
|
|
97
122
|
if (isJsonMode()) {
|
|
98
|
-
console.error(JSON.stringify({ error: payload }));
|
|
123
|
+
console.error(JSON.stringify({ schemaVersion: SCHEMA_VERSION, error: payload }));
|
|
99
124
|
process.exit(payload.code === 2 ? 2 : 1);
|
|
100
125
|
}
|
|
101
126
|
if (payload.kind === 'usage') {
|
|
@@ -135,4 +160,3 @@ function errorHint(code) {
|
|
|
135
160
|
return null;
|
|
136
161
|
}
|
|
137
162
|
}
|
|
138
|
-
//# sourceMappingURL=output.js.map
|
package/dist/utils/quota.js
CHANGED
package/dist/utils/retry.js
CHANGED
package/dist/utils/string.js
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@switchbot/openapi-cli",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "2.0.1",
|
|
4
4
|
"description": "Command-line interface for SwitchBot API v1.1",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"switchbot",
|
|
@@ -37,12 +37,14 @@
|
|
|
37
37
|
},
|
|
38
38
|
"scripts": {
|
|
39
39
|
"build": "tsc",
|
|
40
|
+
"build:prod": "tsc -p tsconfig.build.json",
|
|
41
|
+
"clean": "node -e \"require('fs').rmSync('dist',{recursive:true,force:true})\"",
|
|
40
42
|
"dev": "tsx src/index.ts",
|
|
41
43
|
"start": "node dist/index.js",
|
|
42
44
|
"test": "vitest run",
|
|
43
45
|
"test:watch": "vitest",
|
|
44
46
|
"test:coverage": "vitest run --coverage",
|
|
45
|
-
"prepublishOnly": "npm run
|
|
47
|
+
"prepublishOnly": "npm test && npm run clean && npm run build:prod"
|
|
46
48
|
},
|
|
47
49
|
"dependencies": {
|
|
48
50
|
"@modelcontextprotocol/sdk": "^1.29.0",
|
|
@@ -51,6 +53,8 @@
|
|
|
51
53
|
"cli-table3": "^0.6.5",
|
|
52
54
|
"commander": "^12.1.0",
|
|
53
55
|
"js-yaml": "^4.1.1",
|
|
56
|
+
"mqtt": "^5.3.0",
|
|
57
|
+
"pino": "^9.0.0",
|
|
54
58
|
"uuid": "^11.0.5"
|
|
55
59
|
},
|
|
56
60
|
"devDependencies": {
|