15 โ Setup System¶
Scope: Complete implementation reference for the
setup/package:workflow.py(add_vacuum / import_active_map),drift.py(step tracking + room drift),status.py(setup status response),protection.py(delete protection evaluation), anddelete.py(protected map delete). Every ActionResult shape, protection level rule, drift predicate, and storage path is derived directly from the source.
1. Overview¶
The setup system manages the multi-step onboarding flow for adding new vacuums and importing their maps. It is data-driven โ each adapter declares which setup steps apply to it. The framework iterates those steps and tracks completion state, rather than hard-coding an Eufy-specific flow.
Module roles:
| Module | Role |
|---|---|
setup/workflow.py |
add_vacuum() and import_active_map() โ the two atomic setup actions |
setup/drift.py |
Setup step tracking, room drift detection, discovery-history counters |
setup/status.py |
get_setup_status() โ composite status response for panel rendering |
setup/protection.py |
evaluate_map_protection() โ protection level for destructive operations |
setup/delete.py |
delete_map() โ protected map delete workflow |
2. ActionResult Schema¶
Every public function in workflow.py and delete.py returns an ActionResult dict:
{
"status": str, # "success" | "already_done" | "blocked" | "error" | "requires_confirmation"
"message": str, # human-readable description
"data": dict, # operation-specific payload
"next_actions": list[str], # suggested follow-up actions (e.g. ["import_active_map"])
}
delete.py extends this with:
{
...base fields...
"code": str, # machine-readable reason code
"warnings": list[str], # non-fatal notices (e.g. "This vacuum now has no imported maps.")
}
3. Setup Workflow (workflow.py)¶
3.1 add_vacuum¶
Pre-conditions checked:
1. Manager is available โ returns "error" if not.
2. Vacuum entity exists in HA state machine โ returns "blocked" if not.
3. Vacuum is not already managed โ returns "already_done" with next_actions=["import_active_map"] if it is.
On success (in order):
1. Calls manager.ensure_vacuum_record(vacuum_entity_id=vacuum_entity_id).
2. Calls manager.async_save().
3. Registers a per-vacuum sidebar panel at /eufy-vacuum-{object_id} via panels.async_register_vacuum_panel(hass, vacuum_entity_id, title=effective_panel_title(record)). The panel is registered with config={"vacuum_entity_id": vacuum_entity_id} and a per-vacuum sidebar title โ effective_panel_title(record) returns the vacuum record's panel_title field when set, else the "Vacuum Agent" default โ so two vacuums no longer show two identical sidebar entries once renamed. A ValueError (panel already registered) is caught and logged.
The sidebar title is live-renamable via the eufy_vacuum.setup_set_panel_title service: it stores panel_title on the vacuum record (a blank title reverts to the default), persists it, then re-registers the panel with replace=True (which removes the existing panel first, since panel_custom.async_register_panel raises on a duplicate url) so the sidebar entry renames without a restart.
Returns "success" with next_actions=["import_active_map"].
3.2 import_active_map¶
Pre-conditions checked:
1. Manager is available.
2. Vacuum is already managed โ returns "blocked" with next_actions=["add_vacuum"] if not.
3. Active map ID is detectable โ returns "blocked" if get_active_map_id() returns None.
4. Map is not already imported with rooms โ returns "already_done" if data["maps"][vacuum][map_id]["rooms"] is non-empty.
On success:
1. Calls await async_refresh_room_source(hass, vacuum_entity_id) before discovery. This is a no-op for attribute brands (Eufy, where rooms live in an entity attribute the sync discovery already reads), but for service-response brands (Roborock, where rooms come from the get_maps cloud response) it populates the get_maps cache that the sync discovery reads โ without this step the Roborock import returns no rooms.
2. Calls discover_rooms_for_vacuum() โ returns "blocked" if no rooms found.
3. Caches raw discovery in data["discovery"][vacuum][str(map_id)].
4. Calls manager.save_managed_rooms(vacuum_entity_id, map_id, enabled_room_ids=None, floor_types={}) โ all rooms enabled by default, floor types assigned hardwood defaults.
5. Calls manager.async_save().
Returns "success" with room list in data and next_actions=["configure_rooms"].
Upstream constraint: Only the currently active map can be imported. This is a hard limitation of the upstream cloud API โ there is no way to query alternate maps.
See also: 17-map-manager ยง3 for
ensure_map_bucket()andrebuild_map_bucket()called during import; 08-rooms-system ยง5 forsave_managed_rooms()which writes the discovered rooms into the map bucket.
4. Setup Step Tracking (drift.py)¶
4.1 Setup step IDs¶
SETUP_STEP_IDS: frozenset = frozenset({
"add_vacuum",
"import_active_map",
"save_rooms",
"calibrate_map", # future
"set_dock_position", # future
})
4.2 Step labels and services¶
| Step ID | Label | Service called |
|---|---|---|
add_vacuum |
Add vacuum | eufy_vacuum.setup_add_vacuum |
import_active_map |
Import active map | eufy_vacuum.setup_import_active_map |
save_rooms |
Configure rooms | eufy_vacuum.setup_save_rooms |
calibrate_map |
Calibrate map | eufy_vacuum.calibrate_map |
set_dock_position |
Set dock position | eufy_vacuum.set_dock_anchor |
4.3 Step completion storage¶
data["setup_progress"][vacuum_entity_id] = {
"completed_steps": list[str], # step IDs that have been marked complete
"last_advanced_at": str | None, # ISO timestamp of last step completion
"rejected_rooms": list[int], # room IDs explicitly rejected by user
"room_drift_history": dict, # per-room discovery-pass counters
}
record_step_completed(manager, vacuum_entity_id, step_id) -> None
is_step_completed(progress, step_id) -> bool
record_step_completed is idempotent โ repeated calls do not duplicate the step in the list.
4.4 Adapter-declared steps¶
Reads adapter_config["setup"]["steps"], filters out unknown step IDs (defence in depth), and returns the filtered list. Falls back to ["add_vacuum", "save_rooms"] if the adapter omits the setup block.
4.5 Room drift detection¶
Drift = difference between the rooms the adapter currently reports and the rooms the integration has configured.
Discovery cadence (with defaults):
| Adapter key | Default |
|---|---|
discovery.removal_confirmation_passes |
3 |
discovery.new_room_confirmation_passes |
1 |
discovery.auto_refresh_interval_seconds |
21600 (6 hours) |
discovery.auto_refresh_on |
["vacuum_docked", "active_map_changed", "config_entry_reload"] |
update_drift_history(manager, vacuum_entity_id, discovered_room_ids)
Called on every discovery pass. For each room in (configured_ids | discovered_ids) - rejected_ids:
- If room is in
discovered_room_ids: incrementseen_passes, resetmissing_passesto 0, updatelast_seen_at. - If room is NOT in
discovered_room_ids: incrementmissing_passes, setfirst_missed_atif first miss.
Stale history entries (rooms neither configured nor currently discovered) are cleaned up to prevent unbounded growth.
compute_room_drift(manager, vacuum_entity_id, discovered_room_ids=None) -> dict
{
"in_sync": bool,
"new_rooms": [{room_id, name, map_id}, ...],
"removed_rooms": [{room_id, name, map_id}, ...],
"transiently_missing": [{room_id, name, map_id}, ...],
"rejected_rooms": [room_id, ...],
}
New rooms: discovered but not configured, seen_passes >= new_room_confirmation_passes (default 1 = immediate).
Removed rooms: configured but missing_passes >= removal_confirmation_passes (default 3).
Transiently missing: configured and currently absent but below removal threshold.
in_sync = True when new_rooms, removed_rooms, and transiently_missing are all empty.
4.6 Additional drift operations¶
Moves room IDs intorejected_rooms. Removes them from managed_rooms across all maps. Drops their drift history entries. Returns {rejected, removed_from_managed, affected_map_ids}.
Bypasses the missing-pass counter โ immediately sets missing_passes = removal_confirmation_passes for the room. Used for the "I know this room is gone" manual action.
Runs a live discovery probe, calls update_drift_history(), and returns {vacuum_entity_id, discovered_room_ids, updated_at}.
5. Setup Status (status.py)¶
Called by the panel on load. Returns:
{
# New data-driven fields
"setup_complete": bool,
"vacuums": [
{
"vacuum_entity_id": str,
"display_name": str,
"panel_title": str, # current sidebar panel title (effective_panel_title) โ user-set value or the "Vacuum Agent" default; pre-fills the Setup tab's rename field
"live_map_image_entity": str | None, # user's explicit live-map camera/image override, or None to use the adapter pattern; pre-selects the Setup-tab camera picker
"setup_steps": [
{"id", "label", "completed", "service"},
...
],
"next_step": str | None, # first incomplete step ID
"room_drift": dict, # compute_room_drift() result (no live probe)
"maps": list[dict], # per-map summaries with protection info
# Legacy backward-compat:
"has_imported_map": bool,
},
...
],
# Legacy backward-compat:
"state": "no_vacuums" | "no_map" | "ready",
"next_actions": list[str],
}
setup_complete: True only when all vacuums have all steps completed AND all maps are in_sync.
Drift probe: compute_room_drift() is called without a live discovery probe โ reflects the latest stored history. Discovery passes update history out-of-band via listener triggers.
Maps list: Each entry carries map_id (str), display_name (str | None), room_count (int), imported (bool), and a protection sub-dict from evaluate_map_protection() for imported maps. display_name is the raw stored name or None when the map is unnamed โ status.py no longer fabricates an English "Map {map_id}"; the card renders the localized setup.map_n ("Map {id}") fallback.
6. Delete Protection (protection.py)¶
Returns:
{
"protection_level": "normal" | "elevated" | "high",
"reasons": [{"code": str, "message": str}, ...],
"requires_typed_confirmation": bool, # True ONLY for a NAMED high map
"requires_confirmation": bool, # one-click confirm (elevated, or an unnamed high map)
"typed_confirmation_value": str | None, # the stored map name; None when the map is unnamed
}
Reason codes checked (in order):
| Code | Condition |
|---|---|
only_map |
This is the only imported map for this vacuum |
has_active_job |
An active job has been observed on this map |
has_learning_data |
data["room_history"][vacuum][map_id] is non-empty |
has_rules |
Any room on the map has automation rules |
has_access_graph |
Any room has grants_access_to populated |
Protection level derivation:
if "has_active_job" in reason_codes: โ "high"
elif len(reasons) >= 2: โ "high"
elif len(reasons) == 1: โ "elevated"
else: โ "normal"
requires_typed_confirmation = (level == "high") and bool(stored_name) # NAMED high maps only
requires_confirmation = (level != "normal") # elevated, or an unnamed high map
typed_confirmation_value = stored_name if requires_typed else None # raw metadata.display_name
The backend never fabricates a "Map {id}" name. stored_name is metadata.display_name or None; an unnamed map keeps high-level friction via a one-click confirm rather than demanding a typed token it has no locale-invariant name for. The card renders the localized setup.map_n label when display_name is None.
7. Protected Map Delete (delete.py)¶
Protection gate:
- NAMED "high" map (requires_typed_confirmation): confirmation_token must be provided and exactly match typed_confirmation_value (the stored map name โ a locale-invariant token). Returns "requires_confirmation" if absent; "blocked" on mismatch.
- UNNAMED "high" map, or any "elevated" map (requires_confirmation, typed dropped): any truthy confirmation_token accepted (one-click confirm). Returns "requires_confirmation" if absent. An unnamed map has no locale-invariant name to type, so it drops to a one-click confirm rather than round-tripping a per-locale "Map N" token through the backend.
- "normal" protection: proceeds without confirmation.
delete.py uses a safe display_label (stored name or "Map {id}") for response messages and log lines only โ never as the wire token (which stays the raw stored name, non-None only when requires_typed_confirmation).
On confirmed delete:
1. manager.remove_map(vacuum_entity_id, map_id_str) โ removes all data for the map.
2. manager._notify_rooms_updated(vacuum, map_id) โ triggers entity-platform cleanup.
3. manager._notify_run_profiles_updated(vacuum, map_id) โ triggers run-profile cleanup.
4. Entity registry sweep: removes any eufy_vacuum-platform entities whose unique_id starts with {vacuum_entity_id with '.'->'_'}_{map_id}_ (e.g. vacuum_alfred_6_) to catch stragglers missed by platform teardown callbacks.
5. manager.async_save().
Returns "success" with warnings=["This vacuum now has no imported maps. Import a new map to resume cleaning."] if the vacuum now has no imported maps.
8. Storage Path Reference¶
| Path | Description |
|---|---|
data["setup_progress"][vacuum_entity_id]["completed_steps"] |
List of completed step IDs |
data["setup_progress"][vacuum_entity_id]["room_drift_history"][str(room_id)] |
Per-room discovery-pass counters |
data["setup_progress"][vacuum_entity_id]["rejected_rooms"] |
Room IDs the user has explicitly rejected |
data["vacuums"][vacuum_entity_id] |
Vacuum record (created by ensure_vacuum_record) |
data["maps"][vacuum_entity_id][str(map_id)] |
Map bucket (created by import_active_map) |
data["discovery"][vacuum_entity_id][str(map_id)] |
Raw discovery cache |