Skip to content

01 โ€” Architecture Overview

This document describes the full system architecture of eufy_vacuum (the Home Assistant custom integration) and eufy-vacuum-command-center (the companion Lovelace card). It is written for developers who want to extend, contribute to, or port the system to a different vacuum ecosystem.


Table of Contents

  1. System Boundaries
  2. Integration Internal Layers
  3. Subsystem Package Map
  4. Startup Sequence
  5. Data Flow โ€” Job Start to Finish
  6. State Persistence
  7. The Adapter Pattern
  8. Listener Architecture
  9. Service Layer
  10. Entity Layer
  11. Concurrency & Thread Safety
  12. Extension Points

1. System Boundaries

What the integration is

eufy_vacuum is a Home Assistant custom integration that sits on top of an upstream vacuum integration (currently Eufy via robovac_mqtt). Its purpose is to add a complete job-management, learning, and configuration layer that the bare upstream entities do not provide.

It owns:

  • Managed room configuration (order, per-room clean settings, profiles, rules)
  • A queue engine that assembles multi-room job payloads
  • Active job lifecycle tracking (start โ†’ monitor โ†’ auto-finalize)
  • A learning / ETA estimation system backed by per-job JSON files on disk
  • A theme system for the companion card
  • A mapping subsystem (image-based room segmentation, custom named layouts, the live map source, and point-in-polygon zone membership)
  • Native current-room tracking (per-room completion + dwell fired off the device's current-room signal, not learned coordinates)
  • Battery health tracking (cycle counts, charge rates, degradation metrics)
  • All HA entities that surface integration state into the entity registry
  • HA service endpoints consumed by the Lovelace card

What the integration is not

eufy_vacuum does not:

  • Communicate directly with Eufy cloud or vacuum hardware. All hardware interaction goes through the upstream vacuum entity and its companion sensors.
  • Replace the upstream integration. It subscribes to state-change events on entities that the upstream integration creates.
  • Bundle the Lovelace card. The card is a separate JS project (eufy-vacuum-command-center). The integration serves the compiled card JS as a static file at /eufy_vacuum/frontend/, but the two projects are independently versioned.

External dependencies

Dependency Role
Upstream vacuum integration Hardware control, raw position sensors, battery level
HA Store helper Persistent JSON storage at .storage/eufy_vacuum
HA async_add_executor_job Off-loop disk I/O for learning history files
HA event bus Inbound state changes; outbound job/room events
HA service registry All ~80+ service endpoints

No third-party Python packages are required (requirements is empty).


2. Integration Internal Layers

โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”
โ”‚  Lovelace Card  (eufy-vacuum-command-center)                        โ”‚
โ”‚  JavaScript โ€” actions / state / renderers / bindings / styles       โ”‚
โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜
                         โ”‚  HA WebSocket service calls
โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ–ผโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”
โ”‚  Service Layer  (services/, learning/services.py,                   โ”‚
โ”‚                  themes/services.py, mapping/mapping_services.py)   โ”‚
โ”‚  ~100+ named services โ€” validate input, call manager, return payloadโ”‚
โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜
                         โ”‚  method calls
โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ–ผโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”
โ”‚  core/manager.py โ€” EufyVacuumManager (orchestrator)                โ”‚
โ”‚  Owns self.data (in-memory mirror of .storage), self.runtime        โ”‚
โ”‚  Delegates deep logic to subsystem manager objects                  โ”‚
โ”‚                                                                     โ”‚
โ”‚  Subsystems (each a manager class, holds a back-reference):         โ”‚
โ”‚  themes ยท maintenance ยท dock ยท onboarding ยท profiles                โ”‚
โ”‚  access_graph ยท active_job ยท phase_runner ยท run_plan ยท room_map     โ”‚
โ”‚  live_room_refresh ยท map_source ยท dispatch ยท external_run           โ”‚
โ””โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”˜
   โ”‚           โ”‚          โ”‚                    โ”‚                   โ”‚
   โ–ผ           โ–ผ          โ–ผ                    โ–ผ                   โ–ผ
battery/   mapping/  learning/            listeners/         HA entities
manager    manager   manager              (8 modules)        (sensor/,
BatteryH   Mapping   LearningM            each owns          switch/,
ealthMgr   Manager   anager               its listener       button/,
                                          lifecycle)         number/,
                                                             select/,
                                                             binary_sensor)

