homebridge-roborock-matter 3.35.0 → 3.36.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/CHANGELOG.md +36 -0
- package/README.md +2 -2
- package/package.json +1 -1
- package/roborockLib/lib/localConnector.js +332 -102
- package/roborockLib/lib/message.js +13 -2
- package/roborockLib/lib/messageQueueHandler.js +50 -6
- package/roborockLib/lib/roborock_mqtt_connector.js +32 -1
- package/roborockLib/roborockAPI.js +61 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,41 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 3.36.0
|
|
4
|
+
|
|
5
|
+
**The LAN connection now asks the robot which protocol it speaks, the way python-roborock always has, and a cloud session that has gone quiet is restarted.**
|
|
6
|
+
|
|
7
|
+
### "Connected but answered nothing" (#24, #28)
|
|
8
|
+
|
|
9
|
+
[@Marrand](https://github.com/mathiashornbek/homebridge-roborock-matter/issues/24) and [@CooperCGN](https://github.com/mathiashornbek/homebridge-roborock-matter/issues/28) both have an S8 (`a51`) that accepts the LAN connection and then answers nothing, while python-roborock — the library Home Assistant uses — gets answers from the same robots. Comparing the 2 side by side, this plugin differed in 4 places, and every one of them can produce exactly that symptom:
|
|
10
|
+
|
|
11
|
+
- **No hello.** python-roborock opens every local connection with a hello, first in `1.0` and then in `L01`, and encrypts by whichever the robot answers. This plugin sent none for a `1.0` robot, and took the LAN protocol from the account's `pv`, which python-roborock says outright is a different thing. A robot whose firmware has moved to `L01` on the LAN ignores every `1.0` frame.
|
|
12
|
+
- **The wrong L01 handshake.** Where it did try `L01`, it sent a protocol-1 frame. Protocol 1 is the robot's answer, not the question, so nothing answered it.
|
|
13
|
+
- **Datapoint 4.** A local request went out on datapoint 4, the protocol number. python-roborock sends datapoint 101 on both transports.
|
|
14
|
+
- **Protocol 4 only.** A local reply was accepted only on protocol 4; one on 5 or 102 was dropped without a word.
|
|
15
|
+
|
|
16
|
+
All 4 now follow python-roborock. Against a fake robot built from python-roborock's own codec in 5 firmware variants, 3.35.0 got an answer from 1 and 3.36.0 from all 5.
|
|
17
|
+
|
|
18
|
+
- The hello result is logged once: `answered the local hello in 1.0`, or that the robot speaks `L01` on the LAN, or that it answered no hello. That line is what I need from #24 and #28.
|
|
19
|
+
- A robot that answers no hello keeps 3.35.0's behaviour exactly. Its first request after a connect waits up to 10 seconds for the hellos; after that, not again. A robot listed as `L01` always waits, because no local frame can be built without the hello's nonces.
|
|
20
|
+
|
|
21
|
+
I ran it as 3.36.0-beta.1 on my own server first. My S8 Pro Ultra answered the hello in 1.0 in 4-18 ms on 4 restarts, and its local requests kept being answered for the 10 minutes I watched. Nobody here has a robot that needs `L01`, so that part is tested against python-roborock's codec, not against hardware.
|
|
22
|
+
|
|
23
|
+
### A cloud session that says it is up and delivers nothing
|
|
24
|
+
|
|
25
|
+
python-roborock restarts its MQTT session after 3 cloud timeouts in a row, at most once every 30 minutes, because "the MQTT connection appears to be alive but no messages are being received". This plugin only reconnected when the link reported itself down. CooperCGN's log counts 574 cloud messages by 08:54 and 576 by 12:11, while his robot started, paused and docked in between, and [@pponce](https://github.com/mathiashornbek/homebridge-roborock-matter/issues/27) measured a recreated session answering within 369 ms. Same rule now, with one addition from review: a timeout only counts when nothing at all arrived from that robot while the request waited, so a map request whose acknowledgement arrives and whose map does not is not mistaken for a dead session.
|
|
26
|
+
|
|
27
|
+
A cloud reply on protocol 4 or 5 that carries datapoint 102 is now read as a reply, as python-roborock reads it.
|
|
28
|
+
|
|
29
|
+
### Found in review
|
|
30
|
+
|
|
31
|
+
- A hello still in flight when the socket closed carried on into the dead socket, and a reconnect made in that window could inherit the old socket's hello and never get one of its own. Each hello now belongs to its socket, and a request that waited on a hello re-checks the socket before it is sent.
|
|
32
|
+
- The session restart now never runs while Homebridge is stopping.
|
|
33
|
+
- A socket that closed during its hello while the cloud was also down got the request written into it anyway, and counted it as a mute socket. It is now refused at once.
|
|
34
|
+
|
|
35
|
+
### Tests
|
|
36
|
+
|
|
37
|
+
2,153 tests, 22 more than 3.35.0. Measured against the 3.35.0 sources, file by file: 12 of 13 fail for the hello and the reply rules, 3 of 5 for the session restart, 4 of 4 for the socket each hello belongs to. The 2 that pass there guard against a restart 3.35.0 could not make.
|
|
38
|
+
|
|
3
39
|
## 3.35.0
|
|
4
40
|
|
|
5
41
|
**3.34.0 broke the half of the play button it was meant to leave alone, and an empty water tank could hide a running clean in Apple Home. Both are fixed, with 8 more found by reading this plugin against matter.js 0.17.9 and python-roborock 7.12.0 instead of against its own comments.**
|
package/README.md
CHANGED
|
@@ -37,7 +37,7 @@ This is the most feature-packed, most thoroughly engineered Roborock plugin for
|
|
|
37
37
|
- 📍 **See where it's cleaning — live.** Apple Home shows _"Cleaning — Kitchen"_ with the room the robot is actually inside, updating as it moves from room to room. Works even for cleans started from the robot's button or the Roborock app. No other Homebridge plugin does this.
|
|
38
38
|
- 🧭 **One robot, one tile — and as many robots as you own.** Sign in once and your whole fleet comes along: every vacuum on your account appears as its own clean, native accessory in Apple Home. No clutter of fake fans and helper switches, and rooms appear with the names you gave them in the Roborock app.
|
|
39
39
|
- ⚡ **Fast and reliable.** Commands go directly to the robot over your own network whenever possible, with the Roborock cloud as automatic backup — and built-in diagnostics in the settings if you ever want to look under the hood.
|
|
40
|
-
- 🛡️ **Verified by Homebridge.** Reviewed and endorsed by the Homebridge team.
|
|
40
|
+
- 🛡️ **Verified by Homebridge.** Reviewed and endorsed by the Homebridge team. 2153 automated tests, zero known vulnerabilities, no analytics, and a startup designed to never crash your Homebridge — even when your Wi-Fi or the Roborock cloud has a bad day.
|
|
41
41
|
|
|
42
42
|
## Features
|
|
43
43
|
|
|
@@ -264,7 +264,7 @@ The complete path — robot → plugin → Homebridge → matter.js store — wa
|
|
|
264
264
|
|
|
265
265
|
## Contributing
|
|
266
266
|
|
|
267
|
-
Model reports, diagnostics exports, and pull requests are very welcome. The codebase ships with
|
|
267
|
+
Model reports, diagnostics exports, and pull requests are very welcome. The codebase ships with 2153 tests (protocol fixtures verified against the [python-roborock](https://github.com/Python-roborock/python-roborock) reference), strict TypeScript checking, and CI across Node 22/24 × Homebridge 1.11/2.x — `npm test` before you push and you're set.
|
|
268
268
|
|
|
269
269
|
## Support the project
|
|
270
270
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "homebridge-roborock-matter",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.36.0",
|
|
4
4
|
"description": "The most complete Roborock plugin for Apple Home. Supports the entire Roborock lineup — from the classic S-series to the new 2025 Q7 series that no other plugin can control. Sign in with your Roborock account and get native start/stop, room cleaning, suction levels, battery, and live 'cleaning in the kitchen' room tracking. Verified by Homebridge.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": {
|
|
@@ -4,6 +4,7 @@ const crypto = require("crypto");
|
|
|
4
4
|
const Parser = require("binary-parser").Parser;
|
|
5
5
|
const net = require("net");
|
|
6
6
|
const dgram = require("dgram");
|
|
7
|
+
const CRC32 = require("crc-32");
|
|
7
8
|
const { describeDevice } = require("./describeDevice");
|
|
8
9
|
const { noteLateReply } = require("./lateReplies");
|
|
9
10
|
const {
|
|
@@ -100,6 +101,44 @@ const shortMessageParser = new Parser()
|
|
|
100
101
|
.uint32("timestamp")
|
|
101
102
|
.uint16("protocol");
|
|
102
103
|
|
|
104
|
+
/**
|
|
105
|
+
* The local hello, as python-roborock does it (devices/transport/
|
|
106
|
+
* local_channel.py). Protocol numbers from `RoborockMessageProtocol`.
|
|
107
|
+
*/
|
|
108
|
+
const HELLO_REQUEST = 0;
|
|
109
|
+
const HELLO_RESPONSE = 1;
|
|
110
|
+
const PING_RESPONSE = 3;
|
|
111
|
+
/** python-roborock's `_TIMEOUT` for each hello attempt. */
|
|
112
|
+
const HELLO_TIMEOUT_MS = 5000;
|
|
113
|
+
/** The local protocols a hello can negotiate, in python-roborock's order. */
|
|
114
|
+
const NEGOTIABLE_LOCAL_VERSIONS = ["1.0", "L01"];
|
|
115
|
+
/** Local reply frames: GENERAL_REQUEST, GENERAL_RESPONSE, RPC_RESPONSE. */
|
|
116
|
+
const LOCAL_REPLY_PROTOCOLS = new Set([4, 5, 102]);
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* A hello request frame, byte for byte as python-roborock builds one:
|
|
120
|
+
* version, seq 1, random = our connect nonce, timestamp, protocol 0, no
|
|
121
|
+
* payload (so no length field), CRC32 over all of that. 21 bytes, behind the
|
|
122
|
+
* usual 4-byte length prefix.
|
|
123
|
+
*
|
|
124
|
+
* @param {string} version "1.0" or "L01"
|
|
125
|
+
* @param {number} connectNonce
|
|
126
|
+
* @param {number} timestamp seconds
|
|
127
|
+
* @returns {Buffer}
|
|
128
|
+
*/
|
|
129
|
+
function buildHelloFrame(version, connectNonce, timestamp) {
|
|
130
|
+
const body = Buffer.alloc(21);
|
|
131
|
+
body.write(version, 0, "latin1");
|
|
132
|
+
body.writeUInt32BE(1, 3);
|
|
133
|
+
body.writeUInt32BE(connectNonce >>> 0, 7);
|
|
134
|
+
body.writeUInt32BE(timestamp >>> 0, 11);
|
|
135
|
+
body.writeUInt16BE(HELLO_REQUEST, 15);
|
|
136
|
+
body.writeUInt32BE(CRC32.buf(body.subarray(0, 17)) >>> 0, 17);
|
|
137
|
+
const prefix = Buffer.alloc(4);
|
|
138
|
+
prefix.writeUInt32BE(body.length, 0);
|
|
139
|
+
return Buffer.concat([prefix, body]);
|
|
140
|
+
}
|
|
141
|
+
|
|
103
142
|
class localConnector {
|
|
104
143
|
constructor(adapter) {
|
|
105
144
|
this.adapter = adapter;
|
|
@@ -112,7 +151,31 @@ class localConnector {
|
|
|
112
151
|
* @type {Set<string>}
|
|
113
152
|
*/
|
|
114
153
|
this.pendingClientConnects = new Set();
|
|
115
|
-
|
|
154
|
+
/**
|
|
155
|
+
* The hello in flight per robot: which version was asked, and how to
|
|
156
|
+
* settle it. See negotiateLocalProtocol().
|
|
157
|
+
* @type {Map<string, {version: string, timeout: any, settle: (ackNonce: number | null) => void}>}
|
|
158
|
+
*/
|
|
159
|
+
this.helloWaiters = new Map();
|
|
160
|
+
/**
|
|
161
|
+
* The hello in flight per robot, and the socket it belongs to — a
|
|
162
|
+
* reconnect must not inherit the previous socket's hello.
|
|
163
|
+
* @type {Map<string, {client: any, promise: Promise<string | null>}>}
|
|
164
|
+
*/
|
|
165
|
+
this.negotiations = new Map();
|
|
166
|
+
/**
|
|
167
|
+
* The local protocol the robot answered a hello in, for the CURRENT
|
|
168
|
+
* connection. Cleared when the socket goes; message.js reads it.
|
|
169
|
+
* @type {Map<string, string>}
|
|
170
|
+
*/
|
|
171
|
+
this.negotiatedVersions = new Map();
|
|
172
|
+
/**
|
|
173
|
+
* The last version that worked, kept across reconnects so the next hello
|
|
174
|
+
* asks it first. @type {Map<string, string>}
|
|
175
|
+
*/
|
|
176
|
+
this.preferredVersions = new Map();
|
|
177
|
+
/** @type {Map<string, string>} the last negotiation outcome logged */
|
|
178
|
+
this.reportedNegotiations = new Map();
|
|
116
179
|
this.reconnectTimers = new Map();
|
|
117
180
|
this.connectPromises = new Map();
|
|
118
181
|
// Consecutive failed local connects per duid, used to back the retry delay
|
|
@@ -337,18 +400,7 @@ class localConnector {
|
|
|
337
400
|
delete this.localClients[duid];
|
|
338
401
|
}
|
|
339
402
|
|
|
340
|
-
|
|
341
|
-
if (waiter) {
|
|
342
|
-
this.adapter.clearTimeout(waiter.timeout);
|
|
343
|
-
this.l01HandshakeWaiters.delete(duid);
|
|
344
|
-
waiter.reject(
|
|
345
|
-
new Error(
|
|
346
|
-
`TCP client reset during L01 handshake for ${describeDevice(this.adapter, duid)}`
|
|
347
|
-
)
|
|
348
|
-
);
|
|
349
|
-
}
|
|
350
|
-
|
|
351
|
-
this.adapter.localL01Nonces.delete(duid);
|
|
403
|
+
this.forgetNegotiation(duid);
|
|
352
404
|
client.destroy();
|
|
353
405
|
await this.adapter.updateTransportDiagnostics(duid, {
|
|
354
406
|
tcpConnectionState: "disconnected",
|
|
@@ -453,11 +505,6 @@ class localConnector {
|
|
|
453
505
|
.connect(58867, ip, async () => {
|
|
454
506
|
this.adapter.log.debug(`tcp client for ${duid} connected`);
|
|
455
507
|
await this.markLocalConnected(duid);
|
|
456
|
-
this.ensureL01Handshake(duid).catch((error) => {
|
|
457
|
-
this.adapter.log.debug(
|
|
458
|
-
`L01 handshake on connect failed for ${duid}: ${error.message}`
|
|
459
|
-
);
|
|
460
|
-
});
|
|
461
508
|
finish(resolve);
|
|
462
509
|
})
|
|
463
510
|
.on("error", (error) => {
|
|
@@ -505,17 +552,7 @@ class localConnector {
|
|
|
505
552
|
lastTransport: "cloud",
|
|
506
553
|
lastTransportReason: "tcp-disconnected",
|
|
507
554
|
});
|
|
508
|
-
|
|
509
|
-
if (waiter) {
|
|
510
|
-
this.adapter.clearTimeout(waiter.timeout);
|
|
511
|
-
this.l01HandshakeWaiters.delete(duid);
|
|
512
|
-
waiter.reject(
|
|
513
|
-
new Error(
|
|
514
|
-
`TCP client closed during L01 handshake for ${describeDevice(this.adapter, duid)}`
|
|
515
|
-
)
|
|
516
|
-
);
|
|
517
|
-
}
|
|
518
|
-
this.adapter.localL01Nonces.delete(duid);
|
|
555
|
+
this.forgetNegotiation(duid);
|
|
519
556
|
this.scheduleReconnect(duid, ip, this.nextReconnectDelay(duid));
|
|
520
557
|
client.connected = false;
|
|
521
558
|
});
|
|
@@ -532,6 +569,18 @@ class localConnector {
|
|
|
532
569
|
|
|
533
570
|
this.localClients[duid] = client;
|
|
534
571
|
|
|
572
|
+
if (!connectFailed) {
|
|
573
|
+
// Started only now, with the socket current and its `data` listener
|
|
574
|
+
// attached — the answer has to land somewhere. Every local request
|
|
575
|
+
// awaits it (awaitLocalNegotiation), so nothing goes out on the socket
|
|
576
|
+
// before the robot has said which protocol it speaks.
|
|
577
|
+
this.negotiateLocalProtocol(duid).catch((error) => {
|
|
578
|
+
this.adapter.log.debug(
|
|
579
|
+
`Local hello on connect failed for ${duid}: ${error?.message || error}`
|
|
580
|
+
);
|
|
581
|
+
});
|
|
582
|
+
}
|
|
583
|
+
|
|
535
584
|
if (connectFailed) {
|
|
536
585
|
// The close/error listeners above are attached only now, after the
|
|
537
586
|
// connect promise settled. On a FAILED connect both events already fired
|
|
@@ -658,35 +707,42 @@ class localConnector {
|
|
|
658
707
|
* @param {Buffer} currentBuffer
|
|
659
708
|
*/
|
|
660
709
|
processLocalSegment(duid, segmentLength, currentBuffer) {
|
|
661
|
-
//
|
|
662
|
-
// It
|
|
663
|
-
|
|
710
|
+
// A hello or ping answer: a bare header (17 bytes), or a header with a
|
|
711
|
+
// CRC (21). It carries no payload, so it is settled here and never
|
|
712
|
+
// decoded. Until 3.36.0 only an L01 hello answer was recognised, and only
|
|
713
|
+
// at exactly 17 bytes.
|
|
714
|
+
if (segmentLength === 17 || segmentLength === 21) {
|
|
664
715
|
try {
|
|
665
716
|
const shortMessage = shortMessageParser.parse(currentBuffer);
|
|
666
|
-
if (shortMessage.
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
const waiter = this.l01HandshakeWaiters.get(duid);
|
|
674
|
-
if (waiter) {
|
|
675
|
-
this.adapter.clearTimeout(waiter.timeout);
|
|
676
|
-
this.l01HandshakeWaiters.delete(duid);
|
|
677
|
-
waiter.resolve(true);
|
|
678
|
-
}
|
|
717
|
+
if (shortMessage.protocol === HELLO_RESPONSE) {
|
|
718
|
+
this.settleHello(duid, shortMessage.version, shortMessage.random);
|
|
719
|
+
return;
|
|
720
|
+
}
|
|
721
|
+
if (shortMessage.protocol === PING_RESPONSE) {
|
|
722
|
+
return;
|
|
679
723
|
}
|
|
680
724
|
} catch (error) {
|
|
681
725
|
this.adapter.log.debug(
|
|
682
726
|
`Failed parsing short local message for ${duid}: ${error.message}`
|
|
683
727
|
);
|
|
684
728
|
}
|
|
685
|
-
|
|
729
|
+
if (segmentLength === 17) {
|
|
730
|
+
return;
|
|
731
|
+
}
|
|
686
732
|
}
|
|
687
733
|
|
|
688
734
|
const data = this.adapter.message._decodeMsg(currentBuffer, duid);
|
|
689
|
-
if (!data
|
|
735
|
+
if (!data) {
|
|
736
|
+
return;
|
|
737
|
+
}
|
|
738
|
+
// python-roborock matches a reply by the `102` datapoint on ANY protocol.
|
|
739
|
+
// This plugin accepted protocol 4 only, so a robot that answers on 5
|
|
740
|
+
// (GENERAL_RESPONSE) or 102 had every reply dropped here in silence — on
|
|
741
|
+
// the wire, exactly a socket that "connected but answered nothing".
|
|
742
|
+
if (!LOCAL_REPLY_PROTOCOLS.has(Number(data.protocol))) {
|
|
743
|
+
this.adapter.log.debug(
|
|
744
|
+
`Ignored a local frame with protocol ${data.protocol} from ${describeDevice(this.adapter, duid)}.`
|
|
745
|
+
);
|
|
690
746
|
return;
|
|
691
747
|
}
|
|
692
748
|
|
|
@@ -712,8 +768,8 @@ class localConnector {
|
|
|
712
768
|
const refusal = describeReplyRefusal(parsed_102);
|
|
713
769
|
this.adapter.log.debug(
|
|
714
770
|
typeof result === "undefined"
|
|
715
|
-
? `Local message with protocol
|
|
716
|
-
: `Local message with protocol
|
|
771
|
+
? `Local message with protocol ${data.protocol} and id ${id} received. No result; reply was ${JSON.stringify(parsed_102)}`
|
|
772
|
+
: `Local message with protocol ${data.protocol} and id ${id} received. Result: ${JSON.stringify(result)}`
|
|
717
773
|
);
|
|
718
774
|
const { resolve, reject, timeout, method } =
|
|
719
775
|
this.adapter.pendingRequests.get(id);
|
|
@@ -817,70 +873,247 @@ class localConnector {
|
|
|
817
873
|
}
|
|
818
874
|
}
|
|
819
875
|
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
876
|
+
/**
|
|
877
|
+
* Ask the robot which local protocol it speaks, the way python-roborock
|
|
878
|
+
* does on every connect: a hello in "1.0", and if that goes unanswered for
|
|
879
|
+
* 5 seconds, a hello in "L01". The answer decides how local frames are
|
|
880
|
+
* encrypted (message.js reads getNegotiatedVersion) and, for L01, carries
|
|
881
|
+
* the nonce the session key is built from.
|
|
882
|
+
*
|
|
883
|
+
* WHY 3.36.0 ADDED IT. This plugin sent no hello for a "1.0" robot at all
|
|
884
|
+
* and trusted home data's `pv` for the local protocol, which python-roborock
|
|
885
|
+
* says outright is "different from vacuum protocol versions". A robot whose
|
|
886
|
+
* firmware has moved to L01 on the LAN, or that wants a hello before it
|
|
887
|
+
* answers, accepts the TCP connection and then ignores every frame — the
|
|
888
|
+
* "connected but answered nothing" two S8 owners reported (#24, #28) while
|
|
889
|
+
* python-roborock answered them. Its own L01 handshake was a protocol-1
|
|
890
|
+
* frame (the robot's ANSWER type) with a running sequence number, which
|
|
891
|
+
* nothing answers.
|
|
892
|
+
*
|
|
893
|
+
* If neither hello is answered, nothing else changes: local requests go out
|
|
894
|
+
* exactly as before, so a robot that never needed a hello keeps working.
|
|
895
|
+
*
|
|
896
|
+
* @param {string} duid
|
|
897
|
+
* @returns {Promise<string | null>} the version, or null when not negotiated
|
|
898
|
+
*/
|
|
899
|
+
negotiateLocalProtocol(duid) {
|
|
826
900
|
const client = this.localClients[duid];
|
|
827
|
-
|
|
828
|
-
|
|
901
|
+
const inFlight = this.negotiations.get(duid);
|
|
902
|
+
if (inFlight && inFlight.client === client) {
|
|
903
|
+
return inFlight.promise;
|
|
829
904
|
}
|
|
905
|
+
const entry = {
|
|
906
|
+
client,
|
|
907
|
+
promise: Promise.resolve(/** @type {string | null} */ (null)),
|
|
908
|
+
};
|
|
909
|
+
entry.promise = this.runNegotiation(duid).finally(() => {
|
|
910
|
+
if (this.negotiations.get(duid) === entry) {
|
|
911
|
+
this.negotiations.delete(duid);
|
|
912
|
+
}
|
|
913
|
+
});
|
|
914
|
+
this.negotiations.set(duid, entry);
|
|
915
|
+
return entry.promise;
|
|
916
|
+
}
|
|
830
917
|
|
|
831
|
-
|
|
918
|
+
/**
|
|
919
|
+
* What a local request waits for before it is built: an in-flight hello.
|
|
920
|
+
* Resolves at once when none is running.
|
|
921
|
+
*
|
|
922
|
+
* @param {string} duid
|
|
923
|
+
* @returns {Promise<void>}
|
|
924
|
+
*/
|
|
925
|
+
async awaitLocalNegotiation(duid) {
|
|
926
|
+
// A robot that answered no hello last time is not made to wait 10 s on
|
|
927
|
+
// every reconnect for the same answer; its requests go out as they always
|
|
928
|
+
// did while the hello is retried alongside them.
|
|
929
|
+
// Not for a robot listed as L01: without the hello's nonces no local
|
|
930
|
+
// frame can be built at all, so it always waits (found in final
|
|
931
|
+
// verification).
|
|
832
932
|
if (
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
typeof existingNonces.ackNonce == "number"
|
|
933
|
+
this.reportedNegotiations.get(duid) === "none" &&
|
|
934
|
+
(await this.adapter.getRobotVersion(duid)) !== "L01"
|
|
836
935
|
) {
|
|
837
936
|
return;
|
|
838
937
|
}
|
|
938
|
+
const inFlight = this.negotiations.get(duid);
|
|
939
|
+
if (inFlight && inFlight.client === this.localClients[duid]) {
|
|
940
|
+
await inFlight.promise.catch(() => null);
|
|
941
|
+
}
|
|
942
|
+
}
|
|
839
943
|
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
944
|
+
/**
|
|
945
|
+
* @param {string} duid
|
|
946
|
+
* @returns {string | undefined} the local protocol this connection agreed
|
|
947
|
+
*/
|
|
948
|
+
getNegotiatedVersion(duid) {
|
|
949
|
+
return this.negotiatedVersions.get(duid);
|
|
950
|
+
}
|
|
951
|
+
|
|
952
|
+
/**
|
|
953
|
+
* @param {string} duid
|
|
954
|
+
* @returns {Promise<string | null>}
|
|
955
|
+
*/
|
|
956
|
+
async runNegotiation(duid) {
|
|
957
|
+
const robotVersion = await this.adapter.getRobotVersion(duid);
|
|
958
|
+
if (!NEGOTIABLE_LOCAL_VERSIONS.includes(robotVersion)) {
|
|
959
|
+
return null;
|
|
960
|
+
}
|
|
961
|
+
const client = this.localClients[duid];
|
|
962
|
+
if (!client || !client.connected) {
|
|
963
|
+
return null;
|
|
851
964
|
}
|
|
852
965
|
|
|
853
|
-
const
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
966
|
+
const preferred = this.preferredVersions.get(duid) || robotVersion;
|
|
967
|
+
const order =
|
|
968
|
+
preferred === "L01" ? ["L01", "1.0"] : [...NEGOTIABLE_LOCAL_VERSIONS];
|
|
969
|
+
const startedAt = Date.now();
|
|
970
|
+
|
|
971
|
+
const socketIsCurrent = () =>
|
|
972
|
+
this.localClients[duid] === client && Boolean(client.connected);
|
|
858
973
|
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
974
|
+
for (const version of order) {
|
|
975
|
+
if (!socketIsCurrent()) {
|
|
976
|
+
return null;
|
|
977
|
+
}
|
|
978
|
+
const answer = await this.sendHello(duid, client, version);
|
|
979
|
+
// A socket that closed mid-hello answers nothing, and that says
|
|
980
|
+
// nothing about the robot: no next attempt, no "answered no hello".
|
|
981
|
+
if (!socketIsCurrent()) {
|
|
982
|
+
return null;
|
|
983
|
+
}
|
|
984
|
+
if (answer) {
|
|
985
|
+
this.negotiatedVersions.set(duid, version);
|
|
986
|
+
this.preferredVersions.set(duid, version);
|
|
987
|
+
if (version === "L01") {
|
|
988
|
+
this.adapter.localL01Nonces.set(duid, answer);
|
|
989
|
+
}
|
|
990
|
+
this.reportNegotiation(duid, version, robotVersion, startedAt);
|
|
991
|
+
return version;
|
|
992
|
+
}
|
|
863
993
|
}
|
|
864
994
|
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
new Error(
|
|
870
|
-
`Timed out waiting for L01 handshake response for ${describeDevice(this.adapter, duid)}`
|
|
871
|
-
)
|
|
872
|
-
);
|
|
873
|
-
}, 3000);
|
|
995
|
+
this.negotiatedVersions.delete(duid);
|
|
996
|
+
this.reportNegotiation(duid, null, robotVersion, startedAt);
|
|
997
|
+
return null;
|
|
998
|
+
}
|
|
874
999
|
|
|
875
|
-
|
|
1000
|
+
/**
|
|
1001
|
+
* One hello, settled by the robot's answer or after HELLO_TIMEOUT_MS.
|
|
1002
|
+
*
|
|
1003
|
+
* @param {string} duid
|
|
1004
|
+
* @param {any} client
|
|
1005
|
+
* @param {string} version
|
|
1006
|
+
* @returns {Promise<{connectNonce: number, ackNonce: number} | null>}
|
|
1007
|
+
*/
|
|
1008
|
+
sendHello(duid, client, version) {
|
|
1009
|
+
return new Promise((resolve) => {
|
|
1010
|
+
// python-roborock: get_next_int(10000, 32767).
|
|
1011
|
+
const connectNonce = crypto.randomInt(10000, 32768);
|
|
1012
|
+
let settled = false;
|
|
1013
|
+
const settle = (/** @type {number | null} */ ackNonce) => {
|
|
1014
|
+
if (settled) {
|
|
1015
|
+
return;
|
|
1016
|
+
}
|
|
1017
|
+
settled = true;
|
|
1018
|
+
this.adapter.clearTimeout(timeout);
|
|
1019
|
+
if (this.helloWaiters.get(duid)?.settle === settle) {
|
|
1020
|
+
this.helloWaiters.delete(duid);
|
|
1021
|
+
}
|
|
1022
|
+
resolve(ackNonce === null ? null : { connectNonce, ackNonce });
|
|
1023
|
+
};
|
|
1024
|
+
const timeout = this.adapter.setTimeout(
|
|
1025
|
+
() => settle(null),
|
|
1026
|
+
HELLO_TIMEOUT_MS
|
|
1027
|
+
);
|
|
1028
|
+
this.helloWaiters.set(duid, { version, timeout, settle });
|
|
1029
|
+
try {
|
|
1030
|
+
client.write(
|
|
1031
|
+
buildHelloFrame(version, connectNonce, Math.floor(Date.now() / 1000))
|
|
1032
|
+
);
|
|
1033
|
+
} catch (error) {
|
|
1034
|
+
this.adapter.log.debug(
|
|
1035
|
+
`Could not send the local hello to ${duid}: ${error?.message || error}`
|
|
1036
|
+
);
|
|
1037
|
+
settle(null);
|
|
1038
|
+
}
|
|
876
1039
|
});
|
|
1040
|
+
}
|
|
1041
|
+
|
|
1042
|
+
/**
|
|
1043
|
+
* A hello answer arrived. It settles the waiting hello only when it is in
|
|
1044
|
+
* the version that was asked; an answer in another version is said and
|
|
1045
|
+
* ignored, and the next attempt follows.
|
|
1046
|
+
*
|
|
1047
|
+
* @param {string} duid
|
|
1048
|
+
* @param {string} version
|
|
1049
|
+
* @param {number} random the robot's nonce
|
|
1050
|
+
* @returns {void}
|
|
1051
|
+
*/
|
|
1052
|
+
settleHello(duid, version, random) {
|
|
1053
|
+
const waiter = this.helloWaiters.get(duid);
|
|
1054
|
+
if (!waiter) {
|
|
1055
|
+
return;
|
|
1056
|
+
}
|
|
1057
|
+
if (waiter.version !== version) {
|
|
1058
|
+
this.adapter.log.debug(
|
|
1059
|
+
`${describeDevice(this.adapter, duid)} answered a ${waiter.version} hello in ${version}; trying the next protocol.`
|
|
1060
|
+
);
|
|
1061
|
+
return;
|
|
1062
|
+
}
|
|
1063
|
+
waiter.settle(random);
|
|
1064
|
+
}
|
|
877
1065
|
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
1066
|
+
/**
|
|
1067
|
+
* Say what the hello found, once per change. A robot answering in its
|
|
1068
|
+
* home-data protocol is the normal case: said at info once, then debug.
|
|
1069
|
+
* Anything else is the evidence #24 and #28 needed, so it is said at info
|
|
1070
|
+
* whenever it changes.
|
|
1071
|
+
*
|
|
1072
|
+
* @param {string} duid
|
|
1073
|
+
* @param {string | null} version
|
|
1074
|
+
* @param {string} robotVersion
|
|
1075
|
+
* @param {number} startedAt
|
|
1076
|
+
* @returns {void}
|
|
1077
|
+
*/
|
|
1078
|
+
reportNegotiation(duid, version, robotVersion, startedAt) {
|
|
1079
|
+
const outcome = version ?? "none";
|
|
1080
|
+
const first = !this.reportedNegotiations.has(duid);
|
|
1081
|
+
const changed = this.reportedNegotiations.get(duid) !== outcome;
|
|
1082
|
+
this.reportedNegotiations.set(duid, outcome);
|
|
1083
|
+
const elapsed = Date.now() - startedAt;
|
|
1084
|
+
if (version === robotVersion) {
|
|
1085
|
+
// Once at info per start, so a support log shows the hello worked;
|
|
1086
|
+
// every reconnect after that at debug.
|
|
1087
|
+
(first ? this.adapter.log.info : this.adapter.log.debug).call(
|
|
1088
|
+
this.adapter.log,
|
|
1089
|
+
`${describeDevice(this.adapter, duid)} answered the local hello in ${version} (${elapsed} ms).`
|
|
1090
|
+
);
|
|
1091
|
+
return;
|
|
1092
|
+
}
|
|
1093
|
+
if (!changed) {
|
|
1094
|
+
return;
|
|
1095
|
+
}
|
|
1096
|
+
this.adapter.log.info(
|
|
1097
|
+
version
|
|
1098
|
+
? `${describeDevice(this.adapter, duid)} speaks the ${version} protocol on the LAN, not the ${robotVersion} its Roborock account lists; local requests now use ${version}.`
|
|
1099
|
+
: `${describeDevice(this.adapter, duid)} accepted the local connection but answered no hello, in 1.0 or L01 (5 seconds each). Local requests go out as before; if they go unanswered too, the plugin moves this robot to the Roborock cloud by itself.`
|
|
1100
|
+
);
|
|
1101
|
+
}
|
|
882
1102
|
|
|
883
|
-
|
|
1103
|
+
/**
|
|
1104
|
+
* The socket is gone: settle any hello waiting on it and drop what this
|
|
1105
|
+
* connection agreed. The preferred version is kept for the next hello.
|
|
1106
|
+
*
|
|
1107
|
+
* @param {string} duid
|
|
1108
|
+
* @returns {void}
|
|
1109
|
+
*/
|
|
1110
|
+
forgetNegotiation(duid) {
|
|
1111
|
+
const waiter = this.helloWaiters.get(duid);
|
|
1112
|
+
if (waiter) {
|
|
1113
|
+
waiter.settle(null);
|
|
1114
|
+
}
|
|
1115
|
+
this.negotiatedVersions.delete(duid);
|
|
1116
|
+
this.adapter.localL01Nonces?.delete?.(duid);
|
|
884
1117
|
}
|
|
885
1118
|
|
|
886
1119
|
/**
|
|
@@ -1131,11 +1364,7 @@ class localConnector {
|
|
|
1131
1364
|
for (const duid of Object.keys(this.localClients)) {
|
|
1132
1365
|
const client = this.localClients[duid];
|
|
1133
1366
|
delete this.localClients[duid];
|
|
1134
|
-
|
|
1135
|
-
if (waiter) {
|
|
1136
|
-
this.adapter.clearTimeout(waiter.timeout);
|
|
1137
|
-
this.l01HandshakeWaiters.delete(duid);
|
|
1138
|
-
}
|
|
1367
|
+
this.forgetNegotiation(duid);
|
|
1139
1368
|
try {
|
|
1140
1369
|
client?.removeAllListeners?.();
|
|
1141
1370
|
client?.destroy?.();
|
|
@@ -1147,11 +1376,12 @@ class localConnector {
|
|
|
1147
1376
|
);
|
|
1148
1377
|
}
|
|
1149
1378
|
}
|
|
1150
|
-
this.
|
|
1379
|
+
this.helloWaiters.clear();
|
|
1151
1380
|
this.pendingClientConnects.clear();
|
|
1152
1381
|
}
|
|
1153
1382
|
}
|
|
1154
1383
|
|
|
1155
1384
|
module.exports = {
|
|
1156
1385
|
localConnector,
|
|
1386
|
+
buildHelloFrame,
|
|
1157
1387
|
};
|
|
@@ -120,9 +120,13 @@ class message {
|
|
|
120
120
|
},
|
|
121
121
|
});
|
|
122
122
|
} else {
|
|
123
|
+
// Datapoint 101 on BOTH transports, as python-roborock sends it
|
|
124
|
+
// (`RequestMessage._as_payload`). Until 3.36.0 a local request went on
|
|
125
|
+
// datapoint 4 — the protocol number, not the RPC datapoint — which some
|
|
126
|
+
// robots accept and nothing guarantees.
|
|
123
127
|
payload = JSON.stringify({
|
|
124
128
|
dps: {
|
|
125
|
-
|
|
129
|
+
101: JSON.stringify(inner),
|
|
126
130
|
},
|
|
127
131
|
t: timestamp,
|
|
128
132
|
});
|
|
@@ -132,7 +136,14 @@ class message {
|
|
|
132
136
|
}
|
|
133
137
|
|
|
134
138
|
async buildRoborockMessage(duid, protocol, timestamp, payload) {
|
|
135
|
-
|
|
139
|
+
// A LOCAL frame (protocol 4) is encrypted in the protocol the robot
|
|
140
|
+
// answered the local hello in, which need not be the one its account
|
|
141
|
+
// lists (localConnector.negotiateLocalProtocol). Cloud frames keep `pv`.
|
|
142
|
+
const negotiated =
|
|
143
|
+
protocol == 4
|
|
144
|
+
? this.adapter.localConnector?.getNegotiatedVersion?.(duid)
|
|
145
|
+
: undefined;
|
|
146
|
+
const version = negotiated || (await this.adapter.getRobotVersion(duid));
|
|
136
147
|
|
|
137
148
|
let encrypted;
|
|
138
149
|
|
|
@@ -175,7 +175,8 @@ function describeCloudSilence(adapter, duid, receiptsAtSend) {
|
|
|
175
175
|
* @property {(duid: string) => boolean} isConnected
|
|
176
176
|
* @property {(duid: string, message: Buffer) => void} sendMessage
|
|
177
177
|
* @property {(duid: string) => void} clearChunkBuffer
|
|
178
|
-
* @property {(duid: string) => Promise<void>} [
|
|
178
|
+
* @property {(duid: string) => Promise<void>} [awaitLocalNegotiation]
|
|
179
|
+
* @property {(duid: string) => string | undefined} [getNegotiatedVersion]
|
|
179
180
|
*/
|
|
180
181
|
|
|
181
182
|
/**
|
|
@@ -230,6 +231,9 @@ function unansweredRequestError(message, transportWasUp) {
|
|
|
230
231
|
* @property {(duid: string) => Promise<boolean>} [ensureLocalConnection]
|
|
231
232
|
* @property {(duid: string, method?: string) => Promise<void>} [noteLocalRequestTimedOut]
|
|
232
233
|
* @property {(duid: string, method: string) => void} [noteRequestAnswered]
|
|
234
|
+
* @property {() => void} [noteCloudReply] Any reply over the cloud.
|
|
235
|
+
* @property {() => boolean} [noteCloudSilence] A cloud request timed out
|
|
236
|
+
* while MQTT reported itself connected.
|
|
233
237
|
* @property {(duid: string, method: string, error: unknown) => void} [noteRequestUnanswered]
|
|
234
238
|
* @property {import("./lateReplies").LateReplyTracker} [lateReplies] Remembers
|
|
235
239
|
* timed-out request ids so a reply that turns up after its timeout can be
|
|
@@ -429,16 +433,31 @@ class messageQueueHandler {
|
|
|
429
433
|
);
|
|
430
434
|
}
|
|
431
435
|
|
|
432
|
-
if (!useCloudConnection
|
|
436
|
+
if (!useCloudConnection) {
|
|
437
|
+
// Never put a request on a socket whose hello is still in the air: the
|
|
438
|
+
// answer decides how the frame is encrypted. Bounded by the hello's own
|
|
439
|
+
// timeouts (2 x 5 s), and instant once negotiated.
|
|
433
440
|
try {
|
|
434
|
-
|
|
435
|
-
await this.adapter.localConnector.ensureL01Handshake(duid);
|
|
436
|
-
}
|
|
441
|
+
await this.adapter.localConnector.awaitLocalNegotiation?.(duid);
|
|
437
442
|
} catch (error) {
|
|
438
443
|
const errorMessage =
|
|
439
444
|
error instanceof Error ? error.message : String(error);
|
|
440
445
|
this.adapter.log.debug(
|
|
441
|
-
`
|
|
446
|
+
`Local hello before request failed for ${duid}: ${errorMessage}`
|
|
447
|
+
);
|
|
448
|
+
}
|
|
449
|
+
// The socket may have closed during the wait. The state read before it
|
|
450
|
+
// is stale, so read it again — for the cloud fallback here AND for the
|
|
451
|
+
// "no local connection" refusal further down, which reads the same
|
|
452
|
+
// variable (found in final verification).
|
|
453
|
+
localConnectionState = this.adapter.localConnector.isConnected(duid);
|
|
454
|
+
if (
|
|
455
|
+
!localConnectionState &&
|
|
456
|
+
this.adapter.rr_mqtt_connector.isConnected()
|
|
457
|
+
) {
|
|
458
|
+
useCloudConnection = true;
|
|
459
|
+
this.adapter.log.debug(
|
|
460
|
+
`The local socket for ${duid} closed during its hello. Falling back to cloud connection for method ${method}.`
|
|
442
461
|
);
|
|
443
462
|
}
|
|
444
463
|
}
|
|
@@ -581,6 +600,25 @@ class messageQueueHandler {
|
|
|
581
600
|
);
|
|
582
601
|
this.adapter.noteRequestUnanswered?.(duid, method, error);
|
|
583
602
|
this.adapter.lateReplies?.noteTimedOut(messageID, duid, method);
|
|
603
|
+
// A link that says it is up and delivers NOTHING from this
|
|
604
|
+
// robot while the request waits is the stale session
|
|
605
|
+
// python-roborock restarts. A frame that did arrive — a
|
|
606
|
+
// get_map_v1 acknowledgement whose map never follows, a status
|
|
607
|
+
// push — means the session delivers, so it is not counted
|
|
608
|
+
// (found in review). B01 is left out: a Q10 command is
|
|
609
|
+
// fire-and-forget by design.
|
|
610
|
+
const deliveredMeanwhile =
|
|
611
|
+
receiptsAtSend !== null &&
|
|
612
|
+
typeof this.adapter.getCloudMessageReceiptCount ===
|
|
613
|
+
"function" &&
|
|
614
|
+
this.adapter.getCloudMessageReceiptCount(duid) > receiptsAtSend;
|
|
615
|
+
if (
|
|
616
|
+
transportWasUp &&
|
|
617
|
+
!deliveredMeanwhile &&
|
|
618
|
+
!b01Q7Adapter.isB01Protocol(version)
|
|
619
|
+
) {
|
|
620
|
+
this.adapter.noteCloudSilence?.();
|
|
621
|
+
}
|
|
584
622
|
reject(error);
|
|
585
623
|
} else {
|
|
586
624
|
// A socket that keeps reporting itself connected while every
|
|
@@ -622,6 +660,9 @@ class messageQueueHandler {
|
|
|
622
660
|
// seven methods in #22/#24 that it was built for.
|
|
623
661
|
resolve: (value) => {
|
|
624
662
|
this.adapter.noteRequestAnswered?.(duid, method);
|
|
663
|
+
if (useCloudConnection) {
|
|
664
|
+
this.adapter.noteCloudReply?.();
|
|
665
|
+
}
|
|
625
666
|
resolve(value);
|
|
626
667
|
},
|
|
627
668
|
// A refusal is an answer too. The reply handlers (cloud 102,
|
|
@@ -633,6 +674,9 @@ class messageQueueHandler {
|
|
|
633
674
|
// the method was given up on although the robot had just replied.
|
|
634
675
|
reject: (error) => {
|
|
635
676
|
this.adapter.noteRequestAnswered?.(duid, method);
|
|
677
|
+
if (useCloudConnection) {
|
|
678
|
+
this.adapter.noteCloudReply?.();
|
|
679
|
+
}
|
|
636
680
|
reject(error);
|
|
637
681
|
},
|
|
638
682
|
abandon: reject,
|
|
@@ -66,6 +66,36 @@ const photoBuffers = new Map();
|
|
|
66
66
|
// already paste.
|
|
67
67
|
const droppedFrames = new Map();
|
|
68
68
|
|
|
69
|
+
/**
|
|
70
|
+
* Whether a decoded cloud frame is an RPC reply. Protocol 102 always was;
|
|
71
|
+
* python-roborock also takes a frame on 4 or 5 whose payload carries the
|
|
72
|
+
* `102` datapoint, and until 3.36.0 such a reply was dropped here unread.
|
|
73
|
+
*
|
|
74
|
+
* @param {{protocol?: unknown, payload?: unknown}} data
|
|
75
|
+
* @returns {boolean}
|
|
76
|
+
*/
|
|
77
|
+
function isRpcReplyFrame(data) {
|
|
78
|
+
const protocol = Number(data?.protocol);
|
|
79
|
+
if (protocol === 102) {
|
|
80
|
+
return true;
|
|
81
|
+
}
|
|
82
|
+
if (protocol !== 4 && protocol !== 5) {
|
|
83
|
+
return false;
|
|
84
|
+
}
|
|
85
|
+
try {
|
|
86
|
+
const parsed = JSON.parse(String(data.payload));
|
|
87
|
+
return (
|
|
88
|
+
parsed !== null &&
|
|
89
|
+
typeof parsed === "object" &&
|
|
90
|
+
parsed.dps !== null &&
|
|
91
|
+
typeof parsed.dps === "object" &&
|
|
92
|
+
Object.prototype.hasOwnProperty.call(parsed.dps, "102")
|
|
93
|
+
);
|
|
94
|
+
} catch {
|
|
95
|
+
return false;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
69
99
|
function noteDroppedFrame(duid, reason) {
|
|
70
100
|
let entry = droppedFrames.get(duid);
|
|
71
101
|
if (!entry) {
|
|
@@ -398,7 +428,7 @@ class roborock_mqtt_connector {
|
|
|
398
428
|
// this.adapter.log.debug(`MESSAGE RECEIVED for duid ${duid} with key: ${this.adapter.localKeys.get(duid)} data: ${JSON.stringify(data)}`);
|
|
399
429
|
|
|
400
430
|
// this.adapter.log.debug("Protocol: " + data.protocol);
|
|
401
|
-
if (data
|
|
431
|
+
if (isRpcReplyFrame(data)) {
|
|
402
432
|
const parsedPayload = JSON.parse(data.payload);
|
|
403
433
|
let dps;
|
|
404
434
|
if (typeof parsedPayload.dps["102"] != "undefined") {
|
|
@@ -895,6 +925,7 @@ function resolveB01PendingResponse(adapter, duid, dps) {
|
|
|
895
925
|
|
|
896
926
|
module.exports = {
|
|
897
927
|
describeDroppedFrames,
|
|
928
|
+
isRpcReplyFrame,
|
|
898
929
|
resolveB01PendingResponse,
|
|
899
930
|
roborock_mqtt_connector,
|
|
900
931
|
parseProtocol301Header,
|
|
@@ -439,6 +439,9 @@ const SIMPLE_VACUUM_COMMANDS = new Set([
|
|
|
439
439
|
]);
|
|
440
440
|
|
|
441
441
|
const TRANSIENT_ERROR_LOG_THROTTLE_MS = 6 * 60 * 60 * 1000;
|
|
442
|
+
// python-roborock mqtt/health_manager.py: TIMEOUT_THRESHOLD, RESTART_COOLDOWN.
|
|
443
|
+
const CLOUD_SILENCES_BEFORE_SESSION_RESTART = 3;
|
|
444
|
+
const CLOUD_SESSION_RESTART_COOLDOWN_MS = 30 * 60 * 1000;
|
|
442
445
|
const MATTER_CLEAN_MODE_COMMAND_TIMEOUT_MS = 2000;
|
|
443
446
|
// Reserved out of the caller's prep window so the sequence ends by itself and
|
|
444
447
|
// reports what it could not confirm, rather than being cut off mid-command with
|
|
@@ -537,6 +540,9 @@ class Roborock {
|
|
|
537
540
|
// Replies that came after their request had timed out. See
|
|
538
541
|
// lib/lateReplies.js; the give-up line reads it.
|
|
539
542
|
this.lateReplies = new LateReplyTracker();
|
|
543
|
+
// Consecutive unanswered cloud requests while MQTT says it is connected.
|
|
544
|
+
// See noteCloudSilence().
|
|
545
|
+
this.cloudSessionHealth = { consecutiveSilences: 0, lastRestartAt: 0 };
|
|
540
546
|
this.baseURL = options.baseURL || "usiot.roborock.com";
|
|
541
547
|
|
|
542
548
|
this.userData = options.userData || null;
|
|
@@ -3400,6 +3406,61 @@ class Roborock {
|
|
|
3400
3406
|
return vacuum.getParameter(duid, method);
|
|
3401
3407
|
}
|
|
3402
3408
|
|
|
3409
|
+
/**
|
|
3410
|
+
* Any reply came back over the cloud: the session delivers.
|
|
3411
|
+
*
|
|
3412
|
+
* @returns {void}
|
|
3413
|
+
*/
|
|
3414
|
+
noteCloudReply() {
|
|
3415
|
+
this.cloudSessionHealth.consecutiveSilences = 0;
|
|
3416
|
+
}
|
|
3417
|
+
|
|
3418
|
+
/**
|
|
3419
|
+
* A cloud request timed out while MQTT reported itself connected.
|
|
3420
|
+
*
|
|
3421
|
+
* python-roborock's HealthManager exists for exactly this — "the MQTT
|
|
3422
|
+
* connection appears to be alive but no messages are being received" — and
|
|
3423
|
+
* restarts the session after 3 timeouts in a row, at most once every 30
|
|
3424
|
+
* minutes. This plugin only ever reconnected when mqtt.js said the link was
|
|
3425
|
+
* DOWN, so a session that stayed up and went quiet stayed quiet: CooperCGN's
|
|
3426
|
+
* log in #28 counts 574 cloud messages by 08:54 and 576 by 12:11, while his
|
|
3427
|
+
* robot started, paused and docked in between, and @pponce measured a
|
|
3428
|
+
* recreated session answering again within 369 ms (#27). Same rule here.
|
|
3429
|
+
*
|
|
3430
|
+
* @returns {boolean} whether a restart was started
|
|
3431
|
+
*/
|
|
3432
|
+
noteCloudSilence() {
|
|
3433
|
+
if (this.stopped) {
|
|
3434
|
+
return false;
|
|
3435
|
+
}
|
|
3436
|
+
const health = this.cloudSessionHealth;
|
|
3437
|
+
health.consecutiveSilences += 1;
|
|
3438
|
+
if (health.consecutiveSilences < CLOUD_SILENCES_BEFORE_SESSION_RESTART) {
|
|
3439
|
+
return false;
|
|
3440
|
+
}
|
|
3441
|
+
const now = Date.now();
|
|
3442
|
+
if (
|
|
3443
|
+
health.lastRestartAt !== 0 &&
|
|
3444
|
+
now - health.lastRestartAt < CLOUD_SESSION_RESTART_COOLDOWN_MS
|
|
3445
|
+
) {
|
|
3446
|
+
return false;
|
|
3447
|
+
}
|
|
3448
|
+
const silences = health.consecutiveSilences;
|
|
3449
|
+
health.lastRestartAt = now;
|
|
3450
|
+
health.consecutiveSilences = 0;
|
|
3451
|
+
this.log.info(
|
|
3452
|
+
`${silences} cloud requests in a row went unanswered while the MQTT connection reported itself up, so the plugin is starting a fresh MQTT session — the same rule python-roborock applies (3 in a row, at most once every 30 minutes). A request in flight at this moment may fail once.`
|
|
3453
|
+
);
|
|
3454
|
+
void Promise.resolve()
|
|
3455
|
+
.then(() => this.rr_mqtt_connector?.reconnectClient?.(true))
|
|
3456
|
+
.catch((error) => {
|
|
3457
|
+
this.log.debug(
|
|
3458
|
+
`Restarting the MQTT session failed: ${error?.message || error}`
|
|
3459
|
+
);
|
|
3460
|
+
});
|
|
3461
|
+
return true;
|
|
3462
|
+
}
|
|
3463
|
+
|
|
3403
3464
|
/**
|
|
3404
3465
|
* The message layer saw a reply. Called for EVERY request, including
|
|
3405
3466
|
* `get_status` and every command — the register ignores any pair no
|