Skip to content

24a โ€” The Dreame Adapter

Scope. How the third brand answers the contract, and what it cost to be third. Read 24 โ€” The Roborock Adapter first: Roborock paid the bill for Eufy's residual defaults, and Dreame is the first brand to arrive after that bill was settled.

Roborock's lesson was that most of its verbosity was a name Eufy already occupied. Dreame's lesson is the quieter one: being third cost core very little. The contract absorbed it. What looks in the commit history like a large core change is mostly a method change that ran alongside this port rather than one the brand demanded โ€” ยง5 separates the two, because the difference is the whole answer to "what does a new brand cost us now".

Shipped in v2.2.0 (2026-09-13). The registrar row that reaches this package was deliberately withheld for all 131 commits of its development โ€” see ยง8.


1. What the adapter computes

adapters/dreame/adapter.py::register_dreame_adapter_for_vacuum resolves the model profile, capability flags, and the entity-id map at runtime, then emits a literal config dict into adapters/registry.py::register_adapter_config. Same shape as Roborock โ€” it wraps an upstream HA integration rather than talking to hardware โ€” and like Roborock, nothing in the file executes during a clean.

Capability flags are the model profile OR-ed with live entity presence (core/capabilities.py::detect_capabilities), so the registered config reflects this installation's actual HA surface rather than the catalog's opinion of the model. That matters more here than it did for Roborock, because the catalog is thin (ยง6).


2. Identity, and a station that is not a dock

adapters/dreame/const.py::UPSTREAM_PLATFORMS is ("dreame_vacuum",), matched against the entity registry's platform โ€” the mechanism Roborock introduced, used here without change. This brand is the reason that mechanism exists at all: the deleted per-brand detector had a default arm, and the failure it produced was a Dreame being silently registered as a Eufy, binding 2 of ~10 Eufy roles by coincidence of naming and looking configured rather than wrong.

The station is integral, and that removes a whole resolution step. Roborock's dock is a separate device in the device registry and needs live resolution (24 ยง3). Dreame's station is part of the one device, so there is no dock.py equivalent here: station capabilities come from the model profile and are OR-ed with live entity presence โ€” self_wash_base_status, auto_empty_status and the drying_* sensors are the live gate.


3. The map is decoded, not given

The largest genuine difference from either earlier brand. Eufy's rooms are inferred from map screenshots (25 โ€” The Eufy Segmentor); Roborock's arrive as vendor rooms. Dreame's arrive as a camera attribute carrying encoded map data, which we decode ourselves into rooms, true per-room area, furniture and pose.

Two consequences the rest of the system feels:

  • Geometry is ours, so the projection is ours to get wrong. mapping/map_source_runtime.py carries dreame_correspondences_from_mapdata, and it is a declared replica (RN0Y49XS) of the render's own projection: the go-to / zone affine is fitted from those correspondences, so if the two projections ever disagree the robot drives to the wrong place. That is why it is anchored rather than merely commented.
  • Heading comes from the camera's live angle, h = (a - 90) mod 360 โ€” the render frame and the device frame differ by a quarter turn โ€” with a tunable frame offset for aligning our raster onto the device's.

This is the one area where the brand really did push on core: the map source runtime and coordinator carry Dreame-shaped work that neither earlier brand needed.


4. Zone is a service, not a verb

On Roborock and Eufy an ad-hoc zone clean rides the same send_command verb as a room clean, so the adapter expresses it as a verb override. Dreame's zone is its own upstream service (dreame_vacuum.vacuum_clean_zone, distinct from room-clean's vacuum_clean_segment), which is why the config carries a dedicated zone block alongside goto rather than a dispatch.zone_command. Coordinates are 4-tuples in device mm, not Roborock's 5-tuples.

And a zone clean is a GLOBAL clean on this brand, which forced the more interesting piece. The device applies its global cleaning settings to a zone, and those globals are gated by a customized_cleaning switch โ€” turn per-room custom cleaning on and the globals go unavailable. So the config declares a global_precall sequence: flip the gate off, write suction / water / mode / route to the global selects with readback verification, run the bare zone, then restore every snapshotted value and the gate at zone completion. A zone clean must not persist its own settings, and on this brand that is work rather than a property.

Note what this did not require: no core change. The declaration shape already allowed a brand to describe a command as its own service with a pre-call sequence.


5. What the third brand actually taught core

The honest accounting, because the commit history overstates it.

Separate the method change from the brand. A Dreame-scoped commit range touches adapters/eufy/adapter.py and adapters/roborock/adapter.py, which looks like the third brand reaching into the first two. It is not. Maintenance guidance moved from prose routed by guide family to regime โ†’ i18n keys, and that method change was developed here because this is where the work happened to be โ€” the older family-prose channel was then deleted and both other brands were ported onto the new one. A fourth brand meets the new method already in place and pays none of it. Read that churn as the method changed, not as Dreame forced it.

What the method change left behind is one genuinely neutral module. adapters/upkeep_keys.py says so in its own docstring: "It was written inside the Dreame adapter because Dreame was the only caller. It is now the second and third brand's algorithm too." It sits in adapters/ rather than core/ because that layer is already neutral and checkable โ€” config_loader, config_schema, entity_resolve and registry import zero brand packages, with brands.py the deliberate exception for being the registrar table.