The key insight: EufyVacuumManager is the only writer to self.data, but it delegates the logic of what to write to subsystem manager objects. Subsystem managers hold a self._manager back-reference and call self._manager.data[key] directly โ€” they are trusted collaborators, not external consumers.


3. Subsystem Package Map

Every directory under custom_components/eufy_vacuum/ is either a HA platform module (flat file) or a subsystem package. Here is the full map:

Core orchestration

Package / file Role
core/manager.py EufyVacuumManager โ€” singleton orchestrator, storage owner, callback hub
core/storage.py HA Store wrapper (async_load / async_save)
core/capabilities.py Reads upstream entities to populate data["capabilities"]
core/error_tracker.py ErrorTracker โ€” latches vacuum errors, persists across restarts
core/water_amendment.py Mopping water-level protection rules
core/charging.py Brand-agnostic battery-level / charging-state / low-battery-return reads from adapter-declared entities

Subsystem managers (all constructed in manager.async_initialize())

Object Package Owns
manager.themes themes/ data["theme"] โ€” library, working draft, active theme
manager.maintenance maintenance/ Upkeep metadata, replacement discovery, reset snapshots
manager.dock dock/ Dock action dispatch and dock event recording
manager.onboarding onboarding/ data["onboarding"], setup progress state machine
manager.profiles profiles/ Room profiles and run profiles CRUD; also owns the run-profile START orchestration (start_run_profile โ€” apply the profile, stash charge/wait steps, dispatch), next to apply_run_profile (core keeps a start_run_profile delegator; start_selected_rooms + build_queue/build_room_payload stay on the core manager)
manager.access_graph rooms/access_graph.py Room-to-room access graph (grants_access_to)
manager.active_job jobs/active_job.py Active job slot CRUD and finalization handoff
manager.phase_runner jobs/phase_runner.py Sequenced phase execution โ€” per-phase watchdog (settle/dispatch/verify/retry) + per-phase timing + per-phase global-pre-call dispatch; also runs the charge_wait (_run_charge_wait_phase โ€” dock, poll battery to target_battery_percent) and wait (_run_wait_phase โ€” dock, hold wait_minutes) break phases via maybe_advance_phase, guarding the intentional dock with _phase_dispatch_pending
manager.run_plan planning/run_plan.py Preflight rule evaluation, effective start plan; materializes a run profile's ordered steps (room groups + charge/wait stops) into active_job["phases"] via _build_steps_phases, forcing strict_order when the run carries stops
manager.room_map rooms/room_crud.py Room and map CRUD operations
manager.live_room_refresh live_refresh/manager.py Lever B contiguous-run live current-room refresh (rate-limit + sticky-disable + local-connection pulse)
manager.map_source mapping/map_source_coordinator.py map_state_source backend dispatch (provider segmentation + live-pose reads)
manager.dispatch dispatch/manager.py Send-side wire dispatch: _dispatch_clean_payload, dispatch_zone_clean, _resolve_live_dispatch_payload, _run_global_pre_calls (all four moved out of core; the manager keeps a thin delegator for each)
manager.external_run learning/external_run.py External (app-started) run capture/finalize: maybe_handle_external_run, _finalize_external_run, confirm_external_run, get_external_pending_runs, discard_external_run, resegment_external_run, the _external_grace_* timers/checks/finalize, _extract_return_overhead (the manager keeps a thin delegator for each). The _ingest_*_into_room_history helpers STAY in core โ€” they are shared with the normal finalize path. Exposed from learning/__init__ via a lazy __getattr__ to avoid an import cycle

