homebridge-roborock-matter 2.9.3 → 2.9.5

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,17 @@
1
1
  # Changelog
2
2
 
3
+ ## 2.9.5
4
+
5
+ - **One synchronous disk write per received robot message eliminated.** The per-device diagnostics states (last cloud/local message, transport history) were flushed to disk with a blocking `fs.writeFileSync` on EVERY message a robot pushed — every few seconds per robot while cleaning. They are served from memory (the settings UI never reads the file); the on-disk copy only needs to survive restarts. Disk flushes for these two states are now debounced to at most once per minute, with a guaranteed flush on shutdown. Result: event-loop stalls removed from the message hot path, and meaningfully less SD-card wear on Raspberry Pi installs. Critical states (credentials, HomeData, room caches) still persist immediately.
6
+ - Full suite: 263 passing (3 new persistence-debounce tests).
7
+
8
+ ## 2.9.4
9
+
10
+ Startup-cost cleanup release (also refreshes the npm README with the Donate button and the prominent Verified badge).
11
+
12
+ - **Two fewer RSA-2048 key generations at every startup.** The MQTT connector generated a protocol keypair that nothing ever read (removed), and the message layer generated its keypair eagerly even though it is only needed for the rare photo request path on camera-equipped models (now created lazily on first use). Measured ~50 ms per keygen on fast hardware — substantially more on a Raspberry Pi.
13
+ - Removed dead code: the never-called `decryptWithPrivateKey` helper and the unused `scenesData` field (both HomeKit-era leftovers).
14
+
3
15
  ## 2.9.3
4
16
 
