ADR-0001: Device profiles for hardware quirks¶
Status: Accepted Date: 2026-06
Context¶
Climate Orchestrator drives every managed device through a single
ClimateAdapter (devices/adapter.py) that assumes a well-behaved Home
Assistant climate entity: it reads the room temperature from the
current_temperature attribute, the setpoint from temperature, trusts the
reported hvac_modes / min_temp / max_temp / target_temp_step, and always
applies a command with the same two service calls (set mode, then set
temperature). TRV-specific behaviour — discovering the valve_opening_degree
and local_temperature_calibration number entities — lives in devices/trv.py
and is keyed off configurable name hints rather than per-model code.
That has been enough for the SONOFF TRVZB family this project targets. But real thermostat hardware is full of quirks, and there is currently nowhere to put one:
- Read quirks — a device that reports the room temperature under a
non-standard attribute, or whose
hvac_actionis unreliable. - Capability quirks — a device that misreports
hvac_modes, or a wrongmin/max/step. - Write quirks — a device that needs the mode set after the temperature, a short settle delay between calls, a mode toggle to commit a calibration, or that silently rejects a setpoint write while off.
- Discovery quirks — a valve/calibration number named differently from the hint defaults.
Today the only way to express any of these is an if manufacturer == … branch
inside ClimateAdapter or the coordinator — exactly the kind of creeping
conditional the rest of the codebase has been careful to avoid. There is, in
fact, no device-model identification at all: nothing reads manufacturer /
model from the device registry.
Decision¶
Introduce a single extensibility seam — a DeviceProfile value object,
resolved per entity by (platform, manufacturer, model) from the device
registry, that the ClimateAdapter consults for reading, capability detection,
and entity-name hints. Ship exactly one profile to start: a generic profile
that reproduces today's behaviour byte-for-byte, so the introduction of the seam
is provably behaviour-preserving (the existing device and snapshot tests are the
proof). Quirks then become small data (and, when genuinely needed, pure
functions) in a profile — never edits to the adapter or coordinator.
Options Considered¶
Option A: Status quo (per-device if branches as needed)¶
| Dimension | Assessment |
|---|---|
| Complexity | Low now, high later |
| Cost | Zero upfront |
| Scalability | Poor — quirks tangle the adapter and coordinator |
| Team familiarity | High |
Pros: nothing to build today. Cons: every new device mutates shared, well-tested control code; no model identification; conditionals accrete exactly where the codebase has kept them out.
Option B: DeviceProfile seam, generic-only, profiles added on demand (chosen)¶
| Dimension | Assessment |
|---|---|
| Complexity | Low–medium |
| Cost | One small module + adapter rewire |
| Scalability | Good — new hardware is additive (a new profile) |
| Team familiarity | High (mirrors the existing collaborator pattern) |
Pros: behaviour-preserving introduction; quirks are isolated, typed, and unit-testable; adding hardware never touches the coordinator; profiles are plain value objects, consistent with the rest of the codebase. Cons: a small indirection on every read/capability call; one more concept.
Option C: Per-model registry with IO hooks, up front¶
| Dimension | Assessment |
|---|---|
| Complexity | High |
| Cost | Large, mostly speculative |
| Scalability | Excellent |
| Team familiarity | Medium |
Pros: maximally flexible; battle-tested shape. Cons: builds machinery for devices we do not have; IO-in-hooks is harder to test than the rest of this codebase; over-fit to a many-contributor project.
Trade-off Analysis¶
The real choice is B vs C, and it turns on appetite. C's flexibility is only
worth its complexity with a stream of contributors adding cheap TRVs; this
project has one maintainer and one device family. B captures the structural
win of C — hardware support becomes additive instead of invasive — at a fraction
of the cost, and leaves a clean upgrade path: the write-strategy hook (B's
deferred Phase 4) is the same idea as C's override_set_*, but added the day a
device demands it, as a pure function. Choosing B now and growing into C's
surface later is strictly cheaper than building C speculatively.
Consequences¶
- Easier: adding a device with a read/capability/discovery quirk is a new
DeviceProfileplus a matcher entry — no change to the adapter or coordinator. Quirks are colocated and individually testable. - Easier: the project now identifies hardware by manufacturer/model, opening the door to model-specific diagnostics and repairs later.
- Harder: one extra indirection to understand when reading the device layer (mitigated by the contributor guide and the generic profile being the obvious default).
- To revisit: when the first device needs a write quirk, add a pure
write-strategy hook to
DeviceProfile(ordered steps / pre-post mode toggle / settle delay) and routeClimateAdapter.applythrough it. The seam is shaped for it; the machinery waits until then.
Action Items¶
- [x] Phase 1 —
devices/profiles.py:DeviceProfilevalue object, the generic profile (today's read/capabilities/hints), and the resolver; routeClimateAdapterthrough it. Behaviour-preserving. - [x] Phase 2 — extract a concrete
SONOFF_TRVZBprofile matched by manufacturer/model; route the coordinator's valve/calibration hint resolution through the device's profile (global config hints stay as the fallback). - [x] Phase 3 — contributor guide: Adding hardware support.
- [ ] Phase 4 (deferred) — pure write-strategy hook, added when a real device needs it.