Entry-point singletons (constructed in __init__.async_setup_entry())

Object Key in hass.data[DOMAIN] Role
EufyVacuumManager "runtime" Main orchestrator
LearningManager "learning" Per-job finalization and ETA estimation
BatteryHealthManager "battery" Cycle counting and charge rate tracking
ErrorTracker "error_tracker" Active-run and last-device error state
AdapterCoordinator "adapter_coordinator" Per-entry adapter config registry
MappingTracker "mapping_tracker" Position listener; fires eufy_vacuum_room_completed off the device's native current-room signal, plus a passive dock-coordinate drift diagnostic log

Stateless helper packages

Package Role
adapters/ Adapter config schema, AdapterCoordinator, Eufy-specific constants
queue/ build_queue_from_managed_rooms, build_room_clean_payload
maps/ get_map_bucket, ensure_map_bucket, get_vacuum_maps_summary, rebuild_map_bucket, save_map_discovery_snapshot โ€” pure dict helpers
rooms/ room_manager.py (incl. build_managed_rooms), room_discovery.py, rooms/utils.py โ€” stateless
models/ TypedDict definitions (VacuumRuntimeState, LiveRuleState, etc.)
mapping/ Image-based room segmentation, custom named layouts, the live map source, point_in_polygon zone membership, and the native current-room tracker
learning/ Job finalization pipeline, history store, ETA estimator; also external_run.py โ€” the ExternalRunManager subsystem (a stateful bundled subsystem, not stateless; see the subsystem-managers table above)
setup/ Setup workflow, drift detection, protection rules
jobs/job_monitor.py Lifecycle state machine (pure function)
timestamp_utils.py parse_timestamp, utc_now_iso

HA platform modules

binary_sensor.py, button.py, number.py, sensor/, switch.py โ€” each owns its async_setup_entry and entity classes. Room entities share the base class in room_entities.py.

See also โ€” subsystem deep dives: 05-core-manager ยท 06-job-lifecycle ยท 07-queue-engine ยท 08-rooms-system ยท 09-room-rules-system ยท 10-learning-system ยท 11-mapping-system ยท 12-battery-system ยท 13-maintenance-manager ยท 14-dock-manager ยท 15-setup-system ยท 16-profile-manager ยท 17-map-manager ยท 18-onboarding-manager ยท frontend/architecture-overview ยท frontend/theme-system ยท 23-error-tracker


4. Startup Sequence

async_setup_entry in __init__.py runs the following in order:

  1. AdapterCoordinator construction โ€” must happen before any adapter registration so get_adapter_config lookups route through the coordinator, not the fallback module-level dict.

  2. EufyVacuumManager construction + async_initialize() โ€” loads .storage, seeds top-level keys, runs schema migrations, constructs all subsystem manager objects (themes through external_run โ€” in construction order: themes, maintenance, dock, onboarding, profiles, access_graph, active_job, phase_runner, run_plan, room_map, live_room_refresh, map_source, dispatch, external_run), initialises callback lists. It then re-arms any charge_wait / wait dock phase whose in-memory poller the restart lost (phase_runner.rearm_dock_phase_if_needed, called for every status=='started' active job).