5
17
  **The plugin is now Verified by Homebridge!** 🎉 Reviewed and endorsed by the Homebridge team (homebridge/plugins#1124), with specific praise for the encrypted at-rest session storage, the preserved fork attribution, and the per-release notes.
package/README.md CHANGED
@@ -9,7 +9,6 @@
9
9
  </p>
10
10
 
11
11
  <p align="center">
12
- <a href="https://github.com/homebridge/homebridge/wiki/Verified-Plugins"><img src="https://img.shields.io/badge/homebridge-verified-blueviolet?color=%23491F59&style=flat" alt="verified-by-homebridge"></a>
13
12
  <a href="https://www.npmjs.com/package/homebridge-roborock-matter"><img src="https://img.shields.io/npm/v/homebridge-roborock-matter?label=npm&color=cb3837" alt="npm version"></a>
14
13
  <a href="https://www.npmjs.com/package/homebridge-roborock-matter"><img src="https://img.shields.io/npm/dt/homebridge-roborock-matter?label=downloads&color=8a5cf5" alt="npm downloads"></a>
15
14
  <a href="https://github.com/mathiashornbek/homebridge-roborock-matter/actions"><img src="https://img.shields.io/github/actions/workflow/status/mathiashornbek/homebridge-roborock-matter/nodejs.yml?label=CI" alt="CI status"></a>
@@ -18,6 +17,14 @@
18
17
  <a href="./LICENSE"><img src="https://img.shields.io/badge/license-MIT-blue" alt="MIT license"></a>
19
18
  </p>
20
19
 
20
+ <p align="center">
21
+ <a href="https://paypal.me/MathiasHornbek"><img src="https://img.shields.io/badge/PayPal-Donate-00457C?logo=paypal&logoColor=white" alt="Donate via PayPal"></a>
22
+ </p>
23
+
24
+ <p align="center">
25
+ <a href="https://github.com/homebridge/homebridge/wiki/Verified-Plugins"><img src="https://img.shields.io/badge/homebridge-verified-blueviolet?color=%23491F59&style=for-the-badge&logoColor=%23FFFFFF&logo=homebridge" alt="Verified by Homebridge"></a>
26
+ </p>
27
+
21
28
  ---
22
29
 
23
30
  Log in with your **Roborock app account** — no token extraction, no rooted apps, no packet sniffing — and every robot appears in Apple Home as a first-class **Matter Robotic Vacuum Cleaner**: start, pause, dock, pick rooms, choose cleaning modes, and watch the status pill name the room the robot is _actually inside_, live.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "homebridge-roborock-matter",
3
- "version": "2.9.3",
3
+ "version": "2.9.5",
4
4
  "description": "Matter-only Homebridge plugin publishing Roborock robot vacuums (including 2025 B01/Q7-series) as native Matter accessories for Apple Home. Fork of homebridge-roborock-vacuum2.",
5
5
  "license": "MIT",
6
6
  "author": {
@@ -32,7 +32,18 @@ class message {
32
32
  this.adapter = adapter;
33
33
  this.missingLocalKeyWarnings = new Set();
34
34
 
35
- this.keys = roborockCrypto.generateRsaKeyPair();
35
+ // The protocol RSA keypair is only needed for the rare photo request
36
+ // path (camera-equipped models). Generate it lazily on first use
37
+ // instead of paying a full RSA-2048 keygen (~50 ms on fast hardware,
38
+ // substantially more on a Raspberry Pi) at every startup.
39
+ this._keys = null;
40
+ }
41
+
42
+ get keys() {
43
+ if (!this._keys) {
44
+ this._keys = roborockCrypto.generateRsaKeyPair();
45
+ }
46
+ return this._keys;
36
47
  }
37
48
 
38
49
  async buildPayload(
@@ -77,7 +77,10 @@ class roborock_mqtt_connector {
77
77
 
78
78
  this.connected = false;
79
79
 
80
- this.keys = roborockCrypto.generateRsaKeyPair();
80
+ // NOTE: this class previously generated its own RSA-2048 keypair here,
81
+ // but nothing ever read it — the protocol keypair lives in message.js
82
+ // (lazily created for the rare photo path). Removed: one full RSA
83
+ // keygen less at every startup.
81
84
  }
82
85
 
83
86
  async initUser(userdata) {
@@ -524,24 +527,6 @@ class roborock_mqtt_connector {
524
527
 
525
528
  return false;
526
529
  }
527
-
528
- decryptWithPrivateKey(privateKeyPem, encryptedData) {
529
- const privateKey = crypto.createPrivateKey({
530
- key: privateKeyPem,
531
- format: "pem",
532
- type: "pkcs8",
533
- });
534
-
535
- const decryptedData = crypto.privateDecrypt(
536
- {
537
- key: privateKey,
538
- padding: crypto.constants.RSA_PKCS1_PADDING,
539
- },
540
- encryptedData
541
- );
542
-
543
- return decryptedData;
544
- }
545
530
  }
546
531
 
547
532
  /**
@@ -42,6 +42,15 @@ const B01_LIVE_ROOM_CLEAR_V1_STATES = new Set([3, 8]);
42
42
  // slower cadence than the ~12s active status polls.
43
43
  const B01_LIVE_ROOM_MIN_FETCH_GAP_MS = 20000;
44
44
 
45
+ // Persisted states whose disk flush is debounced (see setStateAsync): they
46
+ // change on every received robot message, are served from memory, and only
47
+ // need the on-disk copy for restart survival.
48
+ const DEBOUNCED_PERSIST_IDS = new Set([
49
+ "TransportDiagnostics",
50
+ "RoborockDiagnostics",
51
+ ]);
52
+ const PERSIST_FLUSH_DEBOUNCE_MS = 60000;
53
+
45
54
  const PERSISTED_STATE_IDS = new Set([
46
55
  "UserData",
47
56
  "clientID",
@@ -122,8 +131,6 @@ class Roborock {
122
131
  this.localDevices = {};
123
132
  this.remoteDevices = new Set();
124
133
 
125
- this.scenesData = null; // Store scenes data locally
126
-
127
134
  this.name = "roborock";
128
135
  this.deviceNotify = null;
129
136
  this.serviceAreaRoomMapRefreshAttempts = new Map();
@@ -281,6 +288,19 @@ class Roborock {
281
288
  async setStateAsync(id, state) {
282
289
  try {
283
290
  if (PERSISTED_STATE_IDS.has(id)) {
291
+ // Chatty diagnostic states update on every received robot message
292
+ // (every few seconds while cleaning). They are read from memory by
293
+ // the settings UI; the on-disk copy only needs to survive restarts.
294
+ // Debouncing their disk flush to once per minute turns one
295
+ // SYNCHRONOUS write per robot message into at most one per minute
296
+ // — a real win for event-loop latency and SD-card wear on
297
+ // Raspberry Pi installs. Critical states (credentials, HomeData,
298
+ // room caches) still persist immediately.
299
+ if (DEBOUNCED_PERSIST_IDS.has(id)) {
300
+ this.states[id] = state;
301
+ this.schedulePersistFlush(id);
302
+ return;
303
+ }
284
304
  const persistPath = this.getPersistPath(id);
285
305
  fs.mkdirSync(path.dirname(persistPath), { recursive: true });
286
306
  fs.writeFileSync(persistPath, JSON.stringify(state, null, 2, "utf8"));
@@ -1530,6 +1550,7 @@ class Roborock {
1530
1550
 
1531
1551
  async stopService() {
1532
1552
  try {
1553
+ this.flushPendingPersistedStates();
1533
1554
  await this.clearTimersAndIntervals();
1534
1555
  this.bInited = false;
1535
1556
  } catch (e) {
@@ -1537,6 +1558,59 @@ class Roborock {
1537
1558
  }
1538
1559
  }
1539
1560
 
1561
+ /**
1562
+ * Schedule a debounced disk flush for a chatty persisted state. The
1563
+ * in-memory copy is already current; the trailing flush (unref'd so it
1564
+ * never keeps the process alive) writes the LATEST value at most once
1565
+ * per PERSIST_FLUSH_DEBOUNCE_MS.
1566
+ * @param {string} id
1567
+ */
1568
+ schedulePersistFlush(id) {
1569
+ if (!this._pendingPersistFlushes) {
1570
+ this._pendingPersistFlushes = new Map();
1571
+ }
1572
+ if (this._pendingPersistFlushes.has(id)) {
1573
+ return;
1574
+ }
1575
+ const timer = setTimeout(() => {
1576
+ this._pendingPersistFlushes.delete(id);
1577
+ this.persistStateToDisk(id);
1578
+ }, PERSIST_FLUSH_DEBOUNCE_MS);
1579
+ if (typeof timer?.unref === "function") {
1580
+ timer.unref();
1581
+ }
1582
+ this._pendingPersistFlushes.set(id, timer);
1583
+ }
1584
+
1585
+ /** Write the current in-memory value of a persisted state to disk now. */
1586
+ persistStateToDisk(id) {
1587
+ try {
1588
+ const state = this.states[id];
1589
+ if (state === undefined) {
1590
+ return;
1591
+ }
1592
+ const persistPath = this.getPersistPath(id);
1593
+ fs.mkdirSync(path.dirname(persistPath), { recursive: true });
1594
+ fs.writeFileSync(persistPath, JSON.stringify(state, null, 2));
1595
+ } catch (error) {
1596
+ this.log.debug(
1597
+ `Debounced persist of '${id}' failed: ${error?.message || error}`
1598
+ );
1599
+ }
1600
+ }
1601
+
1602
+ /** Flush all pending debounced persists immediately (shutdown path). */
1603
+ flushPendingPersistedStates() {
1604
+ if (!this._pendingPersistFlushes) {
1605
+ return;
1606
+ }
1607
+ for (const [id, timer] of this._pendingPersistFlushes) {
1608
+ clearTimeout(timer);
1609
+ this.persistStateToDisk(id);
1610
+ }
1611
+ this._pendingPersistFlushes.clear();
1612
+ }
1613
+
1540
1614
  async getUserData(loginApi) {
1541
1615
  try {
1542
1616
  if (this.isValidUserData(this.userData)) {