|
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 when believed awake, wake-up preamble in every multi-try plan; the post-stop poll gets three tries); amends 0029; low_power_wake_belief switch |
| # | 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, awning, dual_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); state is publish-on-success only |
| 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 |
| # | 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 |
| # | 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
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.