All subsystems are constructed unconditionally here โ€” but only a few are load-bearing to actually fire a room clean. For which of these are the irreducible atom (adapter + dispatch + rooms + spine + run tracking) versus optional rings, where the room-definition primitives are mis-homed, and what a "core stands alone" refactor would entail, see 32-core-minimality-and-deconstruction.

  1. Orphaned entity cleanup โ€” removes legacy eufy_vacuum_icon_* entities from the entity registry if found (silent no-op on clean installs).

  2. Adapter registration โ€” load_stored_adapter_configs reads data["adapter_configs"]; then register_eufy_adapter_for_vacuum registers code adapters for each known vacuum (code adapters always win over stored).

  3. Singletons construction โ€” LearningManager, BatteryHealthManager, ErrorTracker constructed and started. BatteryHealthManager is built at __init__.py (~line 248-250) and stored at hass.data[DOMAIN][DATA_BATTERY] (DATA_BATTERY = "battery", const.py); it is torn down on unload. MappingTracker constructed; position listeners registered for known vacuums.

  4. Service registration โ€” four service groups registered: async_register_services, async_register_learning_services, async_register_theme_services, async_register_mapping_services. One service โ€” battery_rebaseline โ€” is registered inline in __init__.py rather than through a group function (it drives BatteryHealthManager).

  5. Listener registration โ€” eight listener modules registered: lifecycle, job_metrics, dock_events, path_blockers, pause_timeout, job_progress, pose_sampler, discovery.

  6. Platform forward โ€” async_forward_entry_setups triggers async_setup_entry in all five platform modules, creating and registering all HA entities.

  7. Panel registration โ€” one sidebar panel registered per managed vacuum (eufy-vacuum-{object_id}), serving the compiled card JS.

On unload the sequence reverses: services unregistered, listeners removed, platforms unloaded, singletons shut down, coordinator torn down.

See also: 02-ha-integration for the full config-entry lifecycle, platform setup, and entity registration detail; 04-listeners for the listener registration and unsubscription pattern.


5. Data Flow โ€” Job Start to Finish

This traces the complete path of a card-initiated "Start Selected Rooms" action:

Card โ†’ eufy_vacuum.start_selected_rooms service call
  โ”‚
  โ–ผ
services/job_control.py
  Validates vacuum_entity_id, map_id, room_ids
  Calls manager.start_selected_rooms(...)
  โ”‚
  โ–ผ
core/manager.py โ€” start_selected_rooms()
  1. Builds managed_rooms from data["maps"]
  2. Delegates to run_plan._build_effective_start_plan()
     โ”œโ”€โ”€ Runs preflight rule evaluation (per-room blockers/modifiers)
     โ”œโ”€โ”€ Calls manager._update_room_rule_status_snapshot() โ†’ data["room_rule_status"]
     โ””โ”€โ”€ Returns queue, payload, preflight summary
  3. Calls active_job.record_active_job_transition(...)
     โ””โ”€โ”€ Writes data["active_jobs"][vacuum][map_id]
  4. Calls hass.services.async_call("vacuum", "send_command", payload)
     โ””โ”€โ”€ Sends room_clean payload to upstream vacuum entity
  5. Returns start summary to service caller โ†’ card displays result
  โ”‚
  โ–ผ  (vacuum hardware begins cleaning)
  โ”‚
  โ–ผ
listeners/lifecycle.py  (state-change listener on vacuum entity)
  Inlines state handling (no single entry-point method); on completion
  calls manager.finalize_learning_for_active_job(...) then
  manager.mark_active_job_finalized(...)
  โ†’ Updates data["active_jobs"] lifecycle fields
  โ”‚
  โ–ผ
listeners/job_progress.py  (5s time-interval ticker)
  Calls manager.get_job_progress_snapshot()
  โ†’ Fires EVENT_JOB_PROGRESS_TICK (every 5s while running)
  โ†’ Card receives tick, re-fetches snapshot, updates progress display
  โ”‚
  โ–ผ  (vacuum returns to dock)
  โ”‚
  โ–ผ
listeners/dock_events.py  (state-change listener on dock sensors)
  Calls manager.record_dock_event(...)
  โ†’ Records dock event in data["dock_events"]
  โ†’ Triggers finalization
  โ”‚
  โ–ผ
listeners/lifecycle.py  (charging state detected)
  Calls manager.async_finalize_completed_job(...)
  โ”‚
  โ–ผ
