23 โ The Eufy Adapter¶
Scope. How the Eufy X10 Pro Omni answers the contract: what the
adapter computes as opposed to transcribes, the declarations that look like mistakes and are not,
and the surfaces that no longer do what they say. The CV segmenter is a separate subsystem and is
not covered here. The per-field schema is generated, not written โ see
docs/dev/reference/ADAPTER-CONFIG.generated.md in the repo.
This adapter was the reference implementation before there was a contract to implement. That ordering is the single most useful thing to know about it, and most of what follows is downstream of it.
1. The adapter computes five things and transcribes the rest¶
adapters/eufy/adapter.py::register_eufy_adapter_for_vacuum runs once per managed vacuum and
builds one dict of about thirty top-level blocks. Fourteen sibling modules supply the data; the
assembler adds almost nothing of its own. What it genuinely computes:
- The model code โ device registry first, the vacuum's
detected_modelattribute second. - The model family โ via
adapters/eufy/model_catalog.py::MODEL_CODE_FAMILIES. has_attribute_roomsโ read from the livesegmentsattribute at registration.- The entity-candidate lists.
- Two rescue passes over declared entity ids (ยง4).
Everything else is transcription. That matters when reading it: a block that looks like logic is usually a literal that was measured somewhere else, and the interesting question is almost always which file owns the value, not what the assembler does with it.
The registry-first model read is not a preference. The code previously read the attribute
alone, which works because the novel-API path mirrors the model onto it โ and silently fails
everywhere else. The registry carries T2351 on every robovac_mqtt transport; the attribute does
not. Reverting re-pins every scalar and Tuya-transport Eufy to family generic, which drops all
five model-based capability hints. The lookup helper is kept local rather than imported from core,
a knowingly-accepted duplicate in exchange for a self-contained adapter.
2. Eufy is the residual default, and it lives in literals¶
The retired pattern guide stated the norm plainly:
The framework hard-codes no brand knowledge. [โฆ] If you find yourself wanting to special-case a brand in core, the value belongs in the adapter instead.
By imports, this holds. Core reaches into this package in exactly three statements against two
targets: adapters/eufy/const.py (twice, for values that are persisted and so cannot be
derived) and adapters/eufy/segmentor.py (once, flagged in place as a deliberate deviation because
the optional numpy/Pillow stack makes lazy adapter-side registration awkward). None of the eight
pure-data modules is imported from outside the package.
The residue is in literals, and it survives every import-graph check. Core's fallbacks are
Eufy's declared values character for character โ the completion value "completed", the
not-error sentinel set, the cleaning/returning/paused triple. A brand that declares nothing
does not get a neutral default; it gets Eufy's answer wearing the framework's name.
Two concrete instances a reader should not have to discover by accident:
adapters/eufy/const.py::SUPPORTED_TESTED_MODELis"Eufy X10 Pro Omni", and it is the config flow's default for thetested_modelfield on every install, including a Roborock-only one. Roborock's equivalent constant has no reader at all. This is the invariant in its most literal form: a fallback that yields a brand's word.- The same file's
VERSIONis imported into core and read by nobody, whilemanifest.jsonis the version that ships. Six of that file's constants are the integration's identity filed under a brand folder.
The comments that describe the removed fallback¶
Until the room-profile catalog moved into the brand package, the framework catalog was Eufy's
catalog, because Eufy was first. Two core comments still describe that world โ
queue/queue_engine.py and profiles/room_profiles.py each state that an absent catalog falls
back to in-code defaults "byte-identical for Eufy."
There have been no in-code defaults since the move. profiles/room_profiles.py::resolve_profile_catalog
returns empty for every undeclared key and profiles/room_profiles.py::get_room_profile raises.
Both comments sit next to code doing the opposite, in the two places a reader most likely checks.
3. Declarations that look like mistakes¶
Four of these read as bugs on first contact. Each is load-bearing, and each has a named failure mode if undone.
Clean intensity is value-mapped onto different words¶
dispatch.room_fields maps VA's Narrow โ wire normal and Deep โ wire narrow. Passing the
canonical names through unmapped โ the identity rename every other room field uses โ is wrong,
because upstream's extent map collapses narrow and deep onto the same enum value. Unmapped,
Narrow and Deep were the same clean, and the middle density (the app's "Medium") was unreachable
from this product entirely.
The cost is a genuine foot-gun that no amount of naming discipline removes: canonical Narrow and
wire narrow are different densities, and canonical Deep is wire narrow.
path_type is deliberately not declared¶
Declaring it puts the same physical property on the wire twice in one room object. The verified
live payload read {"clean_intensity": "Quick", "path_type": "None"}, and the only reason the
device never had to choose between them is that the stored value was the invalid string "None".
An undeclared canonical field is simply omitted.
The provenance is what makes this non-obvious: path_type appears in the initial release commit,
three weeks before adapters or a second brand existed. It was invented here as a duplicate โ it
merely reads today as Roborock's word.
Four dock actions are declared where only three are probed¶
dock_events.action_buttons iterates the union of candidates and tokens, so it declares four while
entity_candidates probes three. Removing the fourth is not a tidy-up.
adapters/eufy/buttons.py::DOCK_ACTION_TOKENS substring-matches, and the dock manager builds rival
token sets from every other action's tokens to stop one action binding a button another owns.
Drop stop_dry_mop from the declaration and dry_mop binds the stop button.
The floor-type tables were split, not retired together¶
The non-carpet rows of adapters/eufy/room_profiles.py::FLOOR_TYPE_WATER_DEFAULTS were removed
while adapters/eufy/room_profiles.py::FLOOR_TYPE_FAN_DEFAULTS was kept whole. Applying one
reasoning mechanically to both is the trap, and the file says so.
The distinction is preference vs expectation vs safety. Floor type is collected for the map
render and the onboarding gate; nothing told the user it would also pick a water level, so
hardwood, laminate, tile and marble came out. Carpet-is-water-off stays because it is core's only
source for profiles/room_profiles.py::no_water_value on this brand โ a safety property. Carpet
boosts suction stays because firmware does it anyway, so it meets an expectation rather than
imposing a taste.
4. The rescue pass, and the fourth copy of the naming assumption¶
Declared entity ids assume Eufy's naming. On an install that names entities differently, that
assumption fails silently, and adapters/entity_resolve.py::resolve_declared_entities exists to
recover: an id that resolves is returned untouched, and only an unresolvable one reaches a
domain-scoped sibling search.
settings_selects was the fourth place the naming assumption appeared and the only one with no
rescue at all. Adding one is the only deliberate behaviour change in that pass, and its effect is
that a broken install starts working: previously every id in the block resolved to nothing, the
setting entities came back empty, and the zone-clean panel had no controls โ while the matching
sensors, which were rescued, read fine. That asymmetry is the whole bug: sensor.โฆ_water_level
bound and select.โฆ_water_level did not.
Two properties keep this from being a blunt instrument. Overrides are pinned before the rescue runs
and address roles rather than setting selects, so overrides=None is correct here. And the rescue
is domain-scoped, which is why select.โฆ_water_level can never be rescued onto
sensor.โฆ_water_level.
5. The fault table has two axes, and "unknown" is a real answer¶
Error seconds are subtracted from a job's cleaning time, so misclassifying a fault corrupts the learning record rather than merely mislabelling a screen.
adapters/eufy/vocabulary.py splits codes on two independent axes โ source, and whether the fault
invalidates cleaning evidence โ and a code in neither evidence set is unclassified and
preserved. A single set, or a default that treats unknown faults as evidence-invalidating, means a
fault added after the table was written can zero a productive run. That is not hypothetical: five
dock-side pump faults once charged 455 s against a 360 s clean, the job recorded zero cleaning
time, and it was still marked usable for learning โ so the model learned that 4 mยฒ takes no time.
The runtime alternative was drafted and rejected in source. Asking the run timeline "was the robot cleaning when this fired?" fails at its base, because the fault timestamp is when Eufy surfaced the fault, not when it occurred. It also fails when the timeline is absent, and trades a deterministic lookup for an unauditable heuristic.
adapters/eufy/vocabulary.py::EUFY_EVIDENCE_INVALIDATING_ERROR_CODES is derived (robot-sourced minus
adapters/eufy/vocabulary.py::EUFY_EVIDENCE_SAFE_ROBOT_CODES), which is only sound because Eufy's table
is closed โ the full error enum was captured. Roborock hand-declares the same set instead, and its
file records that copying this derivation was checked and would be wrong. Same concept, two
justified shapes.
Upstream's own labelling is overridden where Eufy's protos contradict it. One battery-shutdown code was mirrored from upstream into the dock set, and therefore into evidence-safe on the reasoning that "the robot can be cleaning normally throughout" โ which it cannot, because the run ended when the pack died. Its evidence is truncated by definition.
adapters/eufy/vocabulary.py::NOT_ERROR_SENTINELS is a genuine brand-forced declaration, and the
cross-brand check proves it: core's fallback holds only the three HA-standard values, and Roborock
deliberately excludes normal because a Roborock code could legitimately contain it. Drop the
Eufy declaration and every idle reading is latched as a live fault.
6. Where this adapter instructs the next porter, and is wrong¶
This package was written as the pattern guide, so several comments address a future porter directly. Three of those instructions have now been tested by a second brand and failed.
| The instruction | What Roborock did | Consequence of following it |
|---|---|---|
map_render is Eufy-specific; a brand whose HA-core render is already frame-matched omits it |
Declares it โ the core render was not sufficient, because the parser reads the pixel layer to colour rooms and then discards it | A porter concludes a genuine two-brand axis is single-brand |
Brands with always-on map exposure should drop import_active_map from setup |
Has always-on exposure and keeps the step, as the brand-agnostic "discover + create bucket" op | Drop it and Configure Rooms opens empty |
adapters/eufy/entities.py::build_entity_id keeps a strategy parameter that raises for its one unimplemented value, so "the gap is visible rather than silent" |
Dropped the parameter entirely and kept only the suffix constants | A seam maintained for a case that has never arrived |
The strategy parameter is the honest one of the three: making a gap visible is a reasonable
instinct, and only a genuinely differently-named integration would test it. Both shipped brands use
the same object-id-suffix convention.
Two more places where this adapter's own prose contradicts its own data:
- ~~
adapters/eufy/maintenance_components.pystates thatsensor_suffixisNonefor components that source viaproxy_for.~~ FIXED 2026-09-14. The header now documentsproxy_forand states the resolution order outright. Declaring both a suffix and a proxy is correct and deliberate โ the point a porter needed and the prose denied.
โ The order stated here was also wrong, and the code changed to match the doc's own
rationale rather than the reverse. It said core resolves the proxy first and falls back to
the component's own suffix. That was accurate for v2.1.0 and it defeats the stated purpose: if
the borrow always wins, a genuine per-part counter can never be used on firmware that has one.
Resolution is now own > proxy > clock. proxy_for itself was deleted on 2026-09-12 and
restored on 2026-09-14 โ the failure that removed it (a source part's reset zeroing the
borrower) belonged to the old point-value maths, and core/usage_accumulator.py now re-baselines
on a move against expectation and books nothing. UAC-20 pins that; CAP-5c pins the order.
- adapters/eufy/entities.py::ALL_SUFFIXES is described as covering every suffix this adapter
knows, derived rather than hand-listed, so that a new constant joins automatically. It covers
every suffix that module knows. The assembler builds at least fourteen more as inline
f-strings, which never enter the reserved-suffix universe โ and they include the most recently
added entity names, which is the half a reader would rely on the guarantee for.
7. Surfaces that do not do what they say¶
Everything in this section is live code or live prose that a reader would reasonably trust.
โ CORRECTED 2026-08-23. Every prose defect below has been annotated AT ITS SITE โ the section is kept as written because it is the record of what was found and why it read as trustworthy, not because the comments still say these things. Three items carry a behavioural remainder that was deliberately NOT changed in a prose pass and is named at the site instead: the third hardcoded copy of the robot-position ids in core (a signature change),
FAN_SPEED_ALIASESpointing outside the declared options (removing an alias changes what a stored value migrates to), and_safe_int's missing code guards (narrowing it would change two unrelated call sites). The zone-clean hint is closed โ see D18 in 24 ยง8.
adapters/eufy/lifecycle.py is fully superseded and says the opposite. Its docstring tells a
porter to replace its three functions and to preserve their return shapes exactly, "because the
framework lifecycle listener depends on them." All three are callerless in production. The live
path is listeners/_common.py::completed_finalize_signals, which reads entity ids from the
registered config โ and the shape has already diverged, so a porter who preserves this contract
implements the wrong dict for a function nothing calls. Worse for a live install, this version
re-derives ids by f-string, bypassing both user overrides and the rescue in ยง4. Roborock has no
counterpart. adapters/eufy/vocabulary.py carries a loud banner over its superseded functions;
this file, which is superseded in full, carries none.
Two vocabulary keys have no consumer. blocked_work_mode_states and
blocked_task_status_states are declared here with real values, and nothing reads either. Only
blocked_dock_status_states has a live reader. They are also the only vocabulary entries written
as inline literals in the assembler rather than sourced from adapters/eufy/vocabulary.py, which
is plausibly why they were missed when their consumer went away. The full history โ this was a live
gate, orphaned in a window version control cannot see โ is in
22 ยง5.
Most of constants.py is unreachable, and its live values are unlinked copies elsewhere. The
file presents itself as the measured-constant surface and instructs a porter to re-measure and
replace. Eight of its fifteen constants have no importer. The values that are live are restated
as literals in two other files โ the wash-interval bounds in the assembler, the water rates in
adapters/eufy/water_config.py โ and nothing makes the pairs agree. Re-measuring the documented
constant changes nothing.
The cleaning_intensity entity role is never bound. The assembler sources it from the
capability probe's entities dict, which has ten fixed keys and does not include it, so the
expression is unconditionally None and the key is stripped. The role is probed โ from the
candidate list, as a boolean feeding path-control support โ so the schema's description is true of
the candidate, not of the slot it annotates. The value is discarded, so the remedy is the comment
rather than the code; but the sentence currently reads as authority that the role is bound.
Raw robot position is named in three places, one of them core. The entities module states that
the position entities are excluded because the mapping subsystem manages them. They are hardcoded
in the assembler, and read in core by a third hardcoded copy that bypasses the declared role, the
rescue, and any user override. Because those suffixes are not constants, they are excluded from
ALL_SUFFIXES and therefore from the guard that stops sibling matching handing one role's entity
to another. On an install where the rescue was needed, the declared role would resolve and the core
copy would still return nothing โ the shorter copy is the bug.
One capability hint has nowhere to be declared. A comment explains that zone-clean support is
read from capabilities rather than hardcoded "so a model catalog entry can declare it False and be
believed." The core mechanism is real and the capability is in
core/capabilities.py::KNOWN_CAPABILITY_HINTS โ but on this brand the hints are built from five
fixed model-family membership tests, and adapters/eufy/model_catalog.py maps codes to family
names with no capability fields at all. Roborock's catalog does carry per-model capability rows,
which is presumably where the sentence came from. Declared here today, it would be ignored.
The model catalogs do not describe the same device set. Re-measured 2026-09-13:
adapters/eufy/model_catalog.py::MODEL_CODE_FAMILIES holds 22 codes,
adapters/eufy/upkeep_catalog.py::UPKEEP_MODEL_NAMES holds 13, and
adapters/eufy/water_config.py::WATER_MODEL_CONFIGS contains exactly one model, so every other
Eufy takes core's generic flow rate. Nine of the twenty-two codes have no upkeep name at all.
Every degradation is reported honestly rather than faked; the hazard is that a catalog comment
reading "dock-action entities confirmed" reads as supported when one catalog was updated.
Guide coverage is not a third way to drift. Maintenance guidance is routed by regime, and
adapters/eufy/upkeep_regimes.py::EUFY_MODEL_REGIMES carries one row per model โ 13, agreeing
with the name catalog exactly, in both directions (it reaches the card through
adapters/eufy/upkeep_keys.py, which unpacks it via the shared emitter). A model here cannot
carry a name with no guidance behind it. The name-versus-code gap above is the one still worth
checking when adding a model.
A retired premise is cited under the live name. A comment in adapters/eufy/room_profiles.py
explains that two retired cleaning-path values need no alias because the store repair resets them
to the brand default. That is the pre-fix behaviour, and it is the exact defect
adapters/eufy/vocabulary.py was later written to condemn โ the fold moved every affected room
from the middle density to the fastest one. rooms/vocabulary_migration.py::_alias_target now
resolves the alias first. The comment predates the fix by one day, and it sits in the file that
owns the values.
FAN_SPEED_ALIASES still points at a value no option list contains. It maps the BoostIQ
spellings onto a canonical boost that was removed from the shipped options once it was
established that boost is not a suction level at all โ it is the auto carpet-boost switch, and
the payload resolves fan speed by index, so the chip silently applied no suction. The alias map was
never swept. Live effect splits by consumer: the learning and card path still emits the code, while
rooms/vocabulary_migration.py::_alias_target ignores an alias pointing outside the declared
options โ and its docstring calls exactly that shape an adapter defect. The brand declares an alias
core is documented to refuse.
The "dock is servicing" question has three answers. adapters/eufy/vocabulary.py::HARD_SERVICE_STATES
(seven strings, both spellings of the recycling state, all three dust-empty variants) gates job
start; blocked_dock_status_states (two strings, one spelling, no dust-empty) gates the stranded-run
reaper; and external_mid_run_statuses is a hand-copied duplicate of
adapters/eufy/vocabulary.py::CANCEL_SERVICE_EXCLUSION_STATES in a different case, declared inline
in the assembler. The shorter copy is the exposed one: a dock status using the second spelling
blocks a start but is invisible to the reaper's dock channel. The task-status channel usually
compensates, which is why this reads as working.
One coercion guard is short. The dead adapter helper
adapters/eufy/vocabulary.py::_exact_error_code documents the rule โ never int(), because
int(3.7) is a real code, and bool is an int subclass so True resolves to code 1 โ and
core/error_tracker.py::_code_key preserves both guards verbatim. But
core/error_tracker.py::_safe_int, which reads the code before either sees it, is a bare int()
with neither guard. Both of those coerced values land on codes that are robot-sourced and not
evidence-safe, so their seconds are deducted โ the exact arithmetic the fault table exists to
protect. No non-integer has been observed arriving there, so this is a guard asymmetry with a named
input rather than a confirmed field failure.