homebridge-roborock-matter 3.24.0 → 3.24.2

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,36 @@
1
1
  # Changelog
2
2
 
3
+ ## 3.24.2
4
+
5
+ **Shutting down while LAN discovery was listening left a UDP socket open and every caller waiting on it hung.**
6
+
7
+ Discovery listens for the robots' broadcasts for a fixed five-second window. One timer ends that window: it closes the socket and hands back whatever was heard. Shutdown's only hook into the local transport cleared that timer — which disarmed the one thing that ended the pass.
8
+
9
+ A pass caught in the air when Homebridge stopped therefore never closed its socket, leaving a bound handle holding the event loop open, and never settled its promise, so anything awaiting discovery waited until the process was killed. Shutdown already fixes exactly that hang for pending cloud requests; discovery was missing from the list. It also never released the single-flight claim added in 3.24.1, because that claim is released in the promise's own completion.
10
+
11
+ Shutdown now ends the pass properly: socket closed, promise resolved with the addresses already heard. Resolved rather than rejected, because a rejection would log an error line for an ordinary shutdown, and those addresses were measured facts already recorded — there is no reason to throw them away. Forgetting the pass without closing its socket would have been worse than the leak: the port is fixed, so the next pass would fail to bind with `EADDRINUSE` and reject a discovery that had nothing wrong with it.
12
+
13
+ Nobody would have noticed this as a fault. The process was being torn down anyway, so the leaked handle went with it. It is fixed because a shutdown that cannot finish cleanly is the thing that turns a restart into a kill.
14
+
15
+ ## 3.24.1
16
+
17
+ **A robot whose DHCP lease moved it stayed on the cloud until Homebridge was restarted.**
18
+
19
+ Issue #21 asked the question directly: other plugins for this robot drop the connection when its IP changes, so does this one handle it? The honest answer needed measuring, and the measurement found a gap.
20
+
21
+ The local TCP transport learns each robot's address once, at startup, from the robot's own UDP broadcast. That address was then held in a closure for the life of the process. For every reason a local socket drops — a blip, a robot picked up and carried out of range, a reboot — retrying the same address is exactly right. For the one reason that lasts, a lease that moved the robot, it never was: the retry chain went on probing the old address, backing off to once every fifteen minutes, until somebody restarted Homebridge.
22
+
23
+ Nothing looked broken, which is why this survived. A failed local connect falls back to the Roborock cloud automatically, so commands kept working and Apple Home kept responding. The only symptom was that the fast path never came back.
24
+
25
+ Two things change:
26
+
27
+ - The address a reconnect aims at is resolved when the retry timer fires rather than when it was armed, so any correction from elsewhere is picked up.
28
+ - A reconnect also re-consults the LAN in the background. The robot's UDP broadcast is the one signal that means "this robot is on _this_ network at _this_ address", so it is the right source for the correction — the cloud's `get_network_info` cannot serve here, because a robot whose local connect just failed has already been marked cloud-only, and that mark is what gates the write of its address. The correction is written where every other caller reads it, so a moved robot is picked up by ordinary commands too, not only by the retry.
29
+
30
+ The re-check does not hold the reconnect attempt open: it is for the retry after this one, at least a minute out, and the common case is a robot that blipped and is still where we left it. A pass that hears nothing, or hears the address already held, changes nothing at all. Cloud-only installs never open the port. A move is reported once, by name, with both addresses.
31
+
32
+ LAN discovery is now also single-flight. It binds a fixed port, so a startup pass and a reconnect pass could overlap and the second `bind` would fail with `EADDRINUSE` — rejecting a discovery that had nothing wrong with it.
33
+
3
34
  ## 3.24.0
4
35
 
5
36
  **The probe from 3.23.0 came back, and it found the schedules.**
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. 1812 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. 1831 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
 