learning/manager.py โ€” LearningManager.async_finalize_completed_job()
  (drives the LearningJobFinalizer in learning/job_finalizer.py)
  1. Reads active job, calculates duration
  2. Writes completed_job record to disk (per-job JSON)
  3. Updates learning stats
  4. Returns completed_job dict
  โ”‚
  โ–ผ
core/manager.py โ€” _ingest_completed_job_into_room_history()
  Updates data["room_history"][vacuum][map_id][room_id]
  Fires _notify_room_history_updated()
  โ†’ Room history sensors refresh
  โ”‚
  โ–ผ
core/manager.py โ€” fires EVENT_JOB_FINISHED on HA event bus
  โ†’ Card receives event, refreshes dashboard

See also: 06-job-lifecycle for the complete per-step breakdown of this flow including all paths to job end; 07-queue-engine for the payload construction at step 2; 09-room-rules-system ยง5 for _build_effective_start_plan at step 2; 10-learning-system and 12-battery-system for the two finalization hooks in the last step.


6. State Persistence

.storage/eufy_vacuum

The single source of truth for all persistent integration state. Managed by core/storage.py using HA's Store helper (atomic JSON writes, automatic backup). Loaded into manager.data on startup; all writes go through manager.async_save().

Top-level keys in manager.data:

Key Content
"vacuums" Per-vacuum config record (name, notes)
"capabilities" Discovered entity IDs and feature flags per vacuum
"maps" Per-vacuum, per-map bucket: rooms, summary, queue, payload, segment links
"active_jobs" Per-vacuum, per-map active job state (status, queue, progress)
"room_history" Per-vacuum, per-map, per-room last clean timestamps
"room_rule_status" Per-vacuum, per-map, per-room last rule evaluation result
"profiles" Room profiles library and run profiles library
"theme" Theme library (built-in + user), active theme ID, working draft
"maintenance" Per-vacuum, per-component reset snapshots and intervals
"onboarding" Discovery payloads (pre-approval room data)
"setup_progress" Per-vacuum setup state machine (completed steps)
"adapter_configs" User-built adapter overrides (stored adapter configs)
"dock_events" Recent dock event log per vacuum

Learning history files

Completed job records and derived stats live in <config>/eufy_vacuum/<vacuum_key>/ as individual JSON files. Not part of .storage โ€” written and read by learning/history_store.py via async_add_executor_job. This keeps large per-job payloads off the storage file while the .storage file stays fast to load.

Runtime state

manager.runtime holds VacuumRuntimeState dicts keyed by vacuum_entity_id. Runtime state is never persisted; it is reconstructed from upstream entity states on each HA start. It includes: raw HA state string, battery level, charging flag, dock sensor values.

See also: 03-data-model for the complete schema of every key in manager.data including per-key ownership and storage path references.


7. The Adapter Pattern

The adapter pattern is the key seam that makes eufy_vacuum portable across vacuum brands. Every brand-specific piece of knowledge is encapsulated in an adapter config dict rather than hard-coded.

What an adapter config contains

{
    "entities": {
        "vacuum": "vacuum.alfred",
        "robot_position_x": "sensor.alfred_x",
        "robot_position_y": "sensor.alfred_y",
        "battery_level": "sensor.alfred_battery",
        "error_message": "sensor.alfred_error_message",
        # ... all upstream entity IDs
    },
    "maintenance_components": {
        "brush": {"label": "Main Brush", "icon": "mdi:...", "default_interval_hours": 300},
        # ...
    },
    "vocabulary": {
        "clean_mode_options": [{"value": "vacuum", "label": "Vacuum"}, ...],
        "fan_speed_options": [...],
        "water_level_options": [...],
        "clean_intensity_options": [...],
    },
    "charging_states": ["docked", "charging"],
    "active_states": ["cleaning", "returning"],
    # ...
}

How adapters are resolved

