Skip to content

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_idsensor.{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:

  1. 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.
  2. Lexical — the literal is a value an adapter actually DECLARES, and is not in the explicit CORE_OWNED set.

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_wheelcaster_wheelomnidirectional_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: washboardcleaning_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_wheelcaster_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-1DUK-1), routing that resolves (DUG-8DUK-2), and absent hardware getting no guide (DUG-5DUK-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-4 guarded a defect the data had already shipped: a shared _BASE was 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..5 were 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_profiles was required: False in the schema while _validate_room_profiles rejected its absence outright (DC-2c above is that rejection). Only the CONFIG path was bitten — validate_adapter_config(), behind the save_adapter_config service, honoured the flag and SAVED, then registration refused the stored config. The failure landed at registration instead of at save, which is the exact outcome registry._validate_adapter's own docstring says it exists to prevent. Code adapters never noticed: they bypass the schema walk.
  • low_clean_water_margin_ml was read at planning/run_plan.py::estimate_job_water_usage and documented in doc 22 with a worked example, while absent from water_model_configs.entry_fields. entry_fields IS 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_storage and 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 reports source="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 old if/else had, now reported as "default" instead of being indistinguishable from a positive match).
  • Eufy adapter (separate suite, tests/adapters/eufy/) — model_catalog resolution (code + hint matching), lifecycle helpers, the buttons/entities candidate-data shape, the CV segmentor wrapper + splitter helpers, per-component maintenance_only flag 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_CODES in vocabulary.py — exists because total_error_seconds is subtracted from cleaning_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 in tests/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 captured vacuum.ivy states + 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, by test_adapter_contract.py via its ADAPTER_BUILDERS entry — 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.py pins the ported SweptAreaWindingAttributor (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.py asserts the Eufy adapter declares job_segmenter.engine = "eufy_counter_v1", that its job_segmenter.tuning equals EufyCounterSegmenter.DEFAULT_TUNING (no threshold drift after the move out of live_transition), and that the declared engine resolves and validates clean.
  • Brand-aware diagnostics self_check (DIAG-*, integration, tests/integration/test_diagnostics.py) — _self_check reads a native-integration brand (Roborock: rooms from its own integration, no active_map sensor, no Eufy segments attribute) as rooms/map WORKING and brand-named, driven by the roborock_geometry_drift decode-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 hass for 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%.