homebridge-roborock-matter 2.9.9 → 3.0.1

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,27 @@
1
1
  # Changelog
2
2
 
3
+ ## 3.0.1
4
+
5
+ Follow-up to 3.0.0 after measuring a real restart instead of trusting the reasoning. Two things needed fixing, one of them mine.
6
+
7
+ - **Startup now genuinely finishes in ~2 seconds instead of ~7.** 3.0.0 moved LAN discovery off the critical path but still waited for its full 5-second broadcast window before declaring startup finished, so the total barely moved — the claim in the 3.0.0 notes ("~8 s before, ~3 s after") was wrong, and the corrected numbers are below. Local transport is an optimisation over the cloud path, not a prerequisite for it, so it now attaches in the background after the robots are already live in Apple Home.
8
+ - **Fixed: the first cloud request could fail on a cold start.** `get_network_info` was issued about a second after the MQTT handshake began and failed with "Cloud connection not available" — visible in real logs for a cloud-transported robot. Startup now waits for the broker session to actually come up (up to 10 s, then continues regardless) before the first requests, instead of relying on an unrelated delay to cover the gap.
9
+ - The diagnostics export is no longer headed `homebridge-roborock-vacuum2 diagnostic report` — a leftover from the fork that made reports confusing to read.
10
+
11
+ Measured on a three-robot fleet (two Q7, one S8 Pro Ultra), "Starting adapter" to "Lets go!!!!!!!": 6–7 s on 2.9.x and 3.0.0, ~2 s on 3.0.1.
12
+
13
+ ## 3.0.0
14
+
15
+ Startup and refresh pass: Homebridge restarts are noticeably faster, and a status refresh that had never actually run now does. Nothing here requires re-pairing — existing setups keep working exactly as they are.
16
+
17
+ - **LAN discovery moved off the critical path.** It listens for robot broadcasts for a fixed 5-second window, and startup used to sit and wait for it before even creating the devices. Device setup and network probes now run inside that window instead. (The wall-clock win landed in 3.0.1 — see above; this release only reordered the work.)
18
+ - **Multi-robot startup no longer costs extra time.** Each robot's first status read and network probe used to run one after another; they now run at once, so three robots start as quickly as one. New tests pin the concurrency down so it cannot silently regress.
19
+ - **A dead status refresh has been repaired.** The periodic `get_status` refresh for classic (S/Q-series) robots was gated on a config key this plugin never sets, which made the condition permanently false — the refresh promised by the code has never run in any released version. It now polls each robot at most once a minute (forced refreshes are unaffected), so a dropped MQTT push self-corrects within a minute instead of waiting up to three for the slow full poll.
20
+ - **~86,000 needless timer wake-ups per robot per day removed.** With the refresh properly throttled, the 1-second scheduler tick that served it is now 15 seconds — same refresh rate, a fraction of the idle CPU on Raspberry Pi class hardware.
21
+ - One request removed from every startup: a scene list was fetched from the Roborock cloud and thrown away.
22
+ - **Security:** a newly published high-severity advisory in a transitive dependency (`ip-address`, reached through the MQTT client) is resolved, and the build toolchain was refreshed. `npm audit` reports zero vulnerabilities for both the shipped package and the development tree.
23
+ - Full suite: 279 passing (7 new startup/refresh tests).
24
+
3
25
  ## 2.9.9
4
26
 
5
27
  - **Cleans started outside Apple Home now show the right clean mode.** Starting a vacuum+mop (or mop-only) clean from the Roborock app or the robot's buttons left Apple Home claiming plain "Vacuum". The Q7 series reports its active clean type in every status poll (the plugin sent it on start but never read it back); classic S/Q robots are derived from the mop-only suction signature and the active water-flow setting. Apple Home's mode picker now follows the robot live during a run — no re-pairing needed.
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. 263 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. 279 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
 
@@ -92,7 +92,10 @@ The clean mode follows the robot as well: start a vacuum+mop or mop-only clean f
92
92
  **The entire Roborock lineup.** If it runs in the Roborock app, this plugin can control it:
93
93
 
94
94
  - **2025 Q7 series** (`roborock.vacuum.sc05`, Q7 M5 / M5+) — the only Homebridge plugin that supports these at all, including manual-tank mopping with vacuum/mop mode switching.
