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 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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "homebridge-roborock-vacuum",
3
- "version": "2.2.6",
3
+ "version": "2.2.7",
4
4
  "description": "Roborock Vacuum Cleaner - plugin for Homebridge.",
5
5
  "license": "MIT",
6
6
  "keywords": [
@@ -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 == "UserData" || id == "clientID") {
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
- return null;
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 == "UserData" || id == "clientID") {
220
+ if (this.isPersistedStateId(id)) {
202
221
  const persistPath = this.getPersistPath(id);
203
222
  fs.mkdirSync(path.dirname(persistPath), { recursive: true });
204
- // 0600: UserData contains the cloud token and rriot secrets.
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 (!userdata) {
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 Config UI."
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
- return;
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.loginApi.defaults.headers.common["Authorization"] = userdata.token;
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 login. Most likely wrong token! Deleting HomeData and UserData. Try again! " +
477
- error
573
+ "Failed to initialize devices from home data: " +
574
+ (error && error.stack ? error.stack : error)
478
575
  );
576
+ }
479
577
 
480
- this.deleteStateAsync("HomeData");
481
- this.deleteStateAsync("UserData");
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 homeDetail = await this.loginApi.get("api/v1/getHomeDetail");
527
- if (homeDetail && homeDetail.data && homeDetail.data.data) {
528
- const homeId = homeDetail.data.data.rrHomeId;
652
+ const userdata = await this.getUserData(this.loginApi);
653
+ if (!userdata) {
654
+ return false;
655
+ }
529
656
 
530
- if (this.api) {
531
- const homedata = await this.api.get(`v2/user/homes/${homeId}`);
532
- const homedataResult = homedata.data.result;
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
- await this.setStateAsync("HomeData", {
535
- val: JSON.stringify(homedataResult),
536
- ack: true,
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
- // skip devices whose serial number or device ID is in ignoredDevices or skipDevices
540
- const ignoredDevices = this.config.ignoredDevices || [];
541
- const skipDevices = this.parseSkipDevices(this.config.skipDevices);
542
- const ignoredSet = new Set([...ignoredDevices, ...skipDevices]);
543
- // create devices and set states
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
- const allManagedDevices = (homedataResult.devices || []).concat(
556
- homedataResult.receivedDevices || []
557
- );
558
- const localKeyDevices = [];
559
- for (const device of allManagedDevices) {
560
- if (!device || !device.duid) {
561
- continue;
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
- if (
565
- (device.sn && ignoredSet.has(device.sn)) ||
566
- (device.duid && ignoredSet.has(device.duid))
567
- ) {
568
- continue;
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
- if (!device.localKey) {
572
- this.log.warn(
573
- `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(", ")}`
574
- );
575
- continue;
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
- localKeyDevices.push(device);
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
- this.localKeys = new Map(
582
- localKeyDevices.map((device) => [device.duid, device.localKey])
583
- );
731
+ const homeId = body.data.rrHomeId;
584
732
 
585
- // this.adapter.log.debug(`initUser test: ${JSON.stringify(Array.from(this.adapter.localKeys.entries()))}`);
733
+ if (!this.api) {
734
+ throw new Error("Signed cloud API is not initialized");
735
+ }
586
736
 
587
- await this.rr_mqtt_connector.initUser(userdata);
588
- await this.rr_mqtt_connector.initMQTT_Subscribe();
589
- await this.rr_mqtt_connector.initMQTT_Message();
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
- // store name of each room via ID
592
- const rooms = homedataResult.rooms;
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
- this.roomIDs[roomID] = roomName;
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
- await this.loadSharedDeviceRooms();
601
- this.log.debug(`RoomIDs debug: ${JSON.stringify(this.roomIDs)}`);
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
- // reconnect every 3 hours (10800 seconds)
604
- this.reconnectIntervall = this.setInterval(async () => {
605
- this.log.debug(`Reconnecting after 3 hours!`);
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
- await this.rr_mqtt_connector.reconnectClient();
608
- }, 10800 * 1000);
774
+ const homedataResult = JSON.parse(raw);
775
+ if (!homedataResult) {
776
+ return null;
777
+ }
609
778
 
610
- this.homedataInterval = this.setInterval(
611
- this.updateHomeData.bind(this),
612
- this.updateInterval * 1000,
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
- const discoveredDevices = await this.localConnector.getLocalDevices();
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
- await this.createDevices();
620
- await this.getNetworkInfo();
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
- // merge udp discovered devices with local devices found via mqtt
623
- Object.entries(discoveredDevices).forEach(([duid, ip]) => {
624
- if (
625
- !Object.prototype.hasOwnProperty.call(this.localDevices, duid)
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
- for (const device in this.localDevices) {
633
- const duid = device;
634
- const ip = this.localDevices[device];
807
+ return this.setStateAsync("LocalDevices", {
808
+ val: this.localDevices,
809
+ ack: true,
810
+ });
811
+ }
635
812
 
636
- await this.localConnector.createClient(duid, ip);
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
- await this.initializeDeviceUpdates();
640
- this.bInited = true;
641
- this.log.info(`Starting adapter finished. Lets go!!!!!!!`);
642
- } else {
643
- this.log.info(
644
- `Most likely failed to login. Deleting UserData to force new login!`
645
- );
646
- await this.deleteStateAsync(`UserData`);
647
- }
648
- } else {
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
- "The stored Roborock session is invalid or expired. " +
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
- this.userData = null;
655
- await this.deleteStateAsync(`UserData`);
857
+ continue;
656
858
  }
657
- } catch (error) {
658
- this.log.error("Failed to get home details: " + error.stack);
859
+
860
+ localKeyDevices.push(device);
659
861
  }
660
862
 
661
- if (callback) {
662
- callback();
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
- this.log.error(`Error in getUserData: ${error.message}`);
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
- if (device.online) {
898
- this.log.debug(`${duid} online. Starting update intervals.`);
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
- this.log.debug(`Updating HomeData with homeId: ${homeId}`);
1291
- if (this.api) {
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 home = await this.api.get(`user/homes/${homeId}`);
1294
- const homedata = home.data.result;
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
- if (homedata) {
1297
- await this.setStateAsync("HomeData", {
1298
- val: JSON.stringify(homedata),
1299
- ack: true,
1300
- });
1301
- this.log.debug(`homedata successfully updated`);
1644
+ if (homeId == null) {
1645
+ return;
1646
+ }
1302
1647
 
1303
- await this.getScenes();
1648
+ this.homeId = homeId;
1649
+ await this.setStateAsync("HomeId", { val: homeId, ack: true });
1650
+ }
1304
1651
 
1305
- // Fresh online flags just arrived: stop polling devices that went
1306
- // offline and resume devices that came back (both idempotent).
1307
- for (const duid of this.initializedVacuumDuids) {
1308
- await this.manageDeviceIntervals(duid);
1309
- }
1310
- } else {
1311
- this.log.warn("homedata failed to download");
1312
- }
1313
- } catch (error) {
1314
- if (!this.handleApiUnauthorized(error, "updateHomeData")) {
1315
- this.log.error(
1316
- `Failed to update updateHomeData with error: ${error}`
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(