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 +22 -0
- package/README.md +6 -3
- package/docs/matter-battery-issue-draft.md +37 -42
- package/homebridge-ui/public/index.js +1 -1
- package/package.json +6 -6
- package/roborockLib/lib/roborock_mqtt_connector.js +32 -0
- package/roborockLib/lib/vacuum.js +37 -8
- package/roborockLib/roborockAPI.js +116 -67
- package/roborockLib/roborock_mqtt_connector.js +609 -0
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.
|
|
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
|
|
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
|
-
##
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
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-
|
|
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": "
|
|
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.
|
|
64
|
-
"@babel/preset-env": "7.
|
|
65
|
-
"@babel/preset-typescript": "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.
|
|
70
|
+
"babel-jest": "30.4.1",
|
|
71
71
|
"homebridge": "1.8.3",
|
|
72
|
-
"jest": "30.2
|
|
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
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
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
|
-
|
|
1488
|
-
|
|
1489
|
-
|
|
1490
|
-
|
|
1491
|
-
|
|
1492
|
-
|
|
1493
|
-
|
|
1494
|
-
|
|
1495
|
-
|
|
1496
|
-
|
|
1497
|
-
|
|
1498
|
-
|
|
1499
|
-
|
|
1500
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1522
|
-
const duid = device;
|
|
1523
|
-
const ip = this.localDevices[device];
|
|
1507
|
+
await this.createDevices();
|
|
1524
1508
|
|
|
1525
|
-
|
|
1526
|
-
|
|
1527
|
-
|
|
1528
|
-
|
|
1529
|
-
|
|
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
|
-
|
|
1767
|
-
|
|
1768
|
-
|
|
1769
|
-
|
|
1770
|
-
|
|
1771
|
-
|
|
1772
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
};
|