@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.
- package/README.md +1 -0
- 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 +3 -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
package/dist/commands/plan.js
CHANGED
package/dist/commands/quota.js
CHANGED
package/dist/commands/scenes.js
CHANGED
package/dist/commands/schema.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { printJson
|
|
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
|
-
|
|
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
|
package/dist/commands/watch.js
CHANGED
package/dist/commands/webhook.js
CHANGED
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
|
|
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
|
|
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 =
|
|
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 =
|
|
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
|
package/dist/devices/cache.js
CHANGED
package/dist/devices/catalog.js
CHANGED
package/dist/index.js
CHANGED
package/dist/lib/devices.js
CHANGED
|
@@ -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
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
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
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
result: '
|
|
118
|
-
|
|
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
|
-
|
|
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
|
+
}
|
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
|
+
}
|