adapters/registry.py exposes get_adapter_config(vacuum_entity_id). The lookup chain is:

  1. AdapterCoordinator (per-config-entry; constructed in async_setup_entry)
  2. Module-level _REGISTRY fallback (legacy shim; stays empty in normal operation)

The coordinator's registry is populated in two ways: - Code adapters โ€” adapters/eufy/adapter.py registers the hardcoded Eufy config at startup for each known Eufy vacuum. - Stored adapters โ€” adapters/config_loader.py reads data["adapter_configs"] and registers user-built configs. Code adapters always win if both exist for the same vacuum.

Adding a new brand

Roborock is already shipped as a worked second-brand adapter (adapters/roborock/, registered via register_roborock_adapter_for_vacuum in __init__.async_setup_entry, brand-gated by is_roborock_vacuum) โ€” see 29-roborock-adapter for that walkthrough.

To add a third brand (e.g. Dreame), you would:

  1. Create adapters/dreame/ with adapter.py, const.py, and vocabulary files mirroring the Eufy / Roborock structure.
  2. Register it in __init__.async_setup_entry (a register_dreame_adapter_for_vacuum call, brand-gated like the Roborock branch) for each known Dreame vacuum.
  3. The rest of the codebase calls get_adapter_config(vacuum_entity_id) โ€” no other files change.

See the porting guide for the complete porting checklist.

See also: 21-adapter-system for the registry, validation, and startup registration order; 22-adapter-config-reference for the complete field-by-field schema of every block in the adapter config dict.


8. Listener Architecture

All upstream HA state changes are handled by the eight listener modules in listeners/. Each module owns its listener registration, unsubscription, and any private constants. None of them hold persistent state โ€” they read from manager.data and call manager methods to mutate it.

Module Listens to Purpose
lifecycle.py Vacuum entity state Job start/stop/cancel detection, finalization trigger
job_metrics.py Battery + vacuum state Battery readings during jobs
dock_events.py Dock sensor entities Dock event recording (empty, wash, etc.)
path_blockers.py Rule trigger entities Mid-job blocker rule evaluation
pause_timeout.py HA time + vacuum state Auto-cancel on overlong pause
job_progress.py EVENT_JOB_PROGRESS_TICK Job progress snapshot trigger
pose_sampler.py HA time + vacuum state Buffers the per-tick pose time-series (pose_samples) during an external (app-started) or dispatched run for room auto-attribution, via the adapter-declared room_attribution.source; attribution-capable vacuums only
discovery.py Vacuum entity state Auto-discovery on first non-idle state

Registration pattern (all eight are identical):

# In __init__.py
lifecycle.register(hass)  # subscribes, stores unsub callable in hass.data
# ...
# On unload:
lifecycle.remove(hass)    # calls unsub and cleans up

See also: 04-listeners for the complete listener module breakdown โ€” what each module subscribes to, what it calls, and how the unsubscription chain is managed.


9. Service Layer

Services are the card's primary API surface. const.py defines 100+ SERVICE_* constants, plus the inline battery_rebaseline service registered directly in __init__.py โ€” roughly 100+ services in total. They are registered in four groups (the inline battery_rebaseline registration is the one exception to the group pattern):

