Porting Guide β Adding a Vacuum Brand¶
This guide is for developers adapting eufy_vacuum to a different vacuum brand that has a Home Assistant integration exposing named-room cleaning (Roborock, Ecovacs, Dreame, etc.).
Read 01-architecture-overview.md, 21-adapter-system.md, and 22-adapter-config-reference.md first β this guide is the workflow; those are the reference.
1. What you're actually doing¶
eufy_vacuum is not fundamentally a Eufy integration. It is a room-management, queue-orchestration, learning, and automation layer that sits on top of any HA vacuum entity capable of cleaning named rooms. Roughly all of the framework and all of the frontend have zero brand knowledge.
Everything brand-specific lives in a per-vacuum adapter config β a single dict the framework reads from the adapter registry at runtime. Porting a new brand means writing an adapter (a config dict plus a few small brand modules) and registering it at setup. You do not edit core files, and you do not maintain a fork β the brand-adapter abstraction already exists.
The reference adapter lives at custom_components/eufy_vacuum/adapters/eufy/.
A new brand is a sibling package, adapters/<brand>/, that produces the same
config shape.
For a second worked example β a brand whose discovery, dispatch, and clean-order behave nothing like Eufy's β see dev/29-roborock-adapter. Reading the two side by side is the fastest way to see which config values are brand facts versus framework contract.
2. The adapter, end to end¶
An adapter is a small package mirroring adapters/eufy/. The table below is the
Eufy reference β the maximal surface, not a required checklist: most files
are optional. A divergent brand ships only what it needs β Roborock, for example,
is just adapter.py / const.py / entities.py / vocabulary.py /
model_catalog.py / maintenance_components.py (no buttons / discovery /
lifecycle / water_config / upkeep / segmentor). See the
Roborock adapter Β§2 for that minimal-surface
example.
| File (eufy) | Purpose |
|---|---|
adapter.py |
Builds the config dict and calls register_adapter_config(...). Entry point: register_eufy_adapter_for_vacuum(hass, vacuum_entity_id). |
entities.py |
Entity-ID naming convention (role β entity_id). |
vocabulary.py |
Brand state-string vocabulary sets. |
discovery.py |
How the room list is read from the integration. |
lifecycle.py |
Brand lifecycle signal helpers. |
buttons.py |
Dock-action and replacement-reset button candidate/token lists (the single source adapter.py builds dock_events.action_buttons and maintenance_components[*].reset_button from). |
maintenance_components.py, upkeep_catalog.py, water_config.py, model_catalog.py, constants.py |
Static per-model catalogs and tuned constants. |
segmentor.py |
(Optional) brand CV map segmentor. |
The config dict it builds must match the schema in
adapters/config_schema.py. Registration is one call:
from custom_components.eufy_vacuum.adapters.registry import register_adapter_config
register_adapter_config(vacuum_entity_id, config)
async_setup_entry calls your adapter's register_*_for_vacuum per managed
vacuum. registry._validate_adapter(config) runs at registration and returns a
list of issue strings (logged as warnings); a declared dispatch.template that
doesn't resolve to a registered engine, or a bad mapping.segmenter_engine, is
flagged here.
3. The config blocks you fill in¶
These are the real "coupling points" β all data, no code in core. Every field is documented in 22-adapter-config-reference.md; this is the orientation.
| Block | Required | What it carries |
|---|---|---|
adapter_id, source |
yes | identity (source: "code" for a shipped adapter) |
entities |
yes | role β HA entity-ID map (task_status, dock_status, active_map, battery, charging, robot_position_x/y, β¦). Absent entities degrade the dependent feature; they never raise. |
dispatch |
yes | how to send a clean job (Β§4) |
vocabulary + completion |
no (recommended) | the raw state strings your vacuum reports (Β§5) |
capabilities |
no | feature flags (Β§6) |
discovery |
no | how the room list is exposed (Β§7) |
mapping |
no | pluggable map segmenter β image β room polygons (Β§8) |
job_segmenter + live_transition |
no | pluggable job/run segmenter β counter stream β per-room boundaries β plus live-rollover orchestration (Β§9) |
room_profiles |
no | adapter-sourced room-profile vocabulary (Β§10) |
dock_events, post_job_wash_amendment, error_tracking, maintenance_components, upkeep_catalog, water_model_configs |
no | dock/error/maintenance catalogs β graceful degradation when absent |
4. Dispatch β pick (or add) an engine¶
dispatch.template selects a dispatch engine in
queue/dispatch_engines.py. Each engine produces a different payload
structure (not just renamed fields); the per-room field vocabulary
(room_fields: rename + value_map) is shared across all of them.
| Template | Wire structure | Brands |
|---|---|---|
eufy_room_clean |
rows β list of per-room dicts | Eufy |
roborock_segment_clean |
flat ids + batch scalar β {segments:[ints], repeat:n} |
Roborock |
generic_room_ids |
flat ids + batch scalar (e.g. Ecovacs {rooms:[ints], cleanings:n}) |
Ecovacs / fallback |
dreame_room_clean |
columns β positional parallel arrays | Dreame |
If your brand fits an existing shape, just configure the field names /
value maps β no new code. Roborock (app_segment_clean):
"dispatch": {
"template": "roborock_segment_clean",
"service_domain": "vacuum", "service_name": "send_command",
"command": "app_segment_clean",
"rooms_field": "segments", "clean_passes_field": "repeat",
},
# β {"command": "app_segment_clean", "params": {"segments": [16, 17], "repeat": 2}}
Dreame (vacuum_clean_segment, parallel arrays via a room_fields transpose):
"dispatch": {
"template": "dreame_room_clean",
"service_domain": "dreame_vacuum", "service_name": "vacuum_clean_segment",
"rooms_field": "segments", "clean_passes_field": "repeats",
"room_fields": {
"fan_speed": {"field_name": "suction_level",
"value_map": {"Quiet": 0, "Standard": 1, "Turbo": 2, "Max": 3}},
"water_level": {"field_name": "water_volume",
"value_map": {"Low": 1, "Medium": 2, "High": 3}},
"clean_mode": {"field_name": None}, "clean_intensity": {"field_name": None},
"edge_mopping": {"field_name": None}, "path_type": {"field_name": None},
},
},
# β {"segments": [3, 2], "suction_level": [0, 3], "water_volume": [1, 3], "repeats": [1, 2]}
The send-site envelope is also config-driven: a command produces the wrapped
{command, params} shape (Eufy/Roborock); omitting it merges the payload into
the service data directly (Dreame).
If your wire shape is genuinely new, add an engine in
queue/dispatch_engines.py (subclass the closest one, override build_payload)
and register it under a new template name. build_room_clean_payload in
queue/queue_engine.py is the shared resolver (profile resolution +
capability gating + canonical resolved_rooms) that engines reuse β you do not
replace it.
Job model. Engines declare job_model (atomic_batch default, or
sequenced for sweep-all-then-mop-all style multi-dispatch jobs via
build_phases). See 07-queue-engine.md and
22 Β§13.
5. Lifecycle vocabulary¶
The framework normalizes your vacuum's raw state strings into its internal
lifecycle vocabulary from the adapter config β you do not edit
listeners/lifecycle.py or core/manager.py.
entities.task_status/dock_status/active_cleaning_target/active_mappoint at the companion sensors.vocabularydeclares the raw strings:active_run_task_states,hard_service_states(wash/recycle/empty β hard block),drying_states(warning-only),blocked_work_mode_states, etc.completion.task_status_valueis the normalized "done" value (default"completed");completion.secondary_clear_sentinelsare the values that count the secondary signal as cleared.
Completion fires when task_status reaches the completion value, the secondary
target is cleared, and the job was observed active at least once. If your brand
has no active_cleaning_target concept, point completion.secondary_clear_entity
elsewhere or rely on task_status alone (it degrades gracefully).
6. Capabilities¶
Declare feature flags in the capabilities block:
supports_mop_features, supports_water_control, supports_path_control,
supports_edge_mopping, supports_mop_wash, supports_mop_dry,
supports_empty_dust, supports_robot_position, supports_station_water.
They gate payload fields, dock actions, and card UI.
There are also behavioral flags an entity probe can't see β
honors_clean_order, supports_room_profiles, rooms_unique_per_job,
position_lock_reliable β plus supports_base_station / supports_map_bounds,
which are capability-gated DEFAULTS (the framework shows the matching card tabs
unless the brand sets them False; they are not always set as adapter literals).
All default to True / unchanged, so Eufy omits them. A path-optimizing brand (the
Roborock S6) sets honors_clean_order=False (which gates the strict-order opt-in
and the run-start "order is advisory" note) and supports_room_profiles=False.
The full 15-flag table is in
Adapter config reference Β§14.
The Eufy adapter auto-populates these by calling
core/capabilities.py::detect_capabilities() (Eufy-specific: entity-presence
probes + product-code/name model families). For a new brand, the simplest path
is to set the flags statically in your config's capabilities block based
on the hardware; provide brand-specific detection only if you need it. Either
way the flags' meaning is brand-agnostic β only detection is brand-specific.
6a. Config seams a divergent brand may need¶
The Roborock adapter exercised several config seams Eufy never touches. A divergent brand can opt into the same behavior by declaring these β each is a config knob, not core code. One line each here; the field schemas live in Adapter config reference and the worked context in Roborock adapter.
dispatch.phase_timingβ settle/verify/retry timing for the strict-order per-room watchdog (a path-optimizing device may ignore a clean dispatched the instant it docks; the watchdog re-dispatches).dispatch.per_room_live_settingsβ mid-run per-room fan via a side service call (Roborockset_fan_speedas the robot enters each room).dispatch.passes_is_globalβ collapse per-room passes to one batch scalar for the whole run (e.g.app_segment_clean'srepeat).dispatch.resolve_live_ids_by_slugβ re-resolve each target room's name slug to its LIVE id from a fresh discovery right before send (segment ids that renumber on a re-map).dispatch.params_as_listβ list-wrap theparamspayload on the wire.completion.require_job_active_clearβ key completion on a job-active binary clearing instead of a current-room sentinel.mapping.live_map_image_entity_patternβ an HAimage/cameraentity-id pattern for a live-map backdrop in the card.dispatch.global_pre_callsβ push a setting once before an atomic dispatch for a brand that exposes it only globally (e.g. a singleset_fan_speed). Each entry reads a canonical per-room field across the selected rooms and applies the max-wins value via a side service call (mirrors the batch-passes rule). Best-effort. Use this for a global-only setting; useper_room_live_settings(above) when the device honors mid-run per-room changes.map_state_source/map_renderβ read a provider's own map segmentation (authoritative room bboxes + dock/robot anchors) into normalized, VA-owned geometry, plus the in-memory live pose for the moving overlays.map_renderdeclares how the card sources the raster for its own VA-owned backdrop (the source pointer is reused frommap_state_source, no duplicate schema). Eufy declares both against eufy-clean's storage backend; a brand whose integration already exposes frame-fresh map data (Roborock) omits them.room_attributionβ a pluggable engine that recovers which managed rooms an external (undispatched) run cleaned, from a per-tick pose time-series. A different axis from the job/run segmenter (Β§9): that one owns time/area boundaries, this one owns room identity. Eufy declareseufy_anchor_winding_v1; absent/unknown falls back to it (not noop).dispatch.zone_command+capabilities.supports_zone_cleanβ ad-hoc free-form zone cleaning (draw a box on the live map, clean that rectangle). The verb is thesend_commandcommand name (manager.dispatch_zone_cleanreads it); absence means zone cleaning is unsupported for the brand.
A divergent brand can thus opt into: the strict-order per-run sequenced clean, the live-map backdrop, a VA-owned provider-map source with live pose, external-run room attribution, ad-hoc zone cleaning, name-slug room-identity reconciliation, and a per-vacuum panel rename β without forking core.
7. Discovery¶
The discovery block tells the framework how the room list is exposed. There
are two sources, selected by discovery.source ("entity_attribute" default,
or "service_response"); both flatten into the same list-of-dicts the
normalizer expects, so everything downstream (slug, dedupe, int-coerce) is
unchanged. See rooms/source_refresh.py.
entity_attribute (default β Eufy). The room list is a live attribute on an
HA entity; the sync discovery path reads it directly.
room_list_entityβ"vacuum_entity"to read an attribute off the vacuum entity (Eufysegments, Ecovacsrooms), or a full entity ID.room_list_attribute,room_id_key,room_name_keyβ where the id/name live in each room dict.
service_response (Roborock). The idβname map exists only in the response
of a service call (roborock.get_maps), never as an entity attribute. An async
refresher calls the service at the async discovery boundaries, flattens the
{segment_id_str: name} mapping per map, and caches it for the sync path:
"discovery": {
"source": "service_response",
"maps_service": {"domain": "roborock", "service": "get_maps"},
"maps_rooms_key": "rooms",
"map_name_key": "name", # cache keyed by the active-map NAME
"room_id_key": "segment_id",
"room_name_key": "name",
},
See the Roborock adapter Β§4 for the worked
example. Brands that expose rooms via HA Areas (the 2026.3 vacuum.clean_area
integrations) read those instead. None of these need CV segmentation β the Eufy
CV segmentor exists only because Eufy exposes no structured room geometry.
8. Map segmentor (optional)¶
This is the map segmenter (image β room polygons). It is a different subsystem from the job/run segmenter in Β§9 β do not conflate the two: this one turns a map image into geometry; that one turns a run's counter stream into per-room timing boundaries.
mapping.segmenter_engine selects a map-image segmenter by name from
mapping/segmenter_engines.py (eufy_cv_v1, or noop_fallback to disable the
polygonal overlay while trace-based bounds keep working). The Eufy CV pipeline
lives in adapters/eufy/segmentor.py (detect_room_segments) and is built on
the brand-agnostic primitives in mapping/segment_primitives.py.
A new brand that ships structured room geometry (most lidar brands) does not
need a CV segmentor at all β declare noop_fallback. If you do want an
image-based overlay, write a new segmenter engine using segment_primitives.py
(polygon math, mask ops, HSV helpers, alignment) and register it; copy
adapters/eufy/segmentor.py as the reference and re-tune its HSV thresholds /
scoring heuristics for your brand's map palette.
9. Job/run segmentor (optional)¶
Separate from the map segmentor (Β§8): this is the job (run) segmenter, which
turns a single run's progress stream into ordered per-room cleaning bouts so
learning can attribute time/area to each room. It lives in
learning/job_segmenter_engines.py and mirrors the dispatch-engine seam exactly
β a brand registers an engine under a string name and selects it via
job_segmenter.engine in the adapter config.
The Eufy engine (eufy_counter_v1) detects per-room boundaries by watching the
cleaning_time / cleaning_area counters plateau and jump (Eufy exposes no
native per-room transition events; coordinates drift). A lidar brand that emits
a native "now cleaning room N" signal implements find_candidates by reading
those events instead and returns the same boundary/segment shape, so the
three consumers (live rollover, external-run ingest, learned history) never
change.
The contract β two TypedDicts. Every engine speaks
JobBoundaryCandidate (id, position, gap_s, area_after_m2, kind,
strength, confident, t) and JobSegment (index, boundary_id,
t_start/t_end, ct_start/ct_end, area_*_m2, time_active_s,
time_wall_s, gap_before_s, battery_delta, boundary, increment_count).
These are the cross-engine union; consumers read only these fields.
The JobSegmenter Protocol β what the brand implements:
| Method | Brand owns? | Purpose |
|---|---|---|
engine_name |
yes | the registry key (e.g. "eufy_counter_v1") |
validate_tuning(tuning) |
yes | return issue strings ([] = valid); run at registration |
find_candidates(samples, *, tuning) |
yes | every boundary in the stream, in cleaning order β no discards |
build_segments(samples, active, *, tuning) |
yes | the ordered per-room JobSegments for a chosen boundary set |
segment_legacy(samples, *, expected_rooms, tuning) |
yes | one-shot detect β select β build (live-disabled path + learned history) |
select_active is NOT on the engine β the brand does not implement it. The
middle stage (ranking/filtering candidates down to the chosen boundary set) is
the brand-agnostic framework function counter_segmentation.select_active; it
reads only kind / confident / strength / id off the candidate shape, so
the external-review wizard's count/toggle logic stays uniform across brands. The
engine owns only the two brand-specific stages (find_candidates,
build_segments) plus the segment_legacy composition.
Eufy kind literals stay at the call sites, not in the engine. The Eufy
kind vocabulary ("wash_plateau" / "transit" / "area_jump" / "weak") is
produced by find_candidates and referenced by the Eufy-specific call sites (the
live rollover_kinds list and the legacy {"wash_plateau", "area_jump"} filter).
A brand with a different kind vocabulary supplies its own engine and its own
kind literals at those sites β there is no kind indirection to configure.
Eufy fallback, not noop (mind the asymmetry with Β§8). Unlike the map seam,
get_job_segmenter_engine(name) falls back to the Eufy engine for an absent
or unknown name β like the dispatch seam, not a noop β because the historical
no-adapter default is Eufy counter segmentation and live rollover + history must
keep working byte-for-byte. A noop_job_fallback engine is registered for a
future brand that genuinely emits no segmentable signal, but you must declare it
explicitly; it is never the silent default. An unknown (non-empty) name logs a
warning.
Adapter config. Declare the engine and its thresholds in a job_segmenter
block; live_transition carries only the live-rollover orchestration knobs:
"job_segmenter": {
"engine": "eufy_counter_v1",
# The SINGLE in-code source of the gap/area/cadence thresholds β live
# rollover, external-run ingest, AND learned history all read these.
"tuning": {
"gap_delayed_s": 35.0, "gap_transit_s": 60.0, "gap_plateau_s": 90.0,
"area_jump_m2": 2.0, "cadence_s": 30.0,
},
},
"live_transition": {
# Orchestration only β NOT thresholds (those moved to job_segmenter.tuning).
"enabled": True, # kill-switch
"rollover_kinds": ["wash_plateau", "transit", "area_jump"],
# A brand with a native per-room signal sets True to follow the device's
# current-room sensor directly (filtered to the job's target rooms,
# suppressed for sequenced/strict-order jobs). Eufy leaves it False and
# uses counter-plateau inference.
"native_transition_source": False,
},
A brand whose device reports its live room directly (e.g. Roborock's
_current_room sensor) sets native_transition_source: True; the framework then
follows that signal β name-slug matched, order-agnostic, transit rooms ignored β
instead of the counter/timing heuristic. See the
Roborock adapter and
Adapter config reference Β§13b.
Threshold home moved. The five gap/area/cadence thresholds (
gap_delayed_s,gap_transit_s,gap_plateau_s,area_jump_m2,cadence_s) now live only injob_segmenter.tuning. They are no longer carried inlive_transitionβ any older note that listed them there is stale.
registry._validate_adapter validates a declared job_segmenter block (mirrors
the mapping check): engine is required when the block is present, must be a
known engine name (known_job_engine_names()), and tuning is checked by the
engine's own validate_tuning.
If your brand has no segmentable run signal, declare
{"engine": "noop_job_fallback"} to opt out explicitly (every stage returns
[]); learning then accumulates no per-room boundaries for that brand.
Byte-identical by delegation (Eufy). EufyCounterSegmenter delegates
verbatim to the existing counter_segmentation primitives, and its
DEFAULT_TUNING is defined by reference to that module's constants β so the
Eufy path is byte-for-byte identical to the pre-engine code, and drift is a
compile-time impossibility rather than a vigilance task. Copy this engine as the
shape reference for a new brand.
10. Room-profile vocabulary (optional)¶
A brand can override the room-profile vocabulary via a room_profiles block.
The in-code catalog in profiles/room_profiles.py (BUILT_IN_ROOM_PROFILES,
DEFAULT_CUSTOM_ROOM_PROFILE, the floor-type defaults, the legacy aliases, and
DEFAULT_ROOM_PROFILE_NAME) is the framework default; resolve_profile_catalog(block)
merges your block over those constants per key, so you can override any
subset and a None/empty block yields the defaults verbatim (byte-identical).
"room_profiles": {
"default_profile": "vacuum_quick",
"builtins": {...}, "custom_template": {...}, "legacy_aliases": {...},
"floor_type_water_defaults": {...}, "floor_type_fan_defaults": {...},
"normalize_defaults": {...},
},
The Eufy adapter declares this block by reference to the in-code constants
(no duplication). registry._validate_adapter applies a light check: the block
must be a dict, default_profile a string, and the catalog sub-keys dicts.
Honest boundary. The catalog is wired into the dispatch path only
(queue/queue_engine.py::build_room_clean_payload resolves it from the adapter
and threads it into per-room profile resolution + capability gating). The
global profile editor (profiles/manager.py) and the pure room-builder
defaults (rooms/room_manager.py) lack per-vacuum context, so they use the
framework default catalog. For Eufy this is byte-identical; for a second brand
those editor surfaces would show framework defaults until threaded β a documented
follow-up, not a wired feature.
11. What ports for free (zero changes)¶
These subsystems operate entirely on the internal data model / HA service calls and need no brand work:
- Learning (
learning/) β consumes canonicalresolved_rooms+ lifecycle events. Per-room run segmentation is the one pluggable seam here (Β§9); the rest (history store, external ingest, accumulation) is brand-agnostic. - Queue engine (
queue/queue_engine.py) β ordering, enabled-room filtering, access graph, blocker/modifier rules, start protection. - Room rules (
rooms/,queue/) β blockers, modifiers, access graph, preflight. HA state reads only. - Mapping/bounds (
mapping/) β trace-based bounds from position sensors (coordinate units may need adjustment per brand). - HA entity platforms β
button.py,switch.py,number.py,sensor/(the integration registers five platforms:binary_sensor,button,switch,number,sensorβ there is noselectplatform). - Listeners (
listeners/package) β lifecycle/finalize, job progress, dock events, path blockers, pause timeout. Driven by adapter config. - Card frontend, theme system, profile system β fully brand-agnostic.
12. Validate with the contract harness¶
There is no fork to maintain. The brand-agnostic adapter conformance suite
(tests/adapters/test_adapter_contract.py) validates any adapter's config
against the documented schema and runtime expectations. Add your brand to the
ADAPTER_BUILDERS registry in tests/adapters/conftest.py and the entire
contract suite runs against it automatically β schema conformance, dispatch
shape, entity-ID format, registry validation. Brand-specific deep tests (your
CV segmentor, your model catalog) live under tests/adapters/<brand>/.
13. Port checklist¶
Each step is independently testable.
- Map the HA entities your vacuum exposes to the
entitiesrole keys (task_status,dock_status,active_map,battery, position sensors, β¦). Document the IDs before writing code. - Write the
vocabulary+completionblocks β every raw state string, sorted intoactive_run_task_states/hard_service_states/drying_states/ blocked sets, and the completion value. Ensure at least one state marks the job "observed active." - Choose a
dispatch.template. If an existing shape fits, configure field names + value maps. If not, add an engine indispatch_engines.py. - Set
capabilitiesflags for the hardware. Setdiscoveryfor how rooms are listed. Setmapping.segmenter_enginetonoop_fallbackunless you wrote a map segmentor (Β§8). - Decide the job/run segmenter (Β§9). If you can reuse the Eufy
counter-plateau engine, declare
job_segmenter.{engine: "eufy_counter_v1", tuning}and thelive_transitionorchestration knobs. If your brand emits native room-transition events, write aJobSegmenterengine (find_candidatesplusbuild_segmentsplusvalidate_tuning, returning theJobBoundaryCandidate/JobSegmentshape βselect_activeis framework-provided), register it in_JOB_SEGMENTER_ENGINES, and name it here. To opt out, declarenoop_job_fallback. (Optional β an absent block falls back to the Eufy engine.) Optionally declare aroom_profilescatalog (Β§10) to override the default profile vocabulary. - Build the config dict in
adapters/<brand>/adapter.pyand register it fromasync_setup_entry. - Add the brand to
ADAPTER_BUILDERSand runpytest tests/adaptersβ the conformance suite must pass. - Run a real job with learning disabled: confirm the dispatch reaches the vacuum, the job is observed active, it auto-finalizes, and the active-job record clears. Then enable learning and confirm per-room timing accumulates into the per-room segments your job segmenter produced.
- (Optional) Map overlay: place a PNG at the maps path and use the card's bounds calibration, or rely on colour-coded bounds boxes with no image.
If the brand has a real user base, ship the adapter upstream as a new
adapters/<brand>/ package rather than keeping it local β that is the whole
point of the adapter boundary.