homebridge-roborock-matter 2.1.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.
Files changed (59) hide show
  1. package/.gitattributes +13 -0
  2. package/CHANGELOG.md +469 -0
  3. package/LICENSE +23 -0
  4. package/README.md +45 -0
  5. package/__tests__/testdata/b01_q7_get_prop_response.json +1 -0
  6. package/__tests__/testdata/b01_q7_map_fixture.json +16 -0
  7. package/config.schema.json +118 -0
  8. package/dist/crypto.js +65 -0
  9. package/dist/crypto.js.map +1 -0
  10. package/dist/index.js +10 -0
  11. package/dist/index.js.map +1 -0
  12. package/dist/live_message.js +25 -0
  13. package/dist/live_message.js.map +1 -0
  14. package/dist/logger.js +37 -0
  15. package/dist/logger.js.map +1 -0
  16. package/dist/matter_vacuum_accessory.js +1877 -0
  17. package/dist/matter_vacuum_accessory.js.map +1 -0
  18. package/dist/platform.js +441 -0
  19. package/dist/platform.js.map +1 -0
  20. package/dist/settings.js +8 -0
  21. package/dist/settings.js.map +1 -0
  22. package/dist/types.js +3 -0
  23. package/dist/types.js.map +1 -0
  24. package/dist/ui/index.js +741 -0
  25. package/dist/ui/index.js.map +1 -0
  26. package/docs/matter-rvc-updating-homebridge-report.md +120 -0
  27. package/homebridge-roborock-vacuum.code-workspace +7 -0
  28. package/homebridge-ui/public/index.html +257 -0
  29. package/homebridge-ui/public/index.js +1276 -0
  30. package/homebridge-ui/public/styles.css +611 -0
  31. package/homebridge-ui/server.js +3 -0
  32. package/package.json +91 -0
  33. package/roborockLib/i18n/de/translations.json +188 -0
  34. package/roborockLib/i18n/en/translations.json +208 -0
  35. package/roborockLib/i18n/es/translations.json +188 -0
  36. package/roborockLib/i18n/fr/translations.json +188 -0
  37. package/roborockLib/i18n/it/translations.json +188 -0
  38. package/roborockLib/i18n/nl/translations.json +188 -0
  39. package/roborockLib/i18n/pl/translations.json +188 -0
  40. package/roborockLib/i18n/pt/translations.json +188 -0
  41. package/roborockLib/i18n/ru/translations.json +188 -0
  42. package/roborockLib/i18n/uk/translations.json +188 -0
  43. package/roborockLib/i18n/zh-cn/translations.json +188 -0
  44. package/roborockLib/lib/RRMapParser.js +459 -0
  45. package/roborockLib/lib/b01Q7Adapter.js +468 -0
  46. package/roborockLib/lib/deviceFeatures.js +1349 -0
  47. package/roborockLib/lib/localConnector.js +629 -0
  48. package/roborockLib/lib/map/map.html +137 -0
  49. package/roborockLib/lib/map/zones.js +851 -0
  50. package/roborockLib/lib/message.js +402 -0
  51. package/roborockLib/lib/messageQueueHandler.js +411 -0
  52. package/roborockLib/lib/roborockAuth.js +178 -0
  53. package/roborockLib/lib/roborockCrypto.js +55 -0
  54. package/roborockLib/lib/roborockPackageHelper.js +142 -0
  55. package/roborockLib/lib/roborock_mqtt_connector.js +567 -0
  56. package/roborockLib/lib/sniffing/mitmproxy_roborock.py +300 -0
  57. package/roborockLib/lib/vacuum.js +992 -0
  58. package/roborockLib/roborockAPI.js +3635 -0
  59. package/tsconfig.roborockLib.json +17 -0
