Home IO Control
ESPHome add-on for IO-Homecontrol devices
Loading...
Searching...
No Matches
Architecture Decision Records

Each file here records one architectural decision: the problem it solved, the options weighed, what was chosen, and what that choice costs. They explain why the code looks the way it does — docs/architecture_overview.md describes what it looks like today.

Records by theme

Numbers are stable identifiers, not a reading order. Grouped by theme:

Structure

# Decision In short
0001 Layered protocol / radio / hub architecture Three layers, one composition root; the protocol never names a chip
0004 Hub split into collaborators Ten single-purpose objects (eleven with the compile-gated LR1121 controller) instead of one large controller class
0005 Pure decisions separated from I/O Frame classification is side-effect-free and host-testable

Radio

# Decision In short
0002 Direct SPI radio drivers Own the chip drivers rather than reuse ESPHome's generic radio components
0003 Shared software PHY + IRQ base SX1262 and LR1121 share one framing codec and one RX/TX state machine
0013 Blocking radio work on the loop No FreeRTOS tasks; the operation queue is the concurrency model
0020 Flash LR1121 firmware, not the bootloader Superseded by 0021. Stage the riskier operation for later; keep every failed flash recoverable now
0021 Flash the bootloader, behind an arming switch Reach the CVE-fixing 0x0104; gate the one unrecoverable write behind visible armed state and a build-time recovery image
0042 The start preamble's default comes from the radio driver Measured: the software PHY (SX1262/LR1121) needs 48 bytes where the SX1276's register PHY is answered every time at the protocol's 32; an explicit normal_start_preamble: still wins, including downward
0028 Channel policy is a property of the frame One shared listen primitive, three named policies picked by reply shape — not by chip
0029 Start preamble is a property of the target's power class (amended by 0040) LONG_PREAMBLE iff CTRL1_LOW_POWER, iff the per-device low_power YAML property (default false) — not iff the frame is a start frame (roll-call: ADR 0037)
0035 FEM support is a behaviour profile; boards always supply pins fem: selects front-end switching behaviour only; every board package spells out every pin its profile needs, no implicit defaulting
0037 The roll-call sweeps both power classes A low-power pass (CTRL1=0x30, long preamble, all channels) then the unchanged always-alive pass; scan_power_classes narrows it
0039 Pairing sends a discover-confirm (0x2C) and tolerates no answer Universal pairing_discover_confirm mode (skip/send/send_with_ack); the step never fails the attempt; try 2 alone rotates channels (deviates from ADR 0028)
0040 A low-power device's start preamble follows its wake belief Tries ordered by per-device evidence (short first only while believed travelling, then the wake-up preamble; a silent exchange spends the evidence; the post-stop poll gets three tries); amends 0029; low_power_wake_belief switch, low_power_wake_preamble length
0044 Chip-neutral transmit intent Every transmission carries its wake level; a driver may scan idle channels by itself; a try starts only if its transmission ends inside the exchange budget
0045 Listen-before-talk is part of sending The radio checks the transmit channel inside send_packet(), retuning first when the idle receiver is elsewhere; the engine keeps the retry policy and, since the 2026-10 amendment, checks only frames with START set

Behavior