The rule that came with it is the one worth carrying forward: core owns the key, the brand keeps the word. There is no mop-type alias map in the neutral module โ€” mop_type arrives already in {cloth, pad, roller, track} because the regime generator maps the vendor's term when it writes the table. A brand that wants its own display word declares label_key in its component catalog and the card resolves it. A brand's vocabulary never reaches the shared algorithm.

So what did Dreame itself cost core? Two things, both in ยง3: the camera-attribute map backend, and the projection replica that the go-to / zone affine depends on. Everything else it needed, it declared. That is the contract working as intended, and it is a materially cheaper third brand than Roborock was a second.


6. Capabilities fail closed, and the one that looked like a false CAN'T is not

adapters/dreame/model_catalog.py::DEFAULT_PROFILE is what an unrecognised Dreame gets, and it is deliberately asymmetric. Mop and station flags default True, because they degrade safely โ€” a device that cannot honour a mop control simply has no matching live entity, and entity-presence detection is the real gate. has_path_control and supports_zone_clean default False, because offering a control the device lacks โ€” or worse, inverting a drawn box into the wrong coordinate frame โ€” is the worse failure.

The practical effect at release: one model carries a hardware-verified override (dreame.vacuum.r2469a, the L10s Ultra Gen2 the adapter was built against). Every other Dreame gets rooms, map, maintenance and history, and does not show go-to or zone clean. This opens up per model as hardware is confirmed. MODEL_PROFILES โІ DREAME_MODEL_REGIMES is asserted at import, so the capability source and the guide/name source cannot drift โ€” the two-table lesson from 24 ยง7, applied before it could bite.

supports_edge_mopping is False, and the reason is a mechanism rather than a deferral.

The hardware is there โ€” switch.<obj>_mop_extend and select.<obj>_mop_extend_frequency both exist on a live device (measured 2026-09-13). But this flag gates a PER-ROOM control, and both of those entities are global. The per-room surface upstream exposes is exactly eleven kinds:

cleaning_mode  cleaning_route  cleaning_times  floor_material  floor_material_direction
mop_pad_humidity  name  order  suction_level  visibility  wetness_level

No mop-extend among them. That makes False a real "no" with a mechanism, not an absence of sightings โ€” the distinction between a genuine limit and a false CAN'T, which hides a capability silently and permanently for every user of a brand.

What would change it: upstream exposing a room_<n>_mop_extend alongside the eleven above. A per-model profile flag would not, because the gap is per-room exposure rather than model hardware.

Five more deferrals are marked PHASE 3 / PHASE 4 in adapter.py and carry conservative values.


7. The guides: 700 models, 13 regimes, no prose in the backend

Maintenance guidance is routed by regime, not by model and not by brand: adapters/dreame/upkeep_keys.py over upkeep_regimes.py maps 700 model ids onto 13 regimes, each of which is a list of i18n keys. The backend holds no guide words at all โ€” the card supplies them in the reader's own language, in all eighteen.

A regime is (mop_type, dock_tier, tanks) โ€” three measured facts โ€” and the same three facts now type every brand's models, which is the method described in ยง5 rather than anything Dreame-specific. What is Dreame-specific is the sourcing: the regime table was authored from Dreame's own manual corpus rather than inferred, which is what makes 700 rows worth having instead of a default.


8. The release switch, withheld for 131 commits

There is exactly one thing that reaches this package: the dreame row in adapters/brands.py::BRAND_REGISTRARS. While it was absent the entire adapter was dead code no import touched, and every Dreame vacuum resolved to unsupported. It was never committed during development โ€” git log -S dreame on brands.py is empty across all 131 commits โ€” and a test existed solely to go red if it were added by accident.

That test, test_dreame_has_a_brand_registrar_row [DUK-1], is now inverted rather than deleted. The asymmetry was never real: a row that can be added by accident can be dropped by accident, and dropping it is the silent direction โ€” no import breaks, no other test fails, every Dreame device just quietly becomes unsupported again, precisely because nothing else references it.

On the gate it waited behind. The condition was recorded as "a RELEASED upstream build carrying Tasshack #1707". That premise was retired by measurement on 2026-08-30: only an unmapped first setup is bitten, a mapped device never is, so the remedy is a setup note โ€” map first, then reload โ€” which shipped with the user guide. Do not re-derive the gate from tracker #1742; it is closed as a duplicate and reads green, which it is not.


Registries

What Where
The switch adapters/brands.py::BRAND_REGISTRARS, the dreame row
Config assembly adapters/dreame/adapter.py::register_dreame_adapter_for_vacuum
Identity adapters/dreame/const.py::UPSTREAM_PLATFORMS
Capability profiles adapters/dreame/model_catalog.py (MODEL_PROFILES, DEFAULT_PROFILE)
Live-captured enums adapters/dreame/vocabulary.py โ€” from a device, never a manual
Regime to keys adapters/dreame/upkeep_keys.py, upkeep_regimes.py, upkeep_catalog.py
The shared algorithm adapters/upkeep_keys.py (neutral; ยง5)
Map decode + projection mapping/map_source_runtime.py (dreame_*, replica RN0Y49XS)
Contract tests tests/adapters/ โ€” the brand is a case in the shared contract suite