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 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. 1555 automated tests, zero known vulnerabilities, no analytics, and a startup designed to never crash your Homebridge — even when your Wi-Fi or the Roborock cloud has a bad day.
40
+ - 🛡️ **Verified by Homebridge.** Reviewed and endorsed by the Homebridge team. 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 1555 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.
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.18.0",
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
+ };
@@ -1231,4 +1231,7 @@ module.exports = {
1231
1231
  mapStatusToV1,
1232
1232
  B01_FAMILY,
1233
1233
  b01FamilyForModel,
1234
+ // Exported for b01Q10Adapter, so the Q10 suction table has one home rather
1235
+ // than a second copy that can drift from this one.
1236
+ v1FanPowerToWind,
1234
1237
  };
@@ -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 (version == "B01" || version == "\x81S\x19") {
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
- return ` No Roborock message reached the plugin from this robot while the request was pending (${total} since startup).`;
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
- // B01 (Q7-series) devices are cloud/MQTT-only and speak a different RPC
269
- // dialect. Translate the v1-shaped method to the Q7 equivalent here so a
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. Refusing here says so at the
295
- // one place that knows, and keeps the wire clean until #19 implements
296
- // the dialect. Methods answered from NEUTRAL_RESPONSES never touch the
297
- // wire, so they are left alone — refusing those would regress the
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
- throw refusal(
301
- "b01 q10 dialect unimplemented",
302
- `${describeDevice(this.adapter, duid)} speaks the B01 Q10 dialect, which this plugin does not implement yet (see issue #19), so ${method} was not sent. Q10 models (${model || "ss*"}) use direct datapoint writes rather than the Q7 RPC envelope; sending the Q7 form would be discarded by the robot without reply.`
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
- const translated = b01Q7Adapter.translateOutgoing(method, params, family);
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
- method = translated.method;
326
- params = /** @type {any} */ (translated.params);
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.