# Decision In short
0006 Admin ops as native API actions Rarely-used operations get no permanent entity
0016 Sender events are opt-in The radio has no ownership marker, so nothing fires until named
0017 Poll hint shortens, never stretches The configured interval is a ceiling, not a target
0024 Diagnostic probes, gated and isolated Undecoded opcodes only reach paired devices behind an opted-in config, and can never reach the status decoder
0030 Predictions are kept apart from observations An optimistic guess lives in its own overlay, withdrawn on failure and superseded per-axis by real data — never written into a reported field
0031 1W vendor wire behaviour is driven by manufacturer: manufacturer: selects the 1W CMD_EXECUTE ACEI byte (0x43 Somfy / 0x61 Velux); execute_broadcast: typed\|all is a separate handheld-vs-class-bound axis, not a vendor one
0032 1W enrollment follows the gesture the target's manufacturer: expects velux → 0x39 broadcast, a 0x30 sweep across {roller_shutter, vertical_exterior_awning, dual_roller_shutter} under one sequence, then STOP+DOWN; somfy path unchanged; enrollment_classes: overrides the sweep list
0041 An unset 1W low_power: resolves from the manufacturer profile velux → ALWAYS_ALIVE (an awake VELUX receiver rejects the 1024-byte preamble, and 1W has no ACK to reveal it); Somfy and unrecognised manufacturers stay LEGACY_LONG; an explicit key always wins
0033 Heating send path bypasses the cover machinery 2W heating does a plain send-and-receive — no status decode, no poll backoff, no ADR 0030 overlay (heating has no observation stream); the write-only premise is superseded by 0054
0054 Heaters are read back over the private register protocol One register table drives get, set and decode; a climate entity polls room temperature, setpoint, mode and regulator state (default 5 min) and shows only what the heater answered; a write is confirmed by a read; the window functions are set_open_window_detection and a window_open read
0055 A climate entity's traits come from the device's heating profile One profile row per heater kind (control path, modes, presets, observed-state table, poll registers, features); device_supports_climate_control() means a profile exists, device_uses_heating_registers() gates every register frame; the plain heater is commanded with heating levels in the standard EXECUTE; window companions are declared at validation from the declared kind
0056 Exterior heating is a fan entity Type 21 gets the HEATER_LEVEL capability class and the fan: platform; heat p % is IO position 100 - p like a dimmable light; no motion, one follow-up poll per command; no restored state
0057 Temperature setpoints travel in the main parameter A third control path for the heating temperature interface and the generic heat pump: temperature encoded over a -40..+80 °C default range (YAML override), comfort / eco selected by alias, FP9 mode flags only on the thermostat, mode shown as last command, no current temperature
0043 An unconfirmed movement command is re-sent once to a device that normally confirms Learned in RAM from the first EXECUTE the device closes with a reply; favourite/vent never re-sent; at most one re-send inside the exchange budget; no device list, nothing stored
0049 A rain limitation is derived from observed status, never predicted A separate limited_by_rain flag from two evidence rules on ordinary status replies (rain-sensor originator; a clamped command while rain evidence is fresh); an explicit CMD_ERROR_RESP still wins; no extra polling
0050 Rain is read by an opt-in limitation poll rain_sensor_poll_interval reads the minimum limit on a jittered interval and drives a Rain sensor binary sensor (89 % rule, unknown after three misses); independent of the derived flag; transmit time is the user's choice
0051 Pairing reads the key refusal and checks an unconfirmed key 0xFE byte decoded as code << 3 \| state; a refused key transfer ends the attempt (key_transfer_refused); an unconfirmed one gets an authenticated name read before it fails; pairing_key_transfer: auto pulls a remote controller's key, rolls call under it and registers the devices that hold it
0058 Pairing reads the heater behind an Atlantic pairing node directly After a pull from an Atlantic remote-controller node, one register read of the next address finds the heater; the roll-call is the fallback; a hub-key pull with nothing found ends hub_key_returned; pairing-registered devices keep getting their block; the key proof is retried

Interfaces and naming

# Decision In short
0008 io_device_id, not device_id ESPHome reserves device_id; protocol keys take an io_ prefix
0009 Companion IDs declared at validation Late-created IDs are silently dropped at runtime
0015 Table-driven tuning registry Python and C++ stay hand-written, a build gate catches drift
0018 YAML is the only source of truth The hub persists nothing; pairing ends in paste-and-reflash
0019 Declare, don't guess Undetectable capabilities are YAML options, not inferences
0025 Counters are the one exception to 0018 1W's rolling sequence cannot be re-derived, so it is persisted — and nothing else is
0027 Controller identities for 1W 1W is class-addressed, so the sending identity replaces node addressing
0036 Hub entities come from hub-block flags, never a platform entry Discover & Pair joins its siblings behind discover_and_pair_button:; the old button: platform is deprecated, not deleted outright
0052 Device types follow the IO node-type and subtype table A 16-bit DeviceType and one (type, subtype) table drive names, entity class, platform and behaviour flags; six YAML names change and the old ones fail with the replacement
0053 A device may carry its own system key An optional system_key: on any device entity; the exchange engine picks the key per frame from the peer's node, falling back to the hub key; Scan Paired Devices sweeps once per distinct key

Security

# Decision In short
0007 Self-contained AES-128 Avoid an mbedTLS linkage dependency for one fixed primitive
0011 Key material masked in all logs Masking is unconditional, not tied to the frame-logging flag
0012 Key-extraction responder Emulate a device so a user's own hub hands over its credentials
0022 Unauthenticated status frames are never applied Drop the merge rather than pay a challenge or an attacker-triggerable poll
0026 1W enrollment needs no arming switch The receiver's own physical PROG hold is the interlock, and even a successful enrollment only adds a controller, never displaces one

Testing

# Decision In short
0010 Real captured frames as test truth Committed YAML captures, generated fixtures, no hand-written byte guesses
0014 Host tests on stubbed ESPHome headers Plain g++, no ESPHome or hardware — with a stated fidelity cost
0023 reference-material corpus origin Real devices this project doesn't own, sourced from published third-party material