@@ -0,0 +1,1877 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ const node_timers_1 = require("node:timers");
4
+ const live_message_1 = require("./live_message");
5
+ const MATTER_CLEAN_MODE_COMMAND_TIMEOUT_MS = 2000;
6
+ const MATTER_CLEAN_MODE_PREP_TIMEOUT_MS = 2500;
7
+ function scheduleTimer(callback, delayMs) {
8
+ const setTimer = typeof globalThis.setTimeout === "function"
9
+ ? globalThis.setTimeout
10
+ : node_timers_1.setTimeout;
11
+ return setTimer(callback, delayMs);
12
+ }
13
+ function unrefTimer(timer) {
14
+ if (typeof timer === "object" && typeof timer.unref === "function") {
15
+ timer.unref();
16
+ }
17
+ }
18
+ function clearTimer(timer) {
19
+ const clear = typeof globalThis.clearTimeout === "function"
20
+ ? globalThis.clearTimeout
21
+ : node_timers_1.clearTimeout;
22
+ clear(timer);
23
+ }
24
+ const RUN_MODE_IDLE = 0;
25
+ const RUN_MODE_CLEANING = 1;
26
+ // Live status entries older than this fall back to the HomeData snapshot.
27
+ const LIVE_STATUS_STALENESS_MS = 15 * 60 * 1000;
28
+ const CLEAN_MODE_VACUUM = 0;
29
+ const CLEAN_MODE_MOP = 1;
30
+ const CLEAN_MODE_VACUUM_AND_MOP = 2;
31
+ const RVC_RUN_MODE_TAG_IDLE = 16384;
32
+ const RVC_RUN_MODE_TAG_CLEANING = 16385;
33
+ const RVC_CLEAN_MODE_TAG_VACUUM = 16385;
34
+ const RVC_CLEAN_MODE_TAG_MOP = 16386;
35
+ const ROBOROCK_FAN_POWER_OFF = 105;
36
+ const ROBOROCK_FAN_POWER_BALANCED = 102;
37
+ const ROBOROCK_WATER_BOX_OFF = 200;
38
+ const ROBOROCK_WATER_BOX_MILD = 201;
39
+ const RVC_OPERATIONAL_STATE = {
40
+ STOPPED: 0,
41
+ RUNNING: 1,
42
+ PAUSED: 2,
43
+ ERROR: 3,
44
+ SEEKING_CHARGER: 64,
45
+ CHARGING: 65,
46
+ DOCKED: 66,
47
+ EMPTYING_DUST_BIN: 67,
48
+ CLEANING_MOP: 68,
49
+ UPDATING_MAPS: 70,
50
+ };
51
+ const RVC_OPERATIONAL_STATE_LIST = [
52
+ RVC_OPERATIONAL_STATE.STOPPED,
53
+ RVC_OPERATIONAL_STATE.RUNNING,
54
+ RVC_OPERATIONAL_STATE.PAUSED,
55
+ RVC_OPERATIONAL_STATE.ERROR,
56
+ RVC_OPERATIONAL_STATE.SEEKING_CHARGER,
57
+ ];
58
+ // The basic (non-extended) operational state list is the first four entries
59
+ // of the full list, without SEEKING_CHARGER.
60
+ const RVC_BASIC_OPERATIONAL_STATE_LIST = RVC_OPERATIONAL_STATE_LIST.slice(0, 4);
61
+ // Optional charging/docked additions. CHARGING (0x41) and DOCKED (0x42) are
62
+ // standard RVC operational state IDs (not manufacturer-range), so they are
63
+ // safe to advertise; newer Apple Home versions render them as "Charging" /
64
+ // "Docked" on the tile instead of "Ready".
65
+ const RVC_CHARGING_DOCKED_STATE_LIST = [
66
+ RVC_OPERATIONAL_STATE.CHARGING,
67
+ RVC_OPERATIONAL_STATE.DOCKED,
68
+ ];
69
+ const POWER_SOURCE_STATUS = {
70
+ ACTIVE: 1,
71
+ UNAVAILABLE: 3,
72
+ };
73
+ const BATTERY_CHARGE_LEVEL = {
74
+ OK: 0,
75
+ WARNING: 1,
76
+ CRITICAL: 2,
77
+ };
78
+ const BATTERY_CHARGE_STATE = {
79
+ UNKNOWN: 0,
80
+ IS_CHARGING: 1,
81
+ IS_AT_FULL_CHARGE: 2,
82
+ IS_NOT_CHARGING: 3,
83
+ };
84
+ const BATTERY_REPLACEABILITY = {
85
+ UNSPECIFIED: 0,
86
+ };
87
+ const BATTERY_ESTIMATED_CHARGE_SECONDS_PER_PERCENT = 180;
88
+ const SERVICE_AREA_SELECT_STATUS = {
89
+ SUCCESS: 0,
90
+ UNSUPPORTED_AREA: 1,
91
+ INVALID_IN_MODE: 2,
92
+ INVALID_SET: 3,
93
+ };
94
+ const MATTER_LOCATION_NAME_MAX_LENGTH = 64;
95
+ const MATTER_MAP_NAME_MAX_LENGTH = 64;
96
+ const MATTER_AREA_ID_MAP_MULTIPLIER = 1000000;
97
+ const MATTER_AREA_ID_MAX = 0xffffffff;
98
+ const OPTIMISTIC_STATE_TTL_MS = 2 * 60 * 1000;
99
+ // Number of consecutive contradicting live Roborock states to tolerate before
100
+ // abandoning an optimistic state, so a command the robot acknowledged but did
101
+ // not act on cannot keep Apple Home on a wrong state until the TTL expires.
102
+ const OPTIMISTIC_CONTRADICTION_LIMIT = 2;
103
+ // Window after a Matter start/resume/area-clean command during which a follow-up
104
+ // pause or dock is forwarded to the robot even if the cached state still reads
105
+ // docked/charging. Models that fall back to cloud (e.g. S8 / roborock.vacuum.a51)
106
+ // can take tens of seconds to report Cleaning, and the optimistic state may clear
107
+ // first; without this window the plugin would silently drop a real user command.
108
+ const RECENT_CLEANING_COMMAND_WINDOW_MS = 60 * 1000;
109
+ const SLOW_MATTER_COMMAND_MS = 3000;
110
+ const MATTER_COMMAND_STATUS_REFRESH_DELAYS_MS = [2000, 15000];
111
+ const MATTER_AMBIGUOUS_COMMAND_STATUS_REFRESH_DELAYS_MS = [
112
+ 0, 2000, 5000, 10000, 20000, 30000,
113
+ ];
114
+ const MATTER_RETURN_TO_DOCK_STATUS_REFRESH_DELAYS_MS = [
115
+ 2000, 15000, 30000, 60000, 90000, 120000, 150000, 180000,
116
+ ];
117
+ const MATTER_RETURN_TO_DOCK_RETRY_DELAY_MS = 7000;
118
+ // Slow hosts (Raspberry Pi class hardware, busy child-bridge restarts) can
119
+ // keep the Homebridge Matter endpoint initializing well past 14 seconds, so
120
+ // back off further before giving up and waiting for the next live update.
121
+ const MATTER_INITIALIZATION_RETRY_DELAYS_MS = [
122
+ 1000, 3000, 10000, 30000, 60000,
123
+ ];
124
+ const ROOM_CLEAN_STATE = 18;
125
+ const PAUSED_STATE = 10;
126
+ // Low-frequency safety net. Every publish is a full coherent snapshot and
127
+ // matter.js suppresses no-op writes, so this generates no Matter traffic
128
+ // unless the store actually drifted from the latest Roborock state.
129
+ const MATTER_STATE_HEARTBEAT_INTERVAL_MS = 60 * 1000;
130
+ /**
131
+ * Optional Homebridge 2 Matter exposure for Apple Home's native vacuum UI.
132
+ *
133
+ * This intentionally uses runtime `any` access instead of importing Homebridge
134
+ * Matter types so the plugin still compiles and runs on Homebridge 1.x.
135
+ */
136
+ class RoborockMatterVacuumAccessory {
137
+ constructor(platform, accessory, device, isRegistered = false) {
138
+ this.platform = platform;
139
+ this.accessory = accessory;
140
+ this.optimisticClusters = null;
141
+ this.optimisticExpiresAt = 0;
142
+ this.optimisticGeneration = 0;
143
+ this.optimisticAction = null;
144
+ this.contradictingLiveStateCount = 0;
145
+ this.lastCleaningCommandAt = 0;
146
+ this.selectedServiceAreaIds = [];
147
+ this.roomCleaningAreaConfirmed = false;
148
+ this.lastServiceAreaSummary = "";
149
+ this.liveStatusUpdatedAt = 0;
150
+ this.initialPublishLogged = false;
151
+ this.selectedCleanMode = CLEAN_MODE_VACUUM;
152
+ this.selectedCleanModeNeedsApply = false;
153
+ this.lastVacuumFanPower = null;
154
+ this.lastWaterBoxMode = null;
155
+ this.matterInitializationRetryAttempt = 0;
156
+ this.matterInitializationRetryPending = false;
157
+ this.returnToDockRetryPending = false;
158
+ this.matterStateHeartbeatTimer = null;
159
+ // Serializes every Matter publish so concurrent publishers (live messages,
160
+ // refreshes, command paths) cannot land out of order. Homebridge defers each
161
+ // updateAccessoryState via setImmediate, so without this chain an older
162
+ // snapshot can overwrite a newer one and leave Apple Home on stale state.
163
+ this.matterPublishChain = Promise.resolve();
164
+ // Freshest status values seen from live Roborock messages. Preferred over the
165
+ // slower HomeData snapshot when rebuilding clusters so registration snapshots
166
+ // and attribute reads do not lag behind the latest push.
167
+ this.liveStatus = new Map();
168
+ this.registered = isRegistered;
169
+ this.updateMetadata(device);
170
+ }
171
+ get api() {
172
+ return this.platform.roborockAPI;
173
+ }
174
+ getMatterCommandOptions() {
175
+ const options = {
176
+ waitForResult: true,
177
+ throwOnError: true,
178
+ preferLocal: true,
179
+ allowOfflineCloudSend: true,
180
+ };
181
+ if (this.platform.platformConfig.preferCloudForMatterCommands) {
182
+ options.preferCloud = true;
183
+ delete options.preferLocal;
184
+ }
185
+ return options;
186
+ }
187
+ getMatterMapLoadCommandOptions() {
188
+ const options = {
189
+ ...this.getMatterCommandOptions(),
190
+ // Some older Roborock models apply load_multi_map but never complete the
191
+ // local pending request. The cloud path gives Matter room cleaning a
192
+ // reliable acknowledgement without forcing all Matter commands to cloud.
193
+ preferCloud: true,
194
+ };
195
+ delete options.preferLocal;
196
+ return options;
197
+ }
198
+ getMatterCleanModePrepCommandOptions() {
199
+ return {
200
+ ...this.getMatterCommandOptions(),
201
+ requestTimeoutMs: MATTER_CLEAN_MODE_COMMAND_TIMEOUT_MS,
202
+ };
203
+ }
204
+ markRegistered() {
205
+ this.registered = true;
206
+ }
207
+ /**
208
+ * Stops all background work for this accessory. Called on Homebridge
209
+ * shutdown and when the accessory is unregistered, so no timer fires into a
210
+ * torn-down bridge and no publish races a restarting child bridge.
211
+ */
212
+ dispose() {
213
+ this.registered = false;
214
+ if (this.matterStateHeartbeatTimer) {
215
+ clearTimer(this.matterStateHeartbeatTimer);
216
+ this.matterStateHeartbeatTimer = null;
217
+ }
218
+ this.clearOptimisticState();
219
+ }
220
+ scheduleMatterStateRefresh(reason, delayMs = 0) {
221
+ if (!this.registered) {
222
+ return;
223
+ }
224
+ const timer = scheduleTimer(() => {
225
+ void this.updateMatterStateFromRoborock().catch((error) => {
226
+ this.platform.log.warn(`Unable to refresh Matter state after ${reason} for ${this.getVacuumName()}: ${this.getErrorMessage(error)}`);
227
+ });
228
+ }, delayMs);
229
+ unrefTimer(timer);
230
+ }
231
+ updateMetadata(device) {
232
+ const duid = device.duid;
233
+ const displayName = this.api.getVacuumDeviceInfo(duid, "name") ||
234
+ device.name ||
235
+ "Roborock Vacuum";
236
+ this.accessory.displayName = displayName;
237
+ // Some Matter layers label the node from `name` rather than `displayName`;
238
+ // set both so Apple Home is less likely to show a generic name.
239
+ this.accessory.name = displayName;
240
+ this.accessory.manufacturer = "Roborock";
241
+ this.accessory.model =
242
+ this.api.getProductAttribute(duid, "model") ||
243
+ this.api.getVacuumDeviceInfo(duid, "model") ||
244
+ "Roborock Vacuum";
245
+ this.accessory.serialNumber =
246
+ this.api.getVacuumDeviceInfo(duid, "sn") || duid;
247
+ const firmwareRevision = this.api.getVacuumDeviceInfo(duid, "fv");
248
+ if (firmwareRevision) {
249
+ this.accessory.firmwareRevision = firmwareRevision;
250
+ }
251
+ else {
252
+ delete this.accessory.firmwareRevision;
253
+ }
254
+ this.accessory.context = { ...(this.accessory.context || {}), duid };
255
+ this.accessory.clusters = this.buildClusters();
256
+ this.accessory.handlers = this.buildHandlers();
257
+ this.accessory.getState = async (cluster, attribute) => {
258
+ const clusterState = this.buildCluster(cluster);
259
+ return clusterState ? clusterState[attribute] : undefined;
260
+ };
261
+ }
262
+ async notifyDeviceUpdater(id, data) {
263
+ if (id === "HomeData" || id === "RoomMapping") {
264
+ if (id === "HomeData") {
265
+ this.rememberHomeDataStatus(data);
266
+ }
267
+ await this.updateMatterStateFromRoborock();
268
+ return;
269
+ }
270
+ if (id === "CloudMessage" || id === "LocalMessage") {
271
+ const liveData = this.getLiveMessageForThisAccessory(data);
272
+ if (liveData === null) {
273
+ return;
274
+ }
275
+ await this.updateMatterStateFromMessage(liveData);
276
+ }
277
+ }
278
+ async updateMatterStateFromRoborock() {
279
+ var _a, _b, _c;
280
+ if (!this.registered) {
281
+ return;
282
+ }
283
+ const matter = this.platform.getMatterApi();
284
+ if (!matter || typeof matter.updateAccessoryState !== "function") {
285
+ return;
286
+ }
287
+ const clusters = this.buildClusters();
288
+ const updated = await this.updateMatterState(clusters, "Roborock state refresh");
289
+ if (updated) {
290
+ if (!this.initialPublishLogged) {
291
+ this.initialPublishLogged = true;
292
+ const power = clusters.powerSource;
293
+ const opState = clusters.rvcOperationalState;
294
+ const halfPercent = power === null || power === void 0 ? void 0 : power.batPercentRemaining;
295
+ this.platform.log.info(`Matter publish for ${(_b = (_a = this.accessory.context) === null || _a === void 0 ? void 0 : _a.duid) !== null && _b !== void 0 ? _b : this.accessory.UUID}: battery=${typeof halfPercent === "number" ? halfPercent / 2 + "%" : "n/a"}, operationalState=${(_c = opState === null || opState === void 0 ? void 0 : opState.operationalState) !== null && _c !== void 0 ? _c : "n/a"}.`);
296
+ }
297
+ this.ensureMatterStateHeartbeat();
298
+ }
299
+ }
300
+ buildHandlers() {
301
+ const handlers = {
302
+ identify: {
303
+ identify: async () => {
304
+ await this.identifyVacuum();
305
+ },
306
+ },
307
+ rvcRunMode: {
308
+ changeToMode: async (request) => {
309
+ await this.changeRunMode(request === null || request === void 0 ? void 0 : request.newMode);
310
+ },
311
+ },
312
+ rvcOperationalState: {
313
+ pause: async () => {
314
+ await this.pauseCleaning();
315
+ },
316
+ resume: async () => {
317
+ await this.resumeCleaning();
318
+ },
319
+ goHome: async () => {
320
+ await this.returnToDock();
321
+ },
322
+ },
323
+ };
324
+ if (this.isCleanModeEnabled()) {
325
+ handlers.rvcCleanMode = {
326
+ changeToMode: async (request) => {
327
+ await this.changeCleanMode(request === null || request === void 0 ? void 0 : request.newMode);
328
+ },
329
+ };
330
+ }
331
+ if (this.isServiceAreaEnabled()) {
332
+ handlers.serviceArea = {
333
+ selectAreas: async (request) => {
334
+ return await this.selectServiceAreas(request === null || request === void 0 ? void 0 : request.newAreas);
335
+ },
336
+ };
337
+ }
338
+ return handlers;
339
+ }
340
+ async identifyVacuum() {
341
+ await this.publishCurrentMatterState("Matter identify command", {
342
+ clearOptimistic: true,
343
+ });
344
+ const findMe = this.api.find_me;
345
+ if (typeof findMe !== "function") {
346
+ this.platform.log.debug(`Matter identify requested for ${this.getVacuumName()}, but the Roborock API does not expose find_me.`);
347
+ return;
348
+ }
349
+ try {
350
+ await findMe.call(this.api, this.getDuid(), this.getMatterCommandOptions());
351
+ }
352
+ catch (error) {
353
+ this.platform.log.warn(`Unable to locate ${this.getVacuumName()} from Matter identify: ${this.getErrorMessage(error)}`);
354
+ }
355
+ await this.publishCurrentMatterState("Matter identify command complete", {
356
+ clearOptimistic: true,
357
+ });
358
+ }
359
+ async changeRunMode(newMode) {
360
+ var _a;
361
+ const name = this.getVacuumName();
362
+ const duid = this.getDuid();
363
+ this.platform.log.info(`Matter run mode request for ${name}: ${newMode !== null && newMode !== void 0 ? newMode : "unknown"}.`);
364
+ if (newMode === RUN_MODE_CLEANING) {
365
+ const selectedAreas = this.getSelectedServiceAreaSegments();
366
+ if (selectedAreas.length > 0) {
367
+ const selectedMapIds = this.getSelectedServiceAreaMapIds(selectedAreas);
368
+ const targetMapId = (_a = selectedMapIds[0]) !== null && _a !== void 0 ? _a : null;
369
+ // Roborock can only clean room segments from one map at a time. Service
370
+ // area selection already constrains this to a single map, so this only
371
+ // guards an unexpected multi-map selection by cleaning the first map
372
+ // instead of throwing out of the Matter command handler.
373
+ const areasToClean = selectedMapIds.length > 1
374
+ ? selectedAreas.filter((area) => area.mapId === targetMapId)
375
+ : selectedAreas;
376
+ if (selectedMapIds.length > 1) {
377
+ this.platform.log.warn(`Matter requested room cleaning across multiple Roborock maps for ${name}; cleaning only the areas on map ${targetMapId}.`);
378
+ }
379
+ const selectedAreaNames = areasToClean.map((area) => this.formatServiceAreaName(area));
380
+ this.platform.log.info(`Starting ${name} from Matter for selected service area(s): ${selectedAreaNames.join(", ")}.`);
381
+ const state = {
382
+ rvcRunMode: { currentMode: RUN_MODE_CLEANING },
383
+ rvcOperationalState: {
384
+ operationalState: RVC_OPERATIONAL_STATE.RUNNING,
385
+ },
386
+ };
387
+ this.setAndScheduleOptimisticState(state, "selected-area start");
388
+ this.dispatchRoborockMatterCommand("service area clean", async () => {
389
+ await this.applySelectedCleanModeIfNeeded();
390
+ await this.loadMatterMapIfNeeded(duid, targetMapId);
391
+ await this.api.app_segment_clean_by_ids(duid, areasToClean.map((area) => area.segmentId), this.getMatterCommandOptions());
392
+ });
393
+ return;
394
+ }
395
+ this.platform.log.info(`Starting ${name} from Matter.`);
396
+ const state = {
397
+ rvcRunMode: { currentMode: RUN_MODE_CLEANING },
398
+ rvcOperationalState: {
399
+ operationalState: RVC_OPERATIONAL_STATE.RUNNING,
400
+ },
401
+ };
402
+ this.setAndScheduleOptimisticState(state, "start");
403
+ this.dispatchRoborockMatterCommand("start", async () => {
404
+ await this.applySelectedCleanModeIfNeeded();
405
+ await this.api.app_start(duid, this.getMatterCommandOptions());
406
+ });
407
+ return;
408
+ }
409
+ if (newMode === RUN_MODE_IDLE) {
410
+ this.platform.log.info(`Stopping ${name} from Matter. Use the Home/Dock action to dock intentionally.`);
411
+ const state = {
412
+ rvcRunMode: { currentMode: RUN_MODE_IDLE },
413
+ rvcOperationalState: {
414
+ operationalState: RVC_OPERATIONAL_STATE.STOPPED,
415
+ },
416
+ };
417
+ this.setAndScheduleOptimisticState(state, "stop");
418
+ this.dispatchRoborockMatterCommand("stop", () => this.api.app_stop(duid, this.getMatterCommandOptions()));
419
+ return;
420
+ }
421
+ this.platform.log.warn(`Ignoring unsupported Matter run mode '${newMode}' for ${name}.`);
422
+ }
423
+ async changeCleanMode(newMode) {
424
+ const name = this.getVacuumName();
425
+ this.platform.log.info(`Matter clean mode request for ${name}: ${newMode !== null && newMode !== void 0 ? newMode : "unknown"}.`);
426
+ if (this.isSupportedCleanMode(newMode)) {
427
+ this.rememberCurrentRoborockCleanModeSettings();
428
+ this.selectedCleanMode = newMode;
429
+ this.selectedCleanModeNeedsApply = true;
430
+ const state = {
431
+ rvcCleanMode: { currentMode: newMode },
432
+ };
433
+ this.setAndScheduleOptimisticState(state, "clean mode change");
434
+ return;
435
+ }
436
+ this.platform.log.warn(`Ignoring unsupported Matter clean mode '${newMode}' for ${name}.`);
437
+ }
438
+ async pauseCleaning() {
439
+ const roborockState = this.getNumberStatus("state");
440
+ const chargeStatus = this.getNumberStatus("charge_status");
441
+ const currentOperationalState = this.getOperationalState(roborockState, chargeStatus);
442
+ const looksIdle = this.isRoborockDockedOrCharging(roborockState, chargeStatus) ||
443
+ (roborockState !== null &&
444
+ !this.isInCleaningRunMode(currentOperationalState));
445
+ // Always forward an explicit Matter pause to the robot. The cached snapshot
446
+ // can lag or be overridden by a stale HomeData refresh while the robot is
447
+ // really cleaning (issues #4 and #12), so hard-dropping the command based on
448
+ // it silently failed real pauses. Pausing an already-stopped robot is a
449
+ // harmless no-op, and the optimistic state self-corrects if it was idle.
450
+ if (looksIdle) {
451
+ this.platform.log.info(`Pausing ${this.getVacuumName()} from Matter despite an idle snapshot; the cached state may be stale.`);
452
+ }
453
+ else {
454
+ this.platform.log.info(`Pausing ${this.getVacuumName()} from Matter.`);
455
+ }
456
+ const state = {
457
+ rvcOperationalState: {
458
+ operationalState: RVC_OPERATIONAL_STATE.PAUSED,
459
+ },
460
+ };
461
+ this.setAndScheduleOptimisticState(state, "pause");
462
+ this.dispatchRoborockMatterCommand("pause", () => this.api.app_pause(this.getDuid(), this.getMatterCommandOptions()));
463
+ }
464
+ async resumeCleaning() {
465
+ this.platform.log.info(`Resuming ${this.getVacuumName()} from Matter.`);
466
+ const state = {
467
+ rvcRunMode: { currentMode: RUN_MODE_CLEANING },
468
+ rvcOperationalState: {
469
+ operationalState: RVC_OPERATIONAL_STATE.RUNNING,
470
+ },
471
+ };
472
+ this.setAndScheduleOptimisticState(state, "resume");
473
+ this.dispatchRoborockMatterCommand("resume", async () => {
474
+ await this.applySelectedCleanModeIfNeeded();
475
+ await this.api.app_start(this.getDuid(), this.getMatterCommandOptions());
476
+ });
477
+ }
478
+ async returnToDock() {
479
+ // Always forward an explicit Matter dock to the robot. As with pause, the
480
+ // cached snapshot can lag or be overridden by a stale HomeData refresh while
481
+ // the robot is really cleaning (issues #4 and #12); docking an already-docked
482
+ // robot is a harmless no-op.
483
+ if (this.isDockedOrChargingNow()) {
484
+ this.platform.log.info(`Sending ${this.getVacuumName()} back to dock from Matter despite a docked snapshot; the cached state may be stale.`);
485
+ }
486
+ else {
487
+ this.platform.log.info(`Sending ${this.getVacuumName()} back to dock from Matter.`);
488
+ }
489
+ const returnOperationalState = this.isExtendedOperationalStateEnabled()
490
+ ? RVC_OPERATIONAL_STATE.SEEKING_CHARGER
491
+ : RVC_OPERATIONAL_STATE.STOPPED;
492
+ const state = {
493
+ rvcRunMode: {
494
+ currentMode: this.isInCleaningRunMode(returnOperationalState)
495
+ ? RUN_MODE_CLEANING
496
+ : RUN_MODE_IDLE,
497
+ },
498
+ rvcOperationalState: {
499
+ operationalState: returnOperationalState,
500
+ },
501
+ };
502
+ this.setAndScheduleOptimisticState(state, "return to dock");
503
+ this.dispatchRoborockMatterCommand("return to dock", () => this.api.app_charge(this.getDuid(), this.getMatterCommandOptions()), { retryReturnToDockIfStillActive: true });
504
+ }
505
+ scheduleMatterStateUpdate(reason, optimisticGeneration) {
506
+ if (!this.registered) {
507
+ return;
508
+ }
509
+ const timer = scheduleTimer(() => {
510
+ if (optimisticGeneration !== undefined &&
511
+ optimisticGeneration !== this.optimisticGeneration) {
512
+ this.platform.log.debug(`Skipping stale Matter optimistic state update after ${reason} for ${this.getVacuumName()}.`);
513
+ return;
514
+ }
515
+ // Build the snapshot at execution time so it reflects the freshest
516
+ // Roborock and optimistic state instead of a stale captured copy.
517
+ void this.updateMatterState(this.buildClusters(), reason)
518
+ .then((updated) => {
519
+ if (updated) {
520
+ this.ensureMatterStateHeartbeat();
521
+ }
522
+ })
523
+ .catch((error) => {
524
+ this.platform.log.warn(`Unable to update Matter state after ${reason} for ${this.getVacuumName()}: ${this.getErrorMessage(error)}`);
525
+ });
526
+ }, 0);
527
+ unrefTimer(timer);
528
+ }
529
+ setAndScheduleOptimisticState(partialClusters, reason) {
530
+ var _a;
531
+ if (this.getNumberFromValue((_a = partialClusters.rvcOperationalState) === null || _a === void 0 ? void 0 : _a.operationalState) === RVC_OPERATIONAL_STATE.RUNNING) {
532
+ // Remember when a start/resume/area-clean was issued so a follow-up pause
533
+ // or dock is not dropped while the robot is still spinning up and the
534
+ // cached snapshot lags behind (see RECENT_CLEANING_COMMAND_WINDOW_MS).
535
+ this.lastCleaningCommandAt = Date.now();
536
+ }
537
+ const optimisticGeneration = this.setOptimisticState(partialClusters, reason);
538
+ this.scheduleMatterStateUpdate(reason, optimisticGeneration);
539
+ }
540
+ hasRecentlyCommandedCleaning() {
541
+ return (Date.now() - this.lastCleaningCommandAt <
542
+ RECENT_CLEANING_COMMAND_WINDOW_MS);
543
+ }
544
+ async updateMatterState(partialClusters, reason = "state update") {
545
+ if (!this.registered) {
546
+ return false;
547
+ }
548
+ const matter = this.platform.getMatterApi();
549
+ if (!matter || typeof matter.updateAccessoryState !== "function") {
550
+ return false;
551
+ }
552
+ if (Object.keys(partialClusters).length === 0) {
553
+ return false;
554
+ }
555
+ // Every publish is a full snapshot and writes are serialized in submission
556
+ // order. matter.js suppresses no-op attribute writes at its store level, so
557
+ // no plugin-side change tracking is needed; tracking published values here
558
+ // previously allowed racing publishers to desynchronize the plugin from the
559
+ // Matter store, leaving Apple Home stuck on stale state ("Updating...").
560
+ // Per-cluster fault isolation: one misbehaving cluster (bad attribute
561
+ // shape, transient matter.js error) must never block the others — a
562
+ // frozen battery reading because the operational-state publish failed is
563
+ // exactly the failure mode this prevents. Only a TOTAL failure is
564
+ // rethrown, preserving the initialization-retry semantics below.
565
+ const clusterEntries = Object.entries(partialClusters);
566
+ const publishTask = this.matterPublishChain.then(async () => {
567
+ const failures = [];
568
+ await Promise.all(clusterEntries.map(async ([cluster, attributes]) => {
569
+ try {
570
+ await matter.updateAccessoryState(this.accessory.UUID, cluster, attributes);
571
+ }
572
+ catch (error) {
573
+ failures.push(error);
574
+ this.platform.log.debug(`Matter publish for cluster ${cluster} on ${this.accessory.UUID} failed: ${error instanceof Error ? error.message : String(error)}`);
575
+ }
576
+ }));
577
+ if (failures.length > 0) {
578
+ if (failures.length === clusterEntries.length) {
579
+ throw failures[0];
580
+ }
581
+ // Partial failure: the surviving clusters have landed (that is the
582
+ // isolation), but an initializing endpoint should still get its
583
+ // retry so the failed cluster receives its value too.
584
+ const initFailure = failures.find((failure) => this.isMatterInitializingError(failure));
585
+ if (initFailure) {
586
+ this.scheduleMatterInitializationRetry(reason, initFailure);
587
+ }
588
+ }
589
+ });
590
+ this.matterPublishChain = publishTask.then(() => undefined, () => undefined);
591
+ try {
592
+ await publishTask;
593
+ this.matterInitializationRetryAttempt = 0;
594
+ this.matterInitializationRetryPending = false;
595
+ return true;
596
+ }
597
+ catch (error) {
598
+ if (this.isMatterInitializingError(error)) {
599
+ this.scheduleMatterInitializationRetry(reason, error);
600
+ return false;
601
+ }
602
+ throw error;
603
+ }
604
+ }
605
+ async updateMatterStateFromMessage(data) {
606
+ if (!this.registered) {
607
+ return;
608
+ }
609
+ const status = this.extractStatusUpdate(data);
610
+ if (!status) {
611
+ return;
612
+ }
613
+ const state = this.getNumberFromValue(status.state);
614
+ const chargeStatus = this.getNumberFromValue(status.charge_status);
615
+ const battery = this.getNumberFromValue(status.battery);
616
+ const cleanArea = this.getNumberFromValue(status.clean_area);
617
+ const cleanTime = this.getNumberFromValue(status.clean_time);
618
+ if (state === null &&
619
+ chargeStatus === null &&
620
+ battery === null &&
621
+ cleanArea === null &&
622
+ cleanTime === null) {
623
+ return;
624
+ }
625
+ // Remember the freshest live values so a later full cluster rebuild reflects
626
+ // them instead of the slower HomeData snapshot.
627
+ const previousState = this.getNumberStatus("state");
628
+ if (state !== null &&
629
+ state !== ROOM_CLEAN_STATE &&
630
+ state !== PAUSED_STATE) {
631
+ this.roomCleaningAreaConfirmed = false;
632
+ }
633
+ else if (state === ROOM_CLEAN_STATE &&
634
+ previousState !== ROOM_CLEAN_STATE &&
635
+ previousState !== PAUSED_STATE) {
636
+ this.roomCleaningAreaConfirmed = false;
637
+ }
638
+ this.rememberLiveStatus("state", state);
639
+ this.rememberLiveStatus("charge_status", chargeStatus);
640
+ this.rememberLiveStatus("battery", battery);
641
+ this.rememberLiveStatus("clean_area", cleanArea);
642
+ this.rememberLiveStatus("clean_time", cleanTime);
643
+ if ((state !== null && state !== void 0 ? state : previousState) === ROOM_CLEAN_STATE &&
644
+ cleanArea !== null &&
645
+ cleanTime !== null) {
646
+ this.roomCleaningAreaConfirmed = cleanArea > 0 && cleanTime > 0;
647
+ }
648
+ if (state !== null || chargeStatus !== null) {
649
+ // Confirm or contradict any pending optimistic state. While optimism is
650
+ // active the snapshot below still publishes the optimistic values, so no
651
+ // separate suppression of the live values is needed.
652
+ this.reconcileOptimisticStateWithLive(this.getOperationalState(state, chargeStatus), state, chargeStatus);
653
+ }
654
+ const updated = await this.updateMatterState(this.buildClusters(), "live state");
655
+ if (updated) {
656
+ this.ensureMatterStateHeartbeat();
657
+ }
658
+ }
659
+ buildClusters() {
660
+ const clusters = {
661
+ rvcRunMode: this.buildRunModeCluster(),
662
+ rvcOperationalState: this.buildOperationalStateCluster(),
663
+ };
664
+ this.addCleanModeCluster(clusters);
665
+ this.addPowerSourceCluster(clusters);
666
+ if (this.isServiceAreaEnabled() && this.hasServiceAreasToExpose()) {
667
+ // Publishing a Service Area cluster with an empty supportedAreas list
668
+ // violates Matter conformance (the spec requires at least one area)
669
+ // and makes Apple Home abort commissioning. Robots without room data
670
+ // (e.g. B01/Q7 until the map channel lands) omit the cluster instead.
671
+ clusters.serviceArea = this.buildServiceAreaCluster();
672
+ }
673
+ return this.applyOptimisticState(clusters);
674
+ }
675
+ buildCluster(cluster) {
676
+ var _a;
677
+ let clusterState;
678
+ switch (cluster) {
679
+ case "rvcRunMode":
680
+ clusterState = this.buildRunModeCluster();
681
+ break;
682
+ case "rvcCleanMode":
683
+ clusterState = this.isCleanModeEnabled()
684
+ ? this.buildCleanModeCluster()
685
+ : undefined;
686
+ break;
687
+ case "rvcOperationalState":
688
+ clusterState = this.buildOperationalStateCluster();
689
+ break;
690
+ case "powerSource":
691
+ clusterState = this.isPowerSourceEnabled()
692
+ ? this.buildPowerSourceCluster()
693
+ : undefined;
694
+ break;
695
+ case "serviceArea":
696
+ clusterState =
697
+ this.isServiceAreaEnabled() && this.hasServiceAreasToExpose()
698
+ ? this.buildServiceAreaCluster()
699
+ : undefined;
700
+ break;
701
+ default:
702
+ return undefined;
703
+ }
704
+ if (!clusterState) {
705
+ return undefined;
706
+ }
707
+ const optimisticCluster = (_a = this.getActiveOptimisticState()) === null || _a === void 0 ? void 0 : _a[cluster];
708
+ return optimisticCluster
709
+ ? { ...clusterState, ...optimisticCluster }
710
+ : clusterState;
711
+ }
712
+ buildRunModeCluster() {
713
+ return {
714
+ supportedModes: [
715
+ {
716
+ label: "Idle",
717
+ mode: RUN_MODE_IDLE,
718
+ modeTags: [{ value: RVC_RUN_MODE_TAG_IDLE }],
719
+ },
720
+ {
721
+ label: "Cleaning",
722
+ mode: RUN_MODE_CLEANING,
723
+ modeTags: [{ value: RVC_RUN_MODE_TAG_CLEANING }],
724
+ },
725
+ ],
726
+ currentMode: this.isInCleaningRunMode(this.getOperationalState())
727
+ ? RUN_MODE_CLEANING
728
+ : RUN_MODE_IDLE,
729
+ };
730
+ }
731
+ buildCleanModeCluster() {
732
+ return {
733
+ supportedModes: this.getSupportedCleanModes(),
734
+ currentMode: this.getCurrentCleanMode(),
735
+ };
736
+ }
737
+ getSupportedCleanModes() {
738
+ const supportedModes = [
739
+ {
740
+ label: "Vacuum",
741
+ mode: CLEAN_MODE_VACUUM,
742
+ modeTags: [{ value: RVC_CLEAN_MODE_TAG_VACUUM }],
743
+ },
744
+ ];
745
+ if (this.getMatterCleanModeCapabilities().canMop) {
746
+ supportedModes.push({
747
+ label: "Mop",
748
+ mode: CLEAN_MODE_MOP,
749
+ modeTags: [{ value: RVC_CLEAN_MODE_TAG_MOP }],
750
+ }, {
751
+ // Matter has no dedicated "vacuum then mop" tag, so combine the two
752
+ // standard RVC Clean Mode tags instead of an undefined tag value.
753
+ label: "Vacuum + Mop",
754
+ mode: CLEAN_MODE_VACUUM_AND_MOP,
755
+ modeTags: [
756
+ { value: RVC_CLEAN_MODE_TAG_VACUUM },
757
+ { value: RVC_CLEAN_MODE_TAG_MOP },
758
+ ],
759
+ });
760
+ }
761
+ return supportedModes;
762
+ }
763
+ getCurrentCleanMode() {
764
+ return this.isSupportedCleanMode(this.selectedCleanMode)
765
+ ? this.selectedCleanMode
766
+ : CLEAN_MODE_VACUUM;
767
+ }
768
+ isSupportedCleanMode(mode) {
769
+ return this.getSupportedCleanModes().some((supportedMode) => supportedMode.mode === mode);
770
+ }
771
+ getMatterCleanModeCapabilities() {
772
+ const getCapabilities = this.api.getMatterCleanModeCapabilities;
773
+ if (typeof getCapabilities !== "function") {
774
+ return { canVacuum: true, canMop: false };
775
+ }
776
+ // Guard against older/patched API builds returning undefined so cluster
777
+ // builds (which run inside Matter attribute reads) can never throw.
778
+ const capabilities = getCapabilities.call(this.api, this.getDuid());
779
+ return capabilities !== null && capabilities !== void 0 ? capabilities : { canVacuum: true, canMop: false };
780
+ }
781
+ async applySelectedCleanModeIfNeeded() {
782
+ if (!this.selectedCleanModeNeedsApply) {
783
+ return;
784
+ }
785
+ const applySettings = this.api.applyMatterCleanModeSettings;
786
+ if (typeof applySettings !== "function") {
787
+ this.selectedCleanModeNeedsApply = false;
788
+ return;
789
+ }
790
+ const settings = this.getRoborockCleanModeSettings(this.getCurrentCleanMode());
791
+ if (!settings) {
792
+ this.selectedCleanModeNeedsApply = false;
793
+ return;
794
+ }
795
+ this.platform.log.info(`Applying ${this.getCleanModeLabel(this.getCurrentCleanMode())} mode to ${this.getVacuumName()} before starting.`);
796
+ try {
797
+ await this.withCleanModePrepTimeout(applySettings.call(this.api, this.getDuid(), settings, this.getMatterCleanModePrepCommandOptions()));
798
+ }
799
+ catch (error) {
800
+ this.platform.log.warn(`Unable to apply ${this.getCleanModeLabel(this.getCurrentCleanMode())} mode to ${this.getVacuumName()} before starting; continuing with the start command. ${this.getErrorMessage(error)}`);
801
+ }
802
+ finally {
803
+ this.selectedCleanModeNeedsApply = false;
804
+ }
805
+ }
806
+ async withCleanModePrepTimeout(promise) {
807
+ let timeout;
808
+ const timeoutPromise = new Promise((_, reject) => {
809
+ timeout = scheduleTimer(() => {
810
+ reject(new Error(`Matter clean mode prep timed out after ${MATTER_CLEAN_MODE_PREP_TIMEOUT_MS} ms.`));
811
+ }, MATTER_CLEAN_MODE_PREP_TIMEOUT_MS);
812
+ unrefTimer(timeout);
813
+ });
814
+ try {
815
+ return await Promise.race([promise, timeoutPromise]);
816
+ }
817
+ finally {
818
+ if (timeout) {
819
+ clearTimer(timeout);
820
+ }
821
+ }
822
+ }
823
+ getRoborockCleanModeSettings(cleanMode) {
824
+ const capabilities = this.getMatterCleanModeCapabilities();
825
+ // Always carry the selected Matter clean mode; protocol layers that have
826
+ // a native clean-type concept (B01/Q7) apply it directly and ignore the
827
+ // v1-style fan/water workarounds below.
828
+ const settings = { cleanMode };
829
+ if (capabilities.canControlFanPower) {
830
+ settings.fanPower =
831
+ cleanMode === CLEAN_MODE_MOP
832
+ ? ROBOROCK_FAN_POWER_OFF
833
+ : this.getPreferredVacuumFanPower();
834
+ }
835
+ if (capabilities.canControlWater) {
836
+ settings.waterBoxMode =
837
+ cleanMode === CLEAN_MODE_VACUUM
838
+ ? ROBOROCK_WATER_BOX_OFF
839
+ : this.getPreferredWaterBoxMode();
840
+ }
841
+ return Object.keys(settings).length > 0 ? settings : null;
842
+ }
843
+ rememberCurrentRoborockCleanModeSettings() {
844
+ const fanPower = this.getNumberStatus("fan_power");
845
+ if (fanPower !== null && fanPower !== ROBOROCK_FAN_POWER_OFF) {
846
+ this.lastVacuumFanPower = fanPower;
847
+ }
848
+ const waterBoxMode = this.getWaterBoxModeStatus();
849
+ if (waterBoxMode !== null && waterBoxMode !== ROBOROCK_WATER_BOX_OFF) {
850
+ this.lastWaterBoxMode = waterBoxMode;
851
+ }
852
+ }
853
+ getPreferredVacuumFanPower() {
854
+ var _a;
855
+ const currentFanPower = this.getNumberStatus("fan_power");
856
+ if (currentFanPower !== null &&
857
+ currentFanPower !== ROBOROCK_FAN_POWER_OFF) {
858
+ this.lastVacuumFanPower = currentFanPower;
859
+ return currentFanPower;
860
+ }
861
+ return (_a = this.lastVacuumFanPower) !== null && _a !== void 0 ? _a : ROBOROCK_FAN_POWER_BALANCED;
862
+ }
863
+ getWaterBoxModeStatus() {
864
+ var _a;
865
+ return ((_a = this.getNumberStatus("water_box_custom_mode")) !== null && _a !== void 0 ? _a : this.getNumberStatus("water_box_mode"));
866
+ }
867
+ getPreferredWaterBoxMode() {
868
+ var _a;
869
+ const currentWaterBoxMode = this.getWaterBoxModeStatus();
870
+ if (currentWaterBoxMode !== null &&
871
+ currentWaterBoxMode !== ROBOROCK_WATER_BOX_OFF) {
872
+ this.lastWaterBoxMode = currentWaterBoxMode;
873
+ return currentWaterBoxMode;
874
+ }
875
+ return (_a = this.lastWaterBoxMode) !== null && _a !== void 0 ? _a : ROBOROCK_WATER_BOX_MILD;
876
+ }
877
+ getCleanModeLabel(cleanMode) {
878
+ switch (cleanMode) {
879
+ case CLEAN_MODE_MOP:
880
+ return "Mop";
881
+ case CLEAN_MODE_VACUUM_AND_MOP:
882
+ return "Vacuum + Mop";
883
+ default:
884
+ return "Vacuum";
885
+ }
886
+ }
887
+ buildOperationalStateCluster() {
888
+ const operationalState = this.getOperationalState();
889
+ return {
890
+ // RVC Operational State requires PhaseList and CurrentPhase to be null.
891
+ phaseList: null,
892
+ currentPhase: null,
893
+ // Advertise operational state IDs without labels. Apple Home stops
894
+ // commissioning ("Connecting" forever) when the list carries labels or
895
+ // manufacturer-range IDs, so only bare IDs are exposed here.
896
+ operationalStateList: this.getOperationalStateList().map((operationalStateId) => ({ operationalStateId })),
897
+ operationalState,
898
+ };
899
+ }
900
+ buildPowerSourceCluster(batteryValue, chargeStatusValue, stateValue) {
901
+ const battery = batteryValue === undefined
902
+ ? this.getNumberStatus("battery")
903
+ : batteryValue;
904
+ const chargeStatus = chargeStatusValue === undefined
905
+ ? this.getNumberStatus("charge_status")
906
+ : chargeStatusValue;
907
+ const state = stateValue === undefined ? this.getNumberStatus("state") : stateValue;
908
+ const normalizedBattery = battery === null ? null : Math.max(0, Math.min(100, battery));
909
+ const batChargeState = this.getBatteryChargeState(normalizedBattery, chargeStatus, state);
910
+ return {
911
+ status: normalizedBattery === null
912
+ ? POWER_SOURCE_STATUS.UNAVAILABLE
913
+ : POWER_SOURCE_STATUS.ACTIVE,
914
+ order: 0,
915
+ description: "Roborock vacuum battery",
916
+ batPresent: normalizedBattery !== null,
917
+ batPercentRemaining: normalizedBattery === null ? null : normalizedBattery * 2,
918
+ batChargeLevel: this.getBatteryChargeLevel(normalizedBattery),
919
+ batChargeState,
920
+ batReplacementNeeded: false,
921
+ batReplaceability: BATTERY_REPLACEABILITY.UNSPECIFIED,
922
+ batFunctionalWhileCharging: true,
923
+ batTimeToFullCharge: this.getBatteryTimeToFullCharge(normalizedBattery, batChargeState),
924
+ batChargingCurrent: null,
925
+ };
926
+ }
927
+ addPowerSourceCluster(clusters, batteryValue, chargeStatusValue, stateValue) {
928
+ if (!this.isPowerSourceEnabled()) {
929
+ return;
930
+ }
931
+ clusters.powerSource = this.buildPowerSourceCluster(batteryValue, chargeStatusValue, stateValue);
932
+ }
933
+ addCleanModeCluster(clusters) {
934
+ if (!this.isCleanModeEnabled()) {
935
+ return;
936
+ }
937
+ clusters.rvcCleanMode = this.buildCleanModeCluster();
938
+ }
939
+ hasServiceAreasToExpose() {
940
+ return this.getMatterServiceAreas().length > 0;
941
+ }
942
+ buildServiceAreaCluster() {
943
+ const areas = this.getMatterServiceAreas();
944
+ const supportedMaps = this.getMatterServiceAreaMaps(areas);
945
+ const includeMapNamesInAreaLabels = supportedMaps.length > 1;
946
+ const supportedAreaIds = new Set(areas.map((area) => area.areaId));
947
+ const selectedAreas = this.selectedServiceAreaIds.filter((areaId) => supportedAreaIds.has(areaId));
948
+ if (selectedAreas.length !== this.selectedServiceAreaIds.length) {
949
+ this.selectedServiceAreaIds = selectedAreas;
950
+ }
951
+ this.logMatterServiceAreaSummary(areas, supportedMaps);
952
+ const state = {
953
+ supportedAreas: areas.map((area) => ({
954
+ areaId: area.areaId,
955
+ mapId: area.mapId,
956
+ areaInfo: {
957
+ locationInfo: {
958
+ locationName: this.getMatterLocationDisplayName(area, includeMapNamesInAreaLabels),
959
+ floorNumber: null,
960
+ areaType: null,
961
+ },
962
+ landmarkInfo: null,
963
+ },
964
+ })),
965
+ selectedAreas,
966
+ currentArea: this.getCurrentServiceArea(selectedAreas),
967
+ };
968
+ if (supportedMaps.length > 0) {
969
+ state.supportedMaps = supportedMaps;
970
+ }
971
+ return state;
972
+ }
973
+ getCurrentServiceArea(selectedAreas) {
974
+ if (selectedAreas.length !== 1 || !this.roomCleaningAreaConfirmed) {
975
+ return null;
976
+ }
977
+ const state = this.getNumberStatus("state");
978
+ return state === ROOM_CLEAN_STATE || state === PAUSED_STATE
979
+ ? selectedAreas[0]
980
+ : null;
981
+ }
982
+ async selectServiceAreas(newAreas) {
983
+ const supportedAreas = new Map(this.getMatterServiceAreas().map((area) => [area.areaId, area]));
984
+ const selectedAreas = this.normalizeMatterAreaIds(newAreas);
985
+ const unsupportedArea = selectedAreas.find((areaId) => !supportedAreas.has(areaId));
986
+ this.platform.log.info(`Matter service area selection request for ${this.getVacuumName()}: ${selectedAreas.join(", ") || "none"}.`);
987
+ if (unsupportedArea !== undefined) {
988
+ return {
989
+ status: SERVICE_AREA_SELECT_STATUS.UNSUPPORTED_AREA,
990
+ statusText: `Area ${unsupportedArea} is not available from the Roborock room map.`,
991
+ };
992
+ }
993
+ const selectedMapIds = this.getSelectedServiceAreaMapIds(selectedAreas
994
+ .map((areaId) => supportedAreas.get(areaId))
995
+ .filter((area) => area !== undefined));
996
+ if (selectedMapIds.length > 1) {
997
+ this.platform.log.warn(`Ignoring Matter service area selection spanning multiple Roborock maps for ${this.getVacuumName()}; select areas from one map at a time.`);
998
+ return {
999
+ status: SERVICE_AREA_SELECT_STATUS.INVALID_SET,
1000
+ statusText: "Select service areas from only one Roborock map at a time.",
1001
+ };
1002
+ }
1003
+ this.selectedServiceAreaIds = selectedAreas;
1004
+ if (selectedAreas.length > 0) {
1005
+ const areaNames = selectedAreas
1006
+ .map((areaId) => supportedAreas.get(areaId))
1007
+ .filter((area) => area !== undefined)
1008
+ .map((area) => this.formatServiceAreaName(area));
1009
+ this.platform.log.info(`Selected Matter service area(s) for ${this.getVacuumName()}: ${areaNames.join(", ")}.`);
1010
+ }
1011
+ else {
1012
+ this.platform.log.info(`Cleared Matter service area selection for ${this.getVacuumName()}.`);
1013
+ }
1014
+ // Defer the publish so the selectAreas handler returns promptly; the
1015
+ // snapshot is rebuilt at execution time from the stored selection.
1016
+ const publishTimer = scheduleTimer(() => {
1017
+ void this.updateMatterState(this.buildClusters(), "service area selection").catch((error) => {
1018
+ this.platform.log.warn(`Unable to update Matter service area selection for ${this.getVacuumName()}: ${this.getErrorMessage(error)}`);
1019
+ });
1020
+ }, 0);
1021
+ unrefTimer(publishTimer);
1022
+ return {
1023
+ status: SERVICE_AREA_SELECT_STATUS.SUCCESS,
1024
+ statusText: "",
1025
+ };
1026
+ }
1027
+ getMatterServiceAreas() {
1028
+ var _a;
1029
+ const getRoomMappingsForDevice = this.api.getRoomMappingsForDevice;
1030
+ if (typeof getRoomMappingsForDevice !== "function") {
1031
+ return [];
1032
+ }
1033
+ const rooms = getRoomMappingsForDevice.call(this.api, this.getDuid());
1034
+ if (!Array.isArray(rooms)) {
1035
+ return [];
1036
+ }
1037
+ const areas = [];
1038
+ const mapsById = new Map(this.getMatterServiceAreaMapsFromRoborock().map((map) => [map.mapId, map]));
1039
+ const seenAreaIds = new Set();
1040
+ for (const room of rooms) {
1041
+ const roomRecord = this.asRecord(room);
1042
+ const segmentId = this.getNumberFromValue(roomRecord === null || roomRecord === void 0 ? void 0 : roomRecord.segmentId);
1043
+ const mapId = this.getMatterMapId(roomRecord === null || roomRecord === void 0 ? void 0 : roomRecord.mapId);
1044
+ const areaId = segmentId === null
1045
+ ? null
1046
+ : this.getMatterAreaId(segmentId, mapId, seenAreaIds);
1047
+ if (areaId === null ||
1048
+ segmentId === null ||
1049
+ !Number.isInteger(segmentId) ||
1050
+ segmentId < 0 ||
1051
+ seenAreaIds.has(areaId)) {
1052
+ continue;
1053
+ }
1054
+ seenAreaIds.add(areaId);
1055
+ areas.push({
1056
+ areaId,
1057
+ segmentId,
1058
+ mapId,
1059
+ mapName: mapId === null ? null : ((_a = mapsById.get(mapId)) === null || _a === void 0 ? void 0 : _a.name) || null,
1060
+ name: this.toMatterLocationName(roomRecord === null || roomRecord === void 0 ? void 0 : roomRecord.name, segmentId),
1061
+ });
1062
+ }
1063
+ return areas;
1064
+ }
1065
+ getMatterServiceAreaMaps(areas) {
1066
+ var _a;
1067
+ // Matter controllers can hang if supportedMaps advertises maps with no
1068
+ // matching supportedAreas, or if supportedAreas reference a mapId that has
1069
+ // no supportedMaps entry. Build supportedMaps from exactly the maps that
1070
+ // have areas, preferring Roborock-reported map names and falling back to
1071
+ // the area's map name or a generated label.
1072
+ const roborockMapsById = new Map(this.getMatterServiceAreaMapsFromRoborock().map((map) => [map.mapId, map]));
1073
+ const maps = [];
1074
+ const seenMapIds = new Set();
1075
+ for (const area of areas) {
1076
+ if (area.mapId === null || seenMapIds.has(area.mapId)) {
1077
+ continue;
1078
+ }
1079
+ seenMapIds.add(area.mapId);
1080
+ maps.push({
1081
+ mapId: area.mapId,
1082
+ name: ((_a = roborockMapsById.get(area.mapId)) === null || _a === void 0 ? void 0 : _a.name) ||
1083
+ area.mapName ||
1084
+ `Roborock Map ${area.mapId}`,
1085
+ });
1086
+ }
1087
+ return maps;
1088
+ }
1089
+ getMatterServiceAreaMapsFromRoborock() {
1090
+ const getMapListForDevice = this.api.getMapListForDevice;
1091
+ if (typeof getMapListForDevice !== "function") {
1092
+ return [];
1093
+ }
1094
+ const maps = getMapListForDevice.call(this.api, this.getDuid());
1095
+ if (!Array.isArray(maps)) {
1096
+ return [];
1097
+ }
1098
+ const supportedMaps = [];
1099
+ const seenMapIds = new Set();
1100
+ for (const map of maps) {
1101
+ const mapRecord = this.asRecord(map);
1102
+ const mapId = this.getMatterMapId(mapRecord === null || mapRecord === void 0 ? void 0 : mapRecord.mapId);
1103
+ if (mapId === null || seenMapIds.has(mapId)) {
1104
+ continue;
1105
+ }
1106
+ seenMapIds.add(mapId);
1107
+ supportedMaps.push({
1108
+ mapId,
1109
+ name: this.toMatterMapName(mapRecord === null || mapRecord === void 0 ? void 0 : mapRecord.name, mapId),
1110
+ });
1111
+ }
1112
+ return supportedMaps;
1113
+ }
1114
+ getMatterAreaId(segmentId, mapId, usedAreaIds) {
1115
+ let areaId = mapId === null
1116
+ ? segmentId
1117
+ : mapId * MATTER_AREA_ID_MAP_MULTIPLIER + segmentId;
1118
+ if (!Number.isSafeInteger(areaId) || areaId > MATTER_AREA_ID_MAX) {
1119
+ areaId = this.getHashedMatterAreaId(mapId, segmentId);
1120
+ }
1121
+ while (usedAreaIds.has(areaId)) {
1122
+ areaId = areaId >= MATTER_AREA_ID_MAX ? 0 : areaId + 1;
1123
+ }
1124
+ return areaId;
1125
+ }
1126
+ getHashedMatterAreaId(mapId, segmentId) {
1127
+ const source = `${mapId !== null && mapId !== void 0 ? mapId : "none"}:${segmentId}`;
1128
+ let hash = 2166136261;
1129
+ for (let i = 0; i < source.length; i++) {
1130
+ hash ^= source.charCodeAt(i);
1131
+ hash = Math.imul(hash, 16777619) >>> 0;
1132
+ }
1133
+ return hash;
1134
+ }
1135
+ getMatterMapId(value) {
1136
+ const mapId = this.getNumberFromValue(value);
1137
+ return mapId !== null && Number.isInteger(mapId) && mapId >= 0
1138
+ ? mapId
1139
+ : null;
1140
+ }
1141
+ logMatterServiceAreaSummary(areas, maps) {
1142
+ const summary = [
1143
+ this.getDuid(),
1144
+ areas
1145
+ .map((area) => { var _a; return `${area.areaId}:${(_a = area.mapId) !== null && _a !== void 0 ? _a : "none"}:${area.name}`; })
1146
+ .join("|"),
1147
+ maps.map((map) => `${map.mapId}:${map.name}`).join("|"),
1148
+ ].join(";");
1149
+ if (summary === this.lastServiceAreaSummary) {
1150
+ return;
1151
+ }
1152
+ this.lastServiceAreaSummary = summary;
1153
+ if (areas.length === 0) {
1154
+ this.platform.log.info(`Matter Service Area is enabled for ${this.getVacuumName()}, but no Roborock rooms are available to expose yet.`);
1155
+ return;
1156
+ }
1157
+ this.platform.log.info(`Matter Service Area for ${this.getVacuumName()}: exposing ${areas.length} room(s)` +
1158
+ `${maps.length > 0 ? ` on ${maps.length} map(s)` : ""}: ${areas
1159
+ .map((area) => this.getMatterLocationDisplayName(area, maps.length > 1))
1160
+ .join(", ")}.`);
1161
+ }
1162
+ getSelectedServiceAreaSegments() {
1163
+ if (!this.isServiceAreaEnabled()) {
1164
+ return [];
1165
+ }
1166
+ const areasById = new Map(this.getMatterServiceAreas().map((area) => [area.areaId, area]));
1167
+ return this.selectedServiceAreaIds
1168
+ .map((areaId) => areasById.get(areaId))
1169
+ .filter((area) => area !== undefined);
1170
+ }
1171
+ normalizeMatterAreaIds(newAreas) {
1172
+ if (!Array.isArray(newAreas)) {
1173
+ return [];
1174
+ }
1175
+ const selectedAreas = [];
1176
+ const seenAreaIds = new Set();
1177
+ for (const area of newAreas) {
1178
+ const areaId = this.getNumberFromValue(area);
1179
+ if (areaId === null ||
1180
+ !Number.isInteger(areaId) ||
1181
+ areaId < 0 ||
1182
+ seenAreaIds.has(areaId)) {
1183
+ continue;
1184
+ }
1185
+ seenAreaIds.add(areaId);
1186
+ selectedAreas.push(areaId);
1187
+ }
1188
+ return selectedAreas;
1189
+ }
1190
+ clampMatterName(name, maxLength, fallback) {
1191
+ const normalizedName = typeof name === "string" ? name.replace(/\s+/g, " ").trim() : "";
1192
+ const value = normalizedName || fallback;
1193
+ return value.length > maxLength
1194
+ ? value.slice(0, maxLength).trim() || fallback
1195
+ : value;
1196
+ }
1197
+ toMatterLocationName(name, areaId) {
1198
+ return this.clampMatterName(name, MATTER_LOCATION_NAME_MAX_LENGTH, `Room ${areaId}`);
1199
+ }
1200
+ toMatterMapName(name, mapId) {
1201
+ return this.clampMatterName(name, MATTER_MAP_NAME_MAX_LENGTH, `Roborock Map ${mapId}`);
1202
+ }
1203
+ formatServiceAreaName(area) {
1204
+ return area.mapName ? `${area.name} (${area.mapName})` : area.name;
1205
+ }
1206
+ getMatterLocationDisplayName(area, includeMapName) {
1207
+ if (!includeMapName || !area.mapName) {
1208
+ return area.name;
1209
+ }
1210
+ const fallbackName = this.clampMatterName(`${area.mapName} - Room ${area.segmentId}`, MATTER_LOCATION_NAME_MAX_LENGTH, area.name);
1211
+ return this.clampMatterName(`${area.mapName} - ${area.name}`, MATTER_LOCATION_NAME_MAX_LENGTH, fallbackName);
1212
+ }
1213
+ getSelectedServiceAreaMapIds(selectedAreas) {
1214
+ const selectedMapIds = new Set();
1215
+ for (const area of selectedAreas) {
1216
+ if (area.mapId !== null) {
1217
+ selectedMapIds.add(area.mapId);
1218
+ }
1219
+ }
1220
+ return Array.from(selectedMapIds);
1221
+ }
1222
+ async loadMatterMapIfNeeded(duid, targetMapId) {
1223
+ if (targetMapId === null) {
1224
+ return;
1225
+ }
1226
+ const currentMapId = this.getCurrentMatterMapId();
1227
+ if (currentMapId === targetMapId) {
1228
+ return;
1229
+ }
1230
+ const loadMap = this.api.load_multi_map;
1231
+ if (typeof loadMap !== "function") {
1232
+ throw new Error(`Roborock map ${targetMapId} is not currently loaded and this plugin cannot switch maps.`);
1233
+ }
1234
+ this.platform.log.info(`Loading Roborock map ${targetMapId} for ${this.getVacuumName()} before selected-area cleaning.`);
1235
+ try {
1236
+ await loadMap.call(this.api, duid, targetMapId, this.getMatterMapLoadCommandOptions());
1237
+ }
1238
+ catch (error) {
1239
+ const currentMapIdAfterError = this.getCurrentMatterMapId();
1240
+ if (currentMapIdAfterError === targetMapId) {
1241
+ this.platform.log.warn(`Roborock map ${targetMapId} for ${this.getVacuumName()} became active even though the map-load acknowledgement failed: ${this.getErrorMessage(error)}`);
1242
+ return;
1243
+ }
1244
+ throw error;
1245
+ }
1246
+ }
1247
+ getCurrentMatterMapId() {
1248
+ const getCurrentMapIdForDevice = this.api.getCurrentMapIdForDevice;
1249
+ if (typeof getCurrentMapIdForDevice !== "function") {
1250
+ return null;
1251
+ }
1252
+ const currentMapId = getCurrentMapIdForDevice.call(this.api, this.getDuid());
1253
+ return this.getMatterMapId(currentMapId);
1254
+ }
1255
+ isServiceAreaEnabled() {
1256
+ return this.platform.platformConfig.enableMatterServiceArea !== false;
1257
+ }
1258
+ isPowerSourceEnabled() {
1259
+ return this.platform.platformConfig.enableMatterPowerSource !== false;
1260
+ }
1261
+ isCleanModeEnabled() {
1262
+ return this.platform.platformConfig.enableMatterCleanMode !== false;
1263
+ }
1264
+ isExtendedOperationalStateEnabled() {
1265
+ return (this.platform.platformConfig.enableMatterExtendedOperationalStates ===
1266
+ true);
1267
+ }
1268
+ isChargingDockedStateEnabled() {
1269
+ return (this.platform.platformConfig.enableMatterChargingDockedStates === true);
1270
+ }
1271
+ /**
1272
+ * Battery percentage at which a docked robot switches from Charging to
1273
+ * Docked on the Matter tile. Defaults to 100 (charging until full); users
1274
+ * with worn batteries can lower it so the tile stops claiming Charging once
1275
+ * their realistic full level is reached.
1276
+ */
1277
+ getChargedBatteryThreshold() {
1278
+ const raw = this.platform.platformConfig.matterChargedBatteryThreshold;
1279
+ const value = typeof raw === "string" ? Number(raw) : raw;
1280
+ if (typeof value !== "number" || !Number.isFinite(value)) {
1281
+ return 100;
1282
+ }
1283
+ return Math.min(100, Math.max(1, Math.round(value)));
1284
+ }
1285
+ resolveChargingDockedDisplayState(fallbackState) {
1286
+ const battery = this.getNumberStatus("battery");
1287
+ if (battery === null) {
1288
+ return fallbackState;
1289
+ }
1290
+ return battery < this.getChargedBatteryThreshold()
1291
+ ? RVC_OPERATIONAL_STATE.CHARGING
1292
+ : RVC_OPERATIONAL_STATE.DOCKED;
1293
+ }
1294
+ getOperationalStateList() {
1295
+ const baseList = this.isExtendedOperationalStateEnabled()
1296
+ ? RVC_OPERATIONAL_STATE_LIST
1297
+ : RVC_BASIC_OPERATIONAL_STATE_LIST;
1298
+ // Matter requires operationalState to be a member of operationalStateList,
1299
+ // so only advertise CHARGING/DOCKED when we may actually publish them.
1300
+ return this.isChargingDockedStateEnabled()
1301
+ ? [...baseList, ...RVC_CHARGING_DOCKED_STATE_LIST]
1302
+ : baseList;
1303
+ }
1304
+ getBatteryChargeLevel(battery) {
1305
+ if (battery !== null && battery <= 10) {
1306
+ return BATTERY_CHARGE_LEVEL.CRITICAL;
1307
+ }
1308
+ if (battery !== null && battery < 20) {
1309
+ return BATTERY_CHARGE_LEVEL.WARNING;
1310
+ }
1311
+ return BATTERY_CHARGE_LEVEL.OK;
1312
+ }
1313
+ getBatteryChargeState(battery, chargeStatus, state) {
1314
+ if (battery === null) {
1315
+ return BATTERY_CHARGE_STATE.UNKNOWN;
1316
+ }
1317
+ if (state === 100 || (battery >= 100 && chargeStatus !== 0)) {
1318
+ return BATTERY_CHARGE_STATE.IS_AT_FULL_CHARGE;
1319
+ }
1320
+ if (chargeStatus !== null) {
1321
+ return chargeStatus !== 0
1322
+ ? BATTERY_CHARGE_STATE.IS_CHARGING
1323
+ : BATTERY_CHARGE_STATE.IS_NOT_CHARGING;
1324
+ }
1325
+ if (state === 8) {
1326
+ return BATTERY_CHARGE_STATE.IS_CHARGING;
1327
+ }
1328
+ return BATTERY_CHARGE_STATE.UNKNOWN;
1329
+ }
1330
+ getBatteryTimeToFullCharge(battery, chargeState) {
1331
+ if (battery === null) {
1332
+ return null;
1333
+ }
1334
+ if (chargeState === BATTERY_CHARGE_STATE.IS_AT_FULL_CHARGE) {
1335
+ return 0;
1336
+ }
1337
+ if (chargeState !== BATTERY_CHARGE_STATE.IS_CHARGING) {
1338
+ return null;
1339
+ }
1340
+ return (Math.ceil(100 - battery) * BATTERY_ESTIMATED_CHARGE_SECONDS_PER_PERCENT);
1341
+ }
1342
+ getOperationalState(state = this.getNumberStatus("state"), chargeStatus = this.getNumberStatus("charge_status")) {
1343
+ const operationalState = this.getRoborockOperationalState(state, chargeStatus);
1344
+ return this.toControllerOperationalState(operationalState);
1345
+ }
1346
+ getRoborockOperationalState(state, chargeStatus) {
1347
+ switch (state) {
1348
+ case 5: // Cleaning
1349
+ case 11: // Spot Cleaning
1350
+ case 16: // Go To
1351
+ case 17: // Zone Clean
1352
+ case 18: // Room Clean
1353
+ case 4: // Remote Control
1354
+ case 7: // Manual Mode
1355
+ return RVC_OPERATIONAL_STATE.RUNNING;
1356
+ case 10: // Paused
1357
+ return RVC_OPERATIONAL_STATE.PAUSED;
1358
+ case 6: // Returning Dock
1359
+ case 15: // Docking
1360
+ case 26: // Going to wash the mop
1361
+ return RVC_OPERATIONAL_STATE.SEEKING_CHARGER;
1362
+ case 8: // Charging
1363
+ return RVC_OPERATIONAL_STATE.CHARGING;
1364
+ case 9: // Charging Error
1365
+ case 12: // In Error
1366
+ return RVC_OPERATIONAL_STATE.ERROR;
1367
+ case 22: // Emptying dust container
1368
+ return RVC_OPERATIONAL_STATE.EMPTYING_DUST_BIN;
1369
+ case 23: // Washing the mop
1370
+ return RVC_OPERATIONAL_STATE.CLEANING_MOP;
1371
+ case 29: // Mapping
1372
+ return RVC_OPERATIONAL_STATE.UPDATING_MAPS;
1373
+ case 100: // Fully Charged
1374
+ return RVC_OPERATIONAL_STATE.DOCKED;
1375
+ default:
1376
+ if (chargeStatus !== null && chargeStatus !== 0) {
1377
+ return RVC_OPERATIONAL_STATE.CHARGING;
1378
+ }
1379
+ return RVC_OPERATIONAL_STATE.STOPPED;
1380
+ }
1381
+ }
1382
+ toControllerOperationalState(operationalState) {
1383
+ if (this.isExtendedOperationalStateEnabled() &&
1384
+ operationalState === RVC_OPERATIONAL_STATE.SEEKING_CHARGER) {
1385
+ return operationalState;
1386
+ }
1387
+ if (this.isChargingDockedStateEnabled() &&
1388
+ (operationalState === RVC_OPERATIONAL_STATE.CHARGING ||
1389
+ operationalState === RVC_OPERATIONAL_STATE.DOCKED)) {
1390
+ // Opt-in: report real charging/docked states so Apple Home shows
1391
+ // "Charging"/"Docked" on the tile instead of "Ready". The battery
1392
+ // percentage is the discriminator between the two: worn batteries can
1393
+ // make the robot claim "fully charged" (or drop the charging flag)
1394
+ // early, so trust the percentage against the configured threshold and
1395
+ // only fall back to the state-based value when no battery reading is
1396
+ // available.
1397
+ return this.resolveChargingDockedDisplayState(operationalState);
1398
+ }
1399
+ switch (operationalState) {
1400
+ case RVC_OPERATIONAL_STATE.ERROR:
1401
+ return RVC_OPERATIONAL_STATE.STOPPED;
1402
+ case RVC_OPERATIONAL_STATE.SEEKING_CHARGER:
1403
+ return RVC_OPERATIONAL_STATE.STOPPED;
1404
+ case RVC_OPERATIONAL_STATE.EMPTYING_DUST_BIN:
1405
+ case RVC_OPERATIONAL_STATE.CLEANING_MOP:
1406
+ case RVC_OPERATIONAL_STATE.UPDATING_MAPS:
1407
+ return RVC_OPERATIONAL_STATE.RUNNING;
1408
+ case RVC_OPERATIONAL_STATE.CHARGING:
1409
+ case RVC_OPERATIONAL_STATE.DOCKED:
1410
+ return RVC_OPERATIONAL_STATE.STOPPED;
1411
+ default:
1412
+ return operationalState;
1413
+ }
1414
+ }
1415
+ isInCleaningRunMode(operationalState) {
1416
+ switch (operationalState) {
1417
+ case RVC_OPERATIONAL_STATE.RUNNING:
1418
+ case RVC_OPERATIONAL_STATE.PAUSED:
1419
+ case RVC_OPERATIONAL_STATE.SEEKING_CHARGER:
1420
+ case RVC_OPERATIONAL_STATE.EMPTYING_DUST_BIN:
1421
+ case RVC_OPERATIONAL_STATE.CLEANING_MOP:
1422
+ case RVC_OPERATIONAL_STATE.UPDATING_MAPS:
1423
+ return true;
1424
+ default:
1425
+ return false;
1426
+ }
1427
+ }
1428
+ rememberLiveStatus(property, value) {
1429
+ if (value !== null) {
1430
+ this.liveStatus.set(property, value);
1431
+ this.liveStatusUpdatedAt = Date.now();
1432
+ }
1433
+ }
1434
+ rememberHomeDataStatus(data) {
1435
+ const message = this.asRecord(data);
1436
+ const value = message === null || message === void 0 ? void 0 : message.val;
1437
+ if (typeof value !== "string") {
1438
+ return;
1439
+ }
1440
+ let homeData;
1441
+ try {
1442
+ homeData = JSON.parse(value);
1443
+ }
1444
+ catch (_a) {
1445
+ return;
1446
+ }
1447
+ const home = this.asRecord(homeData);
1448
+ const devices = Array.isArray(home === null || home === void 0 ? void 0 : home.devices) ? home.devices : [];
1449
+ const device = devices
1450
+ .map((entry) => this.asRecord(entry))
1451
+ .find((entry) => (entry === null || entry === void 0 ? void 0 : entry.duid) === this.getDuid());
1452
+ const deviceStatus = this.asRecord(device === null || device === void 0 ? void 0 : device.deviceStatus);
1453
+ if (!deviceStatus) {
1454
+ return;
1455
+ }
1456
+ this.rememberLiveStatus("state", this.getNumberFromValue(deviceStatus.state));
1457
+ this.rememberLiveStatus("battery", this.getNumberFromValue(deviceStatus.battery));
1458
+ this.rememberLiveStatus("charge_status", this.getNumberFromValue(deviceStatus.charge_status));
1459
+ }
1460
+ getNumberStatus(property) {
1461
+ // Prefer the freshest value from a live message, falling back to the
1462
+ // HomeData snapshot for properties live messages do not carry.
1463
+ // A stale live cache must not shadow the periodically refreshed cloud
1464
+ // snapshot forever (dead poller, connectivity loss): live values older
1465
+ // than the staleness window fall back to HomeData, which self-heals.
1466
+ const liveValue = this.liveStatus.get(property);
1467
+ if (liveValue !== undefined &&
1468
+ Date.now() - this.liveStatusUpdatedAt < LIVE_STATUS_STALENESS_MS) {
1469
+ return liveValue;
1470
+ }
1471
+ const value = this.api.getVacuumDeviceStatus(this.getDuid(), property);
1472
+ return this.getNumberFromValue(value);
1473
+ }
1474
+ getNumberFromValue(value) {
1475
+ if (typeof value === "number" && Number.isFinite(value)) {
1476
+ return value;
1477
+ }
1478
+ if (typeof value === "string" && value.trim() !== "") {
1479
+ const parsed = Number(value);
1480
+ return Number.isFinite(parsed) ? parsed : null;
1481
+ }
1482
+ return null;
1483
+ }
1484
+ extractStatusUpdate(data) {
1485
+ const rootMessage = this.asRecord(data);
1486
+ const dps = this.asRecord(rootMessage === null || rootMessage === void 0 ? void 0 : rootMessage.dps);
1487
+ if (dps) {
1488
+ const status = {};
1489
+ if (Object.prototype.hasOwnProperty.call(dps, "121")) {
1490
+ status.state = dps["121"];
1491
+ }
1492
+ if (Object.prototype.hasOwnProperty.call(dps, "122")) {
1493
+ status.battery = dps["122"];
1494
+ }
1495
+ if (Object.prototype.hasOwnProperty.call(dps, "123")) {
1496
+ status.charge_status = dps["123"];
1497
+ }
1498
+ return Object.keys(status).length > 0 ? status : null;
1499
+ }
1500
+ const payload = Array.isArray(data) ? data : data ? [data] : [];
1501
+ const message = this.asRecord(payload[0]);
1502
+ if (!message) {
1503
+ return null;
1504
+ }
1505
+ const hasStatus = Object.prototype.hasOwnProperty.call(message, "state") ||
1506
+ Object.prototype.hasOwnProperty.call(message, "battery") ||
1507
+ Object.prototype.hasOwnProperty.call(message, "charge_status") ||
1508
+ Object.prototype.hasOwnProperty.call(message, "clean_area") ||
1509
+ Object.prototype.hasOwnProperty.call(message, "clean_time");
1510
+ return hasStatus ? message : null;
1511
+ }
1512
+ getLiveMessageForThisAccessory(data) {
1513
+ return (0, live_message_1.getLiveMessageForThisAccessory)(data, {
1514
+ getDuid: () => this.getDuid(),
1515
+ getVacuumName: () => this.getVacuumName(),
1516
+ shouldAcceptUnscopedLiveMessage: () => this.platform.shouldAcceptUnscopedLiveMessage(),
1517
+ logDebug: (message) => this.platform.log.debug(message),
1518
+ });
1519
+ }
1520
+ asRecord(value) {
1521
+ return value !== null && typeof value === "object"
1522
+ ? value
1523
+ : null;
1524
+ }
1525
+ setOptimisticState(partialClusters, action) {
1526
+ this.optimisticClusters = this.mergeClusterState(this.getActiveOptimisticState() || {}, partialClusters);
1527
+ this.optimisticExpiresAt = Date.now() + OPTIMISTIC_STATE_TTL_MS;
1528
+ this.optimisticGeneration += 1;
1529
+ this.optimisticAction = action;
1530
+ this.contradictingLiveStateCount = 0;
1531
+ return this.optimisticGeneration;
1532
+ }
1533
+ reconcileOptimisticStateWithLive(operationalState, roborockState, chargeStatus) {
1534
+ var _a;
1535
+ const optimistic = this.getActiveOptimisticState();
1536
+ const expected = (_a = optimistic === null || optimistic === void 0 ? void 0 : optimistic.rvcOperationalState) === null || _a === void 0 ? void 0 : _a.operationalState;
1537
+ if (typeof expected !== "number") {
1538
+ this.contradictingLiveStateCount = 0;
1539
+ return;
1540
+ }
1541
+ if (this.doesLiveStateConfirmOptimisticState(expected, operationalState, roborockState, chargeStatus)) {
1542
+ this.clearOptimisticState();
1543
+ return;
1544
+ }
1545
+ // While a start/resume/area-clean is still spinning up, cloud-only models
1546
+ // (e.g. S8 / roborock.vacuum.a51) keep reporting docked/charging for tens of
1547
+ // seconds before they report Cleaning. During the recent-command window,
1548
+ // treat those lagging reports as transitional rather than contradictions, so
1549
+ // the optimistic Cleaning state is not starved and Apple Home does not snap
1550
+ // the tile back to Docked right after Start (issue #4).
1551
+ if (expected === RVC_OPERATIONAL_STATE.RUNNING &&
1552
+ this.isRoborockDockedOrCharging(roborockState, chargeStatus) &&
1553
+ this.hasRecentlyCommandedCleaning()) {
1554
+ this.contradictingLiveStateCount = 0;
1555
+ return;
1556
+ }
1557
+ // The command was acknowledged but the robot reports a different state.
1558
+ // Tolerate a couple of transitional reports, then trust the live state so
1559
+ // an optimistic value cannot stay stuck until the TTL expires (e.g. a start
1560
+ // the robot ignored because the bin is full or it is off the dock).
1561
+ this.contradictingLiveStateCount += 1;
1562
+ if (this.contradictingLiveStateCount >= OPTIMISTIC_CONTRADICTION_LIMIT) {
1563
+ this.platform.log.debug(`Clearing optimistic Matter state for ${this.getVacuumName()} after ${this.contradictingLiveStateCount} contradicting Roborock updates (expected ${expected}, got ${operationalState}).`);
1564
+ this.clearOptimisticState();
1565
+ }
1566
+ }
1567
+ doesLiveStateConfirmOptimisticState(expected, actual, roborockState, chargeStatus) {
1568
+ if (expected === actual) {
1569
+ return true;
1570
+ }
1571
+ if (this.optimisticAction === "return to dock" &&
1572
+ expected === RVC_OPERATIONAL_STATE.RUNNING &&
1573
+ !this.isInCleaningRunMode(actual) &&
1574
+ this.isRoborockDockedOrCharging(roborockState, chargeStatus)) {
1575
+ return true;
1576
+ }
1577
+ if (expected === RVC_OPERATIONAL_STATE.RUNNING &&
1578
+ this.isInCleaningRunMode(actual)) {
1579
+ return true;
1580
+ }
1581
+ if (expected === RVC_OPERATIONAL_STATE.STOPPED &&
1582
+ !this.isInCleaningRunMode(actual)) {
1583
+ return true;
1584
+ }
1585
+ return (expected === RVC_OPERATIONAL_STATE.SEEKING_CHARGER &&
1586
+ (actual === RVC_OPERATIONAL_STATE.CHARGING ||
1587
+ actual === RVC_OPERATIONAL_STATE.DOCKED));
1588
+ }
1589
+ isRoborockDockedOrCharging(roborockState, chargeStatus) {
1590
+ return roborockState === 8 || roborockState === 100 || !!chargeStatus;
1591
+ }
1592
+ isDockedOrChargingNow() {
1593
+ return this.isRoborockDockedOrCharging(this.getNumberStatus("state"), this.getNumberStatus("charge_status"));
1594
+ }
1595
+ async publishCurrentMatterState(reason, options = {}) {
1596
+ if (options.clearOptimistic === true) {
1597
+ this.clearOptimisticState();
1598
+ }
1599
+ const updated = await this.updateMatterState(this.buildClusters(), reason);
1600
+ if (updated) {
1601
+ this.ensureMatterStateHeartbeat();
1602
+ }
1603
+ }
1604
+ ensureMatterStateHeartbeat() {
1605
+ if (!this.registered || this.matterStateHeartbeatTimer) {
1606
+ return;
1607
+ }
1608
+ const heartbeatTimer = scheduleTimer(() => {
1609
+ this.matterStateHeartbeatTimer = null;
1610
+ void this.publishCurrentMatterState("Matter state heartbeat")
1611
+ .catch((error) => {
1612
+ this.platform.log.debug(`Unable to publish Matter state heartbeat for ${this.getVacuumName()}: ${this.getErrorMessage(error)}`);
1613
+ })
1614
+ .finally(() => {
1615
+ // Re-arm even after a failed or suppressed publish. Previously the
1616
+ // heartbeat chain only continued after a successful publish, so one
1617
+ // transient Matter error silently disabled the safety net until the
1618
+ // next live Roborock message happened to arrive.
1619
+ if (this.registered) {
1620
+ this.ensureMatterStateHeartbeat();
1621
+ }
1622
+ });
1623
+ }, MATTER_STATE_HEARTBEAT_INTERVAL_MS);
1624
+ this.matterStateHeartbeatTimer = heartbeatTimer;
1625
+ unrefTimer(heartbeatTimer);
1626
+ }
1627
+ applyOptimisticState(clusters) {
1628
+ const optimistic = this.getActiveOptimisticState();
1629
+ return optimistic ? this.mergeClusterState(clusters, optimistic) : clusters;
1630
+ }
1631
+ getActiveOptimisticState() {
1632
+ if (!this.optimisticClusters) {
1633
+ return null;
1634
+ }
1635
+ if (Date.now() > this.optimisticExpiresAt) {
1636
+ this.clearOptimisticState();
1637
+ return null;
1638
+ }
1639
+ return this.optimisticClusters;
1640
+ }
1641
+ clearOptimisticState() {
1642
+ this.optimisticClusters = null;
1643
+ this.optimisticExpiresAt = 0;
1644
+ this.optimisticAction = null;
1645
+ this.optimisticGeneration += 1;
1646
+ this.contradictingLiveStateCount = 0;
1647
+ }
1648
+ dispatchRoborockMatterCommand(action, command, options = {}) {
1649
+ const startedAt = Date.now();
1650
+ void command()
1651
+ .then(() => {
1652
+ this.logMatterCommandDuration(action, startedAt);
1653
+ this.schedulePostCommandStatusRefresh(action);
1654
+ })
1655
+ .catch(async (error) => {
1656
+ if (this.isDeviceNotReadyError(error)) {
1657
+ // The command raced a plugin restart: Roborock login/device setup
1658
+ // has not finished yet. Log calmly, roll the optimistic state back,
1659
+ // and let the user retry once startup completes instead of showing
1660
+ // a scary error with a misleading stack.
1661
+ this.platform.log.warn(`Matter ${action} command for ${this.getVacuumName()} arrived before the Roborock connection finished starting up. Try again in a few seconds. ${this.getErrorMessage(error)}`);
1662
+ await this.recoverMatterStateAfterFailedCommand(action);
1663
+ return;
1664
+ }
1665
+ if (this.isMatterCommandTimeoutError(error)) {
1666
+ this.platform.log.warn(`Matter ${action} command for ${this.getVacuumName()} was sent but Roborock did not acknowledge it before timeout: ${this.getErrorMessage(error)}. Keeping the optimistic Matter state and actively refreshing Roborock status.`);
1667
+ this.schedulePostCommandStatusRefresh(action, {
1668
+ acknowledgementTimedOut: true,
1669
+ });
1670
+ if (options.retryReturnToDockIfStillActive) {
1671
+ this.scheduleReturnToDockRetry(command);
1672
+ }
1673
+ return;
1674
+ }
1675
+ this.platform.log.error(`Error sending Matter ${action} command to ${this.getVacuumName()}: ${this.getErrorMessage(error)}`);
1676
+ await this.recoverMatterStateAfterFailedCommand(action);
1677
+ });
1678
+ }
1679
+ scheduleReturnToDockRetry(command) {
1680
+ if (this.returnToDockRetryPending) {
1681
+ return;
1682
+ }
1683
+ this.returnToDockRetryPending = true;
1684
+ const retryTimer = scheduleTimer(() => {
1685
+ this.returnToDockRetryPending = false;
1686
+ void this.refreshMatterStatusBeforeRetry("return to dock retry")
1687
+ .then(() => {
1688
+ if (!this.shouldRetryReturnToDock()) {
1689
+ this.platform.log.debug(`Skipping Matter return to dock retry for ${this.getVacuumName()} because Roborock no longer reports active cleaning.`);
1690
+ return;
1691
+ }
1692
+ const startedAt = Date.now();
1693
+ this.platform.log.warn(`Retrying Matter return to dock command for ${this.getVacuumName()} because Roborock still reports active cleaning after the first command timed out.`);
1694
+ return command()
1695
+ .then(() => {
1696
+ this.logMatterCommandDuration("return to dock retry", startedAt);
1697
+ this.schedulePostCommandStatusRefresh("return to dock retry");
1698
+ })
1699
+ .catch(async (error) => {
1700
+ if (this.isMatterCommandTimeoutError(error)) {
1701
+ this.platform.log.warn(`Matter return to dock retry for ${this.getVacuumName()} was sent but Roborock did not acknowledge it before timeout: ${this.getErrorMessage(error)}. Keeping the optimistic Matter state and actively refreshing Roborock status.`);
1702
+ this.schedulePostCommandStatusRefresh("return to dock retry", {
1703
+ acknowledgementTimedOut: true,
1704
+ });
1705
+ return;
1706
+ }
1707
+ this.platform.log.error(`Error sending Matter return to dock retry to ${this.getVacuumName()}: ${this.getErrorMessage(error)}`);
1708
+ await this.recoverMatterStateAfterFailedCommand("return to dock retry");
1709
+ });
1710
+ })
1711
+ .catch((error) => {
1712
+ this.platform.log.debug(`Unable to evaluate Matter return to dock retry for ${this.getVacuumName()}: ${this.getErrorMessage(error)}`);
1713
+ });
1714
+ }, MATTER_RETURN_TO_DOCK_RETRY_DELAY_MS);
1715
+ unrefTimer(retryTimer);
1716
+ }
1717
+ async refreshMatterStatusBeforeRetry(reason) {
1718
+ const refreshStatus = this.api.getStatus;
1719
+ if (typeof refreshStatus !== "function") {
1720
+ return;
1721
+ }
1722
+ await refreshStatus.call(this.api, this.getDuid(), this.getMatterStatusRefreshOptions());
1723
+ await this.updateMatterStateFromRoborock();
1724
+ }
1725
+ shouldRetryReturnToDock() {
1726
+ const state = this.getNumberStatus("state");
1727
+ const chargeStatus = this.getNumberStatus("charge_status");
1728
+ if (this.isRoborockDockedOrCharging(state, chargeStatus)) {
1729
+ return false;
1730
+ }
1731
+ return this.isRoborockActivelyCleaningAwayFromDock(state);
1732
+ }
1733
+ isRoborockActivelyCleaningAwayFromDock(state) {
1734
+ switch (state) {
1735
+ case 4: // Remote Control
1736
+ case 5: // Cleaning
1737
+ case 7: // Manual Mode
1738
+ case 10: // Paused
1739
+ case 11: // Spot Cleaning
1740
+ case 16: // Go To
1741
+ case 17: // Zone Clean
1742
+ case 18: // Room Clean
1743
+ case 29: // Mapping
1744
+ return true;
1745
+ default:
1746
+ return false;
1747
+ }
1748
+ }
1749
+ async recoverMatterStateAfterFailedCommand(action) {
1750
+ try {
1751
+ await this.publishCurrentMatterState(`${action} command failure recovery`, { clearOptimistic: true });
1752
+ }
1753
+ catch (error) {
1754
+ this.platform.log.warn(`Unable to recover Matter state after failed ${action} command for ${this.getVacuumName()}: ${this.getErrorMessage(error)}`);
1755
+ }
1756
+ }
1757
+ isMatterCommandTimeoutError(error) {
1758
+ return /timed out after \d+ seconds/.test(this.getErrorMessage(error));
1759
+ }
1760
+ isDeviceNotReadyError(error) {
1761
+ if (error !== null &&
1762
+ typeof error === "object" &&
1763
+ "code" in error &&
1764
+ error.code === "ROBOROCK_DEVICE_NOT_READY") {
1765
+ return true;
1766
+ }
1767
+ // Also match the upstream phrasing used by getServerTimers and
1768
+ // updateServerTimer ("Vacuum <duid> is not initialized.").
1769
+ return /is not initialized/i.test(this.getErrorMessage(error));
1770
+ }
1771
+ isMatterInitializingError(error) {
1772
+ return /\bis still initializing\b/i.test(this.getErrorMessage(error));
1773
+ }
1774
+ scheduleMatterInitializationRetry(reason, error) {
1775
+ if (this.matterInitializationRetryPending) {
1776
+ return;
1777
+ }
1778
+ const delayMs = MATTER_INITIALIZATION_RETRY_DELAYS_MS[this.matterInitializationRetryAttempt];
1779
+ if (delayMs === undefined) {
1780
+ this.platform.log.debug(`Matter state update after ${reason} for ${this.getVacuumName()} is still waiting on Homebridge endpoint initialization; suppressing additional startup retries. Last error: ${this.getErrorMessage(error)}`);
1781
+ return;
1782
+ }
1783
+ this.matterInitializationRetryAttempt += 1;
1784
+ this.matterInitializationRetryPending = true;
1785
+ this.platform.log.debug(`Matter state update after ${reason} for ${this.getVacuumName()} was delayed because Homebridge says the endpoint is still initializing; retrying in ${delayMs} ms.`);
1786
+ const retryTimer = scheduleTimer(() => {
1787
+ this.matterInitializationRetryPending = false;
1788
+ this.scheduleMatterStateRefresh(`endpoint initialization retry (${reason})`);
1789
+ }, delayMs);
1790
+ unrefTimer(retryTimer);
1791
+ }
1792
+ logMatterCommandDuration(action, startedAt) {
1793
+ const durationMs = Date.now() - startedAt;
1794
+ const transport = this.getTransportDescription();
1795
+ const message = `Matter ${action} command for ${this.getVacuumName()} was acknowledged ` +
1796
+ `by Roborock in ${durationMs} ms${transport ? ` via ${transport}` : ""}.`;
1797
+ if (durationMs >= SLOW_MATTER_COMMAND_MS) {
1798
+ this.platform.log.warn(`Slow ${message}`);
1799
+ return;
1800
+ }
1801
+ this.platform.log.info(message);
1802
+ }
1803
+ schedulePostCommandStatusRefresh(action, options = {}) {
1804
+ const refreshStatus = this.api.getStatus;
1805
+ if (!this.registered || typeof refreshStatus !== "function") {
1806
+ return;
1807
+ }
1808
+ const refreshDelays = options.acknowledgementTimedOut
1809
+ ? MATTER_AMBIGUOUS_COMMAND_STATUS_REFRESH_DELAYS_MS
1810
+ : action === "return to dock"
1811
+ ? MATTER_RETURN_TO_DOCK_STATUS_REFRESH_DELAYS_MS
1812
+ : MATTER_COMMAND_STATUS_REFRESH_DELAYS_MS;
1813
+ for (const delayMs of refreshDelays) {
1814
+ const refreshTimer = scheduleTimer(() => {
1815
+ void refreshStatus
1816
+ .call(this.api, this.getDuid(), this.getMatterStatusRefreshOptions())
1817
+ .then(() => this.updateMatterStateFromRoborock())
1818
+ .catch((error) => {
1819
+ this.platform.log.debug(`Unable to refresh Matter status after ${action} for ${this.getVacuumName()}: ${this.getErrorMessage(error)}`);
1820
+ });
1821
+ }, delayMs);
1822
+ unrefTimer(refreshTimer);
1823
+ }
1824
+ }
1825
+ getMatterStatusRefreshOptions() {
1826
+ const options = { force: true };
1827
+ if (this.platform.platformConfig.preferCloudForMatterCommands) {
1828
+ options.preferCloud = true;
1829
+ }
1830
+ return options;
1831
+ }
1832
+ getTransportDescription() {
1833
+ const diagnostics = typeof this.api.getTransportDiagnostics === "function"
1834
+ ? this.api.getTransportDiagnostics()
1835
+ : null;
1836
+ const transport = diagnostics && typeof diagnostics === "object"
1837
+ ? diagnostics[this.getDuid()]
1838
+ : null;
1839
+ if (!transport || typeof transport !== "object") {
1840
+ return "";
1841
+ }
1842
+ const lastTransport = "lastTransport" in transport ? String(transport.lastTransport) : "";
1843
+ const lastReason = "lastTransportReason" in transport
1844
+ ? String(transport.lastTransportReason)
1845
+ : "";
1846
+ if (lastTransport && lastReason) {
1847
+ return `${lastTransport} (${lastReason})`;
1848
+ }
1849
+ return lastTransport;
1850
+ }
1851
+ getErrorMessage(error) {
1852
+ if (error === undefined || error === null) {
1853
+ return "unknown error";
1854
+ }
1855
+ return error instanceof Error ? error.message : String(error);
1856
+ }
1857
+ mergeClusterState(base, override) {
1858
+ const merged = { ...base };
1859
+ for (const [cluster, attributes] of Object.entries(override)) {
1860
+ merged[cluster] = {
1861
+ ...(merged[cluster] || {}),
1862
+ ...attributes,
1863
+ };
1864
+ }
1865
+ return merged;
1866
+ }
1867
+ getVacuumName() {
1868
+ return (this.api.getVacuumDeviceInfo(this.getDuid(), "name") ||
1869
+ this.accessory.displayName ||
1870
+ "Roborock vacuum");
1871
+ }
1872
+ getDuid() {
1873
+ return String(this.accessory.context.duid);
1874
+ }
1875
+ }
1876
+ exports.default = RoborockMatterVacuumAccessory;
1877
+ //# sourceMappingURL=matter_vacuum_accessory.js.map