@switchbot/openapi-cli 3.1.0 → 3.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.
- package/README.md +34 -42
- package/dist/index.js +56945 -169
- package/dist/policy/schema/v0.2.json +1 -1
- package/package.json +3 -2
- package/dist/api/client.js +0 -235
- package/dist/auth.js +0 -20
- package/dist/commands/agent-bootstrap.js +0 -182
- package/dist/commands/auth.js +0 -354
- package/dist/commands/batch.js +0 -413
- package/dist/commands/cache.js +0 -126
- package/dist/commands/capabilities.js +0 -385
- package/dist/commands/catalog.js +0 -359
- package/dist/commands/completion.js +0 -385
- package/dist/commands/config.js +0 -376
- package/dist/commands/daemon.js +0 -367
- package/dist/commands/device-meta.js +0 -159
- package/dist/commands/devices.js +0 -948
- package/dist/commands/doctor.js +0 -1015
- package/dist/commands/events.js +0 -563
- package/dist/commands/expand.js +0 -130
- package/dist/commands/explain.js +0 -139
- package/dist/commands/health.js +0 -113
- package/dist/commands/history.js +0 -320
- package/dist/commands/identity.js +0 -59
- package/dist/commands/install.js +0 -246
- package/dist/commands/mcp.js +0 -2017
- package/dist/commands/plan.js +0 -653
- package/dist/commands/policy.js +0 -586
- package/dist/commands/quota.js +0 -78
- package/dist/commands/rules.js +0 -875
- package/dist/commands/scenes.js +0 -264
- package/dist/commands/schema.js +0 -177
- package/dist/commands/status-sync.js +0 -131
- package/dist/commands/uninstall.js +0 -237
- package/dist/commands/upgrade-check.js +0 -88
- package/dist/commands/watch.js +0 -194
- package/dist/commands/webhook.js +0 -182
- package/dist/config.js +0 -258
- package/dist/credentials/backends/file.js +0 -101
- package/dist/credentials/backends/linux.js +0 -129
- package/dist/credentials/backends/macos.js +0 -129
- package/dist/credentials/backends/windows.js +0 -215
- package/dist/credentials/keychain.js +0 -88
- package/dist/credentials/prime.js +0 -52
- package/dist/devices/cache.js +0 -293
- package/dist/devices/catalog.js +0 -767
- package/dist/devices/device-meta.js +0 -56
- package/dist/devices/history-agg.js +0 -138
- package/dist/devices/history-query.js +0 -181
- package/dist/devices/param-validator.js +0 -433
- package/dist/devices/resources.js +0 -270
- package/dist/install/default-steps.js +0 -257
- package/dist/install/preflight.js +0 -212
- package/dist/install/steps.js +0 -67
- package/dist/lib/command-keywords.js +0 -17
- package/dist/lib/daemon-state.js +0 -46
- package/dist/lib/destructive-mode.js +0 -12
- package/dist/lib/devices.js +0 -382
- package/dist/lib/idempotency.js +0 -106
- package/dist/lib/plan-store.js +0 -68
- package/dist/lib/request-context.js +0 -12
- package/dist/lib/scenes.js +0 -10
- package/dist/logger.js +0 -16
- package/dist/mcp/device-history.js +0 -145
- package/dist/mcp/events-subscription.js +0 -213
- package/dist/mqtt/client.js +0 -180
- package/dist/mqtt/credential.js +0 -30
- package/dist/policy/add-rule.js +0 -124
- package/dist/policy/diff.js +0 -91
- package/dist/policy/format.js +0 -57
- package/dist/policy/load.js +0 -61
- package/dist/policy/migrate.js +0 -67
- package/dist/policy/schema.js +0 -18
- package/dist/policy/validate.js +0 -262
- package/dist/rules/action.js +0 -205
- package/dist/rules/audit-query.js +0 -89
- package/dist/rules/conflict-analyzer.js +0 -203
- package/dist/rules/cron-scheduler.js +0 -186
- package/dist/rules/destructive.js +0 -52
- package/dist/rules/engine.js +0 -757
- package/dist/rules/matcher.js +0 -230
- package/dist/rules/pid-file.js +0 -95
- package/dist/rules/quiet-hours.js +0 -45
- package/dist/rules/suggest.js +0 -95
- package/dist/rules/throttle.js +0 -116
- package/dist/rules/types.js +0 -34
- package/dist/rules/webhook-listener.js +0 -223
- package/dist/rules/webhook-token.js +0 -90
- package/dist/schema/field-aliases.js +0 -131
- package/dist/sinks/dispatcher.js +0 -12
- package/dist/sinks/file.js +0 -19
- package/dist/sinks/format.js +0 -56
- package/dist/sinks/homeassistant.js +0 -44
- package/dist/sinks/openclaw.js +0 -33
- package/dist/sinks/stdout.js +0 -5
- package/dist/sinks/telegram.js +0 -28
- package/dist/sinks/types.js +0 -1
- package/dist/sinks/webhook.js +0 -22
- package/dist/status-sync/manager.js +0 -268
- package/dist/utils/arg-parsers.js +0 -66
- package/dist/utils/audit.js +0 -117
- package/dist/utils/filter.js +0 -189
- package/dist/utils/flags.js +0 -186
- package/dist/utils/format.js +0 -117
- package/dist/utils/health.js +0 -101
- package/dist/utils/help-json.js +0 -54
- package/dist/utils/name-resolver.js +0 -137
- package/dist/utils/output.js +0 -404
- package/dist/utils/quota.js +0 -227
- package/dist/utils/redact.js +0 -68
- package/dist/utils/retry.js +0 -140
- package/dist/utils/string.js +0 -22
- package/dist/version.js +0 -4
|
@@ -1,145 +0,0 @@
|
|
|
1
|
-
import fs from 'node:fs';
|
|
2
|
-
import path from 'node:path';
|
|
3
|
-
import os from 'node:os';
|
|
4
|
-
const MAX_HISTORY = 100;
|
|
5
|
-
const JSONL_ROTATE_BYTES = 50 * 1024 * 1024; // 50 MB
|
|
6
|
-
const JSONL_KEEP_ROTATIONS = 3; // .1 .2 .3
|
|
7
|
-
function historyDir() {
|
|
8
|
-
return path.join(os.homedir(), '.switchbot', 'device-history');
|
|
9
|
-
}
|
|
10
|
-
export class DeviceHistoryStore {
|
|
11
|
-
// In-memory size counter so we don't stat() on every append.
|
|
12
|
-
jsonlSizes = new Map();
|
|
13
|
-
/** Reset the in-memory size counter. Tests use this between runs. */
|
|
14
|
-
resetSizes() {
|
|
15
|
-
this.jsonlSizes.clear();
|
|
16
|
-
}
|
|
17
|
-
get dir() {
|
|
18
|
-
return historyDir();
|
|
19
|
-
}
|
|
20
|
-
record(deviceId, topic, deviceType, payload, t) {
|
|
21
|
-
const entry = { t: t ?? new Date().toISOString(), topic, deviceType, payload };
|
|
22
|
-
try {
|
|
23
|
-
if (!fs.existsSync(this.dir))
|
|
24
|
-
fs.mkdirSync(this.dir, { recursive: true });
|
|
25
|
-
// 1. Ring-buffer JSON (back-compat with existing consumers).
|
|
26
|
-
const file = path.join(this.dir, `${deviceId}.json`);
|
|
27
|
-
const existing = fs.existsSync(file)
|
|
28
|
-
? JSON.parse(fs.readFileSync(file, 'utf-8'))
|
|
29
|
-
: { latest: null, history: [] };
|
|
30
|
-
existing.latest = entry;
|
|
31
|
-
existing.history = [entry, ...existing.history].slice(0, MAX_HISTORY);
|
|
32
|
-
fs.writeFileSync(file, JSON.stringify(existing, null, 2), { mode: 0o600 });
|
|
33
|
-
// 2. Append-only JSONL for range queries.
|
|
34
|
-
this.writeJsonl(deviceId, entry);
|
|
35
|
-
}
|
|
36
|
-
catch {
|
|
37
|
-
// best-effort — history loss is non-fatal
|
|
38
|
-
}
|
|
39
|
-
}
|
|
40
|
-
/** Append a mqtt control event (no deviceId) to the dedicated __control.jsonl file. */
|
|
41
|
-
recordControl(event) {
|
|
42
|
-
try {
|
|
43
|
-
if (!fs.existsSync(this.dir))
|
|
44
|
-
fs.mkdirSync(this.dir, { recursive: true });
|
|
45
|
-
this.writeJsonl('__control', event);
|
|
46
|
-
}
|
|
47
|
-
catch {
|
|
48
|
-
// best-effort — never block the event stream
|
|
49
|
-
}
|
|
50
|
-
}
|
|
51
|
-
writeJsonl(fileKey, record) {
|
|
52
|
-
try {
|
|
53
|
-
const jsonlPath = path.join(this.dir, `${fileKey}.jsonl`);
|
|
54
|
-
const line = JSON.stringify(record) + '\n';
|
|
55
|
-
const lineBytes = Buffer.byteLength(line, 'utf-8');
|
|
56
|
-
// Seed size counter from disk on first touch (avoids drift across restarts).
|
|
57
|
-
let size = this.jsonlSizes.get(fileKey);
|
|
58
|
-
if (size === undefined) {
|
|
59
|
-
try {
|
|
60
|
-
size = fs.existsSync(jsonlPath) ? fs.statSync(jsonlPath).size : 0;
|
|
61
|
-
}
|
|
62
|
-
catch {
|
|
63
|
-
size = 0;
|
|
64
|
-
}
|
|
65
|
-
}
|
|
66
|
-
if (size + lineBytes > JSONL_ROTATE_BYTES) {
|
|
67
|
-
this.rotateJsonl(fileKey);
|
|
68
|
-
size = 0;
|
|
69
|
-
}
|
|
70
|
-
fs.appendFileSync(jsonlPath, line, { mode: 0o600 });
|
|
71
|
-
this.jsonlSizes.set(fileKey, size + lineBytes);
|
|
72
|
-
}
|
|
73
|
-
catch {
|
|
74
|
-
// best-effort
|
|
75
|
-
}
|
|
76
|
-
}
|
|
77
|
-
rotateJsonl(fileKey) {
|
|
78
|
-
const base = path.join(this.dir, `${fileKey}.jsonl`);
|
|
79
|
-
// .jsonl.3 is dropped; .2 → .3, .1 → .2, current → .1
|
|
80
|
-
try {
|
|
81
|
-
const oldest = `${base}.${JSONL_KEEP_ROTATIONS}`;
|
|
82
|
-
if (fs.existsSync(oldest))
|
|
83
|
-
fs.rmSync(oldest);
|
|
84
|
-
}
|
|
85
|
-
catch { /* swallow */ }
|
|
86
|
-
for (let i = JSONL_KEEP_ROTATIONS - 1; i >= 1; i--) {
|
|
87
|
-
const from = `${base}.${i}`;
|
|
88
|
-
const to = `${base}.${i + 1}`;
|
|
89
|
-
try {
|
|
90
|
-
if (fs.existsSync(from))
|
|
91
|
-
fs.renameSync(from, to);
|
|
92
|
-
}
|
|
93
|
-
catch { /* swallow */ }
|
|
94
|
-
}
|
|
95
|
-
try {
|
|
96
|
-
if (fs.existsSync(base))
|
|
97
|
-
fs.renameSync(base, `${base}.1`);
|
|
98
|
-
}
|
|
99
|
-
catch { /* swallow */ }
|
|
100
|
-
}
|
|
101
|
-
getLatest(deviceId) {
|
|
102
|
-
try {
|
|
103
|
-
const file = path.join(this.dir, `${deviceId}.json`);
|
|
104
|
-
if (!fs.existsSync(file))
|
|
105
|
-
return null;
|
|
106
|
-
return JSON.parse(fs.readFileSync(file, 'utf-8')).latest;
|
|
107
|
-
}
|
|
108
|
-
catch {
|
|
109
|
-
return null;
|
|
110
|
-
}
|
|
111
|
-
}
|
|
112
|
-
getHistory(deviceId, limit = 20) {
|
|
113
|
-
try {
|
|
114
|
-
const file = path.join(this.dir, `${deviceId}.json`);
|
|
115
|
-
if (!fs.existsSync(file))
|
|
116
|
-
return [];
|
|
117
|
-
const data = JSON.parse(fs.readFileSync(file, 'utf-8'));
|
|
118
|
-
return data.history.slice(0, Math.min(limit, MAX_HISTORY));
|
|
119
|
-
}
|
|
120
|
-
catch {
|
|
121
|
-
return [];
|
|
122
|
-
}
|
|
123
|
-
}
|
|
124
|
-
listDevices() {
|
|
125
|
-
try {
|
|
126
|
-
if (!fs.existsSync(this.dir))
|
|
127
|
-
return [];
|
|
128
|
-
const seen = new Set();
|
|
129
|
-
for (const f of fs.readdirSync(this.dir)) {
|
|
130
|
-
if (f.endsWith('.json'))
|
|
131
|
-
seen.add(f.slice(0, -5));
|
|
132
|
-
else if (f.endsWith('.jsonl'))
|
|
133
|
-
seen.add(f.slice(0, -6));
|
|
134
|
-
}
|
|
135
|
-
return Array.from(seen);
|
|
136
|
-
}
|
|
137
|
-
catch {
|
|
138
|
-
return [];
|
|
139
|
-
}
|
|
140
|
-
}
|
|
141
|
-
getHistoryDir() {
|
|
142
|
-
return this.dir;
|
|
143
|
-
}
|
|
144
|
-
}
|
|
145
|
-
export const deviceHistoryStore = new DeviceHistoryStore();
|
|
@@ -1,213 +0,0 @@
|
|
|
1
|
-
import { SwitchBotMqttClient } from '../mqtt/client.js';
|
|
2
|
-
import { fetchMqttCredential } from '../mqtt/credential.js';
|
|
3
|
-
import { parseFilter, applyFilter } from '../utils/filter.js';
|
|
4
|
-
import { fetchDeviceList } from '../lib/devices.js';
|
|
5
|
-
import { getCachedDevice } from '../devices/cache.js';
|
|
6
|
-
import { createClient } from '../api/client.js';
|
|
7
|
-
import { log } from '../logger.js';
|
|
8
|
-
import { deviceHistoryStore } from './device-history.js';
|
|
9
|
-
export class EventSubscriptionManager {
|
|
10
|
-
mqttClient = null;
|
|
11
|
-
subscribers = new Map();
|
|
12
|
-
ringBuffer = [];
|
|
13
|
-
ringSize = 1000;
|
|
14
|
-
typeMap = new Map();
|
|
15
|
-
refreshTypeMapTimer = null;
|
|
16
|
-
idleCleanupTimer = null;
|
|
17
|
-
getClient;
|
|
18
|
-
lastRefreshAttempt = 0;
|
|
19
|
-
constructor(mqttClient, getClient) {
|
|
20
|
-
this.mqttClient = mqttClient || null;
|
|
21
|
-
this.getClient = getClient;
|
|
22
|
-
}
|
|
23
|
-
async initialize(token, secret) {
|
|
24
|
-
if (!this.mqttClient) {
|
|
25
|
-
const credential = await fetchMqttCredential(token, secret);
|
|
26
|
-
const client = new SwitchBotMqttClient(credential, () => fetchMqttCredential(token, secret));
|
|
27
|
-
client.onStateChange((state) => {
|
|
28
|
-
if (state === 'connected') {
|
|
29
|
-
this.emit({
|
|
30
|
-
kind: 'events.reconnected',
|
|
31
|
-
timestamp: Date.now(),
|
|
32
|
-
});
|
|
33
|
-
client.subscribe(credential.topics.status);
|
|
34
|
-
}
|
|
35
|
-
});
|
|
36
|
-
client.onMessage((topic, payload) => {
|
|
37
|
-
try {
|
|
38
|
-
const data = JSON.parse(payload.toString());
|
|
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);
|
|
47
|
-
this.addEvent({
|
|
48
|
-
kind: 'shadow.updated',
|
|
49
|
-
deviceId,
|
|
50
|
-
payload: payloadData,
|
|
51
|
-
timestamp: Date.now(),
|
|
52
|
-
});
|
|
53
|
-
}
|
|
54
|
-
}
|
|
55
|
-
catch (err) {
|
|
56
|
-
log.debug({ err, topic }, 'failed to parse shadow payload');
|
|
57
|
-
}
|
|
58
|
-
});
|
|
59
|
-
await client.connect();
|
|
60
|
-
this.mqttClient = client;
|
|
61
|
-
}
|
|
62
|
-
this.scheduleIdleCleanup();
|
|
63
|
-
}
|
|
64
|
-
subscribe(id, handler, filter) {
|
|
65
|
-
// Validate filter syntax if provided
|
|
66
|
-
if (filter) {
|
|
67
|
-
parseFilter(filter);
|
|
68
|
-
}
|
|
69
|
-
const subscriber = {
|
|
70
|
-
id,
|
|
71
|
-
handler,
|
|
72
|
-
filter,
|
|
73
|
-
lastActivity: Date.now(),
|
|
74
|
-
};
|
|
75
|
-
this.subscribers.set(id, subscriber);
|
|
76
|
-
// Send recent events that match the filter
|
|
77
|
-
for (const event of this.ringBuffer) {
|
|
78
|
-
if (this.matchesFilter(event, filter)) {
|
|
79
|
-
handler(event);
|
|
80
|
-
}
|
|
81
|
-
}
|
|
82
|
-
return () => {
|
|
83
|
-
this.subscribers.delete(id);
|
|
84
|
-
};
|
|
85
|
-
}
|
|
86
|
-
addEvent(event) {
|
|
87
|
-
this.ringBuffer.push(event);
|
|
88
|
-
// Check for overflow
|
|
89
|
-
if (this.ringBuffer.length > this.ringSize) {
|
|
90
|
-
const droppedCount = this.ringBuffer.length - this.ringSize;
|
|
91
|
-
const oldestTimestamp = this.ringBuffer[0]?.timestamp || Date.now();
|
|
92
|
-
// Emit overflow notice to all subscribers
|
|
93
|
-
this.emit({
|
|
94
|
-
kind: 'events.dropped',
|
|
95
|
-
count: droppedCount,
|
|
96
|
-
sinceTs: oldestTimestamp,
|
|
97
|
-
});
|
|
98
|
-
// Trim buffer
|
|
99
|
-
this.ringBuffer = this.ringBuffer.slice(-this.ringSize);
|
|
100
|
-
}
|
|
101
|
-
// Broadcast to matching subscribers
|
|
102
|
-
this.emit(event);
|
|
103
|
-
}
|
|
104
|
-
emit(event) {
|
|
105
|
-
for (const subscriber of this.subscribers.values()) {
|
|
106
|
-
if (this.matchesFilter(event, subscriber.filter)) {
|
|
107
|
-
subscriber.lastActivity = Date.now();
|
|
108
|
-
subscriber.handler(event);
|
|
109
|
-
}
|
|
110
|
-
}
|
|
111
|
-
}
|
|
112
|
-
matchesFilter(event, filter) {
|
|
113
|
-
if (!filter)
|
|
114
|
-
return true;
|
|
115
|
-
// Only filter shadow events
|
|
116
|
-
if (event.kind !== 'shadow.updated')
|
|
117
|
-
return true;
|
|
118
|
-
try {
|
|
119
|
-
// Parse filter and match against device metadata
|
|
120
|
-
const clauses = parseFilter(filter);
|
|
121
|
-
const deviceId = event.deviceId;
|
|
122
|
-
// Get device info from cache
|
|
123
|
-
const cached = getCachedDevice(deviceId);
|
|
124
|
-
if (!cached) {
|
|
125
|
-
// Lazily refresh type map if device unknown
|
|
126
|
-
this.scheduleTypeMapRefresh();
|
|
127
|
-
return false; // Conservative: drop if unknown
|
|
128
|
-
}
|
|
129
|
-
// Build a Device-compatible shape for applyFilter
|
|
130
|
-
const device = {
|
|
131
|
-
deviceId,
|
|
132
|
-
deviceType: this.typeMap.get(deviceId) || cached.type,
|
|
133
|
-
deviceName: cached.name,
|
|
134
|
-
familyName: cached.familyName,
|
|
135
|
-
roomName: cached.roomName,
|
|
136
|
-
enableCloudService: true,
|
|
137
|
-
hubDeviceId: '',
|
|
138
|
-
};
|
|
139
|
-
// Use applyFilter with single device in list
|
|
140
|
-
const matched = applyFilter(clauses, [device], [], new Map());
|
|
141
|
-
return matched.length > 0;
|
|
142
|
-
}
|
|
143
|
-
catch {
|
|
144
|
-
return false; // Invalid filter matches nothing
|
|
145
|
-
}
|
|
146
|
-
}
|
|
147
|
-
scheduleTypeMapRefresh() {
|
|
148
|
-
if (this.refreshTypeMapTimer || Date.now() - this.lastRefreshAttempt < 5000) {
|
|
149
|
-
return; // Already scheduled or too recent
|
|
150
|
-
}
|
|
151
|
-
this.refreshTypeMapTimer = setTimeout(async () => {
|
|
152
|
-
this.refreshTypeMapTimer = null;
|
|
153
|
-
this.lastRefreshAttempt = Date.now();
|
|
154
|
-
try {
|
|
155
|
-
const client = this.getClient?.() || createClient();
|
|
156
|
-
const body = await fetchDeviceList(client);
|
|
157
|
-
for (const d of body.deviceList) {
|
|
158
|
-
if (d.deviceType)
|
|
159
|
-
this.typeMap.set(d.deviceId, d.deviceType);
|
|
160
|
-
}
|
|
161
|
-
for (const ir of body.infraredRemoteList) {
|
|
162
|
-
this.typeMap.set(ir.deviceId, ir.remoteType);
|
|
163
|
-
}
|
|
164
|
-
}
|
|
165
|
-
catch {
|
|
166
|
-
// Silently fail type map refresh
|
|
167
|
-
}
|
|
168
|
-
}, 100);
|
|
169
|
-
}
|
|
170
|
-
scheduleIdleCleanup() {
|
|
171
|
-
if (this.idleCleanupTimer)
|
|
172
|
-
return;
|
|
173
|
-
this.idleCleanupTimer = setInterval(() => {
|
|
174
|
-
const now = Date.now();
|
|
175
|
-
const idleThreshold = 10 * 60 * 1000; // 10 minutes
|
|
176
|
-
for (const [id, subscriber] of this.subscribers.entries()) {
|
|
177
|
-
if (now - subscriber.lastActivity > idleThreshold) {
|
|
178
|
-
this.subscribers.delete(id);
|
|
179
|
-
}
|
|
180
|
-
}
|
|
181
|
-
}, 60000); // Check every minute
|
|
182
|
-
}
|
|
183
|
-
extractDeviceId(topic) {
|
|
184
|
-
// Topic format: $aws/things/<deviceId>/shadow/update/accepted
|
|
185
|
-
const match = topic.match(/\$aws\/things\/([^/]+)\/shadow/);
|
|
186
|
-
return match ? match[1] : null;
|
|
187
|
-
}
|
|
188
|
-
getState() {
|
|
189
|
-
if (!this.mqttClient)
|
|
190
|
-
return 'disabled';
|
|
191
|
-
return this.mqttClient.getState();
|
|
192
|
-
}
|
|
193
|
-
getSubscriberCount() {
|
|
194
|
-
return this.subscribers.size;
|
|
195
|
-
}
|
|
196
|
-
getRecentEvents(limit = 100) {
|
|
197
|
-
return this.ringBuffer.slice(-limit);
|
|
198
|
-
}
|
|
199
|
-
async shutdown() {
|
|
200
|
-
if (this.refreshTypeMapTimer) {
|
|
201
|
-
clearTimeout(this.refreshTypeMapTimer);
|
|
202
|
-
}
|
|
203
|
-
if (this.idleCleanupTimer) {
|
|
204
|
-
clearInterval(this.idleCleanupTimer);
|
|
205
|
-
}
|
|
206
|
-
if (this.mqttClient) {
|
|
207
|
-
await this.mqttClient.disconnect();
|
|
208
|
-
this.mqttClient = null;
|
|
209
|
-
}
|
|
210
|
-
this.subscribers.clear();
|
|
211
|
-
this.ringBuffer = [];
|
|
212
|
-
}
|
|
213
|
-
}
|
package/dist/mqtt/client.js
DELETED
|
@@ -1,180 +0,0 @@
|
|
|
1
|
-
import { connect } from 'mqtt';
|
|
2
|
-
export class SwitchBotMqttClient {
|
|
3
|
-
client = null;
|
|
4
|
-
credential;
|
|
5
|
-
state = 'connecting';
|
|
6
|
-
credentialExpired = false;
|
|
7
|
-
reconnectAttempts = 0;
|
|
8
|
-
maxReconnectAttempts = 10;
|
|
9
|
-
disconnecting = false;
|
|
10
|
-
handlers = new Set();
|
|
11
|
-
messageHandlers = new Set();
|
|
12
|
-
credentialRefreshCallback;
|
|
13
|
-
constructor(credential, onCredentialExpired) {
|
|
14
|
-
this.credential = credential;
|
|
15
|
-
this.credentialRefreshCallback = onCredentialExpired;
|
|
16
|
-
}
|
|
17
|
-
async connect() {
|
|
18
|
-
if (this.client && this.state === 'connected')
|
|
19
|
-
return;
|
|
20
|
-
// Remove stale listeners before replacing the client instance, otherwise
|
|
21
|
-
// the old client's close event fires after the new connection is established
|
|
22
|
-
// (AWS IoT drops the old session), triggering a spurious reconnect loop.
|
|
23
|
-
if (this.client) {
|
|
24
|
-
this.client.removeAllListeners();
|
|
25
|
-
this.client.end(true);
|
|
26
|
-
this.client = null;
|
|
27
|
-
}
|
|
28
|
-
this.setState('connecting');
|
|
29
|
-
this.credentialExpired = false;
|
|
30
|
-
this.reconnectAttempts = 0;
|
|
31
|
-
try {
|
|
32
|
-
const { tls, brokerUrl, clientId } = this.credential;
|
|
33
|
-
// tls.ca/cert/keyBase64 are PEM strings despite the misleading field name
|
|
34
|
-
const options = {
|
|
35
|
-
clientId,
|
|
36
|
-
ca: tls.caBase64,
|
|
37
|
-
cert: tls.certBase64,
|
|
38
|
-
key: tls.keyBase64,
|
|
39
|
-
rejectUnauthorized: true,
|
|
40
|
-
clean: true,
|
|
41
|
-
reconnectPeriod: 0,
|
|
42
|
-
connectTimeout: 30000,
|
|
43
|
-
keepalive: 60,
|
|
44
|
-
reschedulePings: true,
|
|
45
|
-
};
|
|
46
|
-
this.client = connect(brokerUrl, options);
|
|
47
|
-
this.client.on('connect', () => {
|
|
48
|
-
this.reconnectAttempts = 0;
|
|
49
|
-
this.setState('connected');
|
|
50
|
-
this.credentialExpired = false;
|
|
51
|
-
});
|
|
52
|
-
this.client.on('message', (topic, payload) => {
|
|
53
|
-
for (const handler of this.messageHandlers) {
|
|
54
|
-
handler(topic, payload);
|
|
55
|
-
}
|
|
56
|
-
});
|
|
57
|
-
this.client.on('error', (err) => {
|
|
58
|
-
if (err instanceof Error &&
|
|
59
|
-
(err.message.includes('certificate') ||
|
|
60
|
-
err.message.includes('ECONNRESET') ||
|
|
61
|
-
err.message.includes('handshake'))) {
|
|
62
|
-
this.credentialExpired = true;
|
|
63
|
-
}
|
|
64
|
-
});
|
|
65
|
-
this.client.on('close', () => {
|
|
66
|
-
if (this.disconnecting)
|
|
67
|
-
return;
|
|
68
|
-
if (this.credentialExpired) {
|
|
69
|
-
this.setState('failed');
|
|
70
|
-
}
|
|
71
|
-
else if (this.reconnectAttempts < this.maxReconnectAttempts) {
|
|
72
|
-
this.attemptReconnect();
|
|
73
|
-
}
|
|
74
|
-
else {
|
|
75
|
-
this.setState('failed');
|
|
76
|
-
}
|
|
77
|
-
});
|
|
78
|
-
await new Promise((resolve, reject) => {
|
|
79
|
-
const timeout = setTimeout(() => {
|
|
80
|
-
reject(new Error('MQTT connection timeout'));
|
|
81
|
-
}, 15000);
|
|
82
|
-
const onConnect = () => {
|
|
83
|
-
clearTimeout(timeout);
|
|
84
|
-
this.client?.removeListener('error', onError);
|
|
85
|
-
resolve();
|
|
86
|
-
};
|
|
87
|
-
const onError = (err) => {
|
|
88
|
-
clearTimeout(timeout);
|
|
89
|
-
this.client?.removeListener('connect', onConnect);
|
|
90
|
-
reject(err);
|
|
91
|
-
};
|
|
92
|
-
if (this.client?.connected) {
|
|
93
|
-
clearTimeout(timeout);
|
|
94
|
-
resolve();
|
|
95
|
-
}
|
|
96
|
-
else {
|
|
97
|
-
this.client?.once('connect', onConnect);
|
|
98
|
-
this.client?.once('error', onError);
|
|
99
|
-
}
|
|
100
|
-
});
|
|
101
|
-
}
|
|
102
|
-
catch (err) {
|
|
103
|
-
this.setState('failed');
|
|
104
|
-
throw err;
|
|
105
|
-
}
|
|
106
|
-
}
|
|
107
|
-
async attemptReconnect() {
|
|
108
|
-
this.reconnectAttempts++;
|
|
109
|
-
this.setState('reconnecting');
|
|
110
|
-
if (this.credentialExpired && this.credentialRefreshCallback) {
|
|
111
|
-
try {
|
|
112
|
-
this.credential = await this.credentialRefreshCallback();
|
|
113
|
-
this.credentialExpired = false;
|
|
114
|
-
}
|
|
115
|
-
catch {
|
|
116
|
-
this.setState('failed');
|
|
117
|
-
return;
|
|
118
|
-
}
|
|
119
|
-
}
|
|
120
|
-
const delay = Math.min(30000, 1000 * Math.pow(2, this.reconnectAttempts - 1));
|
|
121
|
-
await new Promise((r) => setTimeout(r, delay));
|
|
122
|
-
try {
|
|
123
|
-
await this.connect();
|
|
124
|
-
}
|
|
125
|
-
catch {
|
|
126
|
-
if (this.reconnectAttempts < this.maxReconnectAttempts) {
|
|
127
|
-
await this.attemptReconnect();
|
|
128
|
-
}
|
|
129
|
-
else {
|
|
130
|
-
this.setState('failed');
|
|
131
|
-
}
|
|
132
|
-
}
|
|
133
|
-
}
|
|
134
|
-
setState(newState) {
|
|
135
|
-
if (this.state !== newState) {
|
|
136
|
-
this.state = newState;
|
|
137
|
-
for (const handler of this.handlers) {
|
|
138
|
-
handler(newState);
|
|
139
|
-
}
|
|
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.disconnecting = true;
|
|
171
|
-
if (this.client) {
|
|
172
|
-
await new Promise((resolve) => {
|
|
173
|
-
this.client?.end(false, () => resolve());
|
|
174
|
-
});
|
|
175
|
-
this.client = null;
|
|
176
|
-
}
|
|
177
|
-
this.disconnecting = false;
|
|
178
|
-
this.setState('failed');
|
|
179
|
-
}
|
|
180
|
-
}
|
package/dist/mqtt/credential.js
DELETED
|
@@ -1,30 +0,0 @@
|
|
|
1
|
-
import crypto from 'node:crypto';
|
|
2
|
-
import { buildAuthHeaders } from '../auth.js';
|
|
3
|
-
const CREDENTIAL_ENDPOINT = 'https://api.switchbot.net/v1.1/iot/credential';
|
|
4
|
-
export async function fetchMqttCredential(token, secret) {
|
|
5
|
-
// Use a random instanceId so each CLI session gets its own clientId, avoiding
|
|
6
|
-
// conflicts with the SwitchBot cloud service that shares the same account credentials.
|
|
7
|
-
const instanceId = crypto.randomUUID().replace(/-/g, '').slice(0, 16);
|
|
8
|
-
const headers = buildAuthHeaders(token, secret);
|
|
9
|
-
const res = await fetch(CREDENTIAL_ENDPOINT, {
|
|
10
|
-
method: 'POST',
|
|
11
|
-
headers,
|
|
12
|
-
body: JSON.stringify({ instanceId }),
|
|
13
|
-
signal: AbortSignal.timeout(15000),
|
|
14
|
-
});
|
|
15
|
-
if (!res.ok) {
|
|
16
|
-
throw new Error(`MQTT credential request failed: HTTP ${res.status} ${res.statusText}`);
|
|
17
|
-
}
|
|
18
|
-
const json = (await res.json());
|
|
19
|
-
if (json.statusCode !== 100) {
|
|
20
|
-
throw new Error(`MQTT credential API error: statusCode ${json.statusCode}`);
|
|
21
|
-
}
|
|
22
|
-
// Response shape: { statusCode, body: { body: { channels: { mqtt: ... } } } }
|
|
23
|
-
const outer = json.body;
|
|
24
|
-
const inner = (outer.body ?? outer);
|
|
25
|
-
const channels = inner.channels;
|
|
26
|
-
if (!channels?.mqtt) {
|
|
27
|
-
throw new Error('Unexpected MQTT credential response — channels.mqtt missing');
|
|
28
|
-
}
|
|
29
|
-
return channels.mqtt;
|
|
30
|
-
}
|
package/dist/policy/add-rule.js
DELETED
|
@@ -1,124 +0,0 @@
|
|
|
1
|
-
import { parseDocument, isMap, isSeq, isScalar, LineCounter } from 'yaml';
|
|
2
|
-
import { parse as yamlParse } from 'yaml';
|
|
3
|
-
import { loadPolicyFile } from './load.js';
|
|
4
|
-
import { validateLoadedPolicy } from './validate.js';
|
|
5
|
-
import fs from 'node:fs';
|
|
6
|
-
export class AddRuleError extends Error {
|
|
7
|
-
code;
|
|
8
|
-
constructor(message, code) {
|
|
9
|
-
super(message);
|
|
10
|
-
this.code = code;
|
|
11
|
-
this.name = 'AddRuleError';
|
|
12
|
-
}
|
|
13
|
-
}
|
|
14
|
-
function buildDiff(before, after) {
|
|
15
|
-
const beforeLines = before.split('\n');
|
|
16
|
-
const afterLines = after.split('\n');
|
|
17
|
-
const lines = ['--- before', '+++ after'];
|
|
18
|
-
let i = 0;
|
|
19
|
-
let j = 0;
|
|
20
|
-
while (i < beforeLines.length || j < afterLines.length) {
|
|
21
|
-
const b = beforeLines[i];
|
|
22
|
-
const a = afterLines[j];
|
|
23
|
-
if (i < beforeLines.length && j < afterLines.length && b === a) {
|
|
24
|
-
lines.push(` ${b}`);
|
|
25
|
-
i++;
|
|
26
|
-
j++;
|
|
27
|
-
}
|
|
28
|
-
else if (j < afterLines.length && (i >= beforeLines.length || b !== a)) {
|
|
29
|
-
lines.push(`+${a}`);
|
|
30
|
-
j++;
|
|
31
|
-
}
|
|
32
|
-
else {
|
|
33
|
-
lines.push(`-${b}`);
|
|
34
|
-
i++;
|
|
35
|
-
}
|
|
36
|
-
}
|
|
37
|
-
return lines.join('\n');
|
|
38
|
-
}
|
|
39
|
-
function isNullNode(node) {
|
|
40
|
-
return isScalar(node) && node.value === null;
|
|
41
|
-
}
|
|
42
|
-
export function addRuleToPolicySource(opts) {
|
|
43
|
-
const loaded = loadPolicyFile(opts.policyPath);
|
|
44
|
-
const beforeSource = loaded.source;
|
|
45
|
-
// Parse the incoming rule
|
|
46
|
-
let ruleObj;
|
|
47
|
-
try {
|
|
48
|
-
ruleObj = yamlParse(opts.ruleYaml);
|
|
49
|
-
}
|
|
50
|
-
catch (err) {
|
|
51
|
-
throw new AddRuleError(`Could not parse rule YAML: ${err.message}`, 'invalid-rule-yaml');
|
|
52
|
-
}
|
|
53
|
-
if (!ruleObj || typeof ruleObj !== 'object' || Array.isArray(ruleObj)) {
|
|
54
|
-
throw new AddRuleError('Rule YAML must be a single mapping object', 'invalid-rule-shape');
|
|
55
|
-
}
|
|
56
|
-
const ruleName = ruleObj['name'];
|
|
57
|
-
if (typeof ruleName !== 'string' || !ruleName) {
|
|
58
|
-
throw new AddRuleError('Rule must have a non-empty "name" field', 'missing-rule-name');
|
|
59
|
-
}
|
|
60
|
-
// Clone the document using source round-trip (preserves comments)
|
|
61
|
-
const clone = parseDocument(beforeSource, { keepSourceTokens: true });
|
|
62
|
-
if (!isMap(clone.contents)) {
|
|
63
|
-
throw new AddRuleError('Policy root must be a YAML mapping', 'invalid-policy-shape');
|
|
64
|
-
}
|
|
65
|
-
// Ensure automation block exists
|
|
66
|
-
let automationNode = clone.contents.get('automation', true);
|
|
67
|
-
if (!automationNode || isNullNode(automationNode)) {
|
|
68
|
-
clone.setIn(['automation'], clone.createNode({ enabled: false, rules: [] }));
|
|
69
|
-
automationNode = clone.contents.get('automation', true);
|
|
70
|
-
}
|
|
71
|
-
// Ensure automation.rules exists and is a sequence
|
|
72
|
-
const rulesNode = clone.getIn(['automation', 'rules'], true);
|
|
73
|
-
if (!rulesNode || isNullNode(rulesNode)) {
|
|
74
|
-
clone.setIn(['automation', 'rules'], clone.createNode([]));
|
|
75
|
-
}
|
|
76
|
-
else if (!isSeq(rulesNode)) {
|
|
77
|
-
throw new AddRuleError('automation.rules exists but is not a sequence; cannot append', 'invalid-rules-shape');
|
|
78
|
-
}
|
|
79
|
-
// Duplicate name check — use JS conversion for simplicity
|
|
80
|
-
const policyJs = clone.toJS({ maxAliasCount: 100 });
|
|
81
|
-
const existingRulesJs = policyJs['automation']?.['rules'];
|
|
82
|
-
const existingRulesArr = Array.isArray(existingRulesJs) ? existingRulesJs : [];
|
|
83
|
-
const duplicateIdx = existingRulesArr.findIndex((r) => r?.['name'] === ruleName);
|
|
84
|
-
if (duplicateIdx !== -1 && !opts.force) {
|
|
85
|
-
throw new AddRuleError(`Rule named "${ruleName}" already exists. Use --force to overwrite.`, 'duplicate-rule-name');
|
|
86
|
-
}
|
|
87
|
-
if (duplicateIdx !== -1 && opts.force) {
|
|
88
|
-
const rulesSeq = clone.getIn(['automation', 'rules'], true);
|
|
89
|
-
rulesSeq.items.splice(duplicateIdx, 1);
|
|
90
|
-
}
|
|
91
|
-
// Enable automation if requested
|
|
92
|
-
if (opts.enableAutomation) {
|
|
93
|
-
clone.setIn(['automation', 'enabled'], true);
|
|
94
|
-
}
|
|
95
|
-
// Append the rule
|
|
96
|
-
const ruleNode = clone.createNode(ruleObj);
|
|
97
|
-
const rulesSeq = clone.getIn(['automation', 'rules'], true);
|
|
98
|
-
rulesSeq.items.push(ruleNode);
|
|
99
|
-
const nextSource = String(clone);
|
|
100
|
-
// Validate the resulting policy
|
|
101
|
-
const reLC = new LineCounter();
|
|
102
|
-
const reDoc = parseDocument(nextSource, { lineCounter: reLC, keepSourceTokens: true });
|
|
103
|
-
const validation = validateLoadedPolicy({
|
|
104
|
-
path: opts.policyPath,
|
|
105
|
-
source: nextSource,
|
|
106
|
-
doc: reDoc,
|
|
107
|
-
lineCounter: reLC,
|
|
108
|
-
data: reDoc.toJS({ maxAliasCount: 100 }),
|
|
109
|
-
});
|
|
110
|
-
if (!validation.valid) {
|
|
111
|
-
const msgs = validation.errors.map((e) => ` line ${e.line}: ${e.message}`).join('\n');
|
|
112
|
-
throw new AddRuleError(`Policy would be invalid after adding the rule:\n${msgs}`, 'validation-failed');
|
|
113
|
-
}
|
|
114
|
-
const diff = buildDiff(beforeSource, nextSource);
|
|
115
|
-
return { ruleName, diff, nextSource };
|
|
116
|
-
}
|
|
117
|
-
export function addRuleToPolicyFile(opts) {
|
|
118
|
-
const result = addRuleToPolicySource(opts);
|
|
119
|
-
if (!opts.dryRun) {
|
|
120
|
-
fs.writeFileSync(opts.policyPath, result.nextSource, 'utf8');
|
|
121
|
-
return { ...result, written: true };
|
|
122
|
-
}
|
|
123
|
-
return { ...result, written: false };
|
|
124
|
-
}
|