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.
- package/.gitattributes +13 -0
- package/CHANGELOG.md +469 -0
- package/LICENSE +23 -0
- package/README.md +45 -0
- package/__tests__/testdata/b01_q7_get_prop_response.json +1 -0
- package/__tests__/testdata/b01_q7_map_fixture.json +16 -0
- package/config.schema.json +118 -0
- package/dist/crypto.js +65 -0
- package/dist/crypto.js.map +1 -0
- package/dist/index.js +10 -0
- package/dist/index.js.map +1 -0
- package/dist/live_message.js +25 -0
- package/dist/live_message.js.map +1 -0
- package/dist/logger.js +37 -0
- package/dist/logger.js.map +1 -0
- package/dist/matter_vacuum_accessory.js +1877 -0
- package/dist/matter_vacuum_accessory.js.map +1 -0
- package/dist/platform.js +441 -0
- package/dist/platform.js.map +1 -0
- package/dist/settings.js +8 -0
- package/dist/settings.js.map +1 -0
- package/dist/types.js +3 -0
- package/dist/types.js.map +1 -0
- package/dist/ui/index.js +741 -0
- package/dist/ui/index.js.map +1 -0
- package/docs/matter-rvc-updating-homebridge-report.md +120 -0
- package/homebridge-roborock-vacuum.code-workspace +7 -0
- package/homebridge-ui/public/index.html +257 -0
- package/homebridge-ui/public/index.js +1276 -0
- package/homebridge-ui/public/styles.css +611 -0
- package/homebridge-ui/server.js +3 -0
- package/package.json +91 -0
- package/roborockLib/i18n/de/translations.json +188 -0
- package/roborockLib/i18n/en/translations.json +208 -0
- package/roborockLib/i18n/es/translations.json +188 -0
- package/roborockLib/i18n/fr/translations.json +188 -0
- package/roborockLib/i18n/it/translations.json +188 -0
- package/roborockLib/i18n/nl/translations.json +188 -0
- package/roborockLib/i18n/pl/translations.json +188 -0
- package/roborockLib/i18n/pt/translations.json +188 -0
- package/roborockLib/i18n/ru/translations.json +188 -0
- package/roborockLib/i18n/uk/translations.json +188 -0
- package/roborockLib/i18n/zh-cn/translations.json +188 -0
- package/roborockLib/lib/RRMapParser.js +459 -0
- package/roborockLib/lib/b01Q7Adapter.js +468 -0
- package/roborockLib/lib/deviceFeatures.js +1349 -0
- package/roborockLib/lib/localConnector.js +629 -0
- package/roborockLib/lib/map/map.html +137 -0
- package/roborockLib/lib/map/zones.js +851 -0
- package/roborockLib/lib/message.js +402 -0
- package/roborockLib/lib/messageQueueHandler.js +411 -0
- package/roborockLib/lib/roborockAuth.js +178 -0
- package/roborockLib/lib/roborockCrypto.js +55 -0
- package/roborockLib/lib/roborockPackageHelper.js +142 -0
- package/roborockLib/lib/roborock_mqtt_connector.js +567 -0
- package/roborockLib/lib/sniffing/mitmproxy_roborock.py +300 -0
- package/roborockLib/lib/vacuum.js +992 -0
- package/roborockLib/roborockAPI.js +3635 -0
- 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
|