77from functools import cmp_to_key
88import json
99import logging
10- from threading import Lock , RLock , Timer
10+ from threading import Lock , RLock , Thread , Timer
1111import time
12- from typing import TYPE_CHECKING , Any , cast
12+ from typing import TYPE_CHECKING , Any , Final , cast
1313import zlib
1414
1515if 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
97144class 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)
0 commit comments