31 โ MapSourceCoordinator¶
Scope: Implementation reference for
mapping/map_source_coordinator.pyโ the bundled subsystem that owns themap_state_sourcebackend dispatch (the VA's read of the provider's own room segmentation + live pose). It is the runtime brain behind the seam; the pure decode/normalization lives inmapping/map_source.pyandmapping/map_source_runtime.py, and the design rationale lives in map-state-source. This doc is the ownership anchor โ several older docs still attribute these fourasync_*readers tocore/manager.py; they are delegators, the work is here.
1. Overview¶
map_state_source is the VA-owned read of the device/integration's OWN map segmentation โ the provider's authoritative per-room geometry, current room, and live robot/dock pose โ normalized into a brand-general payload the card and consumers use. It is distinct from the CV segmentor (26-eufy-segmentor): the segmentor infers rooms from an uploaded map image with computer vision; map_state_source reads the segmentation the device already computed (room-id raster on Eufy, the parsed MapData on Roborock) and never guesses. It is also distinct from the legacy trace-bounds path (11-mapping-system ยง3), which derived bounds from drifting robot samples; map_state_source is immune to the per-session coordinate drift (see reference_eufy_intersession_coord_drift) because it consumes the provider's own segmentation, not accumulated poses.
MapSourceCoordinator is constructed with the core manager (the bundled-subsystem pattern, the same shape as ActiveJobTracker, PhaseRunner, LiveRoomRefreshManager):
# core/manager.py :: async_initialize
from ..mapping.map_source_coordinator import MapSourceCoordinator
self.map_source = MapSourceCoordinator(manager=self)
It uses manager.hass, writes its normalized result into manager._map_state_source_cache, and shares manager._resolve_live_map_image_entity. Everything brand-specific is in the adapter's map_state_source / map_render blocks; the coordinator only dispatches by the declared backend/format and never makes a brand assumption.
Module: custom_components/eufy_vacuum/mapping/map_source_coordinator.py
Class: MapSourceCoordinator
1.1 What it owns¶
- The pre-warm dispatcher (
async_refresh_map_state_source) that fans out to the three backends and populates the manager cache off-loop. - The three backend branches: storage, memory-primary (Eufy), and memory introspect (Roborock).
- The live-pose overlay path โ layering eufy-clean's fresh in-memory robot/dock/trail onto the static segmentation.
- The lightweight live-pose poll (
async_get_map_live_pose), the verify probe (async_compare_map_sources), and the card own-render raster fetch (async_get_map_render_data). - The zone-dispatch live
MapDataobject lookup (get_live_mapdata_obj) and the raw decodedmap_datadict feed for the pure room readers (async_get_map_data_dict) โ see ยง3.5. - Its two backend-internal caches:
_mem_rooms_cache(content-versioned static scan) and_live_pose_geom_cache(mtime-cached geometry).
1.2 What stays on the manager (and why)¶
Two seams deliberately remain on core/manager.py so the on-loop readers (and their tests) don't change:
_map_state_source_cacheโ the pre-warm writes the normalized result here (self._manager._map_state_source_cache[...]), and two synchronous on-loop readers consume it directly: the dashboard-snapshot composer (get_dashboard_snapshot, themap_state_sourcesnapshot key) and the map-overlays sensor (sensor/map_overlays.py). Keeping the cache on the manager means those readers never reach across into the subsystem. Value shape:{"mtime": float|None, "present_gate": bool, "map_id": str, "result": <map_source result dict>}._resolve_live_map_image_entityโ the live-map presence gate, also used by the snapshot composer for the live backdrop. Sharing it means the snapshot and the coordinator agree on "is the live map present?". The pre-warm calls it viaself._manager._resolve_live_map_image_entity.
The coordinator's own caches (_mem_rooms_cache, _live_pose_geom_cache) live on the coordinator because only its own methods read them.
2. The three backends + the presence gate¶
The adapter declares a map_state_source block (see 25-eufy-adapter for the Eufy values and 22-adapter-config-reference for the full schema). The backend key selects the branch; the optional memory block upgrades the storage backend to memory-primary.
2.1 The presence gate¶
Before any backend runs, async_refresh_map_state_source resolves the live-map image entity and computes present:
live_img = self._manager._resolve_live_map_image_entity(
vacuum_entity_id=vacuum_entity_id, map_id=map_id, adapter_cfg=adapter_cfg
)
present = (live_img is not None) if source_cfg.get(
"present_requires_live_map_image", True
) else True
Most backends require the live-map artifact (the same gate as the live backdrop), so an older or plain eufy-clean install โ which has no camera.<device>_map โ resolves to not present and segmentation features hide, exactly like the model/CV presence gates. An adapter can opt out with present_requires_live_map_image: False. The resolved live_img is also handed to the Roborock introspector (the parsed MapData likely lives on that image entity object).
2.2 storage (and memory-primary)¶
Dispatched when backend == "storage". Two sub-paths:
- Memory-primary โ when the adapter declares a
memoryblock (eufy-clean holds the sameMapDatain memory, fresher + loop-safe). Runs_refresh_eufy_map_source, which falls back to the.storageread internally when the in-memoryMapDatais absent/malformed. - Plain
.storageโ when there's nomemoryblock (legacy / other forks). Runs_refresh_storage_map_source.
The .storage read (map_source_runtime.load_store_json) is blocking โ it's a ~200 KB JSON + base64 decode โ so it runs through hass.async_add_executor_job and is cached by file mtime (plus the presence gate): an unchanged map costs one stat, not a re-parse, on every snapshot.
2.3 memory (Roborock introspect)¶
Dispatched when backend == "memory". Pure in-memory introspection (no IO, loop-safe). It collects candidate roots (map_source_runtime.roborock_candidates โ config-entry runtime_data, hass.data[domain], and the live image entity object) and resolves them via roborock_result_from_candidates, which BFS-walks for a MapData-like object (.rooms + .image) and projects each room's bbox through the parser's own ImageDimensions.to_img(...).rotated(...) transform. It always attaches a diagnostics breadcrumb (a bounded structure tree when nothing is found) for deploy-and-discover tuning.
2.4 Unknown backend / not configured / failure¶
Every miss degrades to an absent marker written to the cache so the on-loop snapshot never errors:
| Condition | reason |
|---|---|
No map_state_source block |
not_configured |
backend unrecognized |
unknown_backend:<backend> |
| Any unforeseen exception in dispatch | refresh_error |
| Storage: no resolvable device | no_device |
| Storage: version guard failed | store_version_mismatch (logs a warning) |
| Presence gate false | live_map_absent |
The whole dispatch is wrapped in a try/except โ async_refresh_map_state_source never raises (it runs on the event loop inside the dashboard-snapshot service).
3. The public methods¶
MapSourceCoordinator exposes six public methods: the four async_* readers below (ยง3.1โยง3.4) plus two more covered in ยง3.5 โ the sync get_live_mapdata_obj (live parser MapData object for zone dispatch) and the async async_get_map_data_dict (raw decoded Eufy map_data dict feeding the pure readers). The four readers are async, keyword-only, adapter-driven, and degrade to an absent marker โ never raise. core/manager.py keeps a 1-line delegator for each (e.g. async_refresh_map_state_source โ self.map_source.async_refresh_map_state_source(...)), so existing call sites are unchanged.
3.1 async_refresh_map_state_source¶
Pre-warm: dispatches to the backend, applies the presence gate, and writes the normalized result into manager._map_state_source_cache[vacuum_entity_id]. Called from the dashboard-snapshot service handler before the synchronous snapshot, so the on-loop snapshot can include the result without doing the blocking .storage read itself. Returns the cached result dict (also handy for tests / a future sensor).
Result shape (present): {present: True, backend, rooms: [{number, name, bbox, area_m2, width_m, height_m, ...}], image_size, current_room?, path?, robot_anchor?, dock_anchor?, robot_heading?, robot_docked?, walls?, no_go?, no_mop?, zones?, obstacles?} โ assembled by map_source.build_map_source_result + the live-pose overlay. Bboxes are normalized to 0โ1 of the rendered image (top-left origin, Y-flip applied). width_m/height_m are the room's real-world box size in metres (Eufy derived from the map resolution, Roborock from the raw mm coordinates), used for per-room framing. Separately, the dashboard snapshot this pre-warms also carries a furnished_render key (resolved by resolve_furnished_render in map_source.py โ the active custom layout's furnished art/transform/mode; see data model).
3.2 async_get_map_live_pose¶
Returns only the moving overlays (robot/dock anchors + current_room + heading + live path) from eufy-clean's fresh in-memory pose โ the lightweight payload the card polls at the ~2s live cadence, vs the full snapshot. It reads the in-memory pose (_read_inmem_pose), loads the mtime-cached static geometry (_load_live_pose_geom) the normalization needs, and runs map_source.live_pose_overlay. Degrades to {present: False, reason, diagnostics}. (Used by the card and by the _handle_get_map_live_pose service and the server-side pose-sampler probe in mapping/mapping_services.py.)
3.3 async_compare_map_sources¶
Diagnostic verify probe (P1): reads eufy-clean's in-memory _map_data AND the .storage map_data and compares them field-by-field via map_source.compare_map_data, to confirm the in-memory bytes are byte-identical before repointing the source to memory. Returns {in_memory_present, storage_present, diagnostics, compare?}, where compare carries a per-field comparison (rasters by len+sha1) and a normalization_safe verdict over the geometry/raster fields the decoders use. Adapter-driven via map_state_source.memory; degrades to a marker on absence.
3.4 async_get_map_render_data¶
Returns the raster + decode params for the card's own map render (Wave 1). Adapter-driven: the adapter's map_render.format selects the decode, and the source pointer is reused from map_state_source (no duplicate schema). Two formats today:
eufy_room_pixels_v1โ read eufy-clean's persistedroom_pixelsraster. Memory-primary (in-memory raster first viarender_data_from_storage) with an off-loop.storagefallback (eufy_render_data_from_store).roborock_raw_map_v1โ Roborock keeps no persisted raster, somapping/roborock_raw_map.pyre-decodes the raw map blob's segment layer (surviving on the v1MapContent.raw_api_responsein HA memory) into a room-id raster, thenroborock_render_data()wraps it in the sameeufy_room_pixels_v1payload shape. So the card decode is identical for both brands; only the backend source differs. v1 (S6 / Q-class) only. See 29 ยงmap_renderfor the byte format + thegeometry_driftdecode-validation.
The card calls this on demand (when the VA-rendered backdrop is selected) and caches by the returned version; the raster is static (changes only on a re-map), so it's fetch-once, not snapshot bloat. Degrades to {present: False, reason}.
3.5 get_live_mapdata_obj + async_get_map_data_dict¶
Beyond the four readers, the coordinator exposes two more public methods that feed zone dispatch and the pure-decode room readers:
get_live_mapdata_obj(*, vacuum_entity_id: str, map_id: str) # sync
async_get_map_data_dict(*, vacuum_entity_id: str) -> dict[str, Any] | None
get_live_mapdata_obj(map_source_coordinator.py:264) โ sync, in-memory introspection only (no IO, loop-safe). Locates the live parserMapDataobject (not the normalized dict) so zone dispatch can project a drawn zone back to device-mm via the parser's own transform (the same projector the overlays use โ the converted dict has lost it). Routes by the adapter'smap_state_sourcebackend. ReturnsNonewhen no live map is available, and the caller MUST then refuse to dispatch. Invoked directly on the coordinator atcore/manager.py:4208(zone dispatch) โ there is no manager delegator for it. Never raises.async_get_map_data_dict(map_source_coordinator.py:564) โ returns the raw decoded Eufymap_datadict (room_pixels + origin/res/dims) for the current map: the input the pure readersrooms_from_room_pixels/current_room_for_pixel/zone_membershipconsume. Memory-primary (the fork's in-memory_map_data, loop-safe) with an off-loop.storagefallback. ReturnsNonefor a non-raster backend (Roborock memory), an old install with no map, or any read failure. It does have a 1-line manager delegator atcore/manager.py:3880.
4. Caches: coordinator-internal vs manager-resident¶
There are three caches in play. Two are internal to the coordinator's backends; one stays on the manager for the on-loop readers (ยง1.2).
| Cache | Lives on | Keying / invalidation | Purpose |
|---|---|---|---|
_mem_rooms_cache |
coordinator | content version (sha1 of the raw room-pixel bytes, map_source.eufy_version_of / eufy_mapdata_obj_from_candidates) |
Memory-primary static per-room scan + the converted map_data dict โ re-runs the BFS scan + base64 convert only on a genuine re-map, not every refresh. |
_live_pose_geom_cache |
coordinator | file mtime | The static map_data geometry the live-pose normalization needs (loaded by _load_live_pose_geom); re-read only when the Store file changes. |
_map_state_source_cache |
manager | file mtime + presence gate (storage) / written fresh each refresh (memory) | The normalized backend result; written by the pre-warm, read synchronously on the loop by the snapshot composer and the map-overlays sensor. |
The _mem_rooms_cache entry holds {version, result, map_data}. On a cache hit the coordinator shallow-copies the cached static result and layers the fresh pose onto the copy โ the static rooms scan is reused, only the moving pose changes each refresh, and the cached static stays clean of the moving fields.
5. The live-pose overlay path¶
The static segmentation comes from .storage (or the in-memory MapData), but the robot position there (robot_trail[-1]) lags eufy-clean's save-throttle and is the stale "robot frozen in the kitchen" ghost. So both the storage and memory backends layer eufy-clean's fresh in-memory pose on top:
_read_inmem_posefinds the pose holder (the object carrying both a robot- and a dock-pixel attr, matched by presence because eufy-clean nulls_robot_pixelwhile docked) viamap_source_runtime.eufy_live_pose_from_candidates, and reads robot pixel (None when docked), dock pixel, trail, and heading off it._apply_inmem_pose_to_resultbuilds the overlay (map_source.live_pose_overlay) and merges it withmap_source.apply_live_pose_override, which clears the live-pose-owned keys (current_room,path) before merging so a stale.storagevalue can't survive next to a fresh dock anchor.
Docked semantics: when the robot pixel is absent but a dock pixel resolves, the robot anchor resolves to the dock and robot_docked is flagged โ mirroring eufy-clean's own render and killing the stale-pose ghost. The whole override is defensive (a raising provider-internal property degrades to the base overlays rather than aborting the on-loop snapshot).
The eufy-clean integration exposes the pose on
hass.data["robovac_mqtt"][<entry>]["coordinators"][0](anEufyCleanCoordinator):_robot_pixel(nulled while docked),_dock_pixel,_robot_trail. There is no in-memory heading attr (eufy-clean bakes orientation into the rendered bytes);heading_attrsis kept future-proof but matches nothing today. See the adapter'slive_poseblock in 25-eufy-adapter.
6. Call sites + the manager seam¶
| Caller | Path | Reads/writes |
|---|---|---|
| Dashboard-snapshot service handler | services/snapshots.py โ manager.async_refresh_map_state_source (pre-warm), then get_dashboard_snapshot reads the map_state_source cache key on-loop |
writes + reads _map_state_source_cache |
| Map-overlays sensor | sensor/map_overlays.py (_result() reads manager._map_state_source_cache) |
reads _map_state_source_cache |
get_map_render_data service |
mapping/mapping_services.py::_handle_get_map_render_data โ manager.async_get_map_render_data |
โ |
get_map_live_pose service + pose-sampler probe |
mapping/mapping_services.py::_handle_get_map_live_pose โ manager.async_get_map_live_pose |
โ |
compare_map_sources service |
mapping/mapping_services.py::_handle_compare_map_sources โ manager.async_compare_map_sources |
โ |
Zone dispatch (live MapData object) |
core/manager.py:4208 calls self.map_source.get_live_mapdata_obj(...) directly (no delegator) |
โ |
Zone dispatch / pure room readers (map_data dict) |
core/manager.py:3880 delegator async_get_map_data_dict โ self.map_source.async_get_map_data_dict |
โ |
Snapshot supports_va_render flag |
core/manager.py::get_dashboard_snapshot gates on isinstance(adapter_cfg.get("map_render"), dict) |
โ |
The manager's five delegators (async_refresh_map_state_source, async_get_map_live_pose, async_compare_map_sources, async_get_map_render_data, and async_get_map_data_dict at core/manager.py:3880) are thin 1-line forwards to self.map_source.*. The sixth public method, get_live_mapdata_obj, has no delegator โ zone dispatch calls it directly on the coordinator (core/manager.py:4208). The cache and _resolve_live_map_image_entity stay on the manager (ยง1.2) โ that is the deliberate boundary of this re-bundle, not an oversight.
7. Cross-links¶
- 11-mapping-system โ the CV segmentor, image variants, trace-bounds path, and the map bucket this subsystem is distinct from.
- map-state-source โ the design doc for the seam (goal, brand reality, wave plan, the pure-vs-runtime split). This doc is the implementation reference for the coordinator; that one is the rationale.
- 22-adapter-config-reference โ the
map_state_source/map_renderadapter-config schema (backend, identifier_domain, store_key, store_version, presence gate,live_pose,memory, renderformat). - 25-eufy-adapter โ the Eufy adapter's concrete
map_state_source/map_rendervalues (storage backend,live_poseattr paths, the in-memoryMapDatasource). - 26-eufy-segmentor โ the CV segmentor (inferred rooms from an uploaded image), the other source of room geometry this one is contrasted with.