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¶
- System Boundaries
- Integration Internal Layers
- Subsystem Package Map
- Startup Sequence
- Data Flow โ Job Start to Finish
- State Persistence
- The Adapter Pattern
- Listener Architecture
- Service Layer
- Entity Layer
- Concurrency & Thread Safety
- 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:
-
AdapterCoordinatorconstruction โ must happen before any adapter registration soget_adapter_configlookups route through the coordinator, not the fallback module-level dict. -
EufyVacuumManagerconstruction +async_initialize()โ loads.storage, seeds top-level keys, runs schema migrations, constructs all subsystem manager objects (themes throughexternal_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 anycharge_wait/waitdock phase whose in-memory poller the restart lost (phase_runner.rearm_dock_phase_if_needed, called for everystatus=='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.
-
Orphaned entity cleanup โ removes legacy
eufy_vacuum_icon_*entities from the entity registry if found (silent no-op on clean installs). -
Adapter registration โ
load_stored_adapter_configsreadsdata["adapter_configs"]; thenregister_eufy_adapter_for_vacuumregisters code adapters for each known vacuum (code adapters always win over stored). -
Singletons construction โ
LearningManager,BatteryHealthManager,ErrorTrackerconstructed and started.BatteryHealthManageris built at__init__.py(~line 248-250) and stored athass.data[DOMAIN][DATA_BATTERY](DATA_BATTERY = "battery",const.py); it is torn down on unload.MappingTrackerconstructed; position listeners registered for known vacuums. -
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__.pyrather than through a group function (it drivesBatteryHealthManager). -
Listener registration โ eight listener modules registered:
lifecycle,job_metrics,dock_events,path_blockers,pause_timeout,job_progress,pose_sampler,discovery. -
Platform forward โ
async_forward_entry_setupstriggersasync_setup_entryin all five platform modules, creating and registering all HA entities. -
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_planat 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.dataincluding 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:
AdapterCoordinator(per-config-entry; constructed inasync_setup_entry)- Module-level
_REGISTRYfallback (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:
- Create
adapters/dreame/withadapter.py,const.py, and vocabulary files mirroring the Eufy / Roborock structure. - Register it in
__init__.async_setup_entry(aregister_dreame_adapter_for_vacuumcall, brand-gated like the Roborock branch) for each known Dreame vacuum. - 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_entrypattern; 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_threadsafepattern โ the battery hook fires from the learning finalizer's executor thread.
12. Extension Points¶
Adding a room entity type¶
- Create a class in
sensor/(or another platform) inheriting fromEufyVacuumRoomEntity. - Add it to the platform's
async_setup_entryloop. - Add a stale/new sync callback in the platform's
_on_rooms_updatedhandler.
Adding a service¶
- Add a constant to
const.py. - Add a handler to the appropriate
services/*.pyfile. - Register it in
async_register_services(or the relevant group function).
Adding a subsystem manager¶
- Create a new package under
custom_components/eufy_vacuum/. - Expose a manager class with
__init__(self, manager). - Instantiate it in
EufyVacuumManager.async_initialize()and assign toself.<name>. - Add shim methods in
core/manager.pythat 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.