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 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. 2131 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.
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 2131 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.
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.35.0",
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
- this.l01HandshakeWaiters = new Map();
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
- const waiter = this.l01HandshakeWaiters.get(duid);
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
- const waiter = this.l01HandshakeWaiters.get(duid);
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
- // length of 17 does not contain any useful data.
662
- // It seems to be protocol handshake metadata.
663
- if (segmentLength == 17) {
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.version == "L01" && shortMessage.protocol == 1) {
667
- const currentNonces = this.adapter.localL01Nonces.get(duid) || {};
668
- this.adapter.localL01Nonces.set(duid, {
669
- connectNonce: currentNonces.connectNonce,
670
- ackNonce: shortMessage.random,
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
- return;
729
+ if (segmentLength === 17) {
730
+ return;
731
+ }
686
732
  }
687
733
 
688
734
  const data = this.adapter.message._decodeMsg(currentBuffer, duid);
689
- if (!data || data.protocol != 4) {
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 4 and id ${id} received. No result; reply was ${JSON.stringify(parsed_102)}`
716
- : `Local message with protocol 4 and id ${id} received. Result: ${JSON.stringify(result)}`
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
- async ensureL01Handshake(duid) {
821
- const version = await this.adapter.getRobotVersion(duid);
822
- if (version != "L01") {
823
- return;
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
- if (!client || !client.connected) {
828
- return;
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
- const existingNonces = this.adapter.localL01Nonces.get(duid);
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
- existingNonces &&
834
- typeof existingNonces.connectNonce == "number" &&
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
- const timestamp = Math.floor(Date.now() / 1000);
841
- const handshakeMessage = await this.adapter.message.buildRoborockMessage(
842
- duid,
843
- 1,
844
- timestamp,
845
- Buffer.alloc(0)
846
- );
847
- if (!handshakeMessage) {
848
- throw new Error(
849
- `Failed to build protocol 1 handshake message for ${describeDevice(this.adapter, duid)}`
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 connectNonce = handshakeMessage.readUInt32BE(7);
854
- this.adapter.localL01Nonces.set(duid, {
855
- connectNonce,
856
- ackNonce: undefined,
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
- if (this.l01HandshakeWaiters.has(duid)) {
860
- const waiter = this.l01HandshakeWaiters.get(duid);
861
- this.adapter.clearTimeout(waiter.timeout);
862
- this.l01HandshakeWaiters.delete(duid);
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
- const handshakePromise = new Promise((resolve, reject) => {
866
- const timeout = this.adapter.setTimeout(() => {
867
- this.l01HandshakeWaiters.delete(duid);
868
- reject(
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
- this.l01HandshakeWaiters.set(duid, { resolve, reject, timeout });
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
- const lengthBuffer = Buffer.alloc(4);
879
- lengthBuffer.writeUInt32BE(handshakeMessage.length, 0);
880
- const fullMessage = Buffer.concat([lengthBuffer, handshakeMessage]);
881
- client.write(fullMessage);
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
- await handshakePromise;
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
- const waiter = this.l01HandshakeWaiters.get(duid);
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.l01HandshakeWaiters.clear();
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
- [protocol]: JSON.stringify(inner),
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
- const version = await this.adapter.getRobotVersion(duid);
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>} [ensureL01Handshake]
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 && version == "L01") {
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
- if (this.adapter.localConnector.ensureL01Handshake) {
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
- `L01 handshake before request failed for ${duid}: ${errorMessage}`
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.protocol == 102) {
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