homebridge-roborock-vacuum 2.2.6 → 2.2.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +10 -0
- package/README.md +27 -0
- package/package.json +1 -1
- package/roborockLib/lib/vacuum.js +3 -0
- package/roborockLib/roborockAPI.js +529 -160
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,15 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 2.2.7
|
|
4
|
+
|
|
5
|
+
- **Fix (connectivity)**: The plugin no longer fails to start when the Roborock cloud is unreachable. Startup used to abandon everything after the home-data request timed out (`AxiosError: timeout of 20000ms exceeded`, `ECONNRESET`), so no accessories were registered and every command was refused with "Adapter not inited" — even though the vacuum was reachable on the LAN the whole time. The home data (device list and local keys) is now cached on disk, and when the cloud fetch fails the plugin starts in **local-only mode** from that cache: it still runs LAN discovery, connects to each vacuum over the local protocol, publishes the accessories, and accepts start/stop/dock commands. Cloud-only features (scenes, push updates, fresh online flags) resume by themselves once the cloud answers again, without a Homebridge restart. One successful online start is needed to seed the cache; with no cache the plugin stays idle as before, now with a message that says why
|
|
6
|
+
- **Fix (connectivity)**: A vacuum the plugin is locally connected to is treated as online even when the cached cloud status says otherwise, so commands and polling are not blocked by a stale offline flag during a cloud outage
|
|
7
|
+
- **Fix (connectivity)**: Local-only mode no longer depends on UDP discovery finding the vacuums. Each device's last known LAN address is cached next to the device list, so vacuums on another VLAN (or when port 58866 is taken by another integration on the same host) are still dialed directly while the cloud is down
|
|
8
|
+
- **Fix (connectivity)**: Local-only mode also works without a cloud session. When the login itself fails while the cloud is down (or the cached session was cleared), the plugin still starts from the cache and logs in again on the periodic refresh once the cloud answers. Only network-level login failures are retried; a rejected login or a pending two-factor prompt is left to the Config UI
|
|
9
|
+
- **Fix (connectivity)**: A degraded cloud no longer costs you the session. The cached session is cleared only when the login API explicitly rejects it with an API error code. An empty or malformed answer, and a `401` from the device API that the login API does not confirm, are treated as transient and retried
|
|
10
|
+
- **Fix (connectivity)**: The cached device list is tagged with the account it was fetched for and ignored after switching accounts, so a local-only start never controls another account's vacuums
|
|
11
|
+
- **Fix (logging)**: During an outage the periodic home-data refresh logs its failure once at error level and then at debug level until the cloud answers again, instead of an error every interval. The signed device API now times out after 20 s instead of hanging on a black-holed connection
|
|
12
|
+
|
|
3
13
|
## 2.2.6
|
|
4
14
|
|
|
5
15
|
- **New Feature**: The Roborock region is now detected automatically from the account email when no endpoint is configured — the config UI's Region selector gains an "Auto detect (recommended)" option and new installs no longer need to know which server their account lives on. An explicitly chosen region keeps working exactly as before, and if detection fails the plugin falls back to the previous US default with a hint to pin the region manually. Two-factor login also benefits: it now sends the account's real registered country from the detection response instead of guessing it from the endpoint host (which mapped every EU account to Germany)
|
package/README.md
CHANGED
|
@@ -115,6 +115,33 @@ Pairing is one-time per node — switching a device between HAP and Matter later
|
|
|
115
115
|
- Scene buttons require the **separate bridge pairing** (step 5) and appear as plain on/off switches in the controller.
|
|
116
116
|
- Multi-floor maps are not exposed (rooms come from the currently active map), and zone cleaning, consumables and dock controls other than the mop-wash switch are not available over Matter yet.
|
|
117
117
|
|
|
118
|
+
## Cloud outages and local-only mode
|
|
119
|
+
|
|
120
|
+
Roborock's cloud is required to log in and to read your device list, but it is
|
|
121
|
+
not required to _control_ a vacuum: once the plugin knows a device's local key,
|
|
122
|
+
it talks to the robot directly over your LAN.
|
|
123
|
+
|
|
124
|
+
The plugin caches the device list, the local keys and each vacuum's last known
|
|
125
|
+
LAN address on disk (owner-readable only) after every successful cloud
|
|
126
|
+
refresh. If the Roborock cloud is unreachable when Homebridge starts, the
|
|
127
|
+
plugin falls back to that cache and starts in **local-only mode**:
|
|
128
|
+
|
|
129
|
+
- your vacuums still appear in HomeKit, and start/stop/dock commands work over the LAN;
|
|
130
|
+
- vacuums are found by UDP discovery, or dialed at their cached address when
|
|
131
|
+
discovery finds nothing (another VLAN, or port 58866 in use);
|
|
132
|
+
- cloud-only features are unavailable until the cloud returns — scenes, push
|
|
133
|
+
status updates, and the cloud online flag;
|
|
134
|
+
- the plugin keeps retrying in the background, so cloud features come back on
|
|
135
|
+
their own without a Homebridge restart. If the login itself failed during
|
|
136
|
+
the outage, the plugin logs in again by itself once the cloud answers (a
|
|
137
|
+
pending two-factor prompt still needs the Config UI).
|
|
138
|
+
|
|
139
|
+
The log says which mode you are in (`Starting adapter finished in local-only
|
|
140
|
+
mode (cloud unreachable).`). Two caveats: the plugin needs **one successful
|
|
141
|
+
online start** to create the cache, so a brand-new install cannot start offline,
|
|
142
|
+
and a vacuum that is shared from another Roborock account can only be controlled
|
|
143
|
+
through the cloud.
|
|
144
|
+
|
|
118
145
|
## Dock mop-wash switch
|
|
119
146
|
|
|
120
147
|
On vacuums whose dock can wash the mop (G10, S7 Pro/MaxV Ultra, S7 Max Ultra, S8 Pro Ultra, Q Revo family, …) the plugin adds a switch that starts and stops a dock mop wash:
|
package/package.json
CHANGED
|
@@ -141,6 +141,9 @@ class vacuum {
|
|
|
141
141
|
// another account can never be local.
|
|
142
142
|
if (attribute == "ip" && !this.adapter.isSharedDevice(duid)) {
|
|
143
143
|
this.adapter.localDevices[duid] = networkInfo[attribute];
|
|
144
|
+
if (typeof this.adapter.persistLocalDevices === "function") {
|
|
145
|
+
this.adapter.persistLocalDevices();
|
|
146
|
+
}
|
|
144
147
|
}
|
|
145
148
|
this.adapter.setStateAsync(
|
|
146
149
|
`Devices.${duid}.networkInfo.${attribute}`,
|
|
@@ -126,6 +126,10 @@ class Roborock {
|
|
|
126
126
|
|
|
127
127
|
this.scenesData = null; // Store scenes data locally
|
|
128
128
|
|
|
129
|
+
// Resolved Roborock home id. May start null on a cloud-failed (local-only)
|
|
130
|
+
// startup; updateHomeData learns it once the cloud is reachable.
|
|
131
|
+
this.homeId = null;
|
|
132
|
+
|
|
129
133
|
this.name = "roborock";
|
|
130
134
|
this.deviceNotify = null;
|
|
131
135
|
this.baseURL = options.baseURL || "usiot.roborock.com";
|
|
@@ -162,9 +166,23 @@ class Roborock {
|
|
|
162
166
|
clearTimeout(timeout);
|
|
163
167
|
}
|
|
164
168
|
|
|
169
|
+
// State ids that are mirrored to disk so they survive a restart: the cloud
|
|
170
|
+
// session/client id, and the home data (device list + local keys), its
|
|
171
|
+
// home id and the devices' last LAN addresses, which let the plugin start
|
|
172
|
+
// over the LAN when the cloud is down.
|
|
173
|
+
isPersistedStateId(id) {
|
|
174
|
+
return (
|
|
175
|
+
id == "UserData" ||
|
|
176
|
+
id == "clientID" ||
|
|
177
|
+
id == "HomeData" ||
|
|
178
|
+
id == "HomeId" ||
|
|
179
|
+
id == "LocalDevices"
|
|
180
|
+
);
|
|
181
|
+
}
|
|
182
|
+
|
|
165
183
|
getStateAsync(id) {
|
|
166
184
|
try {
|
|
167
|
-
if (id
|
|
185
|
+
if (this.isPersistedStateId(id)) {
|
|
168
186
|
const persistPath = this.getPersistPath(id);
|
|
169
187
|
if (fs.existsSync(persistPath)) {
|
|
170
188
|
return JSON.parse(fs.readFileSync(persistPath, "utf8"));
|
|
@@ -182,7 +200,8 @@ class Roborock {
|
|
|
182
200
|
return legacyState;
|
|
183
201
|
}
|
|
184
202
|
|
|
185
|
-
|
|
203
|
+
// Fall back to the in-memory copy when nothing is on disk yet.
|
|
204
|
+
return this.states[id] !== undefined ? this.states[id] : null;
|
|
186
205
|
}
|
|
187
206
|
|
|
188
207
|
return this.states[id];
|
|
@@ -198,10 +217,11 @@ class Roborock {
|
|
|
198
217
|
|
|
199
218
|
async setStateAsync(id, state) {
|
|
200
219
|
try {
|
|
201
|
-
if (id
|
|
220
|
+
if (this.isPersistedStateId(id)) {
|
|
202
221
|
const persistPath = this.getPersistPath(id);
|
|
203
222
|
fs.mkdirSync(path.dirname(persistPath), { recursive: true });
|
|
204
|
-
// 0600: UserData
|
|
223
|
+
// 0600: UserData holds the cloud token/rriot secrets and HomeData
|
|
224
|
+
// holds per-device localKey secrets.
|
|
205
225
|
fs.writeFileSync(persistPath, JSON.stringify(state, null, 2), {
|
|
206
226
|
encoding: "utf8",
|
|
207
227
|
mode: 0o600,
|
|
@@ -221,11 +241,7 @@ class Roborock {
|
|
|
221
241
|
this.deviceNotify(id, state);
|
|
222
242
|
}
|
|
223
243
|
} catch (error) {
|
|
224
|
-
if (
|
|
225
|
-
(id == "UserData" || id == "clientID") &&
|
|
226
|
-
error &&
|
|
227
|
-
error.code == "EACCES"
|
|
228
|
-
) {
|
|
244
|
+
if (this.isPersistedStateId(id) && error && error.code == "EACCES") {
|
|
229
245
|
try {
|
|
230
246
|
const fallbackPath = path.join(
|
|
231
247
|
this.forceTemporaryPersistPath(),
|
|
@@ -250,6 +266,9 @@ class Roborock {
|
|
|
250
266
|
|
|
251
267
|
async deleteStateAsync(id) {
|
|
252
268
|
try {
|
|
269
|
+
// Only the session files are removed from disk. HomeData, HomeId and
|
|
270
|
+
// LocalDevices are the offline cache and deliberately survive a failed
|
|
271
|
+
// login; loadCachedHomeData rejects a cache from another account.
|
|
253
272
|
if (id == "UserData" || id == "clientID") {
|
|
254
273
|
const persistPath = this.getPersistPath(id);
|
|
255
274
|
if (fs.existsSync(persistPath)) {
|
|
@@ -461,30 +480,119 @@ class Roborock {
|
|
|
461
480
|
// api/v1/getUrlByEmail(email = ...)
|
|
462
481
|
|
|
463
482
|
const userdata = await this.getUserData(this.loginApi);
|
|
464
|
-
if (
|
|
483
|
+
if (userdata) {
|
|
484
|
+
this.setupCloudApi(userdata);
|
|
485
|
+
} else {
|
|
486
|
+
// No session (cloud unreachable, or 2FA pending): local control only
|
|
487
|
+
// needs the cached local keys and IPs, so try the local-only start.
|
|
488
|
+
// updateHomeData logs in again once the cloud answers.
|
|
465
489
|
this.log.error(
|
|
466
|
-
"Login failed or requires 2FA. Please complete authentication in the
|
|
490
|
+
"Login failed or requires 2FA. Please complete authentication in the " +
|
|
491
|
+
"Config UI. Trying to start in local-only mode from the cached " +
|
|
492
|
+
"device list."
|
|
467
493
|
);
|
|
468
494
|
await this.setStateAsync("info.connection", { val: false, ack: true });
|
|
469
|
-
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
// Fetch home data (device list + local keys). The cloud is tried first; on
|
|
498
|
+
// failure we fall back to the last cached copy so the plugin can still
|
|
499
|
+
// control the vacuums over the LAN while the cloud is unreachable.
|
|
500
|
+
let homeId = null;
|
|
501
|
+
let homedataResult = null;
|
|
502
|
+
let fromCache = false;
|
|
503
|
+
|
|
504
|
+
if (userdata) {
|
|
505
|
+
try {
|
|
506
|
+
const fetched = await this.fetchHomeDataFromCloud();
|
|
507
|
+
homeId = fetched.homeId;
|
|
508
|
+
homedataResult = fetched.homedataResult;
|
|
509
|
+
|
|
510
|
+
await this.setStateAsync(
|
|
511
|
+
"HomeData",
|
|
512
|
+
this.homeDataState(homedataResult)
|
|
513
|
+
);
|
|
514
|
+
if (homeId != null) {
|
|
515
|
+
await this.setStateAsync("HomeId", { val: homeId, ack: true });
|
|
516
|
+
}
|
|
517
|
+
} catch (error) {
|
|
518
|
+
this.log.error(
|
|
519
|
+
"Failed to fetch home data from the Roborock cloud: " +
|
|
520
|
+
(error && error.message ? error.message : error)
|
|
521
|
+
);
|
|
522
|
+
}
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
if (!homedataResult) {
|
|
526
|
+
const cached = this.loadCachedHomeData();
|
|
527
|
+
if (!cached) {
|
|
528
|
+
this.log.error(
|
|
529
|
+
"No cached device data is available, so the plugin cannot start " +
|
|
530
|
+
"offline. It needs at least one successful online startup to cache " +
|
|
531
|
+
"the device list and local keys. Staying idle until the Roborock " +
|
|
532
|
+
"cloud is reachable."
|
|
533
|
+
);
|
|
534
|
+
await this.setStateAsync("info.connection", { val: false, ack: true });
|
|
535
|
+
if (callback) {
|
|
536
|
+
callback();
|
|
537
|
+
}
|
|
538
|
+
return;
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
homedataResult = cached.homedataResult;
|
|
542
|
+
homeId = cached.homeId;
|
|
543
|
+
fromCache = true;
|
|
544
|
+
// The startup error above already said why; keep the periodic refresh
|
|
545
|
+
// quiet until the cloud answers again.
|
|
546
|
+
this.cloudUnreachable = true;
|
|
547
|
+
|
|
548
|
+
// Publish the cached copy as the live home data. Everything
|
|
549
|
+
// downstream - the device list, the protocol version used to frame
|
|
550
|
+
// local messages, and every status read - goes through
|
|
551
|
+
// getParsedHomeData, which reads this.states. Without this the plugin
|
|
552
|
+
// would look like it owns no devices and would unregister every
|
|
553
|
+
// cached accessory.
|
|
554
|
+
await this.setStateAsync("HomeData", this.homeDataState(homedataResult));
|
|
555
|
+
|
|
556
|
+
this.log.warn(
|
|
557
|
+
"Roborock cloud is unreachable - starting in LOCAL-ONLY mode using " +
|
|
558
|
+
"the last cached device list. Commands are sent over the LAN; " +
|
|
559
|
+
"cloud features (scenes, push updates) resume automatically once " +
|
|
560
|
+
"the cloud is reachable again."
|
|
561
|
+
);
|
|
470
562
|
}
|
|
471
563
|
|
|
472
564
|
try {
|
|
473
|
-
this.
|
|
565
|
+
await this.initializeFromHomeData(
|
|
566
|
+
userdata,
|
|
567
|
+
homedataResult,
|
|
568
|
+
homeId,
|
|
569
|
+
fromCache
|
|
570
|
+
);
|
|
474
571
|
} catch (error) {
|
|
475
572
|
this.log.error(
|
|
476
|
-
"Failed to
|
|
477
|
-
error
|
|
573
|
+
"Failed to initialize devices from home data: " +
|
|
574
|
+
(error && error.stack ? error.stack : error)
|
|
478
575
|
);
|
|
576
|
+
}
|
|
479
577
|
|
|
480
|
-
|
|
481
|
-
|
|
578
|
+
if (callback) {
|
|
579
|
+
callback();
|
|
482
580
|
}
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
// Authorize the login API with the session token and build the Hawk-signed
|
|
584
|
+
// device API. Runs at startup and again from updateHomeData when a
|
|
585
|
+
// session-less (local-only) start logs in once the cloud is back.
|
|
586
|
+
setupCloudApi(userdata) {
|
|
587
|
+
this.userData = userdata;
|
|
588
|
+
this.loginApi.defaults.headers.common["Authorization"] = userdata.token;
|
|
483
589
|
const rriot = userdata.rriot;
|
|
484
590
|
|
|
485
|
-
// Initialize the real API.
|
|
486
591
|
this.api = axios.create({
|
|
487
592
|
baseURL: rriot.r.a,
|
|
593
|
+
// Fail instead of hanging when the cloud black-holes connections;
|
|
594
|
+
// otherwise the periodic refresh piles up requests every interval.
|
|
595
|
+
timeout: 20000,
|
|
488
596
|
});
|
|
489
597
|
this.api.interceptors.request.use((config) => {
|
|
490
598
|
try {
|
|
@@ -520,147 +628,320 @@ class Roborock {
|
|
|
520
628
|
}
|
|
521
629
|
return config;
|
|
522
630
|
});
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
// Start (or restart) the cloud MQTT client for a session. Any previous
|
|
634
|
+
// client is closed first so a re-login never leaves a second one
|
|
635
|
+
// reconnecting in the background.
|
|
636
|
+
async initCloudMqtt(userdata) {
|
|
637
|
+
this.rr_mqtt_connector.disconnectClient();
|
|
638
|
+
await this.rr_mqtt_connector.initUser(userdata);
|
|
639
|
+
await this.rr_mqtt_connector.initMQTT_Subscribe();
|
|
640
|
+
await this.rr_mqtt_connector.initMQTT_Message();
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
// Log in again after a session-less (local-only) start. Only network-level
|
|
644
|
+
// login failures are retried; a rejected login or a pending 2FA is left for
|
|
645
|
+
// the user. Returns true once the signed API and MQTT client are up.
|
|
646
|
+
async reconnectCloud() {
|
|
647
|
+
if (this.loginRetryable === false || !this.config.password) {
|
|
648
|
+
return false;
|
|
649
|
+
}
|
|
523
650
|
|
|
524
|
-
// Get home details.
|
|
525
651
|
try {
|
|
526
|
-
const
|
|
527
|
-
if (
|
|
528
|
-
|
|
652
|
+
const userdata = await this.getUserData(this.loginApi);
|
|
653
|
+
if (!userdata) {
|
|
654
|
+
return false;
|
|
655
|
+
}
|
|
529
656
|
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
657
|
+
this.setupCloudApi(userdata);
|
|
658
|
+
await this.initCloudMqtt(userdata);
|
|
659
|
+
this.log.info("Logged in to the Roborock cloud again.");
|
|
660
|
+
return true;
|
|
661
|
+
} catch (error) {
|
|
662
|
+
this.log.debug(`Cloud re-login failed: ${error && error.message}`);
|
|
663
|
+
return false;
|
|
664
|
+
}
|
|
665
|
+
}
|
|
533
666
|
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
667
|
+
// Cloud failures repeat every refresh interval during an outage: log the
|
|
668
|
+
// first at error level, the rest at debug, and announce the recovery once.
|
|
669
|
+
logCloudFailure(message) {
|
|
670
|
+
if (this.cloudUnreachable) {
|
|
671
|
+
this.log.debug(message);
|
|
672
|
+
return;
|
|
673
|
+
}
|
|
538
674
|
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
this.products = homedataResult.products;
|
|
545
|
-
this.devices = homedataResult.devices || [];
|
|
546
|
-
this.devices = this.devices.filter(
|
|
547
|
-
(device) =>
|
|
548
|
-
!ignoredSet.has(device.sn) && !ignoredSet.has(device.duid)
|
|
549
|
-
);
|
|
550
|
-
this.receivedDevices = (homedataResult.receivedDevices || []).filter(
|
|
551
|
-
(device) =>
|
|
552
|
-
!ignoredSet.has(device.sn) && !ignoredSet.has(device.duid)
|
|
553
|
-
);
|
|
675
|
+
this.cloudUnreachable = true;
|
|
676
|
+
this.log.error(
|
|
677
|
+
`${message} Further failures are logged at debug level until the cloud answers again.`
|
|
678
|
+
);
|
|
679
|
+
}
|
|
554
680
|
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
681
|
+
noteCloudReachable() {
|
|
682
|
+
if (this.cloudUnreachable) {
|
|
683
|
+
this.cloudUnreachable = false;
|
|
684
|
+
this.log.info(
|
|
685
|
+
"Roborock cloud is reachable again; resuming cloud features."
|
|
686
|
+
);
|
|
687
|
+
}
|
|
688
|
+
}
|
|
563
689
|
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
690
|
+
// True when the login API explicitly rejected the session. A degraded cloud
|
|
691
|
+
// can answer 200 with an empty or non-JSON body; that is not a rejection,
|
|
692
|
+
// and wiping the session on it would force a password login (possibly with
|
|
693
|
+
// 2FA) on the next start.
|
|
694
|
+
isSessionRejection(body) {
|
|
695
|
+
return !!(body && typeof body.code === "number" && body.code !== 200);
|
|
696
|
+
}
|
|
570
697
|
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
698
|
+
// Ask the login API whether the current session is still accepted. Returns
|
|
699
|
+
// true only on an explicit rejection; an unreachable cloud cannot tell.
|
|
700
|
+
async isSessionRejected() {
|
|
701
|
+
try {
|
|
702
|
+
const homeDetail = await this.loginApi.get("api/v1/getHomeDetail");
|
|
703
|
+
return this.isSessionRejection(homeDetail && homeDetail.data);
|
|
704
|
+
} catch (error) {
|
|
705
|
+
return false;
|
|
706
|
+
}
|
|
707
|
+
}
|
|
577
708
|
|
|
578
|
-
|
|
579
|
-
|
|
709
|
+
// Fetch the account's home data from the cloud. Throws on any failure
|
|
710
|
+
// (network, timeout, or an unusable response) so the caller can fall back to
|
|
711
|
+
// the cached copy. Only an explicit rejection clears the cached session so
|
|
712
|
+
// the next startup re-logs in.
|
|
713
|
+
async fetchHomeDataFromCloud() {
|
|
714
|
+
const homeDetail = await this.loginApi.get("api/v1/getHomeDetail");
|
|
715
|
+
const body = homeDetail && homeDetail.data;
|
|
716
|
+
if (!(body && body.data)) {
|
|
717
|
+
if (this.isSessionRejection(body)) {
|
|
718
|
+
this.log.warn(
|
|
719
|
+
`The stored Roborock session was rejected (code ${body.code}: ${body.msg || "no message"}). ` +
|
|
720
|
+
"Clearing it so the next startup re-authenticates."
|
|
721
|
+
);
|
|
722
|
+
this.userData = null;
|
|
723
|
+
await this.deleteStateAsync("UserData");
|
|
724
|
+
throw new Error(
|
|
725
|
+
`getHomeDetail rejected the session (code ${body.code})`
|
|
726
|
+
);
|
|
727
|
+
}
|
|
728
|
+
throw new Error("getHomeDetail returned no usable data");
|
|
729
|
+
}
|
|
580
730
|
|
|
581
|
-
|
|
582
|
-
localKeyDevices.map((device) => [device.duid, device.localKey])
|
|
583
|
-
);
|
|
731
|
+
const homeId = body.data.rrHomeId;
|
|
584
732
|
|
|
585
|
-
|
|
733
|
+
if (!this.api) {
|
|
734
|
+
throw new Error("Signed cloud API is not initialized");
|
|
735
|
+
}
|
|
586
736
|
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
737
|
+
const homedata = await this.api.get(`v2/user/homes/${homeId}`);
|
|
738
|
+
const homedataResult = homedata && homedata.data && homedata.data.result;
|
|
739
|
+
if (!homedataResult) {
|
|
740
|
+
throw new Error("home data response was empty");
|
|
741
|
+
}
|
|
590
742
|
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
for (const room in rooms) {
|
|
594
|
-
const roomID = rooms[room].id;
|
|
595
|
-
const roomName = rooms[room].name;
|
|
743
|
+
return { homeId, homedataResult };
|
|
744
|
+
}
|
|
596
745
|
|
|
597
|
-
|
|
598
|
-
|
|
746
|
+
// HomeData state as stored: tagged with the account it belongs to, so a
|
|
747
|
+
// cache left behind by a previous account is never used for a local-only
|
|
748
|
+
// start.
|
|
749
|
+
homeDataState(homedataResult) {
|
|
750
|
+
return {
|
|
751
|
+
val: JSON.stringify(homedataResult),
|
|
752
|
+
ack: true,
|
|
753
|
+
account: this.config.username,
|
|
754
|
+
};
|
|
755
|
+
}
|
|
599
756
|
|
|
600
|
-
|
|
601
|
-
|
|
757
|
+
// Load the last home data cached on disk (written on every successful cloud
|
|
758
|
+
// fetch). Returns { homeId, homedataResult } or null when nothing is cached.
|
|
759
|
+
loadCachedHomeData() {
|
|
760
|
+
try {
|
|
761
|
+
const cached = this.getStateAsync("HomeData");
|
|
762
|
+
const raw = cached && typeof cached.val === "string" ? cached.val : null;
|
|
763
|
+
if (!raw) {
|
|
764
|
+
return null;
|
|
765
|
+
}
|
|
602
766
|
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
767
|
+
if (cached.account && cached.account !== this.config.username) {
|
|
768
|
+
this.log.warn(
|
|
769
|
+
`Ignoring cached device data: it belongs to ${cached.account}, not ${this.config.username}.`
|
|
770
|
+
);
|
|
771
|
+
return null;
|
|
772
|
+
}
|
|
606
773
|
|
|
607
|
-
|
|
608
|
-
|
|
774
|
+
const homedataResult = JSON.parse(raw);
|
|
775
|
+
if (!homedataResult) {
|
|
776
|
+
return null;
|
|
777
|
+
}
|
|
609
778
|
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
homeId
|
|
614
|
-
);
|
|
615
|
-
await this.updateHomeData(homeId);
|
|
779
|
+
const cachedHomeId = this.getStateAsync("HomeId");
|
|
780
|
+
const homeId =
|
|
781
|
+
cachedHomeId && cachedHomeId.val != null ? cachedHomeId.val : null;
|
|
616
782
|
|
|
617
|
-
|
|
783
|
+
return { homeId, homedataResult };
|
|
784
|
+
} catch (error) {
|
|
785
|
+
this.log.debug(
|
|
786
|
+
`Failed to read cached home data: ${error && error.message}`
|
|
787
|
+
);
|
|
788
|
+
return null;
|
|
789
|
+
}
|
|
790
|
+
}
|
|
618
791
|
|
|
619
|
-
|
|
620
|
-
|
|
792
|
+
// Last known LAN address per device, so a local-only start can dial the
|
|
793
|
+
// vacuums even when UDP discovery finds nothing (other VLAN, port 58866
|
|
794
|
+
// taken by another integration) and get_network_info needs the cloud.
|
|
795
|
+
loadCachedLocalDevices() {
|
|
796
|
+
const cached = this.getStateAsync("LocalDevices");
|
|
797
|
+
return cached && cached.val && typeof cached.val === "object"
|
|
798
|
+
? cached.val
|
|
799
|
+
: {};
|
|
800
|
+
}
|
|
621
801
|
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
) {
|
|
627
|
-
this.localDevices[duid] = ip;
|
|
628
|
-
}
|
|
629
|
-
});
|
|
630
|
-
this.log.debug(`localDevices: ${JSON.stringify(this.localDevices)}`);
|
|
802
|
+
persistLocalDevices() {
|
|
803
|
+
if (Object.keys(this.localDevices).length === 0) {
|
|
804
|
+
return; // never replace a useful cache with nothing
|
|
805
|
+
}
|
|
631
806
|
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
807
|
+
return this.setStateAsync("LocalDevices", {
|
|
808
|
+
val: this.localDevices,
|
|
809
|
+
ack: true,
|
|
810
|
+
});
|
|
811
|
+
}
|
|
635
812
|
|
|
636
|
-
|
|
637
|
-
|
|
813
|
+
// Build the device list, local keys, MQTT/local connections and polling from
|
|
814
|
+
// home data - shared by the online path (fresh cloud data) and the
|
|
815
|
+
// local-only path (cached data). The cloud MQTT client and the home-data
|
|
816
|
+
// refresh interval are set up in both cases so cloud features recover on
|
|
817
|
+
// their own once the cloud is reachable again.
|
|
818
|
+
async initializeFromHomeData(userdata, homedataResult, homeId, fromCache) {
|
|
819
|
+
if (homeId != null) {
|
|
820
|
+
this.homeId = homeId;
|
|
821
|
+
}
|
|
822
|
+
|
|
823
|
+
// skip devices whose serial number or device ID is in ignoredDevices or skipDevices
|
|
824
|
+
const ignoredDevices = this.config.ignoredDevices || [];
|
|
825
|
+
const skipDevices = this.parseSkipDevices(this.config.skipDevices);
|
|
826
|
+
const ignoredSet = new Set([...ignoredDevices, ...skipDevices]);
|
|
827
|
+
// create devices and set states
|
|
828
|
+
this.products = homedataResult.products;
|
|
829
|
+
this.devices = homedataResult.devices || [];
|
|
830
|
+
this.devices = this.devices.filter(
|
|
831
|
+
(device) => !ignoredSet.has(device.sn) && !ignoredSet.has(device.duid)
|
|
832
|
+
);
|
|
833
|
+
this.receivedDevices = (homedataResult.receivedDevices || []).filter(
|
|
834
|
+
(device) => !ignoredSet.has(device.sn) && !ignoredSet.has(device.duid)
|
|
835
|
+
);
|
|
638
836
|
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
837
|
+
const allManagedDevices = (homedataResult.devices || []).concat(
|
|
838
|
+
homedataResult.receivedDevices || []
|
|
839
|
+
);
|
|
840
|
+
const localKeyDevices = [];
|
|
841
|
+
for (const device of allManagedDevices) {
|
|
842
|
+
if (!device || !device.duid) {
|
|
843
|
+
continue;
|
|
844
|
+
}
|
|
845
|
+
|
|
846
|
+
if (
|
|
847
|
+
(device.sn && ignoredSet.has(device.sn)) ||
|
|
848
|
+
(device.duid && ignoredSet.has(device.duid))
|
|
849
|
+
) {
|
|
850
|
+
continue;
|
|
851
|
+
}
|
|
852
|
+
|
|
853
|
+
if (!device.localKey) {
|
|
649
854
|
this.log.warn(
|
|
650
|
-
"
|
|
651
|
-
"Deleting cached UserData to force a new login. " +
|
|
652
|
-
"Please re-authenticate in the plugin settings if this persists."
|
|
855
|
+
`Device ${device.duid} (product ${device.productId || "unknown"}) has no localKey in home data and cannot be controlled. Fields returned by the API: ${Object.keys(device).join(", ")}`
|
|
653
856
|
);
|
|
654
|
-
|
|
655
|
-
await this.deleteStateAsync(`UserData`);
|
|
857
|
+
continue;
|
|
656
858
|
}
|
|
657
|
-
|
|
658
|
-
|
|
859
|
+
|
|
860
|
+
localKeyDevices.push(device);
|
|
659
861
|
}
|
|
660
862
|
|
|
661
|
-
|
|
662
|
-
|
|
863
|
+
this.localKeys = new Map(
|
|
864
|
+
localKeyDevices.map((device) => [device.duid, device.localKey])
|
|
865
|
+
);
|
|
866
|
+
|
|
867
|
+
// The cloud MQTT client is initialised from the (cached) session even in
|
|
868
|
+
// local-only mode: it keeps retrying in the background, so the cloud path
|
|
869
|
+
// and push updates recover on their own once the broker is reachable.
|
|
870
|
+
// Without a session, reconnectCloud brings it up after the re-login.
|
|
871
|
+
if (userdata) {
|
|
872
|
+
await this.initCloudMqtt(userdata);
|
|
873
|
+
}
|
|
874
|
+
|
|
875
|
+
// store name of each room via ID
|
|
876
|
+
const rooms = homedataResult.rooms;
|
|
877
|
+
for (const room in rooms) {
|
|
878
|
+
const roomID = rooms[room].id;
|
|
879
|
+
const roomName = rooms[room].name;
|
|
880
|
+
|
|
881
|
+
this.roomIDs[roomID] = roomName;
|
|
663
882
|
}
|
|
883
|
+
|
|
884
|
+
await this.loadSharedDeviceRooms();
|
|
885
|
+
this.log.debug(`RoomIDs debug: ${JSON.stringify(this.roomIDs)}`);
|
|
886
|
+
|
|
887
|
+
// reconnect every 3 hours (10800 seconds)
|
|
888
|
+
this.reconnectIntervall = this.setInterval(async () => {
|
|
889
|
+
this.log.debug(`Reconnecting after 3 hours!`);
|
|
890
|
+
|
|
891
|
+
await this.rr_mqtt_connector.reconnectClient();
|
|
892
|
+
}, 10800 * 1000);
|
|
893
|
+
|
|
894
|
+
// Periodic home-data refresh. It also drives cloud recovery: on a
|
|
895
|
+
// local-only start (no homeId yet) updateHomeData learns it via
|
|
896
|
+
// getHomeDetail before refreshing.
|
|
897
|
+
this.homedataInterval = this.setInterval(
|
|
898
|
+
() => this.updateHomeData(this.homeId),
|
|
899
|
+
this.updateInterval * 1000
|
|
900
|
+
);
|
|
901
|
+
// A fresh online start pulls once more to load scenes and reconcile
|
|
902
|
+
// intervals. In local-only mode the cloud is down, so defer to the interval
|
|
903
|
+
// instead of blocking startup on a call that will fail.
|
|
904
|
+
if (!fromCache) {
|
|
905
|
+
await this.updateHomeData(this.homeId);
|
|
906
|
+
}
|
|
907
|
+
|
|
908
|
+
const discoveredDevices = await this.localConnector.getLocalDevices();
|
|
909
|
+
|
|
910
|
+
await this.createDevices();
|
|
911
|
+
await this.getNetworkInfo();
|
|
912
|
+
|
|
913
|
+
// merge udp discovered devices with local devices found via mqtt
|
|
914
|
+
Object.entries(discoveredDevices).forEach(([duid, ip]) => {
|
|
915
|
+
if (!Object.prototype.hasOwnProperty.call(this.localDevices, duid)) {
|
|
916
|
+
this.localDevices[duid] = ip;
|
|
917
|
+
}
|
|
918
|
+
});
|
|
919
|
+
|
|
920
|
+
// Devices that answered neither the UDP broadcast nor (cloud down) the
|
|
921
|
+
// get_network_info request: dial the address they had last time. A stale
|
|
922
|
+
// one just fails to connect and goes through the usual reconnect loop.
|
|
923
|
+
for (const [duid, ip] of Object.entries(this.loadCachedLocalDevices())) {
|
|
924
|
+
if (!this.localDevices[duid] && this.localKeys.has(duid)) {
|
|
925
|
+
this.localDevices[duid] = ip;
|
|
926
|
+
}
|
|
927
|
+
}
|
|
928
|
+
await this.persistLocalDevices();
|
|
929
|
+
this.log.debug(`localDevices: ${JSON.stringify(this.localDevices)}`);
|
|
930
|
+
|
|
931
|
+
for (const device in this.localDevices) {
|
|
932
|
+
const duid = device;
|
|
933
|
+
const ip = this.localDevices[device];
|
|
934
|
+
|
|
935
|
+
await this.localConnector.createClient(duid, ip);
|
|
936
|
+
}
|
|
937
|
+
|
|
938
|
+
await this.initializeDeviceUpdates();
|
|
939
|
+
this.bInited = true;
|
|
940
|
+
this.log.info(
|
|
941
|
+
fromCache
|
|
942
|
+
? "Starting adapter finished in local-only mode (cloud unreachable)."
|
|
943
|
+
: "Starting adapter finished. Lets go!!!!!!!"
|
|
944
|
+
);
|
|
664
945
|
}
|
|
665
946
|
|
|
666
947
|
async stopService() {
|
|
@@ -695,6 +976,7 @@ class Roborock {
|
|
|
695
976
|
|
|
696
977
|
if (!this.config.password) {
|
|
697
978
|
this.log.error("Password is missing and no valid token is available.");
|
|
979
|
+
this.loginRetryable = false;
|
|
698
980
|
return null;
|
|
699
981
|
}
|
|
700
982
|
|
|
@@ -725,6 +1007,7 @@ class Roborock {
|
|
|
725
1007
|
if (loginResult && loginResult.code === 2031) {
|
|
726
1008
|
this.authState.twoFactorRequired = true;
|
|
727
1009
|
this.authState.statusMessage = "Two-factor authentication required.";
|
|
1010
|
+
this.loginRetryable = false;
|
|
728
1011
|
this.log.error(
|
|
729
1012
|
"Two-factor authentication required. Use the Config UI to continue."
|
|
730
1013
|
);
|
|
@@ -736,8 +1019,20 @@ class Roborock {
|
|
|
736
1019
|
// Failed logins (bad credentials, unreachable cloud) are expected
|
|
737
1020
|
// runtime conditions — report "no user data" so startService aborts
|
|
738
1021
|
// gracefully instead of crashing Homebridge with an unhandled
|
|
739
|
-
// rejection.
|
|
740
|
-
|
|
1022
|
+
// rejection. Only a network-level failure (no HTTP answer, or a server
|
|
1023
|
+
// error) may be retried automatically by reconnectCloud; a rejected
|
|
1024
|
+
// login must never be repeated every interval.
|
|
1025
|
+
this.loginRetryable = !!(
|
|
1026
|
+
error &&
|
|
1027
|
+
error.isAxiosError &&
|
|
1028
|
+
(!error.response || error.response.status >= 500)
|
|
1029
|
+
);
|
|
1030
|
+
if (this.loginRetryable) {
|
|
1031
|
+
// Repeats every refresh interval while the cloud is down.
|
|
1032
|
+
this.logCloudFailure(`Error in getUserData: ${error.message}.`);
|
|
1033
|
+
} else {
|
|
1034
|
+
this.log.error(`Error in getUserData: ${error.message}`);
|
|
1035
|
+
}
|
|
741
1036
|
await this.deleteStateAsync("HomeData");
|
|
742
1037
|
await this.deleteStateAsync("UserData");
|
|
743
1038
|
return null;
|
|
@@ -894,8 +1189,10 @@ class Roborock {
|
|
|
894
1189
|
|
|
895
1190
|
const robotModel = this.getProductAttribute(duid, "model");
|
|
896
1191
|
|
|
897
|
-
|
|
898
|
-
|
|
1192
|
+
// onlineChecker also reports a locally connected device as reachable, so
|
|
1193
|
+
// polling still runs for LAN-reachable devices during a cloud outage.
|
|
1194
|
+
if (await this.onlineChecker(duid)) {
|
|
1195
|
+
this.log.debug(`${duid} reachable. Starting update intervals.`);
|
|
899
1196
|
this.startDeviceIntervals(duid);
|
|
900
1197
|
}
|
|
901
1198
|
|
|
@@ -965,7 +1262,7 @@ class Roborock {
|
|
|
965
1262
|
try {
|
|
966
1263
|
await this.api.post(`user/scene/${sceneID.val}/execute`);
|
|
967
1264
|
} catch (error) {
|
|
968
|
-
if (!this.handleApiUnauthorized(error, "executeScene")) {
|
|
1265
|
+
if (!(await this.handleApiUnauthorized(error, "executeScene"))) {
|
|
969
1266
|
this.catchError(error.stack, "executeScene");
|
|
970
1267
|
}
|
|
971
1268
|
}
|
|
@@ -981,6 +1278,10 @@ class Roborock {
|
|
|
981
1278
|
throw new Error("loginApi is not initialized. Call init() first.");
|
|
982
1279
|
}
|
|
983
1280
|
|
|
1281
|
+
if (this.homeId != null) {
|
|
1282
|
+
return this.homeId; // resolved at startup; no cloud round trip needed
|
|
1283
|
+
}
|
|
1284
|
+
|
|
984
1285
|
try {
|
|
985
1286
|
const homeDetail = await this.loginApi.get("api/v1/getHomeDetail");
|
|
986
1287
|
if (homeDetail && homeDetail.data && homeDetail.data.data) {
|
|
@@ -1014,7 +1315,7 @@ class Roborock {
|
|
|
1014
1315
|
|
|
1015
1316
|
return response.data;
|
|
1016
1317
|
} catch (error) {
|
|
1017
|
-
if (!this.handleApiUnauthorized(error, "getScenes")) {
|
|
1318
|
+
if (!(await this.handleApiUnauthorized(error, "getScenes"))) {
|
|
1018
1319
|
this.log.error(`Failed to get scenes: ${error.message}`);
|
|
1019
1320
|
}
|
|
1020
1321
|
throw error;
|
|
@@ -1096,7 +1397,28 @@ class Roborock {
|
|
|
1096
1397
|
return product ? product[attribute] : null;
|
|
1097
1398
|
}
|
|
1098
1399
|
|
|
1400
|
+
// Whether the per-device local TCP client is currently connected.
|
|
1401
|
+
isLocallyConnected(duid) {
|
|
1402
|
+
try {
|
|
1403
|
+
return !!(
|
|
1404
|
+
this.localConnector &&
|
|
1405
|
+
typeof this.localConnector.isConnected === "function" &&
|
|
1406
|
+
this.localConnector.isConnected(duid)
|
|
1407
|
+
);
|
|
1408
|
+
} catch (error) {
|
|
1409
|
+
return false;
|
|
1410
|
+
}
|
|
1411
|
+
}
|
|
1412
|
+
|
|
1099
1413
|
async onlineChecker(duid) {
|
|
1414
|
+
// A device we hold a live local connection to is reachable regardless of
|
|
1415
|
+
// the cached cloud online flag, which is stale (and may be false) while the
|
|
1416
|
+
// cloud is unreachable. Trusting the live socket is what keeps local
|
|
1417
|
+
// commands from being rejected during a cloud outage.
|
|
1418
|
+
if (this.isLocallyConnected(duid)) {
|
|
1419
|
+
return true;
|
|
1420
|
+
}
|
|
1421
|
+
|
|
1100
1422
|
const homedata = this.getParsedHomeData();
|
|
1101
1423
|
if (!homedata) {
|
|
1102
1424
|
return false;
|
|
@@ -1135,6 +1457,10 @@ class Roborock {
|
|
|
1135
1457
|
// Query them per shared device and merge into roomIDs. Room entries carry
|
|
1136
1458
|
// their id as either `id` or `roomId` depending on the backend.
|
|
1137
1459
|
async loadSharedDeviceRooms() {
|
|
1460
|
+
if (!this.api) {
|
|
1461
|
+
return; // session-less local-only start: shared devices are cloud-only
|
|
1462
|
+
}
|
|
1463
|
+
|
|
1138
1464
|
for (const device of this.receivedDevices || []) {
|
|
1139
1465
|
try {
|
|
1140
1466
|
const response = await this.api.get(
|
|
@@ -1287,35 +1613,67 @@ class Roborock {
|
|
|
1287
1613
|
}
|
|
1288
1614
|
|
|
1289
1615
|
async updateHomeData(homeId) {
|
|
1290
|
-
|
|
1291
|
-
|
|
1616
|
+
// Cloud recovery for a session-less (local-only) start: nothing
|
|
1617
|
+
// cloud-side works until a login succeeds, so retry that first.
|
|
1618
|
+
if (!this.api && !(await this.reconnectCloud())) {
|
|
1619
|
+
return;
|
|
1620
|
+
}
|
|
1621
|
+
|
|
1622
|
+
if (homeId == null) {
|
|
1623
|
+
homeId = this.homeId;
|
|
1624
|
+
}
|
|
1625
|
+
|
|
1626
|
+
// Cloud recovery: a local-only start may have no home id yet. Learn it
|
|
1627
|
+
// from getHomeDetail before refreshing, so cloud features resume without
|
|
1628
|
+
// a Homebridge restart. While the cloud is still down this just retries
|
|
1629
|
+
// on the next tick.
|
|
1630
|
+
if (homeId == null) {
|
|
1292
1631
|
try {
|
|
1293
|
-
const
|
|
1294
|
-
|
|
1632
|
+
const homeDetail = await this.loginApi.get("api/v1/getHomeDetail");
|
|
1633
|
+
homeId =
|
|
1634
|
+
homeDetail && homeDetail.data && homeDetail.data.data
|
|
1635
|
+
? homeDetail.data.data.rrHomeId
|
|
1636
|
+
: null;
|
|
1637
|
+
} catch (error) {
|
|
1638
|
+
this.log.debug(
|
|
1639
|
+
`Home id still unavailable (cloud likely still down): ${error && error.message}`
|
|
1640
|
+
);
|
|
1641
|
+
return;
|
|
1642
|
+
}
|
|
1295
1643
|
|
|
1296
|
-
|
|
1297
|
-
|
|
1298
|
-
|
|
1299
|
-
ack: true,
|
|
1300
|
-
});
|
|
1301
|
-
this.log.debug(`homedata successfully updated`);
|
|
1644
|
+
if (homeId == null) {
|
|
1645
|
+
return;
|
|
1646
|
+
}
|
|
1302
1647
|
|
|
1303
|
-
|
|
1648
|
+
this.homeId = homeId;
|
|
1649
|
+
await this.setStateAsync("HomeId", { val: homeId, ack: true });
|
|
1650
|
+
}
|
|
1304
1651
|
|
|
1305
|
-
|
|
1306
|
-
|
|
1307
|
-
|
|
1308
|
-
|
|
1309
|
-
|
|
1310
|
-
|
|
1311
|
-
|
|
1312
|
-
|
|
1313
|
-
|
|
1314
|
-
|
|
1315
|
-
|
|
1316
|
-
|
|
1317
|
-
|
|
1652
|
+
this.log.debug(`Updating HomeData with homeId: ${homeId}`);
|
|
1653
|
+
try {
|
|
1654
|
+
const home = await this.api.get(`user/homes/${homeId}`);
|
|
1655
|
+
const homedata = home.data.result;
|
|
1656
|
+
|
|
1657
|
+
if (homedata) {
|
|
1658
|
+
await this.setStateAsync("HomeData", this.homeDataState(homedata));
|
|
1659
|
+
this.log.debug(`homedata successfully updated`);
|
|
1660
|
+
this.noteCloudReachable();
|
|
1661
|
+
|
|
1662
|
+
await this.getScenes();
|
|
1663
|
+
|
|
1664
|
+
// Fresh online flags just arrived: stop polling devices that went
|
|
1665
|
+
// offline and resume devices that came back (both idempotent).
|
|
1666
|
+
for (const duid of this.initializedVacuumDuids) {
|
|
1667
|
+
await this.manageDeviceIntervals(duid);
|
|
1318
1668
|
}
|
|
1669
|
+
} else {
|
|
1670
|
+
this.log.warn("homedata failed to download");
|
|
1671
|
+
}
|
|
1672
|
+
} catch (error) {
|
|
1673
|
+
if (!(await this.handleApiUnauthorized(error, "updateHomeData"))) {
|
|
1674
|
+
this.logCloudFailure(
|
|
1675
|
+
`Failed to update updateHomeData with error: ${error}.`
|
|
1676
|
+
);
|
|
1319
1677
|
}
|
|
1320
1678
|
}
|
|
1321
1679
|
}
|
|
@@ -1331,7 +1689,7 @@ class Roborock {
|
|
|
1331
1689
|
* HomeData polling stops until the user re-authenticates.
|
|
1332
1690
|
* Returns true when the error was a 401 and has been handled here.
|
|
1333
1691
|
*/
|
|
1334
|
-
handleApiUnauthorized(error, context) {
|
|
1692
|
+
async handleApiUnauthorized(error, context) {
|
|
1335
1693
|
if (!error || !error.response || error.response.status !== 401) {
|
|
1336
1694
|
return false;
|
|
1337
1695
|
}
|
|
@@ -1361,6 +1719,17 @@ class Roborock {
|
|
|
1361
1719
|
if (this.sessionInvalidated) {
|
|
1362
1720
|
return true;
|
|
1363
1721
|
}
|
|
1722
|
+
|
|
1723
|
+
// A 401 from the signed API normally means a revoked session, but a
|
|
1724
|
+
// degraded cloud can produce one too. Ask the login API before throwing
|
|
1725
|
+
// the session away; while it still accepts the token (or cannot be
|
|
1726
|
+
// reached) the request simply retries on the next interval.
|
|
1727
|
+
if (!(await this.isSessionRejected())) {
|
|
1728
|
+
this.logCloudFailure(
|
|
1729
|
+
`Roborock API rejected the request (401) in ${context}, but the login API still accepts the session. Keeping it and retrying later.`
|
|
1730
|
+
);
|
|
1731
|
+
return true;
|
|
1732
|
+
}
|
|
1364
1733
|
this.sessionInvalidated = true;
|
|
1365
1734
|
|
|
1366
1735
|
this.log.warn(
|