Registration function Module(s) Domain
async_register_services services/*.py All core room/job/queue/setup services
async_register_learning_services learning/services.py Finalization, stats, estimates
async_register_theme_services themes/services.py Theme library CRUD
async_register_mapping_services mapping/mapping_services.py Map image / segment services

Service modules in services/ are split by domain:

File Services
job_control.py start, pause, resume, cancel, get lifecycle state
queue.py build queue, get queue/payload/active-job state
rooms.py discover, save, update room fields
room_profiles.py room profile CRUD + apply
run_profiles.py run profile CRUD + start + set_run_profile_steps (ordered room-group / charge-wait / wait steps)
setup.py setup workflow (add vacuum, import map, save rooms, drift)
maintenance.py reset, set interval, get upkeep snapshot
dock.py wash mop, dry mop, empty dust, dock action status
access_graph.py room access graph editor
adapter_config.py save/delete/get adapter config, entity discovery
errors.py acknowledge error, get recent errors
snapshots.py dashboard snapshot, progress snapshot, job control state

All service handlers follow a three-step pattern: 1. Extract and validate call.data fields 2. Call one or more manager.* methods 3. Fire call.async_set_result(payload) with a structured response dict

See also: advanced/03-services.md for the user-facing service reference (call shapes, parameters, and return payloads).


10. Entity Layer

Five HA platforms create entities:

Platform Entity classes
sensor/ ActiveJob, Profile, MaintenanceRemaining, DockEvent, ThemeState, Onboarding, RoomCleaningHistory, RoomRuleStatus, BatteryHealth (ร—12), ActiveRunError, LastDeviceError, MapOverlays
switch.py RoomEnabledSwitch (one per configured room)
number.py RoomOrderNumber (one per room), MaintenanceIntervalNumber (one per component)
button.py MaintenanceResetButton (one per component), SavedRunProfileButton (one per exposed profile)
binary_sensor.py ActiveRunHasErrorBinarySensor

All entity classes set _attr_has_entity_name = True and use build_vacuum_device_info(vacuum_entity_id) from entity_helpers.py. This groups all entities under a single HA device per vacuum ("Alfred" not "Alfred Rooms").

EufyVacuumActiveJobSensor (one per vacuum/map) is defined in sensor/lifecycle.py and instantiated in sensor/__init__.py. The 12 BatteryHealth sensors are the exception to the "one class per file under sensor/" rule: they are defined in battery/sensors.py and injected into the sensor platform via build_battery_sensors, not as classes under sensor/.

Room entities inherit from EufyVacuumRoomEntity (room_entities.py), which provides _get_room_data(), _async_update_room(), available, and extra_state_attributes.

Dynamic entities (room-based sensors/switches/numbers) are added and removed at runtime via register_room_update_callback. When _notify_rooms_updated fires, each platform's callback adds new entities and removes stale ones.

See also: 02-ha-integration for entity registration detail and the platform async_setup_entry pattern; frontend/architecture-overview for how the panel card reads entity state.


11. Concurrency & Thread Safety

HA's event loop is single-threaded. All manager methods run on the event loop and do not need locking. The one exception is learning history I/O: _load_room_history_cache_sync runs in an executor thread via async_add_executor_job. That method is stateless โ€” it returns a local dict and the caller writes the result back to self.data on the event loop.

Callback lists (_room_update_callbacks, etc.) are mutated only from the event loop. Manager methods called from hass.loop.call_soon_threadsafe (the _request_entity_state_write pattern in sensor setup) are safe because async_write_ha_state is event-loop-safe.

See also: 12-battery-system ยง14.3 for the primary real-world example of the call_soon_threadsafe pattern โ€” the battery hook fires from the learning finalizer's executor thread.


12. Extension Points

Adding a room entity type

  1. Create a class in sensor/ (or another platform) inheriting from EufyVacuumRoomEntity.
  2. Add it to the platform's async_setup_entry loop.
  3. Add a stale/new sync callback in the platform's _on_rooms_updated handler.

Adding a service

  1. Add a constant to const.py.
  2. Add a handler to the appropriate services/*.py file.
  3. Register it in async_register_services (or the relevant group function).

Adding a subsystem manager

  1. Create a new package under custom_components/eufy_vacuum/.
  2. Expose a manager class with __init__(self, manager).
  3. Instantiate it in EufyVacuumManager.async_initialize() and assign to self.<name>.
  4. Add shim methods in core/manager.py that delegate to the subsystem.

Porting to a new brand

The adapter pattern means the only brand-specific files are: - adapters/<brand>/ โ€” entity IDs, vocabulary, maintenance components - __init__.py โ€” one call to register_<brand>_adapter_for_vacuum

See the porting guide for the detailed walkthrough.