15 — Adapters — Subsystem Test Map¶
The adapter subsystem is the brand-abstraction boundary: a registry maps each
vacuum entity to an adapter config (entities, vocabulary, water/upkeep models,
maintenance components), loaded from storage and validated against a schema.
Two concrete adapters now live behind this boundary — Eufy
(adapters/eufy/) and Roborock (adapters/roborock/) — each with its own
focused suite, plus adapters/brands.py (which registrar runs for a given
vacuum) and the brand-agnostic conformance harness that runs every contract
test once per shipped brand. Covered by 166 framework tests across 10 files
(test_adapters.py, test_adapter_contract.py — parametrized over both
brands — and test_brand_selection.py), plus 212 Eufy-adapter tests and
37 Roborock-adapter tests.
A third adapter directory, adapters/dreame/, holds data only and is deliberately
not wired — it has no BRAND_REGISTRARS row, so the conformance harness above does
not reach it and none of those counts include it. Its 37 tests live in
tests/adapters/dreame/ and are documented below; they exist precisely because nothing
else in the tree can fail on that directory.
Source: custom_components/eufy_vacuum/adapters/
Architecture reference: 22 — The Adapter Contract
test_entity_resolve.py — when a DERIVED entity id does not match the install¶
Adapters build companion entity ids from the vacuum's object_id
(build_entity_id → sensor.{object_id}{suffix}). That assumes one device per vacuum,
and two shipping cases break it: Eufy's dock is a separate device, so its entities are
named for that device (declared sensor.alfred_total_cleaning_area, actual
sensor.dining_room_alfred_total_cleaning_area — four dock-owned roles unresolved on live
hardware), and a renamed device or entity breaks every derived id at once.
Both failed SILENTLY, and a declared-but-absent entity reads as "this brand does not report
that" — the capability leak this project keeps removing. HA 2026.8 removed battery_level
from the vacuum entity, deleting the fallback that used to hide a missed battery sensor, so
the derived id is now load-bearing alone (issue #49).
adapters/entity_resolve.py rescues an id that FAILS to resolve by searching the vacuum's
own config entry for a domain + suffix match. Nine tests, and most pin what it REFUSES to
do, because the refusals are what make it safe to run on every install:
| id | what it holds |
|---|---|
ER-1 |
a working id is never touched — the property that makes this unable to break a healthy install, asserted even when a same-suffix sibling exists |
ER-2 |
the live Eufy dock case is rescued, and the remap is REPORTED rather than silently applied |
ER-3 |
two candidates it cannot disambiguate → leave the declared id; a wrong remap aims the framework at another device and is worse than the absence |
ER-4 |
ambiguity broken only by the vacuum's own object_id — what makes <area>_<vacuum>_<suffix> resolvable |
ER-5 |
domain must match; a select.*_active_map is never served for a declared sensor.*_active_map |
ER-6 |
no config entry → no-op. Config-entry scoping IS the safety boundary, so without it we search nothing |
ER-7 |
the documented LIMIT (issue #46 shape): registered-but-stateless is not a naming problem, so it resolves to nothing and reports no repair |
ER-8 |
a raising registry degrades to the declared ids — adapter config assembly is never breakable by this |
ER-9 |
an id not derived from this vacuum has no suffix to match on, so nothing is guessed |
test_adapter_isolation.py — the boundary, not the declarations¶
test_adapter_contract.py asserts what a brand must DECLARE. Nothing asserted
what a brand may TOUCH, so test_adapter_isolation.py (5 tests, added
2026-08-07) fences the dependency direction: a brand package translates VA's
meaning into one provider's vocabulary, and that substitutability is only real if
it cannot reach inward.
| id | what it holds |
|---|---|
ISO-1 |
no brand package imports outside the declared adapter SDK |
ISO-2 |
the known-leak allowlist is SHRINK-ONLY (same discipline as mock_allowlist.json) |
ISO-3 |
no dynamic import (importlib / __import__ / sys.modules) escapes ISO-1's static read |
ISO-4 |
no runtime reach into a passed-in object's privates — the reach an import graph cannot see |
ISO-5 |
the detector is exercised against a MANUFACTURED leak (positive + negative), so it cannot silently stop working |
The SDK is two entries, both adjudicated from measurement:
core.capabilities (BOTH brands call detect_capabilities — a facility every
adapter needs is API that happens to live in core/) and
mapping.segment_primitives (brand-neutral geometry — rdp, polygon_area,
mask_iou; any brand shipping a map IMAGE needs it, and Roborock supplies
segments directly so imports none of it).
The ledger is now EMPTY. It held one entry — profiles.room_profiles in
eufy/adapter.py, because the framework's in-code profile catalog was Eufy's.
That import was cut on 2026-08-07: Eufy's vocabulary moved to
adapters/eufy/room_profiles.py, core kept only the KEY space, and the framework
fallback was deleted rather than relocated. As predicted, it was a STORED-DATA
change and not a refactor — those values were already written onto existing rooms,
so a one-shot repair ships with it (rooms/vocabulary_migration.py).
ISO-5 changed shape with it. It used to assert the real leak was still detected, and instructed whoever fixed it to delete the ledger entry and ISO-5 together. Deleting the self-test would have left the detector unexercised — the exact failure it exists to prevent — so it now manufactures its own leaky and clean modules in a tmp dir. The instrument stays proven with the ledger empty, which is the state it should stay in.
tests/test_vocabulary_invariant.py — the standing invariant¶
Core owns the KEY space. It does not own any brand's WORDS. Enforced (14 tests, added 2026-08-07) by taking two signals together, because either alone is useless:
- Syntactic — the literal is bound to a provider-owned field (
fan_speed,water_level,clean_intensity): assigned to it, used as its.get()default, compared against it, or passed as it. - Lexical — the literal is a value an adapter actually DECLARES, and is not in
the explicit
CORE_OWNEDset.
Lexical alone reports every max, off and low in the repo — wash_frequency_bounds["max"]
is a structural key, confidence == "low" is a confidence level, state == "off" is an
HA entity state. A gate that screams at those gets ignored. Syntactic alone cannot tell a
canonical key from a brand's word. VI-3 pins both classes with a positive and a
negative row each, so the detector stays proven while VI-1 sits green.
CORE_OWNED is the load-bearing declaration: the written statement of what the framework
owns (the canonical water estimator keys off/low/medium/high, the canonical
clean_mode and path_type values, and "" for "nobody said"). Growing it to silence
a finding is how the gate dies.
Case-sensitive on purpose. Folding case would merge Eufy's "Off" with the canonical
"off", so "Off" would be swallowed by CORE_OWNED and the five real leaks in
apply_capability_gate and _protected_room_config would be invisible. "Off" vs
"off" IS the bug. The accepted blind spot is RETIRED values, which no adapter declares —
that is the store migration's job.
It found seven leaks on its first run, after the manual sweep was thought complete:
.get(field, "Max"/"Off"/"Quick") defaults in overwrite_room_profile_from_room,
_snapshot_room_for_run_profile, and the learning ingest path (whose default was
"standard" — a Eufy word Eufy itself had retired). The ledger is empty.
A second gate was measured and rejected. "Every test-registered adapter must declare
a catalog" sounds like the natural sibling, but 208 registrations across 51 test files
declare none and the suite is green — so those tests provably never resolve a room, and
the rule is not required. Shipping it would have meant a 208-row allowlist or 51 files of
churn. What actually covers the hazard is the runtime failure: resolution raises
UndeclaredProfileCatalogError naming the missing declaration, so a test that reaches it
cannot get a quietly wrong answer.
tests/adapters/eufy/test_intensity_wire_mapping.py — three chips, three densities¶
VA declares three cleaning intensities. The device has three. They were not reaching each other (5 tests, added 2026-08-08).
robovac_mqtt resolves the payload string through CLEAN_EXTENT_MAP, where
"narrow" and "deep" are the SAME value — deep is a legacy alias:
| payload word | CleanExtent | Eufy app |
|---|---|---|
quick / fast |
QUICK (2) | Low (widest spacing) |
normal / standard |
NORMAL (0) | Medium |
narrow / deep |
NARROW (1) | High (densest) |
Sending VA's own names unmapped collapsed Narrow and Deep onto NARROW and left
NORMAL unreachable — three chips in the card, two pass densities on the floor, and
the middle one impossible to select. The fix is a dispatch.room_fields value_map
({"Narrow": "normal", "Deep": "narrow"}), so VA's names are unchanged and no stored
room moves.
Verified against hardware, not inferred: the Eufy app was set to each intensity
and select.<vac>_cleaning_intensity read back, cross-checked against the protobuf
enum (clean_param_pb2: NORMAL=0, NARROW=1, QUICK=2). Note the enum is an ARBITRARY
ENUM, not an ordinal — 0 is the middle setting — so nothing may interpolate on it.
That is also why CIW-3 pins the declared option ORDER: fastest→slowest exists only
in the declaration and cannot be recovered from the device.
CIW-1b is the mutation control — with the value_map removed, two of the three
collide — because CIW-1 would otherwise pass if the map were a silent no-op. The
card carries the matching half in _profileIntensityToEditorIntensity; both halves
must agree or the editor shows a density the wire does not send.
test_declaration_contract.py — the declaration, in all three of its states¶
test_adapter_isolation.py fences what a brand may TOUCH. This one (12 tests,
added 2026-08-07) pins what happens when a brand DECLARES — or fails to.
| id | state | what it holds |
|---|---|---|
DC-1 |
declared + populated | every shipped brand's real config resolves ITS words |
DC-1b |
— | Eufy and Roborock actually DIFFER, the premise every relational test rests on |
DC-2 |
not declared | resolution RAISES, naming the missing declaration |
DC-2b |
not declared | negative control: the catalog is empty, not another brand's |
DC-2c |
not declared | registration rejects it, so the failure lands on the porter |
DC-3 |
declared empty | a declared-empty key is carried, not treated as absent |
DC-3b |
partially declared | undeclared keys resolve EMPTY; the block is the gate, not each key |
DC-3c |
wholly empty block | rejected — a brand with no vocabulary can resolve nothing |
DC-4 |
— | the same validator that rejects the bad ACCEPTS every shipped brand |
DC-5 |
— | end to end: the REGISTERED config is what resolution actually reads |
roborock/test_roborock_upkeep_keys.py — Roborock on the shared regime → key guide¶
7 test functions, added 2026-09-12 with the Roborock port. Replaces the three authored guide families and the hand-assigned maintenance TIER that chose between them.
| id | what it holds |
|---|---|
| RUK-1 | 41 models, not one new authored key — its whole washing-cloth line emits Dreame's SimuMop card sets, because SimuMop normalises to cloth at generation. |
| RUK-2 | declared ↔ emitted components, both directions. |
| RUK-3 | seven components, not fourteen — and the eight that went are Chris's rulings, named. |
| RUK-4 | every model is both named and routed; 41 either way. |
| RUK-5 | the walk-back, pinned. The old tier was wrong on 8 of 41 in BOTH directions, and two of the assertions this replaced were holding the defect in place. |
| RUK-6 | RNARRS0S rehoused. Twin flat cloths are cloth/pad, never roller. Was a replica anchor across two files the port deleted; it is a measurement now, and one that can go red. See docs/dev/00c-replicas.md. |
| RUK-7 | 41 models → 6 regimes → 4 card sets. |
eufy/test_eufy_upkeep_keys.py — Eufy on the shared regime → key guide¶
6 test functions, added 2026-09-12 with the Eufy port. It replaces
eufy/test_upkeep_guides_i18n.py, which checked five authored families across seventeen
language packs; there are no families and no Eufy-authored prose any more, so what is worth
asserting changed with them.
| id | what it holds |
|---|---|
| EUK-1 | Eufy adds no key Dreame has not authored. The claim the whole port rests on, and the one that can go quietly wrong: an unauthored key renders as its own name, and every count in EUK-5 would still be green. |
| EUK-2 | declared components ↔ emitted components, both directions. An emitted-but-undeclared panel gets no entity binding; a declared-but-unemitted one can never render. Neither raises. |
| EUK-3 | the canonical ids are used (mop, omnidirectional_wheel), with Eufy's word kept as label_key — and mop deliberately carries none, because "Mopping Cloth" is wrong on its five roller and pad models. |
| EUK-4 | the family system is gone — no UPKEEP_GUIDE_LIBRARY, no UPKEEP_GUIDE_TRANSLATIONS, no family maps. A leftover import would keep it alive unnoticed. |
| EUK-5 | 13 models → 6 regimes → 4 card sets. |
| EUK-6 | the vacuum-only machines carry no mop panel, no tray and no 2-in-1 water clause. Eufy is the only brand whose data can hold this case — Dreame ships no mop-less model. |
core/test_maintenance_component_rename_migration.py — the interval carry-over¶
9 test functions, added 2026-09-12. Maintenance state is keyed by component id, so renaming one
orphans what it held. reset_at self-heals on the next reset; interval_hours does not — it is
a preference, nothing prompts its restoration, and the old value lives only in .storage.
| id | what it holds |
|---|---|
| MCR-1 | a custom interval moves to the new key; the legacy row is left in place, inert. |
| MCR-2 | reset timestamps are not carried — faking them forward claims a service under a name that did not exist. |
| MCR-3 | a value already on the new key is never overwritten; it is the user's later choice. |
| MCR-4 | a rename CHAIN takes the most recently reset row. Pinned from a real defect: swivel_wheel → caster_wheel → omnidirectional_wheel left two dead rows aimed at one destination, and applying both in order let a 65h value dead since May overwrite a 30h one set an hour earlier. |
| MCR-5 | the tie-break from the other side — a row with no reset_at is the weaker claim. |
| MCR-6 | a reset-only legacy row carries no preference, so there is nothing to rescue. |
| MCR-7 | idempotent; the second call is a no-op, not a re-apply. |
| MCR-8 | malformed storage is survived, not raised — this runs during setup on every install. |
| MCR-9 | the table holds only 1:1 renames. An absorption has no single destination (two sources, one panel), so those drop to default by design. A future edit adding one is what this catches. |
[MSK] — the THIRD store, found on a clone and not by the suite (2026-09-14). A component id
keys three things: the interval in data["maintenance"], the entity in HA's registry, and the
RESOLVED SOURCE in data["capabilities"][vacuum]["maintenance_sources"]. The platforms read the
third with refresh=False, so after a rename they look up main_brush in a dict still keyed
rolling_brush, get None, and create no entity at all. Measured on a real upgrade from
v2.1.0: three components had no entities until a SECOND restart happened to refresh the snapshot,
while the registry rename and the interval carry had both worked perfectly.
migrate_maintenance_source_keys runs before async_forward_entry_setups for the same reason
the registry pass does, and deliberately carries NO latch — it is a pure key-rename over a
derived cache, and a latch would make it un-runnable on exactly the boxes that later need it.
| id | what it holds |
|---|---|
| MSK-1 | cached source keys are re-keyed. Red if only storage is migrated — which is what shipped. |
| MSK-2 | a canonical key already present wins; the legacy key still goes. |
| MSK-3 | retired components are dropped from the cache. |
| MSK-4 | idempotent WITHOUT a latch — a second pass must plan nothing, not merely be harmless. |
| MSK-5 | malformed snapshots are survived; this runs during setup on every install. |
⚠ MIGRATION_KEY must be bumped whenever a row is added (now _v2, 2026-09-14). It is a
one-shot latch, so a row added under a key already True can never run on a box that has
migrated — the author's included, which is where it would be verified. That is not theoretical:
washboard → cleaning_tray was renamed on 2026-09-12 (f9cfed39) and no row was ever added, so
the miss sat undetected behind a latched _v1. Re-running is safe by construction — the planner
skips any destination that already holds a value. The same commit added RETIRED_COMPONENTS,
the outright-removals list that has no destination and so cannot live in this table; MER-10
guards it.
integration/test_maintenance_entity_registry_migration.py — the entity half of the same rename¶
10 test functions, added 2026-09-14. The sibling above carries the user's INTERVAL; this carries
the ENTITY. A component id is half of a unique_id
(vacuum_alfred_swivel_wheel_maintenance_interval), so the same rename that orphans a storage
row also orphans three registry rows — a reset button, an interval number and a remaining
sensor.
Why nine green tests next door could not see it. MCR imports nothing from homeassistant:
it builds a dict and asserts on a dict. That purity is why it is fast, and exactly why it was
blind here — there was no registry in the room to be wrong. Measured cost on a real box: 15
orphan rows sitting permanently unavailable, 3 frozen long-term-statistics series, and three
_2 entity ids, because Eufy keeps its own display word via label_key so the friendly names
collide exactly. These tests drive a real registry; nothing is mocked.
The ordering is the fix. The pass runs before async_forward_entry_setups, because the
platforms are what mint the canonical rows. First, and the legacy row is still the only holder
of that unique_id, so the rename keeps the entity id, the history and every automation bound
to it. After, and every component collides — which is precisely how the author's box reached
fifteen-for-fifteen.
| id | what it holds |
|---|---|
| MER-1 | a free destination is renamed in place — same entity id, so history and automations survive. Red if the registry is left untouched. |
| MER-2 | a taken destination does not raise. async_update_entity raises ValueError on a used unique_id, and mid-loop that leaves a box half-migrated — worse than either end state. |
| MER-3 | two legacy ids naming one destination: exactly one wins (table order), the other is pruned. The wheel really did go swivel_wheel → caster_wheel → canonical. |
| MER-4 | all three platforms move together. A number-only pass leaves two thirds of the orphans behind while looking like it worked. |
| MER-5 | idempotent via its own latch. |
| MER-6 | that latch is independent of MCR's. Every box that has run this version already has maintenance_component_renames_* set, so a pass sharing it could never run there — including the author's, the one place it would be verified. |
| MER-7 | an unrenamed component is untouched — red if matching goes substring or prefix. |
| MER-8 | the planner writes nothing, so a release's effect on real users is reviewable first. |
| MER-9 | a RETIRED component is pruned. Of the eight the Roborock 14→7 cut removed, only cleaning_brush and strainer ever minted entities — both declared a suffix a wash-dock machine publishes. Absorptions have no destination, so the rename table excludes them and they would otherwise stay unavailable forever. No machine in this house can show it. |
| MER-10 | the safety guard, and the only way this pass could destroy a live entity. RETIRED_COMPONENTS drives an unconditional delete; if an id on it reappears in a brand catalog, the migration would delete entities the platforms recreate every startup. Also refuses an id that is both renamed and retired, where loop order alone would decide. |
Ablation, recorded: removing the collision guard reddens MER-2 and MER-3; reusing MCR's latch key
reddens MER-6; migrating only the number platform reddens MER-4; ignoring RETIRED_COMPONENTS
reddens MER-9; re-declaring dustbin in the Roborock catalog reddens MER-10.
test_adapter_contract.py::test_the_projection_drops_no_catalog_field¶
A brand's projection is an EXPLICIT FIELD LIST, so a catalog key it does not name is silently dropped between the adapter and core. The declaration stays valid, the schema keeps accepting it, and the consumer simply never sees it.
THIS SHIPPED. proxy_for was restored to Eufy's catalog and to ADAPTER_CONFIG_SCHEMA
(02a229ca) but not to eufy/adapter.py's projection, so _detect_maintenance_sources read
meta.get("proxy_for") as None, the borrow never happened, and omnidirectional_wheel had NO
source — the one component of seven still unavailable after a live upgrade on a clean box.
Why 4903 green tests could not see it: test_core_capabilities hands
_detect_maintenance_sources synthetic component dicts that already carry proxy_for, so they
never travel through the projection. The fixture agreed with the CALLER rather than with what the
system produces (f/test_discipline). This test runs the other way round — it derives its
expectation from the real catalog and checks the real config, once per shipped brand.
Ablation: deleting the proxy_for line from the projection reddens it with
['omnidirectional_wheel.proxy_for'], which is the exact shipped defect.
test_model_gate.py — does THIS MODEL have this component?¶
8 test functions / 10 collected cases, added 2026-09-14. Covers the shared predicate in
adapters/upkeep_keys.py that the three entity platforms and the card's upkeep snapshot all
call. Contract written up as doc 41 §1b.
| id | what it holds |
|---|---|
| MGT-1 | the core five are universal — measured as the intersection of all 754 models' emitted sets, not read off a catalog. Red if any regime omits one, which would mean the gate could strip a filter card off a model we recognise. |
| MGT-2 | a resolved regime gates a SENSOR-BACKED component. The contract change. The old gate exempted anything declaring a sensor_suffix and defended that as keeping Eufy unaffected — but Eufy's tray declares one, so the gate could never reach it, and the exemption rested on a premise measured false (robovac_mqtt gates on protocol, not hardware). Pins all 8 tray-less Eufy models. |
| MGT-3 | routed but THIS model unresolved → guide-only drops, own-counter stands. Red if unresolved returns an empty set, which would also take the filter and brushes. |
| MGT-4 | an adapter with no regime table keeps everything — THE THIRD STATE. Not a defensive case: collapsing it into "unresolved" reddened six test_maintenance_manager cases on this gate's first run. |
| MGT-5 | no model falls below the core five, per brand, per model. |
| MGT-6 | the two live phantoms pinned by model id — roborock.vacuum.s6 (a tray it has no station for) and the 685 non-Matrix10 Dreames (mop_pad_holders_dock). |
Ablation, recorded: restoring the sensor_suffix exemption reddens MGT-2; collapsing
NOT_REGIME_ROUTED into REGIME_UNRESOLVED reddens 7 cases across MGT-4 and
test_maintenance_manager; returning an empty frozenset for an unresolved model reddens MGT-3
and test_an_unresolved_model_gates_the_cleanables_closed.
dreame/test_dreame_upkeep_keys.py — the only gate on an UNWIRED adapter¶
9 test functions / 48 collected cases (DUK-3, DUK-6 and DUK-9 fan out over the 15
regimes), added 2026-09-11 with the key system. Every other suite on this page reaches an
adapter through its BRAND_REGISTRARS row. The Dreame adapter has no such row —
deliberately, since that row is the release — so none of them touch it, and without
this file the whole guide system could be emptied, renamed, or quietly disconnected from
the card with the suite staying green throughout.
It replaces test_dreame_upkeep_guides.py (below), which tested the retired family
system and was deleted with it.
| id | what it holds |
|---|---|
DUK-1 |
there is NO BRAND_REGISTRARS row for Dreame — the release switch, still off (inherited from DUG-1) |
DUK-2 |
every model routes to a regime the key library actually holds — an unrouted regime is a KeyError waiting for the switch (DUG-8 re-expressed) |
DUK-3 |
every component in every regime has a step, and no step or note is an empty string |
DUK-4 |
every component the key library emits has a maintenance_components row |
DUK-5 |
every declared component is reachable — in some regime, or sensor-backed |
DUK-6 |
the regime gates hardware: no washboard on a dockless robot, no track mop on a pad robot, exactly one mop panel per model (DUG-5 re-expressed, now derived) |
DUK-7 |
every key the backend can emit is in the shipped English pack, and nothing ships that nothing emits |
DUK-8 |
the collapse holds — 700 models, 15 regimes, 7 distinct cards, 51 keys |
DUK-9 |
every panel's steps END on a closer, exactly one |
DUK-4, DUK-5 and DUK-7 are the reason this file exists, and they guard a failure
mode the old system did not have. Prose travelled with its own routing key, so a family
guide was either present or absent. A key guide is a JOIN across three tables — regimes,
key lists, and the maintenance_components rows that give a guide somewhere to render —
and any two of them can drift while each stays internally valid and fully populated.
DUK-4 is written from a real miss: the emitter named a panel dust_bin_and_filter while
the card's component was filter, so a correct, translated guide resolved to nothing on a
card that looked entirely healthy. Nothing in the guide library could see it, because
nothing in the guide library was wrong.
DUK-6 replaces thirteen per-family assertions with one derived rule, which is the whole
argument for the regime system in miniature: the old DUG-5 had to name each absent part
on each family by hand, so it only ever covered the families someone thought to list.
DUK-8 is a tripwire, not a law. If a regime change legitimately splits or merges a card,
the number is meant to be updated deliberately — the point is that it cannot drift
silently, because the 700-to-7 collapse is the entire justification for replacing ~280
authored families with 51 keys.
dreame/test_dreame_upkeep_guides.py + _i18n.py — ⛔ DELETED 2026-09-11¶
Both are gone, with the ~280-family Dreame guide library and its 17 language packs that they guarded. Retrievable from git history; nothing in the tree routes through that system.
They held DUG-1..DUG-13 and DI18N-1..DI18N-5. Three of those questions were real
independently of the family model and were carried over rather than dropped: the release
switch (DUG-1 → DUK-1), routing that resolves (DUG-8 → DUK-2), and absent hardware
getting no guide (DUG-5 → DUK-6, now derived from the regime instead of asserted per
family). The other ten were assertions ABOUT families — which two share a body, which
eleven of thirteen components match — and have no referent once families are gone.
Two lessons from them are worth keeping, because both are about testing, not about Dreame:
DUG-4guarded a defect the data had already shipped: a shared_BASEwas factored out of several families because their component NAMES lined up, which put X60 prose on five other platforms. Presence of a part and sameness of its PROCEDURE are different claims, and only the second was ever checked.DI18N-1..5were rewritten once when the premise under them changed. They asserted strict structural equality with English, which was correct while the packs were translated FROM English and became wrong the moment they were lifted from Dreame's own per-language manuals — vendors localize structure, not only words, and only 8.6% of lifted vendor cells matched the English shape. "Fixing" the failures would have meant overwriting real manual content with an English skeleton. A test can be correct and still be resting on a premise that has expired underneath it.
Provenance scoring lived outside the suite in scripts/verify_dreame_guide_provenance.py
(bigram overlap against the source manual — it could catch an invented step at ~8% and a
swapped part at ~47%, but never a single swapped word at ~93%). Deleted with the library it
scored. The key system's equivalent is authored and checked in the fixture, outside git.
dreame/test_dreame_adapter_config.py — the native current-room wires¶
3 tests (DAC-*). Dreame publishes the live room on sensor.<id>_current_room
(active_cleaning_target), and THREE subsystems must ride that one signal: the map
current-room highlight, the pose sampler's attribution, and the room rollover that writes
completed_rooms. Each was a declared-but-unwired gap at some point — the same shape as
the map-highlight fix one layer over. DAC-1 pins live_transition.native_transition_source:
True so the rollover follows the native signal instead of Eufy's counter_plateau (which,
on Dreame, mis-attributed the whole run's area to the first room and dropped the LAST room —
"Not reached", observed live twice). DAC-2 pins room_attribution.source:
native_current_room. DAC-3 asserts both together — a brand that declares the native
attribution source but not the native rollover splits its signal across subsystems, the exact
drift these guard.
../unit/test_adapter_config_parity.py — the schema is a FLOOR, not the contract¶
3 tests, added 2026-08-15. test_declaration_contract.py above pins what happens
when a brand declares or fails to. This one pins something one level up: that
ADAPTER_CONFIG_SCHEMA agrees with the other two authorities on the same config,
because it is not the only one.
| id | holds |
|---|---|
ACP-1 |
a key registry._validate_adapter rejects the ABSENCE of must not read required: False in the schema |
ACP-2 |
a config field documented in a doc-22 field table must exist in the schema |
ACP-3 |
the SCHEMA_ABSENT_BY_DESIGN allowlist does not rot in either direction |
Both directions were live defects, found on 2026-08-15 by a doc generator that trusted the schema as the whole truth:
room_profileswasrequired: Falsein the schema while_validate_room_profilesrejected its absence outright (DC-2cabove is that rejection). Only the CONFIG path was bitten —validate_adapter_config(), behind thesave_adapter_configservice, honoured the flag and SAVED, then registration refused the stored config. The failure landed at registration instead of at save, which is the exact outcomeregistry._validate_adapter's own docstring says it exists to prevent. Code adapters never noticed: they bypass the schema walk.low_clean_water_margin_mlwas read atplanning/run_plan.py::estimate_job_water_usageand documented in doc 22 with a worked example, while absent fromwater_model_configs.entry_fields.entry_fieldsIS enforced, unknown-key rejection included — so a porter following the doc wrote that key and got "key(s) not declared in the schema" on save.
What it deliberately does NOT check, and why the limit is in the file rather than
in someone's memory: "every key the code reads is declared" was measured before it was
designed — a scan of .get("literal") on receivers named config/cfg/model_config
finds 141 distinct keys, 61 of them undeclared, and nearly all 61 are room configs,
estimator internals or map-source sub-dicts rather than adapter config. A gate needing a
61-entry allowlist hides real findings instead of surfacing them. Doc 22's field tables
are the tractable proxy, and they caught the real one.
Sibling: tests/unit/test_service_declaration_parity.py does the same job for the
service surface.
State 2 is the one a normal suite never reaches, and it is why this file exists.
Before the fallback was removed it was indistinguishable from state 1 — an adapter
declaring nothing silently received Eufy's catalog. DC-5 closes the original
defect specifically: four call sites in profiles/manager.py resolved rooms
without ever consulting the registry, and the fallback covered for them.
Why four checks and not one. A ^from-anchored grep reported the Roborock
adapter as reaching nothing; it reaches core.capabilities through a DEFERRED
import inside a function, deferred to dodge an import cycle. Hence AST over every
node in every file. And an import graph cannot see a runtime reach at all, hence
ISO-4. Both ISO-1 and ISO-4 were mutation-verified when added — a fresh
non-SDK import and a planted hass._private_thing each turn their own check red.
../unit/test_clean_order.py — the shape filter that makes a log-scraped read trustworthy¶
18 tests, added 2026-08-20. Roborock exposes a per-map cleaning SEQUENCE, declared by
the adapter under device_clean_order. Reading it is the awkward part:
vacuum.send_command is SupportsResponse.NONE, so the reply never returns through
the service call — it is captured off a DEBUG log line that does not say which
command it answers. Everything therefore rests on is_clean_order telling a real
reply apart from routine poll traffic. A wrong filter reports confident nonsense,
which is the worst failure available here, because it looks like a reading.
The inputs are REAL decoded results captured from Ivy on 2026-08-19 — every distinct shape observed across 53 replies — not invented ones.
| id | what it holds |
|---|---|
CO-1 |
each of the ten real decoded shapes classifies correctly |
CO-2 |
ABLATION: [0] arrives every ~15s poll tick and a naive "flat list of ints" filter accepts it; the known-room-id check is what rejects it. The test also asserts the ABLATED form still passes, so the ablation cannot quietly stop meaning anything |
CO-3 |
isinstance(True, int) is True in Python, so [True] must not read as room id 1 |
CO-4 |
one unknown id invalidates the reading whole — a dropped room would silently reorder the rest |
CO-5 |
an unparseable payload yields None (→ unavailable), never an exception on a path a live run can touch |
CO-6 |
with no known room ids the manager declines to read at all, rather than accept a list it cannot check |
CO-7 |
an unread vacuum must not present as "no order saved": [] is a legitimate ORDER value, so the STATUS field is what carries "we have not looked yet" |
CO-2 is the load-bearing one. [0] arrives roughly every fifteen seconds, so a
filter weakened to a type check would report it as a clean order continuously.
Coverage map¶
| Source module | Stmts | Cov | Test files | Layer | Mocking |
|---|---|---|---|---|---|
registry.py |
226 | 92% | test_adapters.py |
integration | clean |
config_loader.py |
33 | 100% | test_adapters.py |
integration | clean |
config_schema.py |
64 | 94% | test_adapters.py |
integration | clean |
brands.py |
45 | 100% | test_brand_selection.py |
integration | clean |
entity_resolve.py |
193 | 92% | tests/unit/test_entity_resolve.py + tests/adapters/test_entity_resolve.py |
unit + adapter | clean |
eufy/segmentor.py |
872 | 92% | tests/adapters/eufy/ |
adapter | - |
eufy/adapter.py |
61 | 85% | tests/adapters/eufy/ |
adapter | - |
eufy/entities.py |
29 | 100% | test_buttons_entities.py + test_suffix_vocabulary.py |
adapter | clean |
eufy/lifecycle.py |
21 | 100% | test_lifecycle.py |
adapter | clean |
eufy/constants.py |
15 | 100% | tests/adapters/eufy/ |
adapter | - |
eufy/model_catalog.py |
12 | 100% | test_model_catalog.py |
adapter | clean |
eufy/vocabulary.py |
42 | 100% | test_error_source.py + tests/adapters/eufy/ |
adapter | clean |
eufy/const.py |
9 | 100% | tests/adapters/eufy/ |
adapter | - |
eufy/buttons.py |
4 | 100% | test_buttons_entities.py |
adapter | clean |
eufy/upkeep_catalog.py |
3 | 100% | tests/adapters/eufy/ |
adapter | - |
eufy/water_config.py |
3 | 100% | tests/adapters/eufy/ |
adapter | - |
eufy/maintenance_components.py |
1 | 100% | test_maintenance_config.py |
adapter | clean |
eufy/eufy_upkeep_guides.py |
1 | 100% | tests/adapters/eufy/ |
adapter | - |
eufy/upkeep_guides_i18n/*.py (17 languages) |
19 | 100% | test_upkeep_guides_i18n.py |
adapter | |
roborock/adapter.py |
50 | 96% | roborock/test_adapter.py |
adapter | - |
roborock/model_catalog.py |
7 | 100% | roborock/test_adapter.py |
adapter | - |
roborock/vocabulary.py |
19 | 100% | roborock/test_adapter.py |
adapter | - |
roborock/entities.py |
25 | 100% | roborock/test_adapter.py |
adapter | - |
roborock/const.py |
7 | 100% | roborock/test_adapter.py |
adapter | - |
roborock/upkeep_catalog.py |
7 | 100% | roborock/test_adapter.py |
adapter | - |
roborock/roborock_upkeep_guides.py |
8 | 100% | roborock/test_adapter.py |
adapter | - |
roborock/maintenance_components.py |
2 | 100% | roborock/test_adapter.py |
adapter | - |
roborock/upkeep_guides_i18n/*.py (17 languages) |
121 | 100% | roborock/test_adapter.py |
adapter |
eufy/discovery.py no longer exists as a separate module — model detection now
lives in eufy/adapter.py (_registry_model_code, which reads the device
registry) and eufy/model_catalog.py (detect_model_family); there is no
test_discovery.py file to reference for it anymore.
The Eufy adapter also pins two pluggable engine seams that live under
learning/ (the adapter declares the engine; the engine itself is
brand-agnostic — see 06 — learning):
Engine seam (under learning/) |
Test file | Layer |
|---|---|---|
room_attribution_engines.py (SweptAreaWindingAttributor) |
test_room_attribution.py |
adapter |
job_segmenter_engines.py (EufyCounterSegmenter) |
test_job_segmenter_config.py |
adapter |
(Adapter-config services are in 17 — services via
test_services_adapter_config.py.)
What's tested¶
- Registry — register / get adapter config, the module-level shims
(
get_adapter_config,get_adapter_value), coordinator wiring, and the all-configs accessor. - Config loader — loading stored adapter configs from
hass_storageand registering them (incl. the per-config skip-one-on-error resilience). - Brand selection (
brands.py,test_brand_selection.py,BR-1..BR-7) — which registrar runs for a given vacuum: positive detection wins in table order; no match reaches the DECLARED default arm and reportssource="default"(distinct from a positive"detected"match, so a log line can say the brand was assumed, not identified); an explicit per-vacuum override (the UI-selector seam) outranks detection; a malformed / unknown / absent override degrades to detection rather than raising, but an unknown override id is still logged, never silently dropped; a detector that throws is skipped rather than taking setup down; and the real Roborock detector resolves end-to-end against the shipped table (an unrecognised device — blank manufacturer/model — still resolves to the Eufy default, the behaviour the oldif/elsehad, now reported as"default"instead of being indistinguishable from a positive match). - Eufy adapter (separate suite,
tests/adapters/eufy/) —model_catalogresolution (code + hint matching),lifecyclehelpers, thebuttons/entitiescandidate-data shape, the CVsegmentorwrapper + splitter helpers, per-componentmaintenance_onlyflag survival through the adapter's explicit-key config reconstruction (test_maintenance_config.py, issue #38 regression), the localized upkeep-guide data (test_upkeep_guides_i18n.py, 17 languages, subset-of-English + non-empty-and-different-steps invariants), and the dock-vs-robot error-SOURCE classification (test_error_source.py,EUFY_DOCK_SOURCED_ERROR_CODES/EUFY_EVIDENCE_INVALIDATING_ERROR_CODESinvocabulary.py— exists becausetotal_error_secondsis subtracted fromcleaning_time_seconds, so a fault that never stopped the robot cleaning would otherwise silently zero a productive run). (Charging reads are brand-agnostic now and tested intests/unit/test_charging.py— see 01 — core.) - Roborock adapter (separate suite,
tests/adapters/roborock/) — the brand-SPECIFIC wiring: model detection, brand auto-detect (device-registry manufacturer/model), and the key grounded config values, verified against the capturedvacuum.ivystates + a run trace. The device-registry lookup is monkeypatched so the tests don't depend on HA registry plumbing. The brand-agnostic contract (schema conformance, dispatch shape, registry validation, entity-id format) for Roborock is covered separately, bytest_adapter_contract.pyvia itsADAPTER_BUILDERSentry — adding a brand there runs the whole conformance suite against it with no new test code. - Eufy engine seams (also in
tests/adapters/eufy/) — the two pluggable engines the Eufy adapter declares.test_room_attribution.pypins the portedSweptAreaWindingAttributor(learning/room_attribution_engines.py) against the 3 adversarial external-run fixtures (the 9/9 dwell + spread + winding + swept-area attribution, dock-trap exclusion included).test_job_segmenter_config.pyasserts the Eufy adapter declaresjob_segmenter.engine = "eufy_counter_v1", that itsjob_segmenter.tuningequalsEufyCounterSegmenter.DEFAULT_TUNING(no threshold drift after the move out oflive_transition), and that the declared engine resolves and validates clean. - Brand-aware diagnostics self_check (
DIAG-*, integration,tests/integration/test_diagnostics.py) —_self_checkreads a native-integration brand (Roborock: rooms from its own integration, noactive_mapsensor, no Eufysegmentsattribute) as rooms/map WORKING and brand-named, driven by theroborock_geometry_driftdecode-drift block in the dump, rather than the Eufy-shaped "unknown / unavailable / no" the transport-only heuristic produced (DIAG-9); and degrades to a generic "native integration" + map-"pending" summary when the raw map hasn't decoded yet and the brand string is absent (DIAG-10). - Every adapter test file needs
hassfor the config-registration seam — see 01 — overview for which files and why.
The adapter coverage boundary¶
adapters/eufy/* and adapters/roborock/* are counted in the coverage
number — we always test the adapters we ship, so the figure includes both.
The Eufy adapter is well covered: model_catalog, lifecycle, and the
buttons/entities data shape sit at or near 100%. The CV segmentor is
91% — the splitter helpers, recovery / scoring / issue-tag paths, and (via
two map fixtures) the localized-bins SPLIT + child-handling are all covered;
its remaining tail is the splitter-internal alternative sub-branches (see
Known gaps), the natural place a second-brand effort would invest. adapter.py
(85%) is missing 5 lines (124, 156-159): the return None guard in the small
helper _build_button_block when a button key is absent from both candidates
and tokens maps (124), and inside _registry_model_code — the
device-registry model lookup that replaced the old standalone
discovery.py — the device-registry .get() call itself (156), its
None-guard early return (157-158), and the successful resolved-model
return (159): the whole device-registry happy path is untested, not just
an early-return guard. (The earlier entity-registry/no-device-id guard,
154-155, is covered.) The Roborock adapter is well covered too:
adapter.py sits at 96%, and every other Roborock module (model catalog,
vocabulary, entities, const, upkeep) is at 100%. See
01 — overview for the three-layer split.
Known gaps¶
registry.py (91%) leaves mostly defensive validator arms uncovered — the
append-an-issue branches that reject a malformed stored adapter config
(missing lines 180, 287, 378, 387, 407, 411, 424, 433, 453, 467, 489, 523, 589,
618, 651 — --cov-report=term-missing for the current mapping to specific
checks). The job_segmenter engine-validation arms (not-a-dict / missing /
unknown engine) are covered — test_adapters.py asserts that contract so an
unknown engine can't silently fall back. The rest are error paths for invalid
storage, not real behavior holes. adapter.py (85%, see above) is missing the
one defensive button-block guard plus the entire device-registry-lookup
happy path in _registry_model_code (156-159, see above) — that path is
untested outright, not merely a defensive early return. config_schema.py
(94%) is missing 3 lines
(1866, 1893, 1958) in schema-validation branches not yet re-triaged this pass.
The one remaining thin spot is CV segmentor depth (91%, up from 70% —
first the splitter / recovery / scoring / issue-tag tests and the
_prune_localized_siblings extraction ([SP-prune]), then two map fixtures
that drive the full pipeline). The localized-bins SPLIT is the deepest tier,
and it took two fixtures to pin: a dense over-segmented synthetic map
([ECV-8], adversarial_map.png) covers the classification / scoring /
overlap-dedup paths, but it can only make localized-bins run-and-reject —
the accept gate is a narrow hue window. The one input that reaches localized
accept plus its child-handling (reclaim / rank / prune of recovered room
pockets) is a real map run exactly as the integration runs it — dark
primary + light assist — where adjacent rooms fuse with the blue background
into a single >120k-px component ([ECV-9], localized_map_*.png);
diagnostic-confirmed as the only input that hits accept. What's genuinely left
is the splitter-internal alternative sub-branches the accepted path skips
(assist-hue / colour-distance / erosion variants), the env-gated scipy-absent
guard, and defensive continues — each geometry-sensitive or best-effort.
Tested in test_segmentor.py + test_segmentor_splitters.py; held here on
purpose, a known thin spot rather than a framework miss.
The Roborock adapter has no comparable known gap — it is a much smaller,
declarative-config module (no CV pipeline), and every source file except
adapter.py is at 100%.