95
- - **Classic S-, Q- and Saros-series** — S5 through S8 Pro Ultra, Q5/Q7/Q8/Q Revo families, Saros, and newer.
95
+ - **Classic S-, Q- and Saros-series** — S4 / S5 Max through S8 Pro Ultra, Q5/Q7/Q8/Q Revo families, Saros, and newer.
96
+
97
+ > **Heads-up for early models:** a few legacy robots — most notably the original S5 — only work with Xiaomi's Mi Home app and can never be added to a Roborock account, so no Roborock-account plugin can reach them. For those, [homebridge-xiaomi-roborock-vacuum](https://github.com/homebridge-xiaomi-roborock-vacuum/homebridge-xiaomi-roborock-vacuum) is the right tool.
98
+
96
99
  - **Future models** are adopted automatically: the plugin reads what each robot says it can do and adapts, so brand-new releases get sensible defaults from day one. If something looks off, [open a model report](https://github.com/mathiashornbek/homebridge-roborock-matter/issues) with a diagnostics export — that's exactly what it's for.
97
100
 
98
101
  ## Configuration
@@ -113,7 +116,7 @@ Everything is configurable from the Homebridge UI. The essentials:
113
116
 
114
117
  ## Battery percentage in Apple Home
115
118
 
116
- Apple Home renders the battery percentage from pairing time and refreshes it only on a fresh read (commissioning, hub restart) — while charging state on the very same cluster updates live. This is a controller-side limitation, not a plugin bug, and it is being investigated upstream with the Homebridge team ([homebridge#3958](https://github.com/homebridge/homebridge/issues/3958)). Current state of knowledge: as of Matter 1.4 the attribute carries the **"quieter" (Q)** reporting quality — reports ARE sent over the subscription (rate-limited to one per 10 s), a Homebridge maintainer verified that a spec-compliant matter.js controller receives and applies them, yet Apple Home in steady state does not. The likely permanent fix is on Apple's side (Apple Feedback).
119
+ Apple Home renders the battery percentage from pairing time and refreshes it only on a fresh read (commissioning, hub restart) — while charging state on the very same cluster updates live. This is not a plugin bug, and the root cause is now **confirmed in the source of matter.js** (the Matter stack Homebridge uses): the percentage attribute carries the spec's "changes omitted" quality, and matter.js currently never emits subscription reports for such attributes — while Apple Home never re-reads them on its own. The fix is tracked upstream in [matter-js/matter.js#4163](https://github.com/matter-js/matter.js/issues/4163) (an opt-in to report them anyway, which the spec permits); once it lands, Homebridge can enable it for bridged accessories and every plugin gets working battery percentages at once. Full investigation: [homebridge#3958](https://github.com/homebridge/homebridge/issues/3958).
117
120
 
118
121
  <details>
119
122
  <summary>The full evidence chain and workarounds</summary>
@@ -13,45 +13,40 @@ updates continuously; the matter.js store verifiably carries the live value;
13
13
  but the rendered battery **percentage** stays at its commissioning-time value
14
14
  until a fresh read (re-pair or Matter hub restart).
15
15
 
16
- ## Corrected analysis (per Homebridge maintainer verification, 2026-07-15)
17
-
18
- The original analysis assumed the attribute carries the Matter reporting
19
- quality **C (changes omitted)** — never reported via subscription, controllers
20
- must poll. That was true of older spec revisions, **but as of Matter 1.4 the
21
- attribute is quality Q (quieter)** , and matter.js 0.17.x (shipped with every
22
- Homebridge 2.1.x release) models it accordingly:
23
-
24
- - **Q (quieter):** reported via subscription, rate-limited to at most one
25
- report per 10 seconds, plus an immediate report on any null ↔ value
26
- transition.
27
-
28
- A Homebridge maintainer (bwp91) commissioned a matter.js controller against a
29
- bridge exposing `PowerSource(Battery, Rechargeable)` — the same setup
30
- Homebridge builds — and logged the subscription: percentage changes propagate
31
- exactly as Q prescribes (immediate first report, deferred follow-up inside the
32
- 10 s window, correct application after an interleaved `batChargeState` bump).
33
- The "stale cluster data version" theory does not hold on the controller side.
34
-
35
- ## Where that leaves things
36
-
37
- - The bridge **emits** the reports; a spec-compliant controller **applies**
38
- them. Apple Home in steady state does not — consistent with Apple's
39
- controller still treating the attribute under the older changes-omitted
40
- rules and refreshing only on a fresh read.
41
- - The plugin's boot-time resync nudge (null → value transition) does hit the
42
- wire immediately (maintainer-confirmed) and remains useful for controllers
43
- that re-prime their subscriptions; Apple still does not converge.
44
- - **No device-side fix exists**: bumping the data version or re-announcing
45
- only produces more of the reports Apple already receives and ignores.
46
-
47
- ## Next verification steps (requested upstream)
48
-
49
- 1. Run Homebridge with matter.js debug logging during a battery change and
50
- capture the subscription flushes carrying `batPercentRemaining` — proves
51
- the reports leave THIS bridge specifically.
52
- 2. Optionally subscribe with `chip-tool` and confirm it sees (and applies)
53
- the live values.
54
-
55
- If both confirm reports going out, the permanent fix belongs with Apple
56
- (Apple Feedback report about the controller's handling of Q-quality
57
- PowerSource attributes). The upstream issue stays open in the meantime.
16
+ ## Root cause (confirmed in the matter.js source by the Homebridge maintainer, July 2026)
17
+
18
+ `ServerBehaviorBacking#configureEventSuppression()` collects every
19
+ changes-omitted property into a suppressed set; only properties that are ALSO
20
+ marked `quieter` get the observer that re-broadcasts them
21
+ (`broadcastChanges([name])`). `batPercentRemaining` is changes-omitted without
22
+ `quieter`, so it hits the `continue` and **no subscription report is ever
23
+ produced** — the store stays fresh and reads serve the live value, which is
24
+ exactly what this investigation's store dumps showed. `batChargeState`
25
+ carries no C quality, which is why it updates live on the same cluster.
26
+
27
+ Ruled out along the way:
28
+
29
+ - An intermediate theory (Matter 1.4 Q-quality, reports leaving the bridge)
30
+ did not survive the maintainer's source check.
31
+ - The freeze reproduces on Homebridge v2.2.2-beta.12 in a **restart-free**
32
+ window, ruling out the dead-subscription bug fixed by homebridge#3973.
33
+ - matter.js 0.17.7 does not change the behavior (`ServerBehaviorBacking.js`
34
+ is byte-identical to what Homebridge ships).
35
+ - No viable device-side nudge exists from the Homebridge layer:
36
+ `broadcastChanges` is `protected`, and private-internals access or patching
37
+ would break silently on a matter.js update.
38
+
39
+ ## The fix
40
+
41
+ The Matter spec says a server _may_ omit changes for C attributes — reporting
42
+ them anyway is permitted. The clean solution is an opt-in on the matter.js
43
+ side, raised upstream by the Homebridge maintainer as
44
+ [matter-js/matter.js#4163](https://github.com/matter-js/matter.js/issues/4163)
45
+ with this investigation linked as evidence. Once it lands, Homebridge wires it
46
+ up for bridged accessories and every plugin gets working battery percentages
47
+ at once — no plugin-side change needed.
48
+
49
+ Until then: homebridge#3958 stays open to track; the plugin keeps its
50
+ boot-time battery resync (useful for controllers that re-prime their
51
+ subscriptions after a hub restart), and the known refresh paths remain
52
+ restarting the Matter hub or re-pairing.
@@ -982,7 +982,7 @@ async function copyDiagnosticsReport() {
982
982
  function buildDiagnosticsReport(result) {
983
983
  const hasToken = Boolean(result.hasEncryptedToken || state.hasEncryptedToken);
984
984
  const lines = [
985
- "homebridge-roborock-vacuum2 diagnostic report",
985
+ "homebridge-roborock-matter diagnostic report",
986
986
  `generatedAt: ${result.generatedAt || "unknown"}`,
987
987
  `pluginVersion: ${result.pluginVersion || "unknown"}`,
988
988
  `nodeVersion: ${result.nodeVersion || "unknown"}`,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "homebridge-roborock-matter",
3
- "version": "2.9.9",
3
+ "version": "3.0.1",
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": {
@@ -60,16 +60,16 @@
60
60
  "qrcode": "^1.5.4"
61
61
  },
62
62
  "devDependencies": {
63
- "@babel/core": "7.24.8",
64
- "@babel/preset-env": "7.24.8",
65
- "@babel/preset-typescript": "7.24.7",
63
+ "@babel/core": "7.29.7",
64
+ "@babel/preset-env": "7.29.7",
65
+ "@babel/preset-typescript": "7.29.7",
66
66
  "@types/jest": "30.0.0",
67
67
  "@types/node": "20.14.10",
68
68
  "@types/qrcode": "^1.5.6",
69
69
  "@types/semver": "7.5.8",
70
- "babel-jest": "30.2.0",
70
+ "babel-jest": "30.4.1",
71
71
  "homebridge": "1.8.3",
72
- "jest": "30.2.0",
72
+ "jest": "30.4.2",
73
73
  "prettier": "3.3.2",
74
74
  "rimraf": "6.0.1",
75
75
  "typescript": "5.5.3"
@@ -493,6 +493,38 @@ class roborock_mqtt_connector {
493
493
  return this.connected;
494
494
  }
495
495
 
496
+ /**
497
+ * Resolve once the MQTT session is usable, or after `timeoutMs` regardless.
498
+ *
499
+ * The broker handshake takes a few seconds, and cloud requests issued
500
+ * before it completes fail outright with "Cloud connection not available".
501
+ * Startup used to get away with this because an unrelated fixed delay
502
+ * happened to sit in front of the first requests; waiting on the real
503
+ * signal makes that timing explicit rather than accidental. Resolving on
504
+ * timeout (instead of rejecting) keeps a slow or offline broker from
505
+ * blocking startup — callers fall back to their own error handling.
506
+ *
507
+ * @param {number} [timeoutMs=10000]
508
+ * @returns {Promise<boolean>} whether the connection came up in time
509
+ */
510
+ async waitUntilConnected(timeoutMs = 10000) {
511
+ if (this.connected) {
512
+ return true;
513
+ }
514
+
515
+ const deadline = Date.now() + timeoutMs;
516
+ while (!this.connected && Date.now() < deadline) {
517
+ await new Promise((resolve) => {
518
+ const timer = setTimeout(resolve, 100);
519
+ if (typeof timer?.unref === "function") {
520
+ timer.unref();
521
+ }
522
+ });
523
+ }
524
+
525
+ return this.connected;
526
+ }
527
+
496
528
  async ensureConnected() {
497
529
  if (client && this.connected) {
498
530
  this.adapter.log.debug("MQTT health check passed. Reconnect skipped.");
@@ -5,6 +5,11 @@ const RRMapParser = require("./RRMapParser");
5
5
  const fs = require("fs");
6
6
  const zlib = require("zlib");
7
7
 
8
+ // Minimum spacing between periodic (non-forced) get_status polls per robot.
9
+ // MQTT push remains the primary live channel; this is the safety net that
10
+ // catches a dropped push long before the 3-minute full refresh would.
11
+ const STATUS_POLL_MIN_INTERVAL_MS = 60 * 1000;
12
+
8
13
  const mappedCleanSummary = {
9
14
  0: "clean_time",
10
15
  1: "clean_area",
@@ -47,6 +52,25 @@ class vacuum {
47
52
  get_carpet_clean_mode: "deviceStatus",
48
53
  get_carpet_cleaning_mode: "deviceStatus",
49
54
  };
55
+
56
+ /** @type {Map<string, number>} last periodic status poll, per duid */
57
+ this.lastStatusPollAt = new Map();
58
+ }
59
+
60
+ /**
61
+ * True when this robot's periodic status poll is due again.
62
+ * @param {string} duid
63
+ */
64
+ shouldPollStatusNow(duid) {
65
+ const last = this.lastStatusPollAt.get(duid);
66
+ return (
67
+ last === undefined || Date.now() - last >= STATUS_POLL_MIN_INTERVAL_MS
68
+ );
69
+ }
70
+
71
+ /** @param {string} duid */
72
+ markStatusPolled(duid) {
73
+ this.lastStatusPollAt.set(duid, Date.now());
50
74
  }
51
75
 
52
76
  async updateDiagnosticSnapshot(duid, key, payload) {
@@ -332,16 +356,21 @@ class vacuum {
332
356
  }
333
357
  }
334
358
  } else if (parameter == "get_status") {
335
- const now = new Date();
336
- const seconds = now.getSeconds();
337
359
  const force = attribute == "force";
338
360
 
339
- if (
340
- force ||
341
- this.adapter.socket ||
342
- seconds % this.adapter.config.updateInterval == 0
343
- ) {
344
- // only send status every minute or if websocket is connected
361
+ // Periodic status refresh, throttled per robot.
362
+ //
363
+ // The inherited gate here read `config.updateInterval` (a key this
364
+ // plugin never sets) and `adapter.socket` (permanently null), so the
365
+ // expression was `NaN == 0` — always false. The refresh the comment
366
+ // promised has therefore never run: classic robots relied entirely on
367
+ // MQTT push plus the slow 3-minute full poll, and a silently dropped
368
+ // push left Apple Home stale for minutes. An explicit elapsed-time
369
+ // throttle restores the safety net, and being relative rather than
370
+ // aligned to wall-clock seconds also stops every robot in a fleet
371
+ // from polling in the same instant.
372
+ if (force || this.shouldPollStatusNow(duid)) {
373
+ this.markStatusPolled(duid);
345
374
 
346
375
  // const deviceStatus = await this.adapter.messageQueueHandler.sendRequest(duid, "get_status", []);
347
376
  const requestOptions = options.preferCloud
@@ -42,6 +42,12 @@ const B01_LIVE_ROOM_CLEAR_V1_STATES = new Set([3, 8]);
42
42
  // slower cadence than the ~12s active status polls.
43
43
  const B01_LIVE_ROOM_MIN_FETCH_GAP_MS = 20000;
44
44
 
45
+ // Scheduler granularity for the classic (v1-protocol) status refresh. The
46
+ // refresh itself is throttled per robot inside vacuum.getParameter, so this
47
+ // only decides how promptly that window is noticed — a 1-second tick meant
48
+ // ~86k wake-ups per robot per day to serve at most 1440 polls.
49
+ const CLASSIC_STATUS_TICK_MS = 15000;
50
+
45
51
  // Persisted states whose disk flush is debounced (see setStateAsync): they
46
52
  // change on every received robot message, are served from memory, and only
47
53
  // need the on-disk copy for restart survival.
@@ -1404,8 +1410,6 @@ class Roborock {
1404
1410
  const homedata = await this.api.get(`v2/user/homes/${homeId}`);
1405
1411
  const homedataResult = homedata.data.result;
1406
1412
 
1407
- const scene = await this.api.get(`user/scene/home/${homeId}`);
1408
-
1409
1413
  await this.setStateAsync("HomeData", {
1410
1414
  val: JSON.stringify(homedataResult),
1411
1415
  ack: true,
@@ -1482,57 +1486,45 @@ class Roborock {
1482
1486
  this.updateInterval * 1000,
1483
1487
  homeId
1484
1488
  );
1485
- await this.updateHomeData(homeId);
1486
1489
 
1487
- const discoveredDevices = this.isCloudOnlyModeEnabled()
1488
- ? {}
1489
- : await this.localConnector.getLocalDevices();
1490
-
1491
- await this.createDevices();
1492
- await this.getNetworkInfo();
1493
-
1494
- if (!this.isCloudOnlyModeEnabled()) {
1495
- // merge udp discovered devices with local devices found via mqtt
1496
- Object.entries(discoveredDevices).forEach(([duid, ip]) => {
1497
- if (
1498
- !Object.prototype.hasOwnProperty.call(this.localDevices, duid)
1499
- ) {
1500
- this.localDevices[duid] = ip;
1501
- }
1502
- });
1503
- this.log.debug(
1504
- `localDevices: ${JSON.stringify(this.localDevices)}`
1505
- );
1490
+ // LAN discovery listens for UDP broadcasts for a fixed window, so
1491
+ // awaiting it here used to stall startup for the full timeout with
1492
+ // the CPU idle. Its results are only needed at the merge below, and
1493
+ // the local keys it matches against are already loaded, so start it
1494
+ // now and let the home-data refresh, device creation and network
1495
+ // probes run inside that window instead.
1496
+ const localDiscovery = this.isCloudOnlyModeEnabled()
1497
+ ? Promise.resolve({})
1498
+ : this.localConnector.getLocalDevices().catch((error) => {
1499
+ this.log.debug(
1500
+ `LAN discovery failed; continuing with cloud transport: ${error?.message || error}`
1501
+ );
1502
+ return {};
1503
+ });
1506
1504
 
1507
- for (const device of localKeyDevices) {
1508
- if (
1509
- !Object.prototype.hasOwnProperty.call(
1510
- this.localDevices,
1511
- device.duid
1512
- )
1513
- ) {
1514
- await this.updateTransportDiagnostics(device.duid, {
1515
- localDiscoveryState: "not-discovered",
1516
- lastTransportReason: "missing-local-ip",
1517
- });
1518
- }
1519
- }
1505
+ await this.updateHomeData(homeId);
1520
1506
 
1521
- for (const device in this.localDevices) {
1522
- const duid = device;
1523
- const ip = this.localDevices[device];
1507
+ await this.createDevices();
1524
1508
 
1525
- await this.updateTransportDiagnostics(duid, {
1526
- localIp: ip,
1527
- localDiscoveryState: "discovered",
1528
- });
1529
- await this.localConnector.createClient(duid, ip);
1530
- }
1531
- }
1509
+ // Cloud requests fail outright until the MQTT session is up, and
1510
+ // the very first ones (the per-robot network probe, then each
1511
+ // robot's initial poll) used to be issued a second after the
1512
+ // broker handshake started. Wait for the real signal instead of
1513
+ // hoping an unrelated delay covers it; a broker that never comes
1514
+ // up releases the wait and the requests fail as they would have.
1515
+ await this.rr_mqtt_connector.waitUntilConnected();
1532
1516
 
1517
+ await this.getNetworkInfo();
1533
1518
  await this.initializeDeviceUpdates();
1519
+
1534
1520
  this.bInited = true;
1535
1521
  this.log.info(`Starting adapter finished. Lets go!!!!!!!`);
1522
+
1523
+ // LAN attach runs to completion in the background. Everything above
1524
+ // works over the cloud, and the transport layer already falls back
1525
+ // to it, so holding accessory registration hostage to a fixed-length
1526
+ // broadcast listen only delayed the Apple Home tiles.
1527
+ void this.attachLocalTransports(localDiscovery, localKeyDevices);
1536
1528
  } else {
1537
1529
  this.log.info(
1538
1530
  `Most likely failed to login. Deleting UserData to force new login!`
@@ -1549,6 +1541,59 @@ class Roborock {
1549
1541
  }
1550
1542
  }
1551
1543
 
1544
+ /**
1545
+ * Merge LAN-discovered robots and open their local TCP connections.
1546
+ *
1547
+ * Deliberately runs after startup has completed: local transport is an
1548
+ * optimisation over the cloud path, not a prerequisite for it.
1549
+ *
1550
+ * @param {Promise<Record<string, string>>} localDiscovery
1551
+ * @param {Array<{duid: string}>} localKeyDevices
1552
+ */
1553
+ async attachLocalTransports(localDiscovery, localKeyDevices) {
1554
+ if (this.isCloudOnlyModeEnabled()) {
1555
+ return;
1556
+ }
1557
+
1558
+ try {
1559
+ const discoveredDevices = await localDiscovery;
1560
+
1561
+ // merge udp discovered devices with local devices found via mqtt
1562
+ Object.entries(discoveredDevices).forEach(([duid, ip]) => {
1563
+ if (!Object.prototype.hasOwnProperty.call(this.localDevices, duid)) {
1564
+ this.localDevices[duid] = ip;
1565
+ }
1566
+ });
1567
+ this.log.debug(`localDevices: ${JSON.stringify(this.localDevices)}`);
1568
+
1569
+ for (const device of this.normalizeArray(localKeyDevices)) {
1570
+ if (
1571
+ !Object.prototype.hasOwnProperty.call(this.localDevices, device.duid)
1572
+ ) {
1573
+ await this.updateTransportDiagnostics(device.duid, {
1574
+ localDiscoveryState: "not-discovered",
1575
+ lastTransportReason: "missing-local-ip",
1576
+ });
1577
+ }
1578
+ }
1579
+
1580
+ for (const duid in this.localDevices) {
1581
+ const ip = this.localDevices[duid];
1582
+
1583
+ await this.updateTransportDiagnostics(duid, {
1584
+ localIp: ip,
1585
+ localDiscoveryState: "discovered",
1586
+ });
1587
+ await this.localConnector.createClient(duid, ip);
1588
+ }
1589
+ } catch (error) {
1590
+ // Local transport is best-effort; the cloud path stays available.
1591
+ this.log.debug(
1592
+ `Local transport attach failed; continuing on cloud transport: ${error?.message || error}`
1593
+ );
1594
+ }
1595
+ }
1596
+
1552
1597
  async stopService() {
1553
1598
  try {
1554
1599
  this.flushPendingPersistedStates();
@@ -1763,14 +1808,20 @@ class Roborock {
1763
1808
  }
1764
1809
 
1765
1810
  async getNetworkInfo() {
1766
- for (const device of this.devices) {
1767
- const duid = device.duid;
1768
- if (!this.hasInitializedVacuum(duid)) {
1769
- continue;
1770
- }
1771
-
1772
- await this.vacuums[duid].getParameter(duid, "get_network_info");
1773
- }
1811
+ // One round-trip per robot, all independent: probing them in parallel
1812
+ // turns N sequential cloud/LAN waits into one at startup. Failures are
1813
+ // already handled inside getParameter, but allSettled keeps a rejection
1814
+ // from one robot from skipping the others.
1815
+ await Promise.allSettled(
1816
+ this.devices
1817
+ .filter((device) => this.hasInitializedVacuum(device.duid))
1818
+ .map((device) =>
1819
+ this.vacuums[device.duid].getParameter(
1820
+ device.duid,
1821
+ "get_network_info"
1822
+ )
1823
+ )
1824
+ );
1774
1825
  }
1775
1826
 
1776
1827
  async createDevices() {
@@ -1830,6 +1881,11 @@ class Roborock {
1830
1881
  this.log.debug(`initializeDeviceUpdates`);
1831
1882
 
1832
1883
  const devices = this.devices;
1884
+ // Each robot's first poll is a chain of round-trips to that robot alone.
1885
+ // Running the chains for different robots concurrently keeps a
1886
+ // multi-robot startup as fast as a single-robot one; the timers below are
1887
+ // still wired up in order, only the initial reads overlap.
1888
+ const initialPolls = [];
1833
1889
 
1834
1890
  for (const device of devices) {
1835
1891
  const duid = device.duid;
@@ -1863,7 +1919,7 @@ class Roborock {
1863
1919
 
1864
1920
  this.vacuums[duid].getStatusIntervall = () => {
1865
1921
  // B01/Q7 status is owned by the dedicated 15s loop; the per-device
1866
- // 1-second tick would only burn cycles hitting the attempt throttle.
1922
+ // tick would only burn cycles hitting the attempt throttle.
1867
1923
  if (
1868
1924
  this.getVacuumDeviceInfo(duid, "pv") ===
1869
1925
  b01Q7Adapter.B01_PROTOCOL_VERSION
@@ -1873,7 +1929,7 @@ class Roborock {
1873
1929
  this.clearInterval(this.vacuums[duid].getStatusIntervalHandle);
1874
1930
  this.vacuums[duid].getStatusIntervalHandle = this.setInterval(
1875
1931
  this.getStatus.bind(this),
1876
- 1000,
1932
+ CLASSIC_STATUS_TICK_MS,
1877
1933
  duid,
1878
1934
  this.vacuums[duid],
1879
1935
  robotModel
@@ -1886,8 +1942,12 @@ class Roborock {
1886
1942
  this.vacuums[duid].getStatusIntervall(); // actually start getStatusIntervall()
1887
1943
  }
1888
1944
 
1889
- await this.updateDataMinimumData(duid, this.vacuums[duid], robotModel);
1945
+ initialPolls.push(
1946
+ this.updateDataMinimumData(duid, this.vacuums[duid], robotModel)
1947
+ );
1890
1948
  }
1949
+
1950
+ await Promise.allSettled(initialPolls);
1891
1951
  }
1892
1952
 
1893
1953
  async executeScene(sceneID) {
@@ -1921,17 +1981,6 @@ class Roborock {
1921
1981
  }
1922
1982
  }
1923
1983
 
1924
- /**
1925
- * Get scenes from the Roborock API
1926
- * @returns {Promise<Object>} The scenes data
1927
- */
1928
-
1929
- /**
1930
- * Get scenes for a specific device by duid
1931
- * @param {string} duid - The device unique identifier
1932
- * @returns {Array} Array of scenes for the specified device
1933
- */
1934
-
1935
1984
  getProductAttribute(duid, attribute) {
1936
1985
  const device = this.getVacuumDeviceData(duid);
1937
1986
  const deviceValue = this.getDeviceAttribute(device, attribute);
@@ -0,0 +1,609 @@
1
+ "use strict";
2
+
3
+ const mqtt = require("mqtt");
4
+ const crypto = require("crypto");
5
+ const Parser = require("binary-parser").Parser;
6
+ const zlib = require("zlib");
7
+ const roborockCrypto = require("./roborockCrypto");
8
+
9
+ const PHOTO_MAGIC = "ROBOROCK";
10
+ const PHOTO_HEADER_MIN_LENGTH = 9;
11
+ const PROTOCOL_301_HEADER_LENGTH = 24;
12
+
13
+ const protocol301Parser = new Parser()
14
+ .endianess("little")
15
+ .string("endpoint", {
16
+ length: 15,
17
+ stripNull: true,
18
+ })
19
+ .uint8("unknown1")
20
+ .uint16("id")
21
+ .buffer("unknown2", {
22
+ length: 6,
23
+ });
24
+
25
+ const photoParser = new Parser()
26
+ .endianess("little")
27
+ .string("roborock", {
28
+ length: 8,
29
+ stripNull: true,
30
+ })
31
+ .uint8("id");
32
+
33
+ let mqttUser;
34
+ let mqttPassword;
35
+ let client;
36
+ let endpoint;
37
+ let rriot;
38
+
39
+ let photoGzipChunks = [];
40
+ let photoChunkID = 0;
41
+
42
+ function payloadStartsWith(payload, value) {
43
+ return (
44
+ Buffer.isBuffer(payload) &&
45
+ payload.length >= value.length &&
46
+ payload.subarray(0, value.length).toString("utf8") === value
47
+ );
48
+ }
49
+
50
+ function parsePhotoPayload(payload) {
51
+ if (
52
+ !payloadStartsWith(payload, PHOTO_MAGIC) ||
53
+ payload.length < PHOTO_HEADER_MIN_LENGTH
54
+ ) {
55
+ return null;
56
+ }
57
+
58
+ return photoParser.parse(payload);
59
+ }
60
+
61
+ function parseProtocol301Header(payload) {
62
+ if (
63
+ !Buffer.isBuffer(payload) ||
64
+ payload.length < PROTOCOL_301_HEADER_LENGTH
65
+ ) {
66
+ return null;
67
+ }
68
+
69
+ return protocol301Parser.parse(
70
+ payload.subarray(0, PROTOCOL_301_HEADER_LENGTH)
71
+ );
72
+ }
73
+
74
+ class roborock_mqtt_connector {
75
+ constructor(adapter) {
76
+ this.adapter = adapter;
77
+
78
+ this.connected = false;
79
+
80
+ // NOTE: this class previously generated its own RSA-2048 keypair here,
81
+ // but nothing ever read it — the protocol keypair lives in message.js
82
+ // (lazily created for the rare photo path). Removed: one full RSA
83
+ // keygen less at every startup.
84
+ }
85
+
86
+ async initUser(userdata) {
87
+ rriot = userdata.rriot;
88
+
89
+ endpoint = roborockCrypto
90
+ .md5bin(rriot.k)
91
+ .subarray(8, 14)
92
+ .toString("base64"); // Could be a random but rather static string. The app generates it on first run.
93
+ mqttUser = roborockCrypto.md5hex(rriot.u + ":" + rriot.k).substring(2, 10);
94
+ mqttPassword = roborockCrypto.md5hex(rriot.s + ":" + rriot.k).substring(16);
95
+ client = mqtt.connect(rriot.r.m, {
96
+ clientId: mqttUser,
97
+ username: mqttUser,
98
+ password: mqttPassword,
99
+ keepalive: 30,
100
+ });
101
+ }
102
+
103
+ async initMQTT_Subscribe() {
104
+ const timeout = setTimeout(async () => {
105
+ this.adapter.restart();
106
+ }, 30000);
107
+
108
+ await client.on("connect", (result) => {
109
+ if (typeof result != "undefined") {
110
+ client.subscribe(`rr/m/o/${rriot.u}/${mqttUser}/#`, (err, granted) => {
111
+ if (err) {
112
+ this.logConnectionIssue(
113
+ `Failed to subscribe to the Roborock MQTT server: ${err} (granted: ${JSON.stringify(granted)}).`
114
+ );
115
+ }
116
+ });
117
+ clearTimeout(timeout);
118
+
119
+ this.connected = true;
120
+ if (this._connectionIssueActive) {
121
+ this._connectionIssueActive = false;
122
+ this._connectionIssueLog?.clear();
123
+ this.adapter.log.info(
124
+ `Roborock MQTT connection recovered after the reported outage.`
125
+ );
126
+ }
127
+ }
128
+ this.adapter.log.debug(
129
+ `MQTT connection connected ${JSON.stringify(result)}.`
130
+ );
131
+ });
132
+
133
+ // Connection-state events are account-level transport telemetry, not
134
+ // per-robot command failures: log them as clear, throttled warnings
135
+ // instead of routing them through catchError (which used to produce the
136
+ // misleading `Failed to execute client.on("error") on robot undefined`
137
+ // spam twice per reconnect attempt during network outages).
138
+ await client.on("error", (error) => {
139
+ this.connected = false;
140
+ this.logConnectionIssue(
141
+ `Roborock MQTT connection error: ${error?.message || error}. The client keeps reconnecting automatically.`
142
+ );
143
+ });
144
+
145
+ await client.on("close", () => {
146
+ if (this.connected) {
147
+ this.adapter.log.info(`MQTT connection closed; reconnecting.`);
148
+ }
149
+ this.connected = false;
150
+ });
151
+
152
+ await client.on("reconnect", () => {
153
+ client.subscribe(`rr/m/o/${rriot.u}/${mqttUser}/#`, (err, granted) => {
154
+ if (err) {
155
+ this.logConnectionIssue(
156
+ `Failed to subscribe to the Roborock MQTT server after reconnect: ${err} (granted: ${JSON.stringify(granted)}).`
157
+ );
158
+ }
159
+ });
160
+ clearTimeout(timeout);
161
+ this.adapter.log.debug(`MQTT connection reconnect attempt.`);
162
+ });
163
+
164
+ await client.on("offline", () => {
165
+ this.connected = false;
166
+ this.logConnectionIssue(
167
+ `Roborock MQTT connection is offline. The client keeps reconnecting automatically.`
168
+ );
169
+ });
170
+ }
171
+
172
+ /**
173
+ * Warn about a connection problem at most once per 5 minutes per message,
174
+ * and remember that an outage is in progress so the next successful
175
+ * connect logs a single recovery line instead of silence.
176
+ * @param {string} message
177
+ */
178
+ logConnectionIssue(message) {
179
+ if (!this._connectionIssueLog) {
180
+ this._connectionIssueLog = new Map();
181
+ }
182
+ this._connectionIssueActive = true;
183
+ const now = Date.now();
184
+ const lastAt = this._connectionIssueLog.get(message) || 0;
185
+ if (now - lastAt >= 5 * 60 * 1000) {
186
+ this._connectionIssueLog.set(message, now);
187
+ this.adapter.log.warn(message);
188
+ } else {
189
+ this.adapter.log.debug(message);
190
+ }
191
+ }
192
+
193
+ getKnownDeviceDuids() {
194
+ const knownDuids = new Set();
195
+
196
+ if (this.adapter.localKeys instanceof Map) {
197
+ for (const duid of this.adapter.localKeys.keys()) {
198
+ knownDuids.add(duid);
199
+ }
200
+ }
201
+
202
+ if (this.adapter.devices && Array.isArray(this.adapter.devices)) {
203
+ for (const device of this.adapter.devices) {
204
+ if (device && device.duid) {
205
+ knownDuids.add(device.duid);
206
+ }
207
+ }
208
+ }
209
+
210
+ return knownDuids;
211
+ }
212
+
213
+ resolveDuidFromTopic(topic) {
214
+ const topicSegments = topic
215
+ .split("/")
216
+ .filter((segment) => segment && segment.length > 0);
217
+ if (topicSegments.length === 0) {
218
+ return null;
219
+ }
220
+
221
+ const knownDuids = this.getKnownDeviceDuids();
222
+ const topicTail = topicSegments[topicSegments.length - 1];
223
+
224
+ if (knownDuids.has(topicTail)) {
225
+ return topicTail;
226
+ }
227
+
228
+ for (let index = topicSegments.length - 2; index >= 0; index--) {
229
+ if (knownDuids.has(topicSegments[index])) {
230
+ return topicSegments[index];
231
+ }
232
+ }
233
+
234
+ if (knownDuids.size === 0) {
235
+ return topicTail;
236
+ }
237
+
238
+ return null;
239
+ }
240
+
241
+ async initMQTT_Message() {
242
+ this.adapter.log.info(`MQTT initialized`);
243
+
244
+ client.on("message", (topic, message) => {
245
+ try {
246
+ const duid = this.resolveDuidFromTopic(topic);
247
+ if (!duid) {
248
+ this.adapter.log.debug(
249
+ `Skipping MQTT message with unmatched topic '${topic}'.`
250
+ );
251
+ return;
252
+ }
253
+
254
+ const data = this.adapter.message._decodeMsg(message, duid);
255
+ if (!data) {
256
+ return;
257
+ }
258
+ // this.adapter.log.debug(`MESSAGE RECEIVED for duid ${duid} with key: ${this.adapter.localKeys.get(duid)} data: ${JSON.stringify(data)} raw: ${JSON.stringify(mqttMessageParser.parse(message))} message: ${message}`);
259
+ // this.adapter.log.debug(`MESSAGE RECEIVED for duid ${duid} with key: ${this.adapter.localKeys.get(duid)} data: ${JSON.stringify(data.toString("hex"))} message: ${message}`);
260
+ // this.adapter.log.debug(`MESSAGE RECEIVED for duid ${duid} with key: ${this.adapter.localKeys.get(duid)} data: ${JSON.stringify(data)}`);
261
+
262
+ // this.adapter.log.debug("Protocol: " + data.protocol);
263
+ if (data.protocol == 102) {
264
+ const parsedPayload = JSON.parse(data.payload);
265
+ let dps;
266
+ if (typeof parsedPayload.dps["102"] != "undefined") {
267
+ dps = JSON.parse(parsedPayload.dps["102"]);
268
+ } else if (typeof parsedPayload.dps["10001"] != "undefined") {
269
+ if (typeof parsedPayload.dps["10001"] == "string") {
270
+ dps = JSON.parse(parsedPayload.dps["10001"]);
271
+ } else {
272
+ dps = parsedPayload.dps["10001"];
273
+ }
274
+ } else {
275
+ dps = parsedPayload.dps;
276
+ }
277
+
278
+ if (resolveB01PendingResponse(this.adapter, duid, dps)) {
279
+ return;
280
+ }
281
+
282
+ if (dps.id !== undefined) {
283
+ // Runs for every cloud message; only pay the stringify cost
284
+ // when debug logging is actually enabled.
285
+ if (this.adapter.config.debug) {
286
+ this.adapter.log.debug(
287
+ `Cloud message with protocol 102 and id ${dps.id} received. Result: ${JSON.stringify(dps.result)}`
288
+ );
289
+ }
290
+ if (typeof dps.result !== "undefined") {
291
+ this.adapter.setStateAsync("CloudMessage", {
292
+ duid,
293
+ payload: dps.result,
294
+ });
295
+ }
296
+ } else {
297
+ this.adapter.log.debug(
298
+ `Cloud message with protocol 102 received. Result: ${data.payload}`
299
+ );
300
+
301
+ if (this.adapter.deviceNotify !== undefined) {
302
+ this.adapter.deviceNotify("CloudMessage", {
303
+ duid,
304
+ payload: JSON.parse(data.payload),
305
+ });
306
+ }
307
+ }
308
+
309
+ // special check for secure request like get_map_v1 etc. Don't process if result is OK. Instead wait for the actual response for protocol 301
310
+ if (dps.result != "ok") {
311
+ if (this.adapter.pendingRequests.has(dps.id)) {
312
+ const { resolve, timeout } = this.adapter.pendingRequests.get(
313
+ dps.id
314
+ );
315
+ this.adapter.clearTimeout(timeout);
316
+ this.adapter.pendingRequests.delete(dps.id);
317
+ resolve(dps.result);
318
+ }
319
+ }
320
+ // protocol 300 seems to be for get_photo 0 only. get_photo 0 is for large images. 1 is for small images.
321
+ } else if (data.protocol == 300) {
322
+ const photoData = parsePhotoPayload(data.payload);
323
+ if (photoData) {
324
+ if (this.adapter.pendingRequests.has(photoData.id)) {
325
+ this.adapter.log.debug(`First photo gzip chunk detected!`);
326
+
327
+ photoGzipChunks.push(data.payload.slice(56));
328
+ photoChunkID = photoData.id;
329
+ }
330
+ } else {
331
+ this.adapter.log.debug(
332
+ `Skipping protocol 300 MQTT message for ${duid} because the payload is not a complete Roborock photo header.`
333
+ );
334
+ }
335
+ } else if (data.protocol == 301) {
336
+ // B01/Q7 map upload responses arrive on protocol 301 as an opaque
337
+ // base64 blob. Resolve the per-device pending map request first;
338
+ // classic v1 photo/map chunk handling continues below otherwise.
339
+ const pendingMap = this.adapter.pendingB01MapRequests?.get(duid);
340
+ if (pendingMap) {
341
+ this.adapter.clearTimeout(pendingMap.timeout);
342
+ this.adapter.pendingB01MapRequests.delete(duid);
343
+ pendingMap.resolve(data.payload);
344
+ return;
345
+ }
346
+
347
+ if (data.seq == 2 && photoGzipChunks != [] && photoChunkID != 0) {
348
+ this.adapter.log.debug(`Second photo gzip chunk detected!`);
349
+ photoGzipChunks.push(data.payload);
350
+
351
+ if (this.adapter.pendingRequests.has(photoChunkID)) {
352
+ const { resolve, timeout } =
353
+ this.adapter.pendingRequests.get(photoChunkID);
354
+ this.adapter.clearTimeout(timeout);
355
+ this.adapter.pendingRequests.delete(photoChunkID);
356
+
357
+ const finalPhotoGzip = Buffer.concat(photoGzipChunks);
358
+
359
+ photoGzipChunks = [];
360
+ photoChunkID = 0;
361
+
362
+ resolve(finalPhotoGzip);
363
+ }
364
+ } else {
365
+ const photoData = parsePhotoPayload(data.payload);
366
+ if (photoData) {
367
+ this.adapter.log.debug(
368
+ `Cloud message with protocol 301 and photo id ${photoData.id} received.`
369
+ );
370
+
371
+ if (this.adapter.pendingRequests.has(photoData.id)) {
372
+ const { resolve, timeout } = this.adapter.pendingRequests.get(
373
+ photoData.id
374
+ );
375
+ this.adapter.clearTimeout(timeout);
376
+ this.adapter.pendingRequests.delete(photoData.id);
377
+ this.adapter.log.debug(
378
+ `Cloud message with protocol 301 and photo id ${photoData.id} received.`
379
+ );
380
+ resolve(data.payload.slice(56));
381
+ }
382
+ } else {
383
+ const data2 = parseProtocol301Header(data.payload);
384
+ if (!data2) {
385
+ this.adapter.log.debug(
386
+ `Skipping protocol 301 MQTT message for ${duid} because the payload is shorter than ${PROTOCOL_301_HEADER_LENGTH} bytes.`
387
+ );
388
+ return;
389
+ }
390
+
391
+ if (!endpoint.startsWith(data2.endpoint)) {
392
+ return;
393
+ }
394
+
395
+ const iv = Buffer.alloc(16, 0);
396
+ const decipher = crypto.createDecipheriv(
397
+ "aes-128-cbc",
398
+ this.adapter.nonce,
399
+ iv
400
+ );
401
+ let decrypted = Buffer.concat([
402
+ decipher.update(data.payload.subarray(24)),
403
+ decipher.final(),
404
+ ]);
405
+ decrypted = zlib.gunzipSync(decrypted);
406
+ // this.adapter.log.debug("raw 301: " + decrypted);
407
+
408
+ if (this.adapter.pendingRequests.has(data2.id)) {
409
+ const { resolve, timeout } = this.adapter.pendingRequests.get(
410
+ data2.id
411
+ );
412
+ this.adapter.clearTimeout(timeout);
413
+ this.adapter.pendingRequests.delete(data2.id);
414
+ // this.adapter.log.debug("protocol 301 OK check: " + JSON.stringify(decrypted));
415
+ this.adapter.log.debug(
416
+ `Cloud message with protocol 301 and id ${data2.id} received.`
417
+ );
418
+ resolve(decrypted);
419
+ }
420
+ }
421
+ }
422
+ } else if (data.protocol == 500) {
423
+ // 500 is for general information
424
+ const dataString = data.payload.toString("utf8");
425
+ let parsedData;
426
+
427
+ try {
428
+ parsedData = JSON.parse(dataString);
429
+ } catch (error) {
430
+ // If parsing fails, the data might be corrupted or in an unexpected format
431
+ this.adapter.log.warn(
432
+ `Unable to parse message for ${duid}. Error: ${error.message}. Data: ${dataString}`
433
+ );
434
+ return;
435
+ }
436
+
437
+ // Check if the device is online
438
+ if (parsedData.online == false) {
439
+ this.adapter.log.info(
440
+ `Couldn't process message. The device ${duid} is offline.`
441
+ );
442
+ } else if (parsedData.online == true) {
443
+ // this.adapter.log.info(`Device ${duid} is online.`);
444
+ } else if (
445
+ // Check for firmware update information
446
+ parsedData.mqttOtaData
447
+ ) {
448
+ const otaStatus = parsedData.mqttOtaData.mqttOtaStatus?.status;
449
+ const otaProgress =
450
+ parsedData.mqttOtaData.mqttOtaProgress?.progress;
451
+
452
+ if (otaStatus) {
453
+ this.adapter.log.info(
454
+ `Device ${duid} firmware update status: ${otaStatus}`
455
+ );
456
+ }
457
+
458
+ if (otaProgress !== undefined) {
459
+ this.adapter.log.info(
460
+ `Device ${duid} firmware update progress: ${otaProgress}%`
461
+ );
462
+ }
463
+ } else {
464
+ // Received an unrecognized message
465
+ this.adapter.log.warn(
466
+ `Received an unrecognized message for ${duid}. Data: ${dataString}`
467
+ );
468
+ }
469
+ } else {
470
+ this.adapter.log.debug(
471
+ `Received message with unknown protocol ${data.protocol} data: ${JSON.stringify(data)}.`
472
+ );
473
+ }
474
+ } catch (error) {
475
+ this.adapter.log.error(
476
+ `client.on message failed for topic '${topic}': ${error.stack || error}`
477
+ );
478
+ }
479
+ });
480
+ }
481
+
482
+ getEndpoint() {
483
+ return endpoint;
484
+ }
485
+
486
+ sendMessage(duid, roborockMessage) {
487
+ client.publish(`rr/m/i/${rriot.u}/${mqttUser}/${duid}`, roborockMessage, {
488
+ qos: 1,
489
+ });
490
+ }
491
+
492
+ isConnected() {
493
+ return this.connected;
494
+ }
495
+
496
+ /**
497
+ * Resolve once the MQTT session is usable, or after `timeoutMs` regardless.
498
+ *
499
+ * The broker handshake takes a few seconds, and cloud requests issued
500
+ * before it completes fail outright with "Cloud connection not available".
501
+ * Startup used to get away with this because an unrelated fixed delay
502
+ * happened to sit in front of the first requests; waiting on the real
503
+ * signal makes that timing explicit rather than accidental. Resolving on
504
+ * timeout (instead of rejecting) keeps a slow or offline broker from
505
+ * blocking startup — callers fall back to their own error handling.
506
+ *
507
+ * @param {number} [timeoutMs=10000]
508
+ * @returns {Promise<boolean>} whether the connection came up in time
509
+ */
510
+ async waitUntilConnected(timeoutMs = 10000) {
511
+ if (this.connected) {
512
+ return true;
513
+ }
514
+
515
+ const deadline = Date.now() + timeoutMs;
516
+ while (!this.connected && Date.now() < deadline) {
517
+ await new Promise((resolve) => {
518
+ const timer = setTimeout(resolve, 100);
519
+ if (typeof timer?.unref === "function") {
520
+ timer.unref();
521
+ }
522
+ });
523
+ }
524
+
525
+ return this.connected;
526
+ }
527
+
528
+ async ensureConnected() {
529
+ if (client && this.connected) {
530
+ this.adapter.log.debug("MQTT health check passed. Reconnect skipped.");
531
+ return false;
532
+ }
533
+
534
+ await this.reconnectClient(true);
535
+ return true;
536
+ }
537
+
538
+ async reconnectClient(force = false) {
539
+ if (client) {
540
+ try {
541
+ if (!force && this.connected) {
542
+ this.adapter.log.debug(
543
+ "MQTT reconnect skipped because client is already connected."
544
+ );
545
+ return false;
546
+ }
547
+
548
+ this.adapter.log.info("Reconnecting mqtt client!");
549
+ await client.end();
550
+ client.reconnect();
551
+ return true;
552
+ } catch (error) {
553
+ this.adapter.catchError(
554
+ `Failed to reconnect with error: ${error}`,
555
+ `reconnectClient`
556
+ );
557
+ }
558
+ }
559
+
560
+ return false;
561
+ }
562
+ }
563
+
564
+ /**
565
+ * Correlate a Q7/B01 RPC response (dps 10001 payload) to its pending request
566
+ * by msgId. Returns true when the dps object was a B01 message and has been
567
+ * fully handled; false when the caller should continue v1 processing.
568
+ * Robot-initiated B01 pushes (no matching request) trigger a status refresh
569
+ * instead of guessing at undocumented event payload formats.
570
+ */
571
+ function resolveB01PendingResponse(adapter, duid, dps) {
572
+ if (!dps || dps.msgId === undefined || dps.id !== undefined) {
573
+ return false;
574
+ }
575
+
576
+ const b01Key = String(dps.msgId);
577
+ const pendingB01 = adapter.pendingRequests.get(b01Key);
578
+
579
+ if (pendingB01) {
580
+ adapter.clearTimeout(pendingB01.timeout);
581
+ adapter.pendingRequests.delete(b01Key);
582
+ if (dps.code !== undefined && dps.code !== 0) {
583
+ pendingB01.reject(
584
+ new Error(
585
+ `B01 command ${dps.method || "(unknown method)"} failed with code ${dps.code} for ${duid}.`
586
+ )
587
+ );
588
+ } else {
589
+ pendingB01.resolve(dps.data !== undefined ? dps.data : null);
590
+ }
591
+ } else {
592
+ adapter.log.debug(
593
+ `Unsolicited B01 message for ${duid} (${dps.method || "no method"}); scheduling a status refresh.`
594
+ );
595
+ if (typeof adapter.getStatus === "function") {
596
+ void adapter.getStatus(duid, { force: true }).catch(() => undefined);
597
+ }
598
+ }
599
+
600
+ return true;
601
+ }
602
+
603
+ module.exports = {
604
+ resolveB01PendingResponse,
605
+ roborock_mqtt_connector,
606
+ parseProtocol301Header,
607
+ parsePhotoPayload,
608
+ payloadStartsWith,
609
+ };