homebridge-roborock-matter 3.18.0 → 3.19.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +36 -0
- package/README.md +2 -2
- package/package.json +1 -1
- package/roborockLib/lib/b01Q10Adapter.js +226 -0
- package/roborockLib/lib/b01Q7Adapter.js +3 -0
- package/roborockLib/lib/message.js +23 -2
- package/roborockLib/lib/messageQueueHandler.js +82 -17
- package/roborockLib/roborockAPI.js +49 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,41 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 3.19.0
|
|
4
|
+
|
|
5
|
+
**The B01 Q10 command dialect has now been run on a Q10, and it works. That measurement is the whole reason this goes to `latest`.**
|
|
6
|
+
|
|
7
|
+
[@niclasreich](https://github.com/niclasreich) installed `3.19.0-beta.1` on his Q10 S5 (`roborock.vacuum.ss07`) and reported on 27 August 2026 that the robot starts cleaning from both the Homebridge interface and the Home app, and that commands are accepted. Every datapoint code in the dialect was read from python-roborock's docstrings rather than measured here, and the beta shipped saying so; his robot is the first `ss*` device this project has ever had a result from. Commands, return to dock and the state the tile shows while returning all behave as the dialect predicts.
|
|
8
|
+
|
|
9
|
+
Nothing in the command path changed between the beta and this release. It is the same code with a field result behind it, which is the only thing the beta was waiting for. Details of what the dialect covers are in the `3.19.0-beta.1` notes below.
|
|
10
|
+
|
|
11
|
+
**A Q10 is no longer polled for status, and no longer reports its own by-design refusal as a failure.** The dedicated B01 status loop asked every Q10 for `get_status` every 25 seconds. The send choke point refused each one correctly — a fire-and-forget dialect cannot answer a read — but a refused request was still a counted one: each refusal incremented the consecutive-failure tally and every tenth logged `B01 status has failed N times in a row` at warn level. On a healthy Q10 that is a warning every four minutes about a robot doing exactly what its protocol says. It was the first thing the beta put in the one field log this project has, and a diagnostic that fires on a working robot is worse than none — it is the same false alarm that made [#14](https://github.com/mathiashornbek/homebridge-roborock-matter/issues/14) take three rounds to diagnose. The loop now skips a Q10 before the request is built and states once, per robot, that the dialect sends no reply, that state comes from home data over HTTPS instead, and where the remaining work is tracked. Asking a question whose refusal is certain before it is asked was never a diagnostic.
|
|
12
|
+
|
|
13
|
+
A Q7 is unchanged and pinned as such: it is still polled, its answer is still mapped and dispatched, an unrecognised B01 model is still treated as a Q7, and a Q7 that genuinely stops answering still raises the warning. The warning is kept off a robot that cannot answer by design, not removed.
|
|
14
|
+
|
|
15
|
+
**Known and not fixed by this release: a Q10's tile does not follow the robot into a clean.** State on a Q10 still comes from the home data snapshot, so docked and charging read correctly while a run in progress does not. Reading state from the datapoint updates the robot pushes is the remaining half of [#19](https://github.com/mathiashornbek/homebridge-roborock-matter/issues/19). It is deliberately not in this release: it is the one change that has to touch the incoming MQTT path that Q7 and Q10 share, and bundling it with the promotion of a command path that has exactly one field measurement behind it would risk three working Q7 robots to save a release.
|
|
16
|
+
|
|
17
|
+
A cloud request that times out without anything having arrived during it now says what its message total actually counts. It used to end with a bare `(8 since startup)`, and that figure is incremented in the MQTT receiver alone — a robot answering on the local socket never touches it, while the 180-second poll chain a reader would compare it against runs over whichever transport is up.
|
|
18
|
+
|
|
19
|
+
Measured on an S8 Pro Ultra (`roborock.vacuum.a70`) on 27 August 2026: one transient cloud timeout reported `(8 since startup)` after eight and a half hours of polling. Read as a like-for-like ratio that says a link dropping about 95 % of replies; read correctly it says nothing is wrong, because the robot had been answering locally the whole time. The sentence now states that the total covers cloud traffic only and that a low number on a locally-answering robot is normal. A diagnostic that exists to stop wrong conclusions should not hand the reader a ratio that cannot be taken.
|
|
20
|
+
|
|
21
|
+
## 3.19.0-beta.1
|
|
22
|
+
|
|
23
|
+
**Beta channel only — `npm install homebridge-roborock-matter@beta`. This does not go to `latest` and no existing installation will pick it up.** It contains the first implementation of the B01 Q10 command dialect, and there is no Q10 robot available to this project to verify it on. Every datapoint code in it is read from python-roborock, where the docstrings mark them verified live against `ss07` hardware, but none of it has been measured here. Shipping that to everyone on `latest` on the strength of someone else's docstrings would be the same mistake [#14](https://github.com/mathiashornbek/homebridge-roborock-matter/issues/14) already cost three rounds of wrong diagnosis.
|
|
24
|
+
|
|
25
|
+
3.18.0 stopped this plugin sending Q7 frames to a Q10, which the robot discards, and refused instead. That made the plugin honest on a Q10; it did not make one work. This is the command half of [#19](https://github.com/mathiashornbek/homebridge-roborock-matter/issues/19).
|
|
26
|
+
|
|
27
|
+
A Q10 command is now published in the dialect the robot speaks — a direct write to a numbered datapoint, `{"dps":{"201":1}}`, with no method name, no `msgId` and no datapoint 10000. Start, stop, pause, return to dock, empty the dustbin, room clean, suction level and clean mode are covered.
|
|
28
|
+
|
|
29
|
+
**The dialect is fire-and-forget, and that shapes the rest of the change.** A Q10 sends no RPC reply at all, so a Q10 request no longer registers a pending request or arms a timeout. Arming one would guarantee expiry on a perfectly healthy link, and reporting that expiry is exactly the false "the Roborock cloud has gone silent" that sent #14's reporter chasing a fault at Roborock. A command resolves when the write leaves the plugin, which means published and not acknowledged; the state Apple Home shows still comes from the optimistic-state machinery, which corrects itself against what the robot reports.
|
|
30
|
+
|
|
31
|
+
**Reads stay refused on a Q10, and that is the dialect rather than unfinished work.** `get_status`, `get_map_list` and `get_prop` exist to return a value, and a protocol that never answers cannot return one. Serving them would hand the caller something the robot never sent and publish that non-answer to Apple Home as the robot's state. A Q10's status continues to come from home data over HTTPS, a separate transport measured working in #14. Reading state from the datapoint updates the robot pushes is the remaining half of #19.
|
|
32
|
+
|
|
33
|
+
Two known limitations, stated rather than papered over. Resume restarts a whole-home clean instead of resuming, because the plugin has no distinct resume method to map onto the dialect's dedicated `205`. And `find_me` is refused: upstream has a datapoint number for it but no verified payload, and guessing one on hardware nobody here owns is how #14 happened.
|
|
34
|
+
|
|
35
|
+
The two clean-type tables are not interchangeable and neither are the two suction scales — Q7 is vacuum=0, vac+mop=1, mop=2 while Q10 is vac+mop=1, vacuum=2, mop=3, and Max+ is 5 on a Q7 and 8 on a Q10. The numbers overlap, so the wrong table does not fail loudly; it mops when it was asked to vacuum. Both directions are pinned by tests.
|
|
36
|
+
|
|
37
|
+
Q7 is unchanged. Three `sc*` robots run on the maintainer's own bridge, and the regression cover asserts that a Q7 still builds the RPC envelope on datapoint 10000, still arms its timeout, still registers its pending request, and that nothing addressed to a Q10 can ever carry the Q7 form — checked against the whole B01 method surface rather than a list of today's commands, with a guard that fails if that check ever becomes vacuous.
|
|
38
|
+
|
|
3
39
|
## 3.18.0
|
|
4
40
|
|
|
5
41
|
**`B01` is two wire protocols, this plugin only implements one of them, and it has been sending the wrong one to Q10 robots.** This is the root cause behind [#14](https://github.com/mathiashornbek/homebridge-roborock-matter/issues/14), where [@niclasreich](https://github.com/niclasreich)'s Q10 S5 (`roborock.vacuum.ss07`) could not run a single command. The two diagnostic releases before this one, 3.17.7 and 3.17.8, both measured the symptom correctly and both pointed at Roborock's cloud. The cause was here.
|
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. 1602 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
|
|
|
@@ -252,7 +252,7 @@ The complete path — robot → plugin → Homebridge → matter.js store — wa
|
|
|
252
252
|
|
|
253
253
|
## Contributing
|
|
254
254
|
|
|
255
|
-
Model reports, diagnostics exports, and pull requests are very welcome. The codebase ships with
|
|
255
|
+
Model reports, diagnostics exports, and pull requests are very welcome. The codebase ships with 1602 tests (protocol fixtures verified against the [python-roborock](https://github.com/Python-roborock/python-roborock) reference), strict TypeScript checking, and CI across Node 22/24 × Homebridge 1.11/2.x — `npm test` before you push and you're set.
|
|
256
256
|
|
|
257
257
|
## Support the project
|
|
258
258
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "homebridge-roborock-matter",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.19.0",
|
|
4
4
|
"description": "The most complete Roborock plugin for Apple Home. Supports the entire Roborock lineup — from the classic S-series to the new 2025 Q7 series that no other plugin can control. Sign in with your Roborock account and get native start/stop, room cleaning, suction levels, battery, and live 'cleaning in the kitchen' room tracking. Verified by Homebridge.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": {
|
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
"use strict";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* The B01 Q10 dialect (`roborock.vacuum.ss*`).
|
|
6
|
+
*
|
|
7
|
+
* `pv === "B01"` is TWO wire protocols, not one. `b01Q7Adapter` implements the
|
|
8
|
+
* Q7 dialect (`sc*`), which carries an RPC envelope on datapoint 10000:
|
|
9
|
+
*
|
|
10
|
+
* {"dps":{"10000":{"method":"prop.set","msgId":"…","params":…}}}
|
|
11
|
+
*
|
|
12
|
+
* The Q10 dialect writes a numbered datapoint directly, with no method, no
|
|
13
|
+
* msgId and no datapoint 10000 at all:
|
|
14
|
+
*
|
|
15
|
+
* {"dps":{"201":1}}
|
|
16
|
+
*
|
|
17
|
+
* The consequence that matters for this file: **a Q10 command is
|
|
18
|
+
* fire-and-forget.** The dialect defines no RPC reply, so there is nothing to
|
|
19
|
+
* correlate a msgId against even when the command works perfectly. Upstream's
|
|
20
|
+
* own channel says so in its type signature — `send_command(...) -> None`.
|
|
21
|
+
*
|
|
22
|
+
* That is why this adapter translates COMMANDS ONLY, and deliberately refuses
|
|
23
|
+
* to translate reads such as `get_status`, `get_map_list` and `get_prop`. A
|
|
24
|
+
* read whose value is the answer cannot be served by a dialect that never
|
|
25
|
+
* answers; translating one would resolve the caller with a fabricated
|
|
26
|
+
* acknowledgement and then feed that non-answer to `mapStatusToV1`, publishing
|
|
27
|
+
* nonsense to Apple Home. Status on a Q10 continues to come from home data
|
|
28
|
+
* over HTTPS, which is a different transport and is measured to work (#14,
|
|
29
|
+
* 26 Aug 2026). Reading state off the datapoint updates the robot pushes is
|
|
30
|
+
* the other half of #19 and needs the incoming path, not this file.
|
|
31
|
+
*
|
|
32
|
+
* Every code below is read from python-roborock rather than guessed:
|
|
33
|
+
* `roborock/data/b01_q10/b01_q10_code_mappings.py` for the datapoint numbers
|
|
34
|
+
* and enums, `roborock/devices/traits/b01/q10/vacuum.py` for the payload of
|
|
35
|
+
* each command, where the docstrings mark them "Verified live against ss07
|
|
36
|
+
* hardware". None of it is verified by this project — there is no Q10 here.
|
|
37
|
+
* See #19.
|
|
38
|
+
*/
|
|
39
|
+
|
|
40
|
+
const b01Q7Adapter = require("./b01Q7Adapter");
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Q10 datapoint codes (`B01_Q10_DP`). Only the ones this file uses are
|
|
44
|
+
* listed; the upstream enum is far longer.
|
|
45
|
+
*/
|
|
46
|
+
const B01_Q10_DP = {
|
|
47
|
+
FAULT: 90,
|
|
48
|
+
COMMON: 101,
|
|
49
|
+
REQUEST_DPS: 102,
|
|
50
|
+
STATUS: 121,
|
|
51
|
+
BATTERY: 122,
|
|
52
|
+
FAN_LEVEL: 123,
|
|
53
|
+
CLEAN_MODE: 137,
|
|
54
|
+
START_CLEAN: 201,
|
|
55
|
+
START_BACK: 202,
|
|
56
|
+
START_DOCK_TASK: 203,
|
|
57
|
+
PAUSE: 204,
|
|
58
|
+
RESUME: 205,
|
|
59
|
+
STOP: 206,
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* `YXDeviceCleanTask` — the task selector carried by dpStartClean (201).
|
|
64
|
+
* Spot (`PART`, 5) is in the dialect but is not listed here: the plugin never
|
|
65
|
+
* issues `app_spot`, and a mapping no caller can reach is untested surface.
|
|
66
|
+
*/
|
|
67
|
+
const YX_CLEAN_TASK = {
|
|
68
|
+
SMART: 1, // whole home
|
|
69
|
+
ELECTORAL: 2, // segment / room
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* dpStartBack (202) task codes. 5 = charge, which is what the official app
|
|
74
|
+
* sends for "return to dock"; 1 washes the mop en route and 4 collects dust
|
|
75
|
+
* en route, and neither is what a Matter "go home" means.
|
|
76
|
+
*/
|
|
77
|
+
const Q10_BACK_TO_DOCK_CHARGE = 5;
|
|
78
|
+
|
|
79
|
+
/** dpStartDockTask (203): 2 = collect dust. */
|
|
80
|
+
const Q10_DOCK_TASK_COLLECT_DUST = 2;
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Matter clean-type codes -> `YXCleanType`.
|
|
84
|
+
*
|
|
85
|
+
* THIS IS NOT THE Q7 TABLE AND MUST NEVER BE SUBSTITUTED FOR IT. Q7 is
|
|
86
|
+
* vacuum=0, vac+mop=1, mop=2. Q10 is vac_and_mop=1, vacuum=2, mop=3. The
|
|
87
|
+
* numbers overlap, so a wrong table does not fail loudly — it mops when it was
|
|
88
|
+
* asked to vacuum.
|
|
89
|
+
* @type {Record<number, number>}
|
|
90
|
+
*/
|
|
91
|
+
const MATTER_TO_Q10_CLEAN_TYPE = { 0: 2, 1: 3, 2: 1 };
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Inverse of the above, for reading a Q10's reported clean type back into
|
|
95
|
+
* Matter terms.
|
|
96
|
+
* @type {Record<number, number>}
|
|
97
|
+
*/
|
|
98
|
+
const Q10_CLEAN_TYPE_TO_MATTER = { 1: 2, 2: 0, 3: 1 };
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Segment ids out of the several shapes callers use, matching the Q7
|
|
102
|
+
* adapter's own normalisation so the two families accept the same inputs.
|
|
103
|
+
* @param {any} params
|
|
104
|
+
* @returns {number[]}
|
|
105
|
+
*/
|
|
106
|
+
function normalizeSegmentIds(params) {
|
|
107
|
+
if (Array.isArray(params)) {
|
|
108
|
+
if (params.length === 1 && params[0] && Array.isArray(params[0].segments)) {
|
|
109
|
+
return params[0].segments.map(Number);
|
|
110
|
+
}
|
|
111
|
+
return params.map(Number).filter((value) => Number.isFinite(value));
|
|
112
|
+
}
|
|
113
|
+
if (params && Array.isArray(params.segments)) {
|
|
114
|
+
return params.segments.map(Number);
|
|
115
|
+
}
|
|
116
|
+
return [];
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Translate a v1-shaped outgoing command to a Q10 datapoint write.
|
|
121
|
+
*
|
|
122
|
+
* Returns `{dp, params}` for a command the dialect can express, or `null` for
|
|
123
|
+
* anything else — including every read. `null` means the caller must refuse
|
|
124
|
+
* rather than invent a payload; a guessed datapoint on a robot nobody here
|
|
125
|
+
* owns is exactly the class of mistake #14 cost three rounds.
|
|
126
|
+
*
|
|
127
|
+
* @param {string} method
|
|
128
|
+
* @param {any} params
|
|
129
|
+
* @returns {{dp: number, params: any} | null}
|
|
130
|
+
*/
|
|
131
|
+
function translateOutgoing(method, params) {
|
|
132
|
+
switch (method) {
|
|
133
|
+
case "app_start":
|
|
134
|
+
return { dp: B01_Q10_DP.START_CLEAN, params: YX_CLEAN_TASK.SMART };
|
|
135
|
+
case "app_stop":
|
|
136
|
+
return { dp: B01_Q10_DP.STOP, params: 0 };
|
|
137
|
+
case "app_pause":
|
|
138
|
+
return { dp: B01_Q10_DP.PAUSE, params: 0 };
|
|
139
|
+
case "app_charge":
|
|
140
|
+
return { dp: B01_Q10_DP.START_BACK, params: Q10_BACK_TO_DOCK_CHARGE };
|
|
141
|
+
case "app_start_collect_dust":
|
|
142
|
+
return {
|
|
143
|
+
dp: B01_Q10_DP.START_DOCK_TASK,
|
|
144
|
+
params: Q10_DOCK_TASK_COLLECT_DUST,
|
|
145
|
+
};
|
|
146
|
+
case "app_segment_clean":
|
|
147
|
+
case "app_segment_clean_by_ids": {
|
|
148
|
+
const roomIds = normalizeSegmentIds(params);
|
|
149
|
+
if (!roomIds.length) {
|
|
150
|
+
return null;
|
|
151
|
+
}
|
|
152
|
+
return {
|
|
153
|
+
dp: B01_Q10_DP.START_CLEAN,
|
|
154
|
+
params: {
|
|
155
|
+
cmd: YX_CLEAN_TASK.ELECTORAL,
|
|
156
|
+
// "clean_paramters" mirrors the firmware's own misspelling of
|
|
157
|
+
// "parameters". Upstream documents that the device accepts that key
|
|
158
|
+
// and only that key. Do not correct it.
|
|
159
|
+
clean_paramters: roomIds,
|
|
160
|
+
},
|
|
161
|
+
};
|
|
162
|
+
}
|
|
163
|
+
case "set_custom_mode": {
|
|
164
|
+
const v1Code = Array.isArray(params) ? params[0] : params;
|
|
165
|
+
// One suction table, shared with the Q7 adapter's family switch, rather
|
|
166
|
+
// than a second copy here that could drift from it.
|
|
167
|
+
const wind = b01Q7Adapter.v1FanPowerToWind(b01Q7Adapter.B01_FAMILY.Q10)[
|
|
168
|
+
v1Code
|
|
169
|
+
];
|
|
170
|
+
return wind === undefined
|
|
171
|
+
? null
|
|
172
|
+
: { dp: B01_Q10_DP.FAN_LEVEL, params: wind };
|
|
173
|
+
}
|
|
174
|
+
case "set_clean_type": {
|
|
175
|
+
const matterMode = Array.isArray(params) ? params[0] : params;
|
|
176
|
+
const q10Mode = MATTER_TO_Q10_CLEAN_TYPE[matterMode];
|
|
177
|
+
return q10Mode === undefined
|
|
178
|
+
? null
|
|
179
|
+
: { dp: B01_Q10_DP.CLEAN_MODE, params: q10Mode };
|
|
180
|
+
}
|
|
181
|
+
default:
|
|
182
|
+
// Everything else, reads included, is refused by the caller.
|
|
183
|
+
return null;
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* The wire payload body for a datapoint write: `{"dps":{"<dp>": params}}`.
|
|
189
|
+
*
|
|
190
|
+
* `params` of `0` is a real value on this dialect — pause, resume and stop all
|
|
191
|
+
* send it — so only `null`/`undefined` degrade to the empty object, exactly as
|
|
192
|
+
* upstream's `encode_mqtt_payload` does.
|
|
193
|
+
*
|
|
194
|
+
* @param {number} dp
|
|
195
|
+
* @param {any} params
|
|
196
|
+
* @returns {Record<string, any>}
|
|
197
|
+
*/
|
|
198
|
+
function buildDps(dp, params) {
|
|
199
|
+
return {
|
|
200
|
+
[String(dp)]: params !== undefined && params !== null ? params : {},
|
|
201
|
+
};
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* True when the Q10 dialect can express this v1 method at all.
|
|
206
|
+
* @param {string} method
|
|
207
|
+
* @param {any} [params]
|
|
208
|
+
* @returns {boolean}
|
|
209
|
+
*/
|
|
210
|
+
function canSendV1Method(method, params) {
|
|
211
|
+
try {
|
|
212
|
+
return Boolean(translateOutgoing(method, params ?? []));
|
|
213
|
+
} catch {
|
|
214
|
+
return false;
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
module.exports = {
|
|
219
|
+
B01_Q10_DP,
|
|
220
|
+
YX_CLEAN_TASK,
|
|
221
|
+
MATTER_TO_Q10_CLEAN_TYPE,
|
|
222
|
+
Q10_CLEAN_TYPE_TO_MATTER,
|
|
223
|
+
translateOutgoing,
|
|
224
|
+
buildDps,
|
|
225
|
+
canSendV1Method,
|
|
226
|
+
};
|
|
@@ -47,6 +47,18 @@ class message {
|
|
|
47
47
|
return this._keys;
|
|
48
48
|
}
|
|
49
49
|
|
|
50
|
+
/**
|
|
51
|
+
* @param {string} duid
|
|
52
|
+
* @param {number} protocol
|
|
53
|
+
* @param {any} messageID
|
|
54
|
+
* @param {string} method
|
|
55
|
+
* @param {any} params
|
|
56
|
+
* @param {boolean} [secure]
|
|
57
|
+
* @param {boolean} [photo]
|
|
58
|
+
* @param {{b01Q10Dps?: Record<string, any>}} [options] `b01Q10Dps` carries a
|
|
59
|
+
* pre-built Q10 datapoint map from the send choke point, which is the only
|
|
60
|
+
* place that knows the device family. Absent for every other device.
|
|
61
|
+
*/
|
|
50
62
|
async buildPayload(
|
|
51
63
|
duid,
|
|
52
64
|
protocol,
|
|
@@ -54,7 +66,8 @@ class message {
|
|
|
54
66
|
method,
|
|
55
67
|
params,
|
|
56
68
|
secure = false,
|
|
57
|
-
photo = false
|
|
69
|
+
photo = false,
|
|
70
|
+
options = {}
|
|
58
71
|
) {
|
|
59
72
|
const timestamp = Math.floor(Date.now() / 1000);
|
|
60
73
|
const endpoint = this.adapter.rr_mqtt_connector.getEndpoint();
|
|
@@ -84,7 +97,15 @@ class message {
|
|
|
84
97
|
}
|
|
85
98
|
|
|
86
99
|
let payload;
|
|
87
|
-
if (
|
|
100
|
+
if (options && options.b01Q10Dps) {
|
|
101
|
+
// B01 Q10 (`ss*`) wire format: the datapoint is written directly. No
|
|
102
|
+
// RPC envelope, no `method`, no `msgId`, no top-level `t`, and no
|
|
103
|
+
// datapoint 10000 — that datapoint does not exist on this family, which
|
|
104
|
+
// is why the Q7 form below was discarded by the robot without reply
|
|
105
|
+
// (#14). The dps map is built in b01Q10Adapter and passed through here
|
|
106
|
+
// rather than rebuilt, so the family test lives in exactly one place.
|
|
107
|
+
payload = JSON.stringify({ dps: options.b01Q10Dps });
|
|
108
|
+
} else if (version == "B01" || version == "\x81S\x19") {
|
|
88
109
|
// Q7/B01 wire format (verified against the python-roborock reference
|
|
89
110
|
// fixtures): a single object on dps 10000 carrying method/msgId/params
|
|
90
111
|
// only. No top-level "t", no numeric "id", no security block; method
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
"use strict";
|
|
3
3
|
|
|
4
4
|
const b01Q7Adapter = require("./b01Q7Adapter");
|
|
5
|
+
const b01Q10Adapter = require("./b01Q10Adapter");
|
|
5
6
|
const { describeDevice } = require("./describeDevice");
|
|
6
7
|
|
|
7
8
|
/**
|
|
@@ -120,7 +121,17 @@ function describeCloudSilence(adapter, duid, receiptsAtSend) {
|
|
|
120
121
|
return ` ${duringRequest} Roborock message(s) reached the plugin from this robot while the request was pending, so the link is delivering; the reply was either never sent or not recognised.`;
|
|
121
122
|
}
|
|
122
123
|
|
|
123
|
-
|
|
124
|
+
// The bare total was uninterpretable, and it misleads in the one direction
|
|
125
|
+
// that matters. This counter is incremented in the MQTT receiver only, so a
|
|
126
|
+
// robot that answers on the local socket never touches it — yet the poll
|
|
127
|
+
// chain a reader would compare it against runs over whichever transport is
|
|
128
|
+
// up. Measured on Mathias' own S8 Pro Ultra (a70, 27 Aug 2026): one cloud
|
|
129
|
+
// timeout reported "(8 since startup)" after 8.5 hours in which the 180 s
|
|
130
|
+
// poll had issued hundreds of requests, all answered locally. Read as a
|
|
131
|
+
// like-for-like ratio that says a 95 % dead link; read correctly it says
|
|
132
|
+
// nothing is wrong. Say what the number counts so the comparison is not
|
|
133
|
+
// available to make.
|
|
134
|
+
return ` No Roborock message reached the plugin from this robot while the request was pending (${total} cloud message(s) since startup). That total counts cloud traffic only — replies over the local socket are never counted here — so a low number on a robot that usually answers locally is normal and is not evidence the link is failing.`;
|
|
124
135
|
}
|
|
125
136
|
|
|
126
137
|
/**
|
|
@@ -142,7 +153,7 @@ function describeCloudSilence(adapter, duid, receiptsAtSend) {
|
|
|
142
153
|
|
|
143
154
|
/**
|
|
144
155
|
* @typedef {Object} MessageBuilder
|
|
145
|
-
* @property {(duid: string, protocol: number, messageID: number, method: string, params: unknown[], secure: boolean, photo: boolean) => Promise<unknown>} buildPayload
|
|
156
|
+
* @property {(duid: string, protocol: number, messageID: number, method: string, params: unknown[], secure: boolean, photo: boolean, options?: {b01Q10Dps?: Record<string, unknown>}) => Promise<unknown>} buildPayload
|
|
146
157
|
* @property {(duid: string, protocol: number, timestamp: number, payload: unknown) => Promise<Buffer | null | undefined>} buildRoborockMessage
|
|
147
158
|
*/
|
|
148
159
|
|
|
@@ -265,8 +276,14 @@ class messageQueueHandler {
|
|
|
265
276
|
localConnectionState = this.adapter.localConnector.isConnected(duid);
|
|
266
277
|
}
|
|
267
278
|
|
|
268
|
-
//
|
|
269
|
-
//
|
|
279
|
+
// The Q10 datapoint write, when this request is bound for a Q10. Non-null
|
|
280
|
+
// is what makes the send below fire-and-forget, because that is the only
|
|
281
|
+
// family whose dialect never replies.
|
|
282
|
+
/** @type {Record<string, any> | null} */
|
|
283
|
+
let b01Q10Dps = null;
|
|
284
|
+
|
|
285
|
+
// B01 devices are cloud/MQTT-only and speak a different RPC dialect.
|
|
286
|
+
// Translate the v1-shaped method to the family's equivalent here so a
|
|
270
287
|
// single choke point covers every caller (Matter, polling, UI).
|
|
271
288
|
if (b01Q7Adapter.isB01Protocol(version)) {
|
|
272
289
|
const model = this.adapter?.getProductAttribute?.(duid, "model");
|
|
@@ -291,21 +308,43 @@ class messageQueueHandler {
|
|
|
291
308
|
// That is what #14 spent three rounds on: the timeout diagnostics
|
|
292
309
|
// correctly reported total MQTT silence and thereby sent the reporter
|
|
293
310
|
// after a Roborock-side fault, when the cause was this plugin speaking
|
|
294
|
-
// the wrong language into a working link.
|
|
295
|
-
//
|
|
296
|
-
//
|
|
297
|
-
//
|
|
311
|
+
// the wrong language into a working link.
|
|
312
|
+
//
|
|
313
|
+
// Since 3.19.0 the Q10 dialect is spoken for COMMANDS: the datapoint
|
|
314
|
+
// write is built here and travels to the encoder in the options bag.
|
|
315
|
+
// READS stay refused, and that is a property of the dialect rather than
|
|
316
|
+
// unfinished work — a Q10 sends no RPC reply at all, so a read has
|
|
317
|
+
// nothing to resolve with. Serving one would hand the caller a value the
|
|
318
|
+
// robot never sent, and `mapStatusToV1` would publish that non-answer to
|
|
319
|
+
// Apple Home as the robot's state. A Q10's status comes from home data
|
|
320
|
+
// over HTTPS instead, a separate transport measured working in #14;
|
|
321
|
+
// reading it off pushed datapoint updates is the remaining half of #19.
|
|
322
|
+
//
|
|
323
|
+
// Methods answered from NEUTRAL_RESPONSES never touch the wire, so they
|
|
324
|
+
// are left alone either way — refusing those would regress the
|
|
298
325
|
// room-mapping fix from 3.17.3, opened by this same reporter.
|
|
299
326
|
if (family === b01Q7Adapter.B01_FAMILY.Q10 && !neutral) {
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
327
|
+
const q10 = b01Q10Adapter.translateOutgoing(method, params);
|
|
328
|
+
|
|
329
|
+
if (!q10) {
|
|
330
|
+
throw refusal(
|
|
331
|
+
"b01 q10 method unsupported",
|
|
332
|
+
`${describeDevice(this.adapter, duid)} speaks the B01 Q10 dialect, which has no equivalent for ${method}, so it was not sent. The Q10 dialect (${model || "ss*"}) writes numbered datapoints and sends no reply, so a request that exists to read a value cannot be answered over it; see issue #19.`
|
|
333
|
+
);
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
b01Q10Dps = b01Q10Adapter.buildDps(q10.dp, q10.params);
|
|
304
337
|
}
|
|
305
338
|
|
|
306
|
-
|
|
339
|
+
// A Q10 request is already fully encoded; running it through the Q7
|
|
340
|
+
// translation as well would overwrite `method` and `params` with the
|
|
341
|
+
// wrong dialect's names, and `set_custom_mode` would then be refused
|
|
342
|
+
// outright because the Q10 wind codes are not in the Q7 table.
|
|
343
|
+
const translated = b01Q10Dps
|
|
344
|
+
? null
|
|
345
|
+
: b01Q7Adapter.translateOutgoing(method, params, family);
|
|
307
346
|
|
|
308
|
-
if (!translated) {
|
|
347
|
+
if (!b01Q10Dps && !translated) {
|
|
309
348
|
if (neutral) {
|
|
310
349
|
this.adapter.log.debug(
|
|
311
350
|
`Method ${method} has no B01/Q7 equivalent for ${duid}; returning a neutral response.`
|
|
@@ -322,8 +361,10 @@ class messageQueueHandler {
|
|
|
322
361
|
throw unsupported;
|
|
323
362
|
}
|
|
324
363
|
|
|
325
|
-
|
|
326
|
-
|
|
364
|
+
if (translated) {
|
|
365
|
+
method = translated.method;
|
|
366
|
+
params = /** @type {any} */ (translated.params);
|
|
367
|
+
}
|
|
327
368
|
}
|
|
328
369
|
|
|
329
370
|
let useCloudConnection =
|
|
@@ -380,7 +421,8 @@ class messageQueueHandler {
|
|
|
380
421
|
method,
|
|
381
422
|
params,
|
|
382
423
|
secure,
|
|
383
|
-
photo
|
|
424
|
+
photo,
|
|
425
|
+
b01Q10Dps ? { b01Q10Dps } : {}
|
|
384
426
|
);
|
|
385
427
|
const roborockMessage = await this.adapter.message.buildRoborockMessage(
|
|
386
428
|
duid,
|
|
@@ -443,6 +485,29 @@ class messageQueueHandler {
|
|
|
443
485
|
`No local connection to ${describeDevice(this.adapter, duid)}, so the ${method} request was not sent.`
|
|
444
486
|
)
|
|
445
487
|
);
|
|
488
|
+
} else if (b01Q10Dps) {
|
|
489
|
+
// THE Q10 DIALECT IS FIRE-AND-FORGET. It defines no RPC reply, so
|
|
490
|
+
// there is no acknowledgement to wait for and nothing to correlate
|
|
491
|
+
// against — upstream's own channel returns None. Registering a
|
|
492
|
+
// pending request here would arm a timeout that is guaranteed to
|
|
493
|
+
// expire on a perfectly healthy link, and reporting that expiry is
|
|
494
|
+
// exactly the false "the cloud has gone silent" diagnosis that cost
|
|
495
|
+
// #14 three rounds.
|
|
496
|
+
//
|
|
497
|
+
// Resolving means "the write left this plugin", not "the robot did
|
|
498
|
+
// it". Nothing downstream may treat it as confirmation: the state
|
|
499
|
+
// the tile shows still comes from the optimistic-state machinery,
|
|
500
|
+
// which self-corrects against what the robot actually reports.
|
|
501
|
+
this.adapter.rr_mqtt_connector.sendMessage(duid, roborockMessage);
|
|
502
|
+
this.adapter.updateTransportDiagnostics(duid, {
|
|
503
|
+
lastTransport: "cloud",
|
|
504
|
+
lastTransportReason: "b01-q10-fire-and-forget",
|
|
505
|
+
lastCommandMethod: method,
|
|
506
|
+
});
|
|
507
|
+
this.adapter.log.debug(
|
|
508
|
+
`Published B01 Q10 datapoint write for ${duid} with ${payload}. The Q10 dialect is fire-and-forget, so this is a publish confirmation and not a robot acknowledgement; no reply is expected.`
|
|
509
|
+
);
|
|
510
|
+
resolve(["ok"]);
|
|
446
511
|
} else {
|
|
447
512
|
// setup Timeout
|
|
448
513
|
const requestTimeout = getRequestTimeout(
|
|
@@ -4353,7 +4353,56 @@ class Roborock {
|
|
|
4353
4353
|
return this._b01StatusState?.get(duid)?.lastV1Status || null;
|
|
4354
4354
|
}
|
|
4355
4355
|
|
|
4356
|
+
/**
|
|
4357
|
+
* Say once per Q10 robot that its status does not come from this loop.
|
|
4358
|
+
*
|
|
4359
|
+
* Once per robot rather than once per poll: at the idle cadence the loop
|
|
4360
|
+
* reaches this 144 times an hour, and a line repeated that often stops
|
|
4361
|
+
* being read. The reader's question is not "was a request skipped" but
|
|
4362
|
+
* "why is my tile not updating and where does its state come from" — so the
|
|
4363
|
+
* notice answers that and points at the issue tracking the remaining half.
|
|
4364
|
+
*
|
|
4365
|
+
* @param {string} duid
|
|
4366
|
+
* @returns {void}
|
|
4367
|
+
*/
|
|
4368
|
+
noteB01Q10StatusPollNotAttempted(duid) {
|
|
4369
|
+
if (!this._b01Q10StatusNoticed) {
|
|
4370
|
+
this._b01Q10StatusNoticed = new Set();
|
|
4371
|
+
}
|
|
4372
|
+
if (this._b01Q10StatusNoticed.has(duid)) {
|
|
4373
|
+
return;
|
|
4374
|
+
}
|
|
4375
|
+
this._b01Q10StatusNoticed.add(duid);
|
|
4376
|
+
this.log.info(
|
|
4377
|
+
`${this.describeDevice(duid)} speaks the B01 Q10 dialect, which sends no reply to a status read, so the dedicated B01 status loop does not poll it. Its state comes from the home data snapshot over HTTPS instead. This is a property of the dialect and not a fault; reading state from the datapoint updates the robot pushes is tracked in issue #19.`
|
|
4378
|
+
);
|
|
4379
|
+
}
|
|
4380
|
+
|
|
4356
4381
|
async refreshB01Status(duid, options = {}) {
|
|
4382
|
+
// A Q10 (`ss*`) IS NOT POLLED AT ALL, and that gate belongs here rather
|
|
4383
|
+
// than at the send choke point. The choke point already refuses
|
|
4384
|
+
// `get_status` for a Q10 by name — correctly, since a fire-and-forget
|
|
4385
|
+
// dialect cannot answer a read — but a request refused is still a request
|
|
4386
|
+
// counted: the catch below incremented `consecutiveFailures` on every one
|
|
4387
|
+
// and warned "B01 status has failed N times in a row" every tenth. At the
|
|
4388
|
+
// 25-second idle cadence that is a warning every four minutes about a
|
|
4389
|
+
// robot that is working exactly as designed, which is the same false alarm
|
|
4390
|
+
// #14 spent three rounds chasing. Asking a question whose refusal is
|
|
4391
|
+
// certain before it is asked is not a diagnostic.
|
|
4392
|
+
//
|
|
4393
|
+
// Returning null is what the throttle below already returns, so every
|
|
4394
|
+
// caller handles it; no state entry is allocated either, and
|
|
4395
|
+
// `getLastKnownLiveStatus` answers null for a Q10 as it does for a classic
|
|
4396
|
+
// robot — "nothing known", never a value.
|
|
4397
|
+
if (
|
|
4398
|
+
b01Q7Adapter.b01FamilyForModel(
|
|
4399
|
+
this.getProductAttribute(duid, "model")
|
|
4400
|
+
) === b01Q7Adapter.B01_FAMILY.Q10
|
|
4401
|
+
) {
|
|
4402
|
+
this.noteB01Q10StatusPollNotAttempted(duid);
|
|
4403
|
+
return null;
|
|
4404
|
+
}
|
|
4405
|
+
|
|
4357
4406
|
// Q7/B01 status snapshot via prop.get, mapped to v1-shaped fields and
|
|
4358
4407
|
// dispatched on the existing live-message path so the Matter accessory
|
|
4359
4408
|
// updates exactly like it does for classic robots.
|