Documentation

# Decision In short
0034 Doxygen syntax generated, not committed .md files stay pure GitHub Markdown; scripts/stage-docs.py injects {#label} / \subpage / link rewrites into the build copy

All records

Every record, in number order (the tables above group them by theme).

ADR 0001: Layered protocol / radio-driver / hub architecture ADR 0002: Direct SPI radio drivers instead of ESPHome's built-in radio components ADR 0003: Shared software PHY and IRQ orchestration base for SX1262/LR1121 ADR 0004: Hub responsibilities split into single-purpose collaborators ADR 0005: Pure decision logic kept separate from I/O ADR 0006: Hub-level admin operations as native API actions, not permanent entities ADR 0007: Self-contained AES-128 implementation ADR 0008: `io_device_id`, not `device_id` — and an `io_` prefix for protocol keys ADR 0009: Companion entity IDs are declared during schema validation ADR 0010: Real captured frames are the regression-test source of truth ADR 0011: Key material is masked wherever frames are logged, unconditionally ADR 0012: Key-extraction responder for recovering credentials from an owned installation ADR 0013: Radio work is blocking, on the ESPHome loop, with no FreeRTOS tasks ADR 0014: Host unit tests build against stubbed ESPHome headers ADR 0015: Table-driven tuning registry, guarded by a cross-language sync gate ADR 0016: Sender events are opt-in, by explicit allowlist ADR 0017: A device's settle hint may shorten the poll interval, never stretch it ADR 0018: YAML is the source of truth; the hub persists no state of its own ADR 0019: What the protocol cannot report is declared, not guessed ADR 0020: Flash LR1121 transceiver firmware, not the bootloader ADR 0021: Flash the LR1121 bootloader, behind an arming switch ADR 0022: Unauthenticated status frames are never applied to device state ADR 0023: `reference-material` as a fourth corpus origin, for real devices this project doesn't own ADR 0024: Diagnostic probes are gated, paired-devices-only, and isolated from the status decoder ADR 0025: Monotonic counters are the one thing the hub persists ADR 0026: 1W enrollment is required, and its safety comes from a physical interlock ADR 0027: Controller identities replace node addressing for 1W ADR 0028: Channel policy is a property of the frame, not of the chip ADR 0029: The start preamble is a property of the target's power class, not of the frame's position ADR 0030: A hub prediction is kept apart from a device observation ADR 0031: 1W vendor wire behaviour is driven by `manufacturer:`, and `execute_broadcast` is a remote-shape axis ADR 0032: 1W enrollment follows the gesture the target's `manufacturer:` expects ADR 0033: The heating send path bypasses the cover status/optimistic machinery ADR 0034: Doxygen page syntax is generated at staging time, never committed ADR 0035: FEM support is a behaviour profile; boards always supply their own pins ADR 0036: Hub-level entities come from `home_io_control:` flags, never a platform entry ADR 0037: The roll-call sweeps both power classes ADR 0038: 1W bursts follow the identity's power class, not a hard-coded preamble ADR 0039: Pairing sends a discover-confirm (0x2C) and tolerates no answer ADR 0040: A low-power device's start preamble follows its wake belief ADR 0041: An unset 1W `low_power:` resolves from the manufacturer profile ADR 0042: The directed start preamble's default comes from the radio driver ADR 0043: An unconfirmed movement command is re-sent once to a device that normally confirms ADR 0044: Chip-neutral transmit intent: wake level, idle scanning and air time ADR 0045: Listen-before-talk is part of sending ADR 0049: A rain limitation is derived from observed status, never predicted ADR 0050: Rain is read by an opt-in limitation poll ADR 0051: Pairing reads the key refusal and checks an unconfirmed key ADR 0052: Device types follow the IO node-type and subtype table ADR 0053: A device may carry its own system key ADR 0054: Heaters are read back over the private register protocol ADR 0055: A climate entity's traits come from the device's heating profile ADR 0056: Exterior heating is a fan entity ADR 0057: Temperature setpoints travel in the main parameter ADR 0058: Pairing reads the heater behind an Atlantic pairing node directly

Writing a new one

Number sequentially from the highest existing number. Use the same section headings (Status / Context / Options considered / Decision / Consequences), and keep "Options considered" only when real alternatives were weighed — don't invent losing options to fill the template.

State consequences honestly, including the ones that cost something. A consequences section listing only benefits means the decision hasn't been thought through, or the ADR is selling rather than recording.

Diagrams are welcome where structure or sequence is genuinely hard to hold in your head. Skip them where prose is clearer — a decorative box-and-arrow adds maintenance without adding understanding.