|
Home IO Control
ESPHome add-on for IO-Homecontrol devices
|
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.
Numbers are stable identifiers, not a reading order. Grouped by theme:
| # | 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 |
| # | 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 |
| # | 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 |
| # | 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 |
| # | 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 |
| # | 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 |
| # | 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 |
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
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.