homebridge-roborock-matter 3.0.0 → 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,10 +1,20 @@
|
|
|
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
|
+
|
|
3
13
|
## 3.0.0
|
|
4
14
|
|
|
5
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.
|
|
6
16
|
|
|
7
|
-
- **
|
|
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.)
|
|
8
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.
|
|
9
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.
|
|
10
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.
|
|
@@ -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.0.
|
|
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": {
|
|
@@ -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.");
|
|
@@ -1505,52 +1505,26 @@ class Roborock {
|
|
|
1505
1505
|
await this.updateHomeData(homeId);
|
|
1506
1506
|
|
|
1507
1507
|
await this.createDevices();
|
|
1508
|
-
await this.getNetworkInfo();
|
|
1509
|
-
|
|
1510
|
-
const discoveredDevices = await localDiscovery;
|
|
1511
|
-
|
|
1512
|
-
if (!this.isCloudOnlyModeEnabled()) {
|
|
1513
|
-
// merge udp discovered devices with local devices found via mqtt
|
|
1514
|
-
Object.entries(discoveredDevices).forEach(([duid, ip]) => {
|
|
1515
|
-
if (
|
|
1516
|
-
!Object.prototype.hasOwnProperty.call(this.localDevices, duid)
|
|
1517
|
-
) {
|
|
1518
|
-
this.localDevices[duid] = ip;
|
|
1519
|
-
}
|
|
1520
|
-
});
|
|
1521
|
-
this.log.debug(
|
|
1522
|
-
`localDevices: ${JSON.stringify(this.localDevices)}`
|
|
1523
|
-
);
|
|
1524
|
-
|
|
1525
|
-
for (const device of localKeyDevices) {
|
|
1526
|
-
if (
|
|
1527
|
-
!Object.prototype.hasOwnProperty.call(
|
|
1528
|
-
this.localDevices,
|
|
1529
|
-
device.duid
|
|
1530
|
-
)
|
|
1531
|
-
) {
|
|
1532
|
-
await this.updateTransportDiagnostics(device.duid, {
|
|
1533
|
-
localDiscoveryState: "not-discovered",
|
|
1534
|
-
lastTransportReason: "missing-local-ip",
|
|
1535
|
-
});
|
|
1536
|
-
}
|
|
1537
|
-
}
|
|
1538
|
-
|
|
1539
|
-
for (const device in this.localDevices) {
|
|
1540
|
-
const duid = device;
|
|
1541
|
-
const ip = this.localDevices[device];
|
|
1542
1508
|
|
|
1543
|
-
|
|
1544
|
-
|
|
1545
|
-
|
|
1546
|
-
|
|
1547
|
-
|
|
1548
|
-
|
|
1549
|
-
|
|
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();
|
|
1550
1516
|
|
|
1517
|
+
await this.getNetworkInfo();
|
|
1551
1518
|
await this.initializeDeviceUpdates();
|
|
1519
|
+
|
|
1552
1520
|
this.bInited = true;
|
|
1553
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);
|
|
1554
1528
|
} else {
|
|
1555
1529
|
this.log.info(
|
|
1556
1530
|
`Most likely failed to login. Deleting UserData to force new login!`
|
|
@@ -1567,6 +1541,59 @@ class Roborock {
|
|
|
1567
1541
|
}
|
|
1568
1542
|
}
|
|
1569
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
|
+
|
|
1570
1597
|
async stopService() {
|
|
1571
1598
|
try {
|
|
1572
1599
|
this.flushPendingPersistedStates();
|
|
@@ -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
|
+
};
|