Repository navigation
fix: recover missing selected maps without caching placeholders - #995
endoftheline818 wants to merge 3 commits into
Conversation
swastiktawate
left a comment
There was a problem hiding this comment.
This is a thorough fix. The recovery path is properly guarded — idle/charging states only, never while cleaning or returning — and the fallback to recover_missing=False on a busy device means a locked action degrades gracefully instead of failing the whole refresh. Filtering placeholder 'NO MAP' images out of both the in-memory and persisted cache is the right call, and the guard matrix plus the exactly-once reload assertion in the tests show the edge cases were thought through.
Lash-L
left a comment
There was a problem hiding this comment.
Automated code review by Gemini 4 Argon in Antigravity using our /review (/code-review) skill (.agents/skills/code-review/SKILL.md / AGENTS.md).
Summary
Well-structured fix for #985 that keeps missing-map recovery and placeholder filtering encapsulated within HomeTrait, avoids redundant LOAD_MULTI_MAP calls during multi-map discovery, and degrades gracefully when map switching is locked (RoborockInvalidStatus -> RoborockDeviceBusy).
- Layer 1 — Public Trait APIs & Consumer Contracts: Preserves the public
HomeTraitAPI (discover_home(),refresh(),home_map_info,home_map_content) while broadeningRoborockDeviceBusyhandling when the vacuum cannot switch maps. - Layer 2 — Data Lifecycle & State Management: Filters empty placeholder maps (
_has_map_image) before updating both in-memory_home_map_contentand persistentDeviceCache, preserving the last known good map across transient empty responses. - Layer 5 — Tests & Fixtures: Comprehensive parameterized coverage in
tests/devices/traits/v1/test_home.pycovering state/flag guards, action-locked fallback, placeholder eviction, and single- vs. multi-map discovery.
A few lifecycle and state-handling items noted inline:
- Include
RoborockStateCode.charger_disconnected(and checkin_fresh_state/is_locating) in_MAP_LOADING_STATES(roborock/devices/traits/v1/home.py#L39): State2(charger_disconnected) is the V1 sleeping / idle-off-dock state when the robot is not on the charger and no task is active (in_fresh_state == 1). Excluding it causesdiscover_home()and missing-map recovery to treat a sleeping/idle off-dock robot as busy. set_current_map()RefreshesStatusTraitBeforeMAP_SLEEP(roborock/devices/traits/v1/home.py#L178-L181):MapsTrait.set_current_map()sendsLOAD_MULTI_MAPand immediately callsself._status_trait.refresh()beforeawait asyncio.sleep(MAP_SLEEP). If the vacuum does not flipmap_presentto1until theMAP_SLEEPdelay completes,self._status_trait.map_presentremains0in memory and can trigger another reload on the nextHomeTrait.refresh()ifStatusTraitis not refreshed again.- Evicting Cached Placeholders in
discover_home()Without Re-Discovering (roborock/devices/traits/v1/home.py#L112-L122): Stripping placeholder entries fromhome_map_content_base64and returning early withself._discovery_completed = Trueleaves non-active maps on multi-map devices permanently unpopulated (sincerefresh()only refreshes the active map once_discovery_completedisTrue). Also, settingself._discovery_completed = Truebefore the cache-parsingtryblock leaks_discovery_completed = Trueif cache parsing fails and the subsequent busy check raisesRoborockDeviceBusy.
| _LOGGER = logging.getLogger(__name__) | ||
|
|
||
| MAP_SLEEP = 3 | ||
| _MAP_LOADING_STATES = (RoborockStateCode.idle, RoborockStateCode.charging, RoborockStateCode.charging_complete) |
There was a problem hiding this comment.
Include Off-Dock Idle / Sleeping State (RoborockStateCode.charger_disconnected) and Guard Against is_locating
On V1 devices, state 2 (RoborockStateCode.charger_disconnected) represents the sleeping / idle-off-dock state when the vacuum is stationary off the dock with no task running (in_fresh_state == 1, in_cleaning == 0, in_returning == 0). Excluding RoborockStateCode.charger_disconnected from _MAP_LOADING_STATES means a vacuum sitting idle/sleeping off the charger will raise RoborockDeviceBusy during discover_home() and never recover a missing map.
Additionally, in _refresh_map_content() / discover_home(), this should also avoid loading a map when self._status_trait.is_locating == 1 (or when self._status_trait.in_fresh_state is not None and self._status_trait.in_fresh_state != 1), as the robot invalidates the current map ID while locating.
| _MAP_LOADING_STATES = (RoborockStateCode.idle, RoborockStateCode.charging, RoborockStateCode.charging_complete) | |
| _MAP_LOADING_STATES = ( | |
| RoborockStateCode.charger_disconnected, | |
| RoborockStateCode.idle, | |
| RoborockStateCode.charging, | |
| RoborockStateCode.charging_complete, | |
| ) |
There was a problem hiding this comment.
Thanks for reviewing. I’ve left state 2 and the additional status guards unchanged for now. Could you share device diagnostics or protocol references supporting state 2 as safe for map loading and clarify the expected in_fresh_state and is_locating values
| for k, v in (device_cache_data.home_map_content_base64 or {}).items() | ||
| if k in self._home_map_content | ||
| } | ||
| await self._device_cache.set(device_cache_data) |
There was a problem hiding this comment.
Cached Placeholder Cleanup Skips Re-Discovery for Missing Maps & Sets _discovery_completed Too Early
Two subtle lifecycle issues here:
self._discovery_completed = Trueis set on line 102 before thetryblock. Ifasync_parse_map_contentraises(ValueError, RoborockException)and the fallback discovery then raisesRoborockDeviceBusyat line 129,self._discovery_completedremainsTrueeven though discovery failed andself._home_map_contentis{}.- When an older version cached a
"NO MAP"placeholder for a non-active floor on a multi-map device, filtering it out ofself._home_map_contentanddevice_cache_data.home_map_content_base64and returning early withself._discovery_completed = Truemeans that floor's map will never be fetched (since subsequentrefresh()calls only updatecurrent_map_info). Similarly, for a single-map device,discover_home()returns withself._home_map_content == {}until a separaterefresh()is called.
Consider only returning early when all maps in self._home_map_info still have valid map content (or re-running discovery when cached entries had to be evicted), and moving self._discovery_completed = True into the else: branch after cache validation succeeds.
There was a problem hiding this comment.
Addressed in fdc71a8 and ed7b28e. Cached placeholders now trigger rediscovery while good cached images remain available. Discovery stays pending if the robot is busy or no current map is available, and the saved cache remains unchanged until discovery completes. Regression tests cover busy-to-idle recovery and recovery after a current map becomes available.
| await self._maps_trait.set_current_map(current_map_info.map_flag) | ||
| except RoborockInvalidStatus as ex: | ||
| raise RoborockDeviceBusy("Cannot reload selected map right now (device action locked)") from ex | ||
| await asyncio.sleep(MAP_SLEEP) |
There was a problem hiding this comment.
StatusTrait.refresh() Inside set_current_map() Runs Before MAP_SLEEP
self._maps_trait.set_current_map(...) sends LOAD_MULTI_MAP and immediately calls await self._status_trait.refresh() before await asyncio.sleep(MAP_SLEEP) here. As modeled in test_multi_map_discovery_does_not_reload_twice, the immediate GET_STATUS response right after LOAD_MULTI_MAP can still report map_present == 0 while the vacuum is loading the map over MAP_SLEEP.
If a caller runs home_trait.refresh() again without an intervening status_trait.refresh(), self._status_trait.map_present will still be 0 and _refresh_map_content() will issue LOAD_MULTI_MAP + sleep(MAP_SLEEP) again even though the map is now loaded. Refreshing self._status_trait after await asyncio.sleep(MAP_SLEEP) (or skipping recovery when self._home_map_content already holds a valid image for current_map_info.map_flag unless map_present == 0 was freshly observed) avoids redundant reloads.
There was a problem hiding this comment.
Addressed in fdc71a8. Both single-map recovery and multi-map discovery now refresh status again after the map-loading delay. Tests model the immediate status reporting map_present == 0, the later read reporting 1, and a subsequent refresh sending no additional load command.
Discovery used to drop a cached "NO MAP" placeholder, rewrite the cache without it and mark discovery complete, so that floor stayed empty until it became the active map. A placeholder now leaves discovery pending: the good cached maps stay available, the saved cache is not touched, and the home is discovered again on the first idle refresh. A completed discovery replaces the cache, and a floor that answers with a placeholder keeps its last good image. The device cache can hand out the object it stores, so discovery keeps copies in memory instead of changing the cached objects before they are saved. Discovery is marked complete only after it succeeds. MapsTrait.set_current_map() reads the status right after LOAD_MULTI_MAP, which can still report map_present == 0. Read the status again after the load delay in both single-map recovery and multi-map discovery so the next refresh does not send the same load command again.
When a cached placeholder needed rediscovery but the device reported no current map, discovery was marked complete, so the missing floor was never rediscovered once a map became current. Raise RoborockDeviceBusy instead, so refresh() falls back to reading the current map and retries discovery on the next refresh. The saved cache is still left untouched meanwhile. The regression test now continues past the missing current map: a floor becomes current and an ordinary refresh rediscovers the other floor.
|
Updated the PR description with the follow-up fixes and final Linux validation results. I've also replied to all three review threads. Hosted CI for |
allenporter
left a comment
There was a problem hiding this comment.
Automated code review by Gemini 4 Argon in Antigravity using our code-review skill and AGENTS.md.
Thanks for digging into #985 / home-assistant/core#185133 and iterating on the earlier feedback!
Problem Acknowledgment
The underlying issue you are targeting is definitely real and belongs in HomeTrait: V1 vacuums can wake from a reboot or sleep with a valid selected map (current_map / GET_MULTI_MAPS_LIST) while reporting map_present == 0 in GET_STATUS, causing GET_MAP_V1 to return an empty 0x0 "NO MAP" image (map_data.image.is_empty). Today on main, HomeTrait skips LOAD_MULTI_MAP during single-map discovery and overwrites previously valid maps in both memory and DeviceCache with "NO MAP" placeholders.
Issues with the Current Implementation (ed7b28e)
Stepping back to look at the full lifecycle across polls, restarts, and downstream callers, the current state machine introduces several regressions and edge-case traps:
-
Cache-completeness check (
len(self._home_map_content) == len(cached_content)) locks out rediscovery across restarts (home.py#L118-L123,#L340-L348):- If any floor returns a placeholder during
discover_home(),_update_home_cache()saves all floors inhome_map_infowhile omitting the placeholder floor fromhome_map_content_base64. - On the next restart,
cached_contentonly contains the non-empty floor(s) (or{}), solen(self._home_map_content) == len(cached_content)evaluatesTrue(1 == 1or0 == 0), sets_discovery_completed = True, and permanently skips rediscovery of the missing floor. - Note that several
cached=Truetest cases intest_home.py(#L700-L705,#L791-L795,#L849-L853) seedhome_map_infowithhome_map_content_base64 = Noneand only pass because0 == 0treats a mapless cache as complete.
- If any floor returns a placeholder during
-
Repeated
discover_home()calls clobber live in-memory maps and re-parse cached maps on every poll (home.py#L103-L129,#L257-L266):- While
_discovery_completedisFalse(e.g., a cached placeholder while the vacuum is cleaning), everyrefresh()poll callsdiscover_home(), which unconditionally overwritesself._home_map_infoandself._home_map_contentback to the stale disk cache—wiping out live maps fetched on earlier busy polls and spawningasyncio.to_threadPIL renders for every cached floor on every poll. When the robot later goes idle, line 118 again overwrites live in-memory maps with older cached copies right before line 151's fallback merge. - Even after
_discovery_completedisTrue,discover_home()has noif self._discovery_completed: returnguard. Callers that runawait discover_home(); await refresh()(such as Home Assistant'scoordinator.update_map()) re-decode and re-parse all cached maps on every update—and re-run full physical map switching whenNoCache(the default inDeviceManager) is used.
- While
-
Deleted-map wedge, asymmetric
current_map_info is Nonehandling, and corrupt-cache data loss (home.py#L109-L146):- Lines 138–142 (
if self._maps_trait.current_map_info is None: if keep_cached_home: raise RoborockDeviceBusy(...)) conflate "no current map selected yet (current_map is None)" with "all maps were deleted on the vacuum (map_info == [])". If a user deletes all maps on an idle vacuum whileDeviceCacheholds a legacy placeholder,discover_home()raisesRoborockDeviceBusyforever instead of clearing the stale cache. Conversely, whenkeep_cached_homeisFalseand saved maps exist (map_infonon-empty) whilecurrent_mapis temporarilyNone(map_status == 252), lines 144–145 mark_discovery_completed = Trueand write{}to cache. - Also note that callers like Home Assistant's
coordinator.update_map()callawait discover_home()beforeawait refresh()without catchingRoborockDeviceBusyseparately; raisingRoborockDeviceBusyon cached-placeholder homes across all non-idle states abortsupdate_map()beforerefresh()can update the active map during cleaning/returning/paused states. - In
except (ValueError, RoborockException):(#L114, which should also catchTypeErrorto matchMapContentTrait.refresh()),keep_cached_homeremainsFalse(so a subsequentcurrent_map_info is Nonewipes valid persistedhome_map_infowith{}), and parsing inside a single dict comprehension discards all valid cached floors when only one floor fails to parse.
- Lines 138–142 (
-
Unbounded
LOAD_MULTI_MAPloop whenmap_presentstays0, plus double reloads on manual map switches (home.py#L178-L207,maps.py#L80-L84):- If a device continues reporting
map_present == 0afterLOAD_MULTI_MAP+MAP_SLEEP(modeled intest_multi_map_discovery_does_not_reload_twice, or on an unmapped floor slot), every subsequentrefresh()will issueLOAD_MULTI_MAP(over cloud-only MQTT) +sleep(3)+GET_STATUSindefinitely, with no per-map retry guard and even when_home_map_contentalready holds a valid image. - Patching around
MapsTrait.set_current_map()'s immediatestatus.refresh()insideHomeTrait._wait_for_map_load()leaves external callers ofMapsTrait.set_current_map()(e.g., Home Assistant's mapselectentity, which runsset_current_map()→sleep(MAP_SLEEP)→home.refresh()) with a stalemap_present == 0, triggering a secondLOAD_MULTI_MAP+ 3s sleep on every manual map switch, and doublingGET_STATUSRPCs during multi-map discovery. - When
discover_home()raisesRoborockDeviceBusyafter refreshing_maps_trait(e.g.,-10007action locked),refresh()immediately calls_maps_trait.refresh()(GET_MULTI_MAPS_LISTover cloud MQTT) a second time (#L269).
- If a device continues reporting
-
Omitting placeholders when no prior image exists desynchronizes
home_map_infoandhome_map_content(home.py#L340,#L383-L384):- Keeping placeholders out of persistent
DeviceCacheand never letting a placeholder overwrite an existing valid map in_home_map_contentmakes sense. However, droppingmap_contentfrom_home_map_contenteven whenmap_flaghas no prior image leavesmap_flaginhome_map_infowhile absent fromhome_map_content, causing downstream consumers (such as Home Assistant'sRoborockMap.async_image()) to raisemap_not_founderrors instead of serving the rendered"NO MAP"PNG until a real map is available.
- Keeping placeholders out of persistent
-
Concurrency, mid-discovery state changes, and V1 state guards (
home.py#L39,#L215-L247):- Both
discover_home()andrefresh()can now switch maps on the device and yield across 3s+MAP_SLEEPwindows without anasyncio.Lock(risking cross-floor cache contamination if calls overlap) and without re-checking idle state between loop iterations in_build_home_map_info(). - Regarding the earlier thread question on V1 status flags:
RoborockStateCode.charger_disconnected(2) is the standard V1 stationary/idle-off-dock state when the robot is off the charging contacts with no active task (in_cleaning == 0,in_returning == 0; mapped toVacuumActivity.IDLEin Home Assistant and present in V1 protocol/sensor snapshots), so excluding it blocks recovery whenever a vacuum rests off-dock or its dock loses power. Conversely,is_locating == 1(active positioning) andis_exploring == 1should block map loading.
- Both
-
Test mocks & sizing (
test_home.py):test_missing_selected_map_recovery(#L726-L731) returnsMAP_BYTES_RESPONSE_2(a valid map) rather thanEMPTY_MAP_BYTESprior toLOAD_MULTI_MAP.test_placeholder_rediscovery_waits_for_current_map(#L1088-L1090) simulates "no current map" usingmulti_map_count: 0, map_info: [](all maps deleted) rather thanmap_status = 252(NO_MAP = 63→current_map is None) with saved maps present.V1VacuumSimulatorinroborock.testingonmainnow supports serving V1 maps directly (seetests/devices/traits/v1/test_map_content.py), which could replace much of the customTwoFloorDeviceharness (+590lines).
Suggested Simpler Alternative
Rather than managing a multi-branch keep_cached_home rediscovery state machine, this can likely be solved in ~35–45 lines with much simpler invariants:
-
One-time cache hydration & fast-path in
discover_home():- Serialize
discover_home()/refresh()with anasyncio.Lockand return immediately at the top ofdiscover_home()ifself._discovery_completedisTrue. - Hydrate
DeviceCacheat most once (self._cache_loaded), parsinghome_map_content_base64per floor (catching(TypeError, ValueError, RoborockException)per entry) and merging beneath any newer in-memory entries. - Mark
self._discovery_completed = Truefrom cache iffself._home_map_infois non-empty and everymap_flaginself._home_map_infohas a_has_map_imageentry inself._home_map_content. - If
not _can_load_map(): whenself._home_map_infois already populated from cache, return early (leavingself._discovery_completed = Falseso rediscovery runs once idle, without breakingawait discover_home(); await refresh()callers while busy); only raiseRoborockDeviceBusywhen no cached home exists yet.
- Serialize
-
Preserve in-memory fallback while keeping
DeviceCacheplaceholder-free:- Never persist
not _has_map_image(content)entries todevice_cache_data.home_map_content_base64. - In memory (
self._home_map_content), never overwrite an existing_has_map_imageentry with a placeholder, but still store the placeholder ifmap_flag not in self._home_map_contentso every floor inhome_map_infoalways has renderable PNG bytes.
- Never persist
-
Bounded, single-shot missing-map recovery inside
_refresh_map_content():- Track
self._attempted_map_reload: set[int](cleared whenmap_present == 1is observed) so a device or unmapped slot whosemap_presentstays0only triggersLOAD_MULTI_MAPonce per missing state rather than on every poll. - Define
_can_load_map()asstate in (charger_disconnected, idle, charging, charging_complete)and not(in_cleaning, in_returning, is_locating, is_exploring). - In
_refresh_map_content(), ifrecover_missing and map_present == 0 and _can_load_map()andcurrent_map_infohasn't been attempted yet: record itsmap_flag, callset_current_map(), waitMAP_SLEEP+ refresh status, and ifRoborockInvalidStatusis raised, log debug and fall through directly toawait self._map_content.refresh()(avoiding the exception bounce and duplicate calls inrefresh()).
- Track
-
Clean
map_infovs.current_map_infocheck indiscover_home():- After
await self._maps_trait.refresh(), ifnot self._maps_trait.map_info(genuinely zero maps on the vacuum), clear the cache (_update_home_cache({}, {})) and set_discovery_completed = True. - Otherwise, if
self._maps_trait.current_map_info is None(saved maps exist, none currently active), defer discovery without wiping the cache.
- After
Some vacuums show "NO MAP" even though a saved map is already selected. Selecting that same map again restores the image. This change performs that recovery automatically when the vacuum is idle or charging, without interrupting cleaning or a return to the dock.
The fix belongs in HomeTrait, as requested in home-assistant/core#186678:
set_current_map()can still reportmap_present == 0while the map is loading, causing another load command on the next refresh.Fixes #985. Related: home-assistant/core#185133 and home-assistant/core#186678.
Validation:
Tests use simulated device responses. A physical Q5 Pro has not been tested with this change.
AI assistance: OpenAI Codex and Anthropic Claude Code helped prepare, review, and test this change under the contributor's direction.