Skip to content

Commit b2d87a6

Browse files
authored
perf: warm-boot fast path + atomic cloud request ids (#29)
* fix(protocol): allocate cloud request ids atomically Concurrent send() calls (MQTT thread + polling + entity commands) read self._id outside the lock and incremented it after the call, so two in-flight requests could share the same id and the cloud rejects the duplicate with an empty result (silent, DEBUG-only). Also documents why the first-refresh property fetch stays sequential with 50-key batches: the robot rejects larger get_properties payloads (100/200 fail) and answers the cloud bridge serially, so parallel batches bring zero wall-clock gain (measured on device, 2026-07-02). * perf: warm-boot fast path via persisted property inventory First refresh previously blocked on 4 serial robot round-trips (~5-8 s depending on Dreame cloud latency). The coordinator now persists the device's answered-property inventory (model/firmware keyed, homeassistant.helpers.storage.Store). On warm boots only the priority batch (every property DreameVacuumDeviceCapability.load() reads — some flags are absence-based — plus the primary state) is fetched synchronously; the remaining present properties load from a background thread. Deferred entities are created identically (existence = inventory) but stay unavailable until their first value arrives, i.e. exactly the timeline they had before. Cold boots (first setup, firmware change) keep the full synchronous load and then publish the fresh inventory. Measured on HA dev (same degraded-cloud window): cold 10.74 s -> warm 5.84 s entry setup; priority batch 1.8-2.0 s vs 4.7-8.4 s full. Proof of no loss: entity registry diff empty (258/258, unique_id and disabled state included) and zero entities downgraded from valued to unavailable/unknown across the change (146 recorder states compared). * docs: changelog for warm-boot fast path & request-id fix
1 parent ace22f6 commit b2d87a6

9 files changed

Lines changed: 387 additions & 14 deletions

File tree

CHANGELOG.md

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,23 @@ All notable changes to this project will be documented in this file.
55
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
66
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
77

8+
## [Unreleased]
9+
10+
### Changed
11+
- Faster startup on warm boots: the integration now persists the device's
12+
answered-property inventory (model/firmware keyed) and only loads the
13+
priority batch (capability inputs + primary state) synchronously during
14+
the first refresh; the remaining properties load in the background while
15+
their entities stay unavailable until the first value arrives — the exact
16+
timeline they had before. Measured on the same network window: entry
17+
setup 10.7 s → 5.8 s (≈3.5-4 s under normal cloud latency). First setup
18+
and firmware changes keep the full synchronous load.
19+
20+
### Fixed
21+
- Cloud request ids are now allocated atomically: concurrent requests
22+
(MQTT push handling, polling, entity commands) could share the same id
23+
and the Dreame cloud silently rejected the duplicate (empty result).
24+
825
## [6.6.0] - 2026-07-02
926

1027
### Changed

custom_components/dreame_vacuum/coordinator.py

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@
3131
from homeassistant.helpers.dispatcher import async_dispatcher_connect
3232
from homeassistant.helpers.entity import generate_entity_id
3333
from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue, async_delete_issue
34+
from homeassistant.helpers.storage import Store
3435
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
3536

3637
from .const import (
@@ -571,6 +572,28 @@ def _fire_event(self, event_id: str, data: Any) -> None:
571572
event_data.update(data)
572573
self.hass.loop.call_soon_threadsafe(self.hass.bus.async_fire, f"{DOMAIN}_{event_id}", event_data)
573574

575+
async def _async_setup(self) -> None:
576+
"""Load the persisted property inventory before the first refresh.
577+
578+
The inventory (model/firmware keyed) lets the device fetch only the
579+
priority property batch synchronously on warm boots; the rest loads
580+
in the background while the affected entities stay unavailable.
581+
"""
582+
self._inventory_store: Store[dict[str, Any]] = Store(
583+
self.hass, 1, f"{DOMAIN}.{self._entry.entry_id}.property_inventory"
584+
)
585+
inventory = await self._inventory_store.async_load()
586+
if self._device:
587+
self._device.set_property_inventory(inventory, self._save_property_inventory)
588+
589+
def _save_property_inventory(self, inventory: dict[str, Any]) -> None:
590+
"""Persist the inventory (called from device worker threads)."""
591+
592+
def _schedule() -> None:
593+
self.hass.async_create_task(self._inventory_store.async_save(inventory))
594+
595+
self.hass.loop.call_soon_threadsafe(_schedule)
596+
574597
async def _async_update_data(self) -> DreameVacuumDevice:
575598
"""Update Dreame Vacuum."""
576599
if self._device is None:

custom_components/dreame_vacuum/dreame/device.py

Lines changed: 166 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -7,9 +7,9 @@
77
from functools import cmp_to_key
88
import json
99
import logging
10-
from threading import Lock, RLock, Timer
10+
from threading import Lock, RLock, Thread, Timer
1111
import time
12-
from typing import TYPE_CHECKING, Any, cast
12+
from typing import TYPE_CHECKING, Any, Final, cast
1313
import zlib
1414

1515
if TYPE_CHECKING:
@@ -93,6 +93,53 @@ def _get_map_module() -> Any:
9393

9494
_LOGGER = logging.getLogger(__name__)
9595

96+
# Properties that MUST be loaded synchronously on the first refresh:
97+
# everything DreameVacuumDeviceCapability.load() reads (some flags are
98+
# absence-based, e.g. MAP_SAVING -> lidar_navigation) plus the primary
99+
# user-facing state. Everything else can arrive from the background tail
100+
# of the warm-boot load (entities stay unavailable until their value lands).
101+
_PRIORITY_BOOT_PROPERTIES: Final = (
102+
DreameVacuumProperty.STATE,
103+
DreameVacuumProperty.STATUS,
104+
DreameVacuumProperty.TASK_STATUS,
105+
DreameVacuumProperty.ERROR,
106+
DreameVacuumProperty.BATTERY_LEVEL,
107+
DreameVacuumProperty.CHARGING_STATUS,
108+
DreameVacuumProperty.CLEANING_MODE,
109+
DreameVacuumProperty.SUCTION_LEVEL,
110+
DreameVacuumProperty.WATER_VOLUME,
111+
DreameVacuumProperty.CLEANING_TIME,
112+
DreameVacuumProperty.CLEANED_AREA,
113+
DreameVacuumProperty.AI_DETECTION,
114+
DreameVacuumProperty.AUTO_MOUNT_MOP,
115+
DreameVacuumProperty.AUTO_SWITCH_SETTINGS,
116+
DreameVacuumProperty.CAMERA_LIGHT_BRIGHTNESS,
117+
DreameVacuumProperty.CARPET_CLEANING,
118+
DreameVacuumProperty.CARPET_RECOGNITION,
119+
DreameVacuumProperty.CRUISE_SCHEDULE,
120+
DreameVacuumProperty.CUSTOMIZED_CLEANING,
121+
DreameVacuumProperty.DETERGENT_LEFT,
122+
DreameVacuumProperty.DND,
123+
DreameVacuumProperty.DND_TASK,
124+
DreameVacuumProperty.DRAINAGE_STATUS,
125+
DreameVacuumProperty.DUST_COLLECTION,
126+
DreameVacuumProperty.MAP_BACKUP_STATUS,
127+
DreameVacuumProperty.MAP_SAVING,
128+
DreameVacuumProperty.MULTI_FLOOR_MAP,
129+
DreameVacuumProperty.OBSTACLE_AVOIDANCE,
130+
DreameVacuumProperty.OFF_PEAK_CHARGING,
131+
DreameVacuumProperty.PET_DETECTIVE,
132+
DreameVacuumProperty.SELF_WASH_BASE_STATUS,
133+
DreameVacuumProperty.SENSOR_DIRTY_LEFT,
134+
DreameVacuumProperty.SHORTCUTS,
135+
DreameVacuumProperty.TASK_TYPE,
136+
DreameVacuumProperty.TIGHT_MOPPING,
137+
DreameVacuumProperty.VOICE_ASSISTANT,
138+
DreameVacuumProperty.WETNESS_LEVEL,
139+
DreameVacuumProperty.WIFI_MAP,
140+
)
141+
_PRIORITY_BOOT_DIDS: Final = frozenset(prop.value for prop in _PRIORITY_BOOT_PROPERTIES)
142+
96143

97144
class DreameVacuumDevice(
98145
DreameVacuumDeviceSettersMixin,
@@ -185,6 +232,12 @@ def __init__(
185232

186233
# Startup flags: two-phase loading for faster startup
187234
self._full_properties_loaded = False
235+
# Warm-boot fast path: persisted property inventory (model/firmware
236+
# keyed, provided by the coordinator). Deferred properties keep their
237+
# entities unavailable until the first value arrives.
238+
self._property_inventory: dict[str, Any] | None = None
239+
self._pending_properties: set[int] = set()
240+
self._inventory_callback: Callable[[dict[str, Any]], None] | None = None
188241
self._map_initialized = False
189242
self._deferred_cloud_loaded = False
190243

@@ -666,9 +719,15 @@ def _request_properties(
666719
if "aiid" not in mapping and (force_all or not self._ready or prop.value in self.data):
667720
property_list.append({"did": str(prop.value), **mapping})
668721

722+
# Batch size is a device-side limit: the robot rejects get_properties
723+
# beyond ~50 keys (100/200 fail), and it answers the cloud bridge
724+
# serially, so parallel batches do not speed anything up either
725+
# (measured 2026-07-02). The first refresh is therefore bound by
726+
# 4 robot round-trips (~1.2 s each) — do not "optimize" this again
727+
# without re-measuring on a real device.
669728
props = property_list.copy()
670729
results = []
671-
batch_size = 50 # Increased from 25 for faster startup (fewer network calls)
730+
batch_size = 50
672731
while props:
673732
result = self._protocol.get_properties(props[:batch_size])
674733
if result is None:
@@ -681,6 +740,108 @@ def _request_properties(
681740

682741
return self._handle_properties(results)
683742

743+
def set_property_inventory(
744+
self,
745+
inventory: dict[str, Any] | None,
746+
callback: Callable[[dict[str, Any]], None] | None = None,
747+
) -> None:
748+
"""Provide the persisted property inventory and its save callback."""
749+
self._property_inventory = inventory
750+
self._inventory_callback = callback
751+
752+
def property_pending(self, prop: DreameVacuumProperty) -> bool:
753+
"""Return True while a property's first value has not arrived yet."""
754+
return prop.value in self._pending_properties
755+
756+
@property
757+
def pending_properties(self) -> set[int]:
758+
"""Property ids whose first value has not arrived yet."""
759+
return self._pending_properties
760+
761+
def _request_initial_properties(self) -> None:
762+
"""Load properties for the first refresh.
763+
764+
With a persisted inventory matching the current model/firmware, only
765+
the priority batch (capability inputs + primary state) is fetched
766+
synchronously; the remaining present properties load from a
767+
background thread while their entities stay unavailable. Without a
768+
usable inventory (first setup, firmware change) the full load stays
769+
synchronous and the fresh inventory is published for persistence.
770+
"""
771+
inventory = self._property_inventory
772+
usable = bool(
773+
inventory
774+
and self.info is not None
775+
and inventory.get("model") == self.info.model
776+
and inventory.get("firmware") == self.info.firmware_version
777+
and inventory.get("present")
778+
)
779+
if not usable:
780+
self._request_properties(force_all=True)
781+
self._full_properties_loaded = True
782+
self._publish_inventory()
783+
return
784+
785+
present = set(inventory["present"])
786+
priority: list[DreameVacuumProperty] = []
787+
deferred: list[DreameVacuumProperty] = []
788+
for prop in self._default_properties:
789+
mapping = self.property_mapping.get(prop)
790+
if mapping is None or "aiid" in mapping:
791+
continue
792+
if prop.value in _PRIORITY_BOOT_DIDS:
793+
priority.append(prop)
794+
elif prop.value in present:
795+
deferred.append(prop)
796+
self._pending_properties = {prop.value for prop in deferred}
797+
try:
798+
self._request_properties(priority, force_all=True)
799+
except Exception:
800+
self._pending_properties = set()
801+
raise
802+
Thread(target=self._load_deferred_properties, args=(deferred,), daemon=True).start()
803+
804+
def _load_deferred_properties(self, deferred: list[DreameVacuumProperty]) -> None:
805+
"""Background tail of the warm-boot load (everything after batch 1)."""
806+
try:
807+
batch_size = 50
808+
for i in range(0, len(deferred), batch_size):
809+
if self.disconnected:
810+
return
811+
batch = deferred[i : i + batch_size]
812+
try:
813+
self._request_properties(batch, force_all=True)
814+
finally:
815+
self._pending_properties -= {prop.value for prop in batch}
816+
self._full_properties_loaded = True
817+
self._publish_inventory()
818+
except Exception:
819+
_LOGGER.warning("Deferred property load failed", exc_info=True)
820+
finally:
821+
if self._pending_properties:
822+
# Never leave entities gated on properties nobody will load;
823+
# the regular update cycle takes over from here.
824+
self._pending_properties = set()
825+
self._property_changed(False)
826+
827+
def _publish_inventory(self) -> None:
828+
"""Report the answered-property inventory for persistence."""
829+
if self._inventory_callback is None or self.info is None:
830+
return
831+
mapped = [
832+
prop.value
833+
for prop in self._default_properties
834+
if prop in self.property_mapping and "aiid" not in self.property_mapping[prop]
835+
]
836+
self._inventory_callback(
837+
{
838+
"model": self.info.model,
839+
"firmware": self.info.firmware_version,
840+
"present": [did for did in mapped if did in self.data],
841+
"absent": [did for did in mapped if did not in self.data],
842+
}
843+
)
844+
684845
def _update_status(self, task_status: DreameVacuumTaskStatus, status: DreameVacuumStatus) -> None:
685846
"""Update status properties on memory for map renderer to update the image before action is sent to the device."""
686847
if task_status is not DreameVacuumTaskStatus.COMPLETED:
@@ -1718,11 +1879,9 @@ def connect_device(self) -> None:
17181879
self._dirty_auto_switch_data = {}
17191880
self._dirty_ai_data = {}
17201881

1721-
# Load ALL properties in a single pass (eliminates separate capability batch overhead)
17221882
t_props = time.time()
1723-
self._request_properties(force_all=True)
1724-
self._full_properties_loaded = True
1725-
_LOGGER.debug("connect_device: all properties loaded in %.2fs", time.time() - t_props)
1883+
self._request_initial_properties()
1884+
_LOGGER.debug("connect_device: initial properties loaded in %.2fs", time.time() - t_props)
17261885
self._last_update_failed = None
17271886

17281887
# Set up map manager (capabilities are now available from loaded properties)

custom_components/dreame_vacuum/dreame/protocol.py

Lines changed: 11 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -569,6 +569,7 @@ def send_async(self, callback: Any, method: Any, parameters: Any, retry_count: i
569569

570570
with self._id_lock:
571571
self._id = self._id + 1
572+
request_id = self._id
572573
self._api_call_async(
573574
lambda api_response: callback(
574575
None
@@ -581,10 +582,10 @@ def send_async(self, callback: Any, method: Any, parameters: Any, retry_count: i
581582
f"{self._strings[37]}{host}/{self._strings[27]}/{self._strings[38]}",
582583
{
583584
"did": str(self._did),
584-
"id": self._id,
585+
"id": request_id,
585586
"data": {
586587
"did": str(self._did),
587-
"id": self._id,
588+
"id": request_id,
588589
"method": method,
589590
"params": parameters,
590591
},
@@ -597,22 +598,26 @@ def send(self, method: Any, parameters: Any, retry_count: int = 2) -> Any:
597598
if self._host and len(self._host):
598599
host = f"-{self._host.split('.')[0]}"
599600

601+
# Allocate the request id atomically BEFORE building the payload:
602+
# concurrent send() calls previously shared the same id (read outside
603+
# the lock, incremented afterwards), and the cloud rejects duplicates.
604+
with self._id_lock:
605+
self._id = self._id + 1
606+
request_id = self._id
600607
api_response = self._api_call(
601608
f"{self._strings[37]}{host}/{self._strings[27]}/{self._strings[38]}",
602609
{
603610
"did": str(self._did),
604-
"id": self._id,
611+
"id": request_id,
605612
"data": {
606613
"did": str(self._did),
607-
"id": self._id,
614+
"id": request_id,
608615
"method": method,
609616
"params": parameters,
610617
},
611618
},
612619
retry_count,
613620
)
614-
with self._id_lock:
615-
self._id = self._id + 1
616621
if (
617622
api_response is None
618623
or "data" not in api_response

custom_components/dreame_vacuum/entity.py

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -53,7 +53,11 @@ def default_exists_fn(description: Any, device: Any) -> bool:
5353
(description.action_key is not None and description.action_key in device.action_mapping)
5454
or description.property_key is None
5555
or (
56-
isinstance(description.property_key, DreameVacuumProperty) and description.property_key.value in device.data
56+
isinstance(description.property_key, DreameVacuumProperty)
57+
and (
58+
description.property_key.value in device.data
59+
or description.property_key.value in device.pending_properties
60+
)
5761
)
5862
or (
5963
isinstance(description.property_key, DreameVacuumAutoSwitchProperty)
@@ -404,6 +408,12 @@ def available(self) -> bool:
404408
if self.device is None or not self.device.device_connected:
405409
return False
406410

411+
prop = getattr(self.entity_description, "property_key", None)
412+
if isinstance(prop, DreameVacuumProperty) and prop.value in self.device.pending_properties:
413+
# Warm-boot: the entity exists (persisted inventory) but its first
414+
# value is still loading in the background.
415+
return False
416+
407417
if self._computed_available_fn is not None:
408418
return self._computed_available_fn(self.device)
409419
return self._attr_available

0 commit comments

Comments
 (0)