homebridge-roborock-matter 2.1.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/.gitattributes +13 -0
- package/CHANGELOG.md +469 -0
- package/LICENSE +23 -0
- package/README.md +45 -0
- package/__tests__/testdata/b01_q7_get_prop_response.json +1 -0
- package/__tests__/testdata/b01_q7_map_fixture.json +16 -0
- package/config.schema.json +118 -0
- package/dist/crypto.js +65 -0
- package/dist/crypto.js.map +1 -0
- package/dist/index.js +10 -0
- package/dist/index.js.map +1 -0
- package/dist/live_message.js +25 -0
- package/dist/live_message.js.map +1 -0
- package/dist/logger.js +37 -0
- package/dist/logger.js.map +1 -0
- package/dist/matter_vacuum_accessory.js +1877 -0
- package/dist/matter_vacuum_accessory.js.map +1 -0
- package/dist/platform.js +441 -0
- package/dist/platform.js.map +1 -0
- package/dist/settings.js +8 -0
- package/dist/settings.js.map +1 -0
- package/dist/types.js +3 -0
- package/dist/types.js.map +1 -0
- package/dist/ui/index.js +741 -0
- package/dist/ui/index.js.map +1 -0
- package/docs/matter-rvc-updating-homebridge-report.md +120 -0
- package/homebridge-roborock-vacuum.code-workspace +7 -0
- package/homebridge-ui/public/index.html +257 -0
- package/homebridge-ui/public/index.js +1276 -0
- package/homebridge-ui/public/styles.css +611 -0
- package/homebridge-ui/server.js +3 -0
- package/package.json +91 -0
- package/roborockLib/i18n/de/translations.json +188 -0
- package/roborockLib/i18n/en/translations.json +208 -0
- package/roborockLib/i18n/es/translations.json +188 -0
- package/roborockLib/i18n/fr/translations.json +188 -0
- package/roborockLib/i18n/it/translations.json +188 -0
- package/roborockLib/i18n/nl/translations.json +188 -0
- package/roborockLib/i18n/pl/translations.json +188 -0
- package/roborockLib/i18n/pt/translations.json +188 -0
- package/roborockLib/i18n/ru/translations.json +188 -0
- package/roborockLib/i18n/uk/translations.json +188 -0
- package/roborockLib/i18n/zh-cn/translations.json +188 -0
- package/roborockLib/lib/RRMapParser.js +459 -0
- package/roborockLib/lib/b01Q7Adapter.js +468 -0
- package/roborockLib/lib/deviceFeatures.js +1349 -0
- package/roborockLib/lib/localConnector.js +629 -0
- package/roborockLib/lib/map/map.html +137 -0
- package/roborockLib/lib/map/zones.js +851 -0
- package/roborockLib/lib/message.js +402 -0
- package/roborockLib/lib/messageQueueHandler.js +411 -0
- package/roborockLib/lib/roborockAuth.js +178 -0
- package/roborockLib/lib/roborockCrypto.js +55 -0
- package/roborockLib/lib/roborockPackageHelper.js +142 -0
- package/roborockLib/lib/roborock_mqtt_connector.js +567 -0
- package/roborockLib/lib/sniffing/mitmproxy_roborock.py +300 -0
- package/roborockLib/lib/vacuum.js +992 -0
- package/roborockLib/roborockAPI.js +3635 -0
- package/tsconfig.roborockLib.json +17 -0
|
@@ -0,0 +1,629 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
const crypto = require("crypto");
|
|
4
|
+
const Parser = require("binary-parser").Parser;
|
|
5
|
+
const net = require("net");
|
|
6
|
+
const dgram = require("dgram");
|
|
7
|
+
|
|
8
|
+
const server = dgram.createSocket("udp4");
|
|
9
|
+
const PORT = 58866;
|
|
10
|
+
const TIMEOUT = 5000; // 5 Sekunden Timeout
|
|
11
|
+
const LOCAL_RECONNECT_DELAY_MS = 60000;
|
|
12
|
+
const LOCAL_CONNECT_TIMEOUT_MS = 4000;
|
|
13
|
+
|
|
14
|
+
const BROADCAST_TOKEN = Buffer.from("qWKYcdQWrbm9hPqe", "utf8");
|
|
15
|
+
|
|
16
|
+
// Some adapters provide their own setTimeout/clearTimeout (e.g. to keep timers
|
|
17
|
+
// on the same event loop / for testability). Fall back to the global timer
|
|
18
|
+
// functions when the adapter doesn't provide them.
|
|
19
|
+
function getTimerFns(adapter) {
|
|
20
|
+
return {
|
|
21
|
+
setTimer:
|
|
22
|
+
typeof adapter.setTimeout === "function"
|
|
23
|
+
? adapter.setTimeout.bind(adapter)
|
|
24
|
+
: setTimeout,
|
|
25
|
+
clearTimer:
|
|
26
|
+
typeof adapter.clearTimeout === "function"
|
|
27
|
+
? adapter.clearTimeout.bind(adapter)
|
|
28
|
+
: clearTimeout,
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
class EnhancedSocket extends net.Socket {
|
|
33
|
+
constructor(options) {
|
|
34
|
+
super(options);
|
|
35
|
+
this.connected = false;
|
|
36
|
+
this.chunkBuffer = Buffer.alloc(0);
|
|
37
|
+
|
|
38
|
+
this.on("connect", () => {
|
|
39
|
+
this.connected = true;
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
this.on("close", () => {
|
|
43
|
+
this.connected = false;
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
this.on("error", () => {
|
|
47
|
+
this.connected = false;
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
this.on("end", () => {
|
|
51
|
+
this.connected = false;
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const localMessageParser = new Parser()
|
|
57
|
+
.endianess("big")
|
|
58
|
+
.string("version", {
|
|
59
|
+
length: 3,
|
|
60
|
+
})
|
|
61
|
+
.uint32("seq")
|
|
62
|
+
.uint16("protocol")
|
|
63
|
+
.uint16("payloadLen")
|
|
64
|
+
.buffer("payload", {
|
|
65
|
+
length: "payloadLen",
|
|
66
|
+
})
|
|
67
|
+
.uint32("crc32");
|
|
68
|
+
|
|
69
|
+
const shortMessageParser = new Parser()
|
|
70
|
+
.endianess("big")
|
|
71
|
+
.string("version", {
|
|
72
|
+
length: 3,
|
|
73
|
+
})
|
|
74
|
+
.uint32("seq")
|
|
75
|
+
.uint32("random")
|
|
76
|
+
.uint32("timestamp")
|
|
77
|
+
.uint16("protocol");
|
|
78
|
+
|
|
79
|
+
class localConnector {
|
|
80
|
+
constructor(adapter) {
|
|
81
|
+
this.adapter = adapter;
|
|
82
|
+
|
|
83
|
+
this.localClients = {};
|
|
84
|
+
this.l01HandshakeWaiters = new Map();
|
|
85
|
+
this.reconnectTimers = new Map();
|
|
86
|
+
this.connectPromises = new Map();
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
clearReconnectTimer(duid) {
|
|
90
|
+
const timer = this.reconnectTimers.get(duid);
|
|
91
|
+
if (!timer) {
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
if (typeof this.adapter.clearTimeout === "function") {
|
|
96
|
+
this.adapter.clearTimeout(timer);
|
|
97
|
+
} else {
|
|
98
|
+
clearTimeout(timer);
|
|
99
|
+
}
|
|
100
|
+
this.reconnectTimers.delete(duid);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
scheduleReconnect(duid, ip, delayMs = LOCAL_RECONNECT_DELAY_MS) {
|
|
104
|
+
this.clearReconnectTimer(duid);
|
|
105
|
+
const { setTimer } = getTimerFns(this.adapter);
|
|
106
|
+
const timer = setTimer(async () => {
|
|
107
|
+
this.reconnectTimers.delete(duid);
|
|
108
|
+
await this.createClient(duid, ip);
|
|
109
|
+
}, delayMs);
|
|
110
|
+
this.reconnectTimers.set(duid, timer);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
async ensureConnected(duid, ip) {
|
|
114
|
+
if (!ip) {
|
|
115
|
+
return false;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
if (this.isConnected(duid)) {
|
|
119
|
+
return true;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const pending = this.connectPromises.get(duid);
|
|
123
|
+
if (pending) {
|
|
124
|
+
await pending;
|
|
125
|
+
return Boolean(this.isConnected(duid));
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const connectPromise = this.createClient(duid, ip)
|
|
129
|
+
.catch((error) => {
|
|
130
|
+
this.adapter.log.debug(
|
|
131
|
+
`Immediate local reconnect failed for ${duid}: ${error.message || error}`
|
|
132
|
+
);
|
|
133
|
+
})
|
|
134
|
+
.finally(() => {
|
|
135
|
+
this.connectPromises.delete(duid);
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
this.connectPromises.set(duid, connectPromise);
|
|
139
|
+
await connectPromise;
|
|
140
|
+
return Boolean(this.isConnected(duid));
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
async resetClient(duid, reason = "local-client-reset") {
|
|
144
|
+
this.clearReconnectTimer(duid);
|
|
145
|
+
const client = this.localClients[duid];
|
|
146
|
+
if (!client) {
|
|
147
|
+
return;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
if (this.localClients[duid] === client) {
|
|
151
|
+
delete this.localClients[duid];
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
const waiter = this.l01HandshakeWaiters.get(duid);
|
|
155
|
+
if (waiter) {
|
|
156
|
+
this.adapter.clearTimeout(waiter.timeout);
|
|
157
|
+
this.l01HandshakeWaiters.delete(duid);
|
|
158
|
+
waiter.reject(
|
|
159
|
+
new Error(`TCP client reset during L01 handshake for ${duid}`)
|
|
160
|
+
);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
this.adapter.localL01Nonces.delete(duid);
|
|
164
|
+
client.destroy();
|
|
165
|
+
await this.adapter.updateTransportDiagnostics(duid, {
|
|
166
|
+
tcpConnectionState: "disconnected",
|
|
167
|
+
lastTransport: "cloud",
|
|
168
|
+
lastTransportReason: reason,
|
|
169
|
+
});
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
async markLocalConnected(duid) {
|
|
173
|
+
if (this.adapter.remoteDevices?.delete(duid)) {
|
|
174
|
+
this.adapter.log.debug(
|
|
175
|
+
`Local TCP connected for ${duid}; clearing remote fallback marker.`
|
|
176
|
+
);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
await this.adapter.updateTransportDiagnostics(duid, {
|
|
180
|
+
tcpConnectionState: "connected",
|
|
181
|
+
isRemote: false,
|
|
182
|
+
remoteReason: null,
|
|
183
|
+
lastTransport: "local",
|
|
184
|
+
lastTransportReason: "tcp-connected",
|
|
185
|
+
});
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
async createClient(duid, ip) {
|
|
189
|
+
this.clearReconnectTimer(duid);
|
|
190
|
+
const existingClient = this.localClients[duid];
|
|
191
|
+
if (existingClient?.connected || existingClient?.connecting) {
|
|
192
|
+
return;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
const client = new EnhancedSocket();
|
|
196
|
+
await this.adapter.updateTransportDiagnostics(duid, {
|
|
197
|
+
localIp: ip,
|
|
198
|
+
tcpConnectionState: "connecting",
|
|
199
|
+
lastTransport: "local-pending",
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
// Wrap the connect method in a promise to await its completion
|
|
203
|
+
await new Promise((resolve, reject) => {
|
|
204
|
+
let settled = false;
|
|
205
|
+
const { setTimer, clearTimer } = getTimerFns(this.adapter);
|
|
206
|
+
const timeout = setTimer(() => {
|
|
207
|
+
if (settled) {
|
|
208
|
+
return;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
settled = true;
|
|
212
|
+
client.destroy();
|
|
213
|
+
reject(
|
|
214
|
+
new Error(
|
|
215
|
+
`Timed out connecting local TCP client for ${duid} at ${ip}`
|
|
216
|
+
)
|
|
217
|
+
);
|
|
218
|
+
}, LOCAL_CONNECT_TIMEOUT_MS);
|
|
219
|
+
const finish = (callback, value) => {
|
|
220
|
+
if (settled) {
|
|
221
|
+
return;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
settled = true;
|
|
225
|
+
clearTimer(timeout);
|
|
226
|
+
callback(value);
|
|
227
|
+
};
|
|
228
|
+
|
|
229
|
+
client
|
|
230
|
+
.connect(58867, ip, async () => {
|
|
231
|
+
this.adapter.log.debug(`tcp client for ${duid} connected`);
|
|
232
|
+
await this.markLocalConnected(duid);
|
|
233
|
+
this.ensureL01Handshake(duid).catch((error) => {
|
|
234
|
+
this.adapter.log.debug(
|
|
235
|
+
`L01 handshake on connect failed for ${duid}: ${error.message}`
|
|
236
|
+
);
|
|
237
|
+
});
|
|
238
|
+
finish(resolve);
|
|
239
|
+
})
|
|
240
|
+
.on("error", (error) => {
|
|
241
|
+
this.adapter.log.debug(
|
|
242
|
+
`error on tcp client for ${duid}. ${error.message}`
|
|
243
|
+
);
|
|
244
|
+
finish(reject, error);
|
|
245
|
+
});
|
|
246
|
+
}).catch(async (error) => {
|
|
247
|
+
const online = await this.adapter.onlineChecker(duid);
|
|
248
|
+
await this.adapter.updateTransportDiagnostics(duid, {
|
|
249
|
+
tcpConnectionState: "connect-failed",
|
|
250
|
+
lastTransport: "cloud",
|
|
251
|
+
lastTransportReason: online
|
|
252
|
+
? "tcp-connect-failed"
|
|
253
|
+
: "device-offline-during-connect",
|
|
254
|
+
});
|
|
255
|
+
if (online) {
|
|
256
|
+
// if the device is online, we can assume that the device is a remote device
|
|
257
|
+
this.adapter.log.debug(
|
|
258
|
+
`error on tcp client for ${duid}. Marking this device as remote device. Connecting via MQTT instead ${error.message}`
|
|
259
|
+
);
|
|
260
|
+
this.adapter.remoteDevices.add(duid);
|
|
261
|
+
await this.adapter.updateTransportDiagnostics(duid, {
|
|
262
|
+
isRemote: true,
|
|
263
|
+
remoteReason: "marked-remote-after-connect-failure",
|
|
264
|
+
});
|
|
265
|
+
// this.adapter.catchError(`Failed to create tcp client: ${error.stack}`, `function createClient`, duid);
|
|
266
|
+
}
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
client.on("data", async (message) => {
|
|
270
|
+
try {
|
|
271
|
+
if (client.chunkBuffer.length == 0) {
|
|
272
|
+
this.adapter.log.debug(`new chunk started`);
|
|
273
|
+
client.chunkBuffer = message;
|
|
274
|
+
} else {
|
|
275
|
+
this.adapter.log.debug(`new chunk received`);
|
|
276
|
+
client.chunkBuffer = Buffer.concat([client.chunkBuffer, message]);
|
|
277
|
+
}
|
|
278
|
+
// this.adapter.log.debug(`new chunk received: ${message.toString("hex")}`);
|
|
279
|
+
|
|
280
|
+
let offset = 0;
|
|
281
|
+
if (this.checkComplete(client.chunkBuffer)) {
|
|
282
|
+
this.adapter.log.debug(
|
|
283
|
+
`Chunk buffer data is complete. Processing...`
|
|
284
|
+
);
|
|
285
|
+
// this.adapter.log.debug(`chunkBuffer: ${client.chunkBuffer.toString("hex")}`);
|
|
286
|
+
while (offset + 4 <= client.chunkBuffer.length) {
|
|
287
|
+
const segmentLength = client.chunkBuffer.readUInt32BE(offset);
|
|
288
|
+
const currentBuffer = client.chunkBuffer.subarray(
|
|
289
|
+
offset + 4,
|
|
290
|
+
offset + segmentLength + 4
|
|
291
|
+
);
|
|
292
|
+
// length of 17 does not contain any useful data.
|
|
293
|
+
// It seems to be protocol handshake metadata.
|
|
294
|
+
if (segmentLength != 17) {
|
|
295
|
+
const data = this.adapter.message._decodeMsg(currentBuffer, duid);
|
|
296
|
+
if (!data) {
|
|
297
|
+
offset += 4 + segmentLength;
|
|
298
|
+
continue;
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
if (data.protocol == 4) {
|
|
302
|
+
const dps = JSON.parse(data.payload).dps;
|
|
303
|
+
|
|
304
|
+
if (dps) {
|
|
305
|
+
const _102 = JSON.stringify(dps["102"]);
|
|
306
|
+
const parsed_102 = JSON.parse(JSON.parse(_102));
|
|
307
|
+
const id = parsed_102.id;
|
|
308
|
+
const result = parsed_102.result;
|
|
309
|
+
|
|
310
|
+
if (this.adapter.pendingRequests.has(id)) {
|
|
311
|
+
this.adapter.log.debug(
|
|
312
|
+
`Local message with protocol 4 and id ${id} received. Result: ${JSON.stringify(result)}`
|
|
313
|
+
);
|
|
314
|
+
const { resolve, timeout } =
|
|
315
|
+
this.adapter.pendingRequests.get(id);
|
|
316
|
+
this.adapter.clearTimeout(timeout);
|
|
317
|
+
this.adapter.pendingRequests.delete(id);
|
|
318
|
+
resolve(result);
|
|
319
|
+
|
|
320
|
+
if (this.adapter.deviceNotify !== undefined) {
|
|
321
|
+
this.adapter.deviceNotify("LocalMessage", {
|
|
322
|
+
duid,
|
|
323
|
+
payload: result,
|
|
324
|
+
});
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
} else {
|
|
330
|
+
try {
|
|
331
|
+
const shortMessage = shortMessageParser.parse(currentBuffer);
|
|
332
|
+
if (
|
|
333
|
+
shortMessage.version == "L01" &&
|
|
334
|
+
shortMessage.protocol == 1
|
|
335
|
+
) {
|
|
336
|
+
const currentNonces =
|
|
337
|
+
this.adapter.localL01Nonces.get(duid) || {};
|
|
338
|
+
this.adapter.localL01Nonces.set(duid, {
|
|
339
|
+
connectNonce: currentNonces.connectNonce,
|
|
340
|
+
ackNonce: shortMessage.random,
|
|
341
|
+
});
|
|
342
|
+
|
|
343
|
+
const waiter = this.l01HandshakeWaiters.get(duid);
|
|
344
|
+
if (waiter) {
|
|
345
|
+
this.adapter.clearTimeout(waiter.timeout);
|
|
346
|
+
this.l01HandshakeWaiters.delete(duid);
|
|
347
|
+
waiter.resolve(true);
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
} catch (error) {
|
|
351
|
+
this.adapter.log.debug(
|
|
352
|
+
`Failed parsing short local message for ${duid}: ${error.message}`
|
|
353
|
+
);
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
offset += 4 + segmentLength;
|
|
357
|
+
}
|
|
358
|
+
this.clearChunkBuffer(duid);
|
|
359
|
+
}
|
|
360
|
+
} catch (error) {
|
|
361
|
+
this.adapter.catchError(
|
|
362
|
+
`Failed to create tcp client: ${error.stack}`,
|
|
363
|
+
`function createClient`,
|
|
364
|
+
duid
|
|
365
|
+
);
|
|
366
|
+
}
|
|
367
|
+
});
|
|
368
|
+
|
|
369
|
+
client.on("close", () => {
|
|
370
|
+
if (this.localClients[duid] !== client) {
|
|
371
|
+
return;
|
|
372
|
+
}
|
|
373
|
+
this.adapter.log.debug(
|
|
374
|
+
`tcp client for ${duid} disconnected, attempting to reconnect...`
|
|
375
|
+
);
|
|
376
|
+
this.adapter.updateTransportDiagnostics(duid, {
|
|
377
|
+
tcpConnectionState: "disconnected",
|
|
378
|
+
lastTransport: "cloud",
|
|
379
|
+
lastTransportReason: "tcp-disconnected",
|
|
380
|
+
});
|
|
381
|
+
const waiter = this.l01HandshakeWaiters.get(duid);
|
|
382
|
+
if (waiter) {
|
|
383
|
+
this.adapter.clearTimeout(waiter.timeout);
|
|
384
|
+
this.l01HandshakeWaiters.delete(duid);
|
|
385
|
+
waiter.reject(
|
|
386
|
+
new Error(`TCP client closed during L01 handshake for ${duid}`)
|
|
387
|
+
);
|
|
388
|
+
}
|
|
389
|
+
this.adapter.localL01Nonces.delete(duid);
|
|
390
|
+
this.scheduleReconnect(duid, ip);
|
|
391
|
+
client.connected = false;
|
|
392
|
+
});
|
|
393
|
+
|
|
394
|
+
client.on("error", (error) => {
|
|
395
|
+
this.adapter.log.debug(
|
|
396
|
+
`error on tcp client for ${duid}. ${error.message}`
|
|
397
|
+
);
|
|
398
|
+
this.adapter.updateTransportDiagnostics(duid, {
|
|
399
|
+
tcpConnectionState: "error",
|
|
400
|
+
lastTransportReason: `tcp-error: ${error.message}`,
|
|
401
|
+
});
|
|
402
|
+
});
|
|
403
|
+
|
|
404
|
+
this.localClients[duid] = client;
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
checkComplete(buffer) {
|
|
408
|
+
let totalLength = 0;
|
|
409
|
+
let offset = 0;
|
|
410
|
+
|
|
411
|
+
while (offset + 4 <= buffer.length) {
|
|
412
|
+
const segmentLength = buffer.readUInt32BE(offset);
|
|
413
|
+
totalLength += 4 + segmentLength;
|
|
414
|
+
offset += 4 + segmentLength;
|
|
415
|
+
|
|
416
|
+
if (offset > buffer.length) {
|
|
417
|
+
return false; // Data is not complete yet
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
return totalLength <= buffer.length;
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
clearChunkBuffer(duid) {
|
|
425
|
+
if (this.localClients[duid]) {
|
|
426
|
+
this.localClients[duid].chunkBuffer = Buffer.alloc(0);
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
sendMessage(duid, message) {
|
|
431
|
+
const client = this.localClients[duid];
|
|
432
|
+
if (client) {
|
|
433
|
+
client.write(message);
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
isConnected(duid) {
|
|
438
|
+
if (this.localClients[duid]) {
|
|
439
|
+
return this.localClients[duid].connected;
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
async ensureL01Handshake(duid) {
|
|
444
|
+
const version = await this.adapter.getRobotVersion(duid);
|
|
445
|
+
if (version != "L01") {
|
|
446
|
+
return;
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
const client = this.localClients[duid];
|
|
450
|
+
if (!client || !client.connected) {
|
|
451
|
+
return;
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
const existingNonces = this.adapter.localL01Nonces.get(duid);
|
|
455
|
+
if (
|
|
456
|
+
existingNonces &&
|
|
457
|
+
typeof existingNonces.connectNonce == "number" &&
|
|
458
|
+
typeof existingNonces.ackNonce == "number"
|
|
459
|
+
) {
|
|
460
|
+
return;
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
const timestamp = Math.floor(Date.now() / 1000);
|
|
464
|
+
const handshakeMessage = await this.adapter.message.buildRoborockMessage(
|
|
465
|
+
duid,
|
|
466
|
+
1,
|
|
467
|
+
timestamp,
|
|
468
|
+
Buffer.alloc(0)
|
|
469
|
+
);
|
|
470
|
+
if (!handshakeMessage) {
|
|
471
|
+
throw new Error(
|
|
472
|
+
`Failed to build protocol 1 handshake message for ${duid}`
|
|
473
|
+
);
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
const connectNonce = handshakeMessage.readUInt32BE(7);
|
|
477
|
+
this.adapter.localL01Nonces.set(duid, {
|
|
478
|
+
connectNonce,
|
|
479
|
+
ackNonce: undefined,
|
|
480
|
+
});
|
|
481
|
+
|
|
482
|
+
if (this.l01HandshakeWaiters.has(duid)) {
|
|
483
|
+
const waiter = this.l01HandshakeWaiters.get(duid);
|
|
484
|
+
this.adapter.clearTimeout(waiter.timeout);
|
|
485
|
+
this.l01HandshakeWaiters.delete(duid);
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
const handshakePromise = new Promise((resolve, reject) => {
|
|
489
|
+
const timeout = this.adapter.setTimeout(() => {
|
|
490
|
+
this.l01HandshakeWaiters.delete(duid);
|
|
491
|
+
reject(
|
|
492
|
+
new Error(`Timed out waiting for L01 handshake response for ${duid}`)
|
|
493
|
+
);
|
|
494
|
+
}, 3000);
|
|
495
|
+
|
|
496
|
+
this.l01HandshakeWaiters.set(duid, { resolve, reject, timeout });
|
|
497
|
+
});
|
|
498
|
+
|
|
499
|
+
const lengthBuffer = Buffer.alloc(4);
|
|
500
|
+
lengthBuffer.writeUInt32BE(handshakeMessage.length, 0);
|
|
501
|
+
const fullMessage = Buffer.concat([lengthBuffer, handshakeMessage]);
|
|
502
|
+
client.write(fullMessage);
|
|
503
|
+
|
|
504
|
+
await handshakePromise;
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
async getLocalDevices() {
|
|
508
|
+
return new Promise((resolve, reject) => {
|
|
509
|
+
const devices = {};
|
|
510
|
+
|
|
511
|
+
server.on("message", (msg) => {
|
|
512
|
+
const parsedMessage = localMessageParser.parse(msg);
|
|
513
|
+
const decodedMessage = this.decryptECB(
|
|
514
|
+
parsedMessage.payload,
|
|
515
|
+
BROADCAST_TOKEN
|
|
516
|
+
); // this might be decryptCBC for A01. Haven't checked this yet
|
|
517
|
+
|
|
518
|
+
if (decodedMessage == null) {
|
|
519
|
+
this.adapter.log.debug(`getLocalDevices: decodedMessage is null`);
|
|
520
|
+
return;
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
const parsedDecodedMessage = JSON.parse(decodedMessage);
|
|
524
|
+
this.adapter.log.debug(
|
|
525
|
+
`getLocalDevices parsedDecodedMessage: ${JSON.stringify(parsedDecodedMessage)}`
|
|
526
|
+
);
|
|
527
|
+
|
|
528
|
+
if (parsedDecodedMessage) {
|
|
529
|
+
const localKey = this.adapter.localKeys.get(
|
|
530
|
+
parsedDecodedMessage.duid
|
|
531
|
+
);
|
|
532
|
+
this.adapter.log.debug(
|
|
533
|
+
`getLocalDevices localKey present: ${Boolean(localKey)}`
|
|
534
|
+
);
|
|
535
|
+
|
|
536
|
+
if (localKey) {
|
|
537
|
+
// if there's no localKey, decryption cannot work. For example when the found robot is not associated with a roborock account
|
|
538
|
+
if (!devices[parsedDecodedMessage.duid]) {
|
|
539
|
+
devices[parsedDecodedMessage.duid] = parsedDecodedMessage.ip;
|
|
540
|
+
this.adapter.updateTransportDiagnostics(
|
|
541
|
+
parsedDecodedMessage.duid,
|
|
542
|
+
{
|
|
543
|
+
localIp: parsedDecodedMessage.ip,
|
|
544
|
+
localDiscoveryState: "broadcast-detected",
|
|
545
|
+
lastTransportReason: "udp-broadcast-discovery",
|
|
546
|
+
}
|
|
547
|
+
);
|
|
548
|
+
}
|
|
549
|
+
}
|
|
550
|
+
}
|
|
551
|
+
});
|
|
552
|
+
|
|
553
|
+
server.on("error", (error) => {
|
|
554
|
+
this.adapter.catchError(`Discover server error: ${error.stack}`);
|
|
555
|
+
server.close();
|
|
556
|
+
reject(error);
|
|
557
|
+
});
|
|
558
|
+
|
|
559
|
+
server.bind(PORT);
|
|
560
|
+
|
|
561
|
+
this.localDevicesTimeout = this.adapter.setTimeout(() => {
|
|
562
|
+
server.close();
|
|
563
|
+
|
|
564
|
+
resolve(devices);
|
|
565
|
+
}, TIMEOUT);
|
|
566
|
+
});
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
safeRemovePkcs7(buf) {
|
|
570
|
+
if (!buf || buf.length === 0) return Buffer.alloc(0);
|
|
571
|
+
const pad = buf[buf.length - 1];
|
|
572
|
+
// 僅在 1..16 且最後 pad 個 byte 都等於 pad 時才移除
|
|
573
|
+
if (pad > 0 && pad <= 16) {
|
|
574
|
+
for (let i = 0; i < pad; i++) {
|
|
575
|
+
if (buf[buf.length - 1 - i] !== pad) return buf; // padding 形狀不對,視為無 padding
|
|
576
|
+
}
|
|
577
|
+
return buf.slice(0, buf.length - pad);
|
|
578
|
+
}
|
|
579
|
+
return buf; // 看起來沒有標準 PKCS#7 padding
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
decryptECB(encrypted, aesKey) {
|
|
583
|
+
// --- 1) Key/輸入檢查 ---
|
|
584
|
+
const key = Buffer.isBuffer(aesKey) ? aesKey : Buffer.from(aesKey);
|
|
585
|
+
if (key.length !== 16) {
|
|
586
|
+
// AES-128 需要 16 bytes 的 key
|
|
587
|
+
return null;
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
const input = Buffer.isBuffer(encrypted)
|
|
591
|
+
? encrypted
|
|
592
|
+
: Buffer.from(encrypted, "latin1"); // "binary" 等同 latin1
|
|
593
|
+
if (input.length === 0 || input.length % 16 !== 0) {
|
|
594
|
+
// 密文長度不是 16 的倍數,多半是封包不完整;丟回 null 讓上層忽略本次
|
|
595
|
+
return null;
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
try {
|
|
599
|
+
// --- 2) 固定用 Buffer,關閉自動 padding(你要自己移除) ---
|
|
600
|
+
const decipher = crypto.createDecipheriv("aes-128-ecb", key, null);
|
|
601
|
+
decipher.setAutoPadding(false);
|
|
602
|
+
|
|
603
|
+
const decryptedBuf = Buffer.concat([
|
|
604
|
+
decipher.update(input),
|
|
605
|
+
decipher.final(),
|
|
606
|
+
]);
|
|
607
|
+
const unpadded = safeRemovePkcs7(decryptedBuf);
|
|
608
|
+
|
|
609
|
+
// 若原協定內容是 UTF-8,這裡再轉字串;否則直接回傳 Buffer 讓上層處理
|
|
610
|
+
return unpadded.toString("utf8");
|
|
611
|
+
} catch (err) {
|
|
612
|
+
// 例如 wrong final block length、key 不對等情況
|
|
613
|
+
// 這裡不要讓程式炸掉,直接忽略這個封包
|
|
614
|
+
// 你也可以在這裡做一次 debug log
|
|
615
|
+
// console.debug("decryptECB error:", err);
|
|
616
|
+
return null;
|
|
617
|
+
}
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
clearLocalDevicedTimeout() {
|
|
621
|
+
if (this.localDevicesTimeout) {
|
|
622
|
+
this.adapter.clearTimeout(this.localDevicesTimeout);
|
|
623
|
+
}
|
|
624
|
+
}
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
module.exports = {
|
|
628
|
+
localConnector,
|
|
629
|
+
};
|