@@ -250,6 +250,7 @@ The complete path — robot → plugin → Homebridge → matter.js store — wa
250
250
  - **"It only happens to my robot vacuum, never to my other Matter accessories" — that is expected, and it is not evidence the plugin is at fault.** Apple Home requires a robot vacuum to be its own Matter node, so Homebridge publishes each one on its own dedicated Matter server with its own port and its own pairing code, while every other accessory you own sits behind the single bridge node. In Homebridge 2.4.x the robot vacuum is the _only_ device type treated that way (`EXTERNAL_DEVICE_TYPES` in `dist/matter/MatterAPIImpl.js` contains `RoboticVacuumCleaner` and nothing else). A controller that loses one subscription therefore loses exactly one tile if that subscription was to a vacuum, whereas losing the bridge's subscription would blank out dozens of accessories at once and be unmistakable. **Your vacuum is not the accessory that breaks most often; it is the only one that can break alone.** So "only the vacuum is affected" is what the controller-side explanation above predicts, rather than something that contradicts it.
251
251
  - **Robot shows "Updating…" on every Apple device at once:** _now_ remove the robot from Apple Home and pair it again — a pairing carrying state over from an earlier install is the usual cause (tracked upstream in homebridge/homebridge#3951). What finally worked for the reporter in [#5](https://github.com/mathiashornbek/homebridge-roborock-matter/issues/5) was the full teardown in this exact order: unpair, **uninstall** the plugin, install the current version, pair fresh. A re-pair on top of the existing install did not work for him, so the order is part of the remedy.
252
252
  - **Rooms missing for a Q7/B01 robot:** wait for the `B01 rooms for ...` log line, then re-pair once so the Service Area cluster is announced with room data.
253
+ - **Pairing fails with "Accessory not found", and the robot works fine in the Homebridge UI:** that combination is a network path, not a plugin fault — the robot is running, the plugin is publishing, and only the phone cannot reach the node it is being asked to commission. **Try the pairing again with the phone on your 2.4 GHz SSID.** That, and nothing else, is what fixed it for the reporter in [#21](https://github.com/mathiashornbek/homebridge-roborock-matter/issues/21) after both codes had failed repeatedly: routers that keep the bands on separate segments, or that run client isolation on one of them, break commissioning while leaving every other symptom looking healthy. Worth checking in the same pass: the phone and Homebridge on the same subnet, IPv6 enabled on the router, and an Apple TV or HomePod present as a home hub. See also [the switches and sensors need their own pairing](#the-switches-and-sensors-need-their-own-pairing--a-different-qr-code) if you are unsure which of the QR codes you are scanning.
253
254
  - **Apple Home shows Manufacturer "Homebridge", and the Model row repeats the robot's _name_:** that is a Homebridge version, not a plugin setting, and there is nothing to configure here. The plugin hands Homebridge `Roborock` plus the real model name for every robot, but for an _external_ Matter accessory — which every robot vacuum is — Homebridge `2.4.0` and earlier hardcode the vendor name and derive the product name from the display name instead (the `basicInformation` block in `dist/matter/server/ServerLifecycle.js`). Fixed upstream in [homebridge/homebridge#3996](https://github.com/homebridge/homebridge/pull/3996) and shipping since `homebridge@2.4.1-beta.3`, where that same block reads the plugin's values whenever the node is external. **Nothing needs re-pairing when you get there:** both attributes are Fixed quality in Matter and are therefore never persisted, so the correct values simply apply on the next restart. The **Serial Number** and **Firmware** rows are unaffected and are already correct on `2.4.0` — the serial is the one Roborock has on file for that robot, falling back to its internal device id if your account carries none.
254
255
  - **The Roborock app does not appear on the accessory page in Apple Home, and no plugin change can put it there:** Apple keys that card to the Matter **Vendor ID** in the node's attestation certificate, not to the Manufacturer row above it. Homebridge commissions every node with the Matter _test_ vendor ID `0xFFF1` (`DEFAULT_VENDOR_ID` in `dist/matter/server/ServerConfig.js`; it appears as `vendorId=65521` in the pairing log line), because an uncertified node may not claim a certified manufacturer's ID. Getting the card would take Roborock certifying this bridge, not a setting. The Manufacturer row is free text and unrelated to the ID, which is exactly why that one is fixable and this one is not.
255
256
  - **Debug logging needs two switches, not one:** the plugin's own **Debug Mode** only decides whether it _calls_ the debug logger — Homebridge decides whether anything is _printed_, and it suppresses plugin debug output unless Homebridge itself runs with `-D`. Turn on **Homebridge Settings → Homebridge Debug Mode** as well, or the log will look exactly the same as before.
@@ -257,7 +258,7 @@ The complete path — robot → plugin → Homebridge → matter.js store — wa
257
258
 
258
259
  ## Contributing
259
260
 
260
- Model reports, diagnostics exports, and pull requests are very welcome. The codebase ships with 1812 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.
261
+ Model reports, diagnostics exports, and pull requests are very welcome. The codebase ships with 1831 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.
261
262
 
262
263
  ## Support the project
263
264
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "homebridge-roborock-matter",
3
- "version": "3.24.0",
3
+ "version": "3.24.2",
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": {
@@ -117,6 +117,22 @@ class localConnector {
117
117
  // Consecutive failed local connects per duid, used to back the retry delay
118
118
  // off. Reset the moment a connect succeeds.
119
119
  this.reconnectAttempts = new Map();
120
+ /**
121
+ * The discovery pass currently listening, if any. See getLocalDevices for
122
+ * why there can only be one.
123
+ * @type {Promise<Record<string, string>>|null}
124
+ */
125
+ this.discoveryInFlight = null;
126
+ /**
127
+ * Tear down the listening discovery pass: close its socket and settle its
128
+ * promise. Set while a pass is in the air, cleared the moment it ends by
129
+ * any route, so shutdown can never reach for a pass that is already gone.
130
+ *
131
+ * A single slot is enough only because `getLocalDevices` is single-flight;
132
+ * two overlapping passes would clobber it and leak the first socket.
133
+ * @type {(() => void)|null}
134
+ */
135
+ this.closeDiscoveryPass = null;
120
136
  }
121
137
 
122
138
  /**
@@ -161,17 +177,121 @@ class localConnector {
161
177
  // swallowing them here only prevents the crash.
162
178
  const timer = setTimer(() => {
163
179
  this.reconnectTimers.delete(duid);
180
+ // The address is re-read here rather than taken from the closure. See
181
+ // reconnectTargetFor.
164
182
  Promise.resolve()
165
- .then(() => this.createClient(duid, ip))
183
+ .then(() => this.createClient(duid, this.reconnectTargetFor(duid, ip)))
166
184
  .catch((error) => {
167
185
  this.adapter.log.debug(
168
186
  `Local reconnect attempt for ${duid} failed: ${error?.message || error}`
169
187
  );
170
188
  });
189
+ // Deliberately not awaited before the attempt above: the correction it
190
+ // produces is for the retry AFTER this one, which is at least a minute
191
+ // out, and a robot that merely blipped is still at the address we hold.
192
+ // Making every attempt wait five seconds for a listen window would slow
193
+ // the common case down to fix the rare one.
194
+ void this.refreshLocalIpFromBroadcast(duid);
171
195
  }, delayMs);
172
196
  this.reconnectTimers.set(duid, timer);
173
197
  }
174
198
 
199
+ /**
200
+ * The address the next reconnect should aim at.
201
+ *
202
+ * `scheduleReconnect` used to close over the address the socket was built
203
+ * with, and that is right for every reason a local socket drops but the one
204
+ * that lasts: a DHCP lease that moved the robot. That robot sat reachable at
205
+ * a new address while the retry chain probed the old one — backing off to
206
+ * once every fifteen minutes, for the life of the process. Nothing looked
207
+ * broken, because a failed local connect falls back to the cloud
208
+ * automatically and every command still worked; the only symptom was that
209
+ * the fast path never returned until Homebridge was restarted.
210
+ *
211
+ * The captured address stays the fallback. An adapter that knows no address
212
+ * for this robot must not turn a failed connect into a `connect(undefined)`,
213
+ * which is a thrown TypeError inside a timer callback rather than a
214
+ * connection failure the retry logic understands.
215
+ *
216
+ * @param {string} duid
217
+ * @param {string} fallbackIp the address the socket was originally built with
218
+ * @returns {string}
219
+ */
220
+ reconnectTargetFor(duid, fallbackIp) {
221
+ const known = this.adapter.getKnownLocalIp?.(duid);
222
+
223
+ return typeof known == "string" && known ? known : fallbackIp;
224
+ }
225
+
226
+ /**
227
+ * Re-run LAN discovery and adopt whatever address `duid` broadcasts now.
228
+ *
229
+ * The UDP broadcast is the one signal that means "this robot is on THIS LAN
230
+ * at THIS address", which is exactly what a local connection needs to know —
231
+ * so it, and not the cloud's `get_network_info`, is the right source for a
232
+ * correction. (`get_network_info` cannot serve here anyway: a robot whose
233
+ * local connect just failed has already been marked remote, and that mark is
234
+ * what gates the write of its address.)
235
+ *
236
+ * A pass that hears nothing changes nothing, and neither does one that hears
237
+ * the address we already hold: writing diagnostics unconditionally would let
238
+ * a background re-check overwrite the `tcp-connected` reason of a reconnect
239
+ * that had meanwhile succeeded.
240
+ *
241
+ * @param {string} duid
242
+ * @returns {Promise<string|null>} the new address, or null if nothing changed
243
+ */
244
+ async refreshLocalIpFromBroadcast(duid) {
245
+ if (this.adapter.isCloudOnlyModeEnabled?.()) {
246
+ return null;
247
+ }
248
+
249
+ let discovered;
250
+ try {
251
+ const devices = await this.getLocalDevices();
252
+ discovered = devices?.[duid];
253
+ } catch (error) {
254
+ this.adapter.log.debug(
255
+ `Re-discovery for ${duid} found nothing usable: ${error?.message || error}`
256
+ );
257
+ return null;
258
+ }
259
+
260
+ if (typeof discovered != "string" || !discovered) {
261
+ return null;
262
+ }
263
+
264
+ const previous = this.adapter.localDevices?.[duid];
265
+ if (previous === discovered) {
266
+ return null;
267
+ }
268
+
269
+ // Written back to the adapter rather than kept here, because
270
+ // `getKnownLocalIp` and `ensureLocalConnection` read that map: a
271
+ // correction only this module knew about would leave every other caller
272
+ // aiming at the dead address.
273
+ if (this.adapter.localDevices) {
274
+ this.adapter.localDevices[duid] = discovered;
275
+ }
276
+
277
+ if (previous) {
278
+ this.adapter.log.info(
279
+ `${describeDevice(this.adapter, duid)} is answering at a new local address ` +
280
+ `(${previous} → ${discovered}), so the local connection will be remade there. ` +
281
+ `A DHCP lease that moves is normal; reserve the address on the router if you ` +
282
+ `would rather it did not.`
283
+ );
284
+ }
285
+
286
+ await this.adapter.updateTransportDiagnostics(duid, {
287
+ localIp: discovered,
288
+ localDiscoveryState: "rediscovered",
289
+ lastTransportReason: "udp-broadcast-rediscovery",
290
+ });
291
+
292
+ return discovered;
293
+ }
294
+
175
295
  async ensureConnected(duid, ip) {
176
296
  if (!ip) {
177
297
  return false;
@@ -755,7 +875,38 @@ class localConnector {
755
875
  await handshakePromise;
756
876
  }
757
877
 
758
- async getLocalDevices() {
878
+ /**
879
+ * Listen for the robots' UDP broadcasts and answer duid → address.
880
+ *
881
+ * SINGLE-FLIGHT, because the listen socket binds a fixed port. Startup runs
882
+ * one pass, and a failing reconnect now runs one of its own, so two can
883
+ * genuinely overlap — and the second `bind` would fail with EADDRINUSE,
884
+ * which reaches `catchError` and rejects a discovery that had nothing wrong
885
+ * with it. A caller that arrives while a pass is listening joins that pass
886
+ * instead of opening a second one.
887
+ *
888
+ * The claim is released in a `finally`, including on rejection: a claim that
889
+ * leaked would be worse than the collision it prevents, because nothing
890
+ * would ever discover again.
891
+ *
892
+ * @returns {Promise<Record<string, string>>}
893
+ */
894
+ getLocalDevices() {
895
+ if (this.discoveryInFlight) {
896
+ return this.discoveryInFlight;
897
+ }
898
+
899
+ const pass = this.listenForLocalDevices().finally(() => {
900
+ if (this.discoveryInFlight === pass) {
901
+ this.discoveryInFlight = null;
902
+ }
903
+ });
904
+ this.discoveryInFlight = pass;
905
+
906
+ return pass;
907
+ }
908
+
909
+ async listenForLocalDevices() {
759
910
  return new Promise((resolve, reject) => {
760
911
  const devices = {};
761
912
 
@@ -845,6 +996,7 @@ class localConnector {
845
996
  });
846
997
 
847
998
  server.on("error", (error) => {
999
+ this.closeDiscoveryPass = null;
848
1000
  this.adapter.catchError(`Discover server error: ${error.stack}`);
849
1001
  closeServer();
850
1002
  reject(error);
@@ -852,7 +1004,23 @@ class localConnector {
852
1004
 
853
1005
  server.bind(PORT);
854
1006
 
1007
+ // Shutdown's only hook into this module clears the timer below, which
1008
+ // used to be the sole route to `closeServer()` and `resolve()` — so a
1009
+ // pass caught in the air leaked a bound UDP socket AND hung every
1010
+ // caller awaiting it. Hand shutdown the pass's own teardown instead.
1011
+ //
1012
+ // It resolves rather than rejects: a rejection reaches `catchError` and
1013
+ // would log an error line for an ordinary shutdown. The addresses heard
1014
+ // so far are already-measured facts, written to diagnostics as they
1015
+ // arrived, so they are handed over rather than discarded.
1016
+ this.closeDiscoveryPass = () => {
1017
+ this.closeDiscoveryPass = null;
1018
+ closeServer();
1019
+ resolve(devices);
1020
+ };
1021
+
855
1022
  this.localDevicesTimeout = this.adapter.setTimeout(() => {
1023
+ this.closeDiscoveryPass = null;
856
1024
  closeServer();
857
1025
 
858
1026
  resolve(devices);
@@ -914,8 +1082,18 @@ class localConnector {
914
1082
  clearLocalDevicedTimeout() {
915
1083
  if (this.localDevicesTimeout) {
916
1084
  this.adapter.clearTimeout(this.localDevicesTimeout);
1085
+ this.localDevicesTimeout = null;
917
1086
  }
918
1087
 
1088
+ // Clearing that timer disarms the pass's own ending, so the pass has to be
1089
+ // ended here instead: its socket closed and its promise settled. Dropping
1090
+ // the single-flight claim while leaving the socket bound would be worse
1091
+ // than the leak — the port is fixed (58866), so the next pass would fail
1092
+ // to bind with EADDRINUSE and reject a discovery that had nothing wrong
1093
+ // with it. No pass in flight (a cloud-only install never opens one) makes
1094
+ // this a no-op by construction.
1095
+ this.closeDiscoveryPass?.();
1096
+
919
1097
  // This is the only local-transport hook the adapter's
920
1098
  // clearTimersAndIntervals calls on shutdown. Reconnect timers are armed as
921
1099
  // far out as LOCAL_RECONNECT_MAX_DELAY_MS, so without this they would