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
0072 Diagnostic probes are their own collaborator (amends 0024) DiagnosticProbes holds no hub pointer, only three injected calls; the isolation from the status decoder rests on that boundary

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 (amended by 0073, 0074, 0075, 0079) 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, 0078) 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)
0078 Discovery follows the start preamble (amends 0029) Discovery (0x28/0x2E) and the pairing frames after it use normal_start_preamble unless pairing_discovery_preamble is set; the low-power discovery flag takes the wake-up preamble; a set value wins, 1024 included
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 (amended by 0073, 0074, 0077, 0079) 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 (amended by 0073) 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
0070 A preamble on air keeps the software-PHY receiver running SX1262/LR1121: a dwell that times out on a preamble keeps RX until the frame, a retune or 30 ms; a started frame gets 12 ms past the deadline; the SX1262 preamble detector is 16 bits, as on the LR1121

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 (amended by 0060, 0062, 0063, 0072) 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; amended 2026-10: the setpoint heater offers no presence preset, its automatic modes are the presets Auto and Program
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; amended 2026-10: every preset is observed, a profile carries no last-commanded marker
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 (amended by 0073, 0075) 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 (amended by 0064) 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, an unanswered poll retried once a minute later (0064)); independent of the derived flag; transmit time is the user's choice
0066 A cover is read at rest by an opt-in status refresh (amended by 0073, 0074) status_refresh_interval reads a cover with the ordinary status read once it has reported no status for a jittered interval; every status report moves the refresh out; transmit time is the user's choice
0073 A read plans its attempts, and an attempt must fit the budget with its windows (amends 0013, 0040, 0043, 0044, 0066; amended by 0074, 0075, 0076) A plan gives attempts, gap and window per read; a settling read has no gap and as many attempts as fit (at most 9, three for a low-power target, 550 ms window); an attempt starts only if its gap, transmission, window and, once challenged, final-reply window end inside the budget; the 750 ms re-send gap is the total wait
0074 Settling reads take the attempt plan, and progress reads stay single tries (amends 0013, 0040, 0066, 0073; amended by 0075, 0076, 0079) The poll after a stop, the confirming read, the ladder band, one-off polls and the status refresh get the plan; progress reads and heater polls keep today's tries; a failing settling read may block for nearly the whole budget
0075 Commands and the key pull take the attempt plan, and early attempts to a mains device listen short (amends 0043, 0073, 0074) Repeat-safe commands to a mains device get as many gapless attempts as fit (at most 9; favourite and other stored positions keep 3); attempts before the last listen exchange_early_response_wait_ms (200 ms); every attempt costs a measured 10 ms overhead in plan and guard; other challenged requests are never copied beyond three attempts; the key pull takes the plan
0076 A moving cover is read once at its predicted end of travel (amends 0073, 0074; amended by 0079) A moving report with a count (0 included) plans one settling read at the count plus 1 s, re-aimed by each falling count and given up after three non-falling reports; progress reads stay single tries and one due right before it is dropped; a stopped report never schedules from its count, so the confirming read keeps the default delay; confirmed on a Somfy Sunea io, experimental for other covers
0077 A moving cover stays awake-believed until its remaining time runs out (amends 0040) A trusted moving report with a count records when it runs out; a silent exchange before then does not spend the moving evidence; the predicted-end read to a low-power cover leads with the wake-up preamble; experimental until a low-power cover with a count is captured
0079 A cover is followed by its execution state (amends 0013, 0040, 0061, 0074, 0076; amended by 0080) Byte 0 of a status reply is the KLF 200 execution state (2-4 moving, 0/1/5 not, 3 shows waiting for power); 240 s window fixed at opening; a frozen count backs off 10/15/20 s; a settling status read to a low-power cover in an unknown state leads with one short attempt, over the budget by at most its cost
0051 Pairing reads the key refusal and checks an unconfirmed key (amended by 0071) 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
0071 A pulled key without an answer is proven by the devices that hold it A pulled key whose giver sends no 0x3D is accepted when a device answers a roll-call under it; challenge bytes repeat within an attempt so late answers still verify; amends 0051
0080 Linked-remote activity is followed by an opt-in catch-up read (amends 0079) remote_activity_poll_delay_ms sets the delay before the first poll after a linked-remote press; remote_activity_catchup_poll_ms reads every linked device once, that long after the last exchange of the burst, as a one-off read that treats the receiver's state as unknown (short lead, then wake-up attempts); a moving answer is followed; both under tuning:, defaults unchanged
0081 A water heater's boost and away travel in the operating-mode word (amends 0068) One EXECUTE with MP D400 and FP16 4900 boost / 4A00 off / 4600 away, after a duration write (0x0000 = 1, 0x0005 = 255); the state is FP16 from an FPI2 03 status read that also carries the target and the FP15 capabilities; registers 0x0000/0x0005 are durations; away always offered, refused when FP15 lacks it; experimental
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 a config error that names the flag
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
0059 Device capabilities come from the type table (amended by 0067) One capability word per (type, subtype) row, resolved once per device; the subtype-58 rule is one function; named positions are one table; the Python mirror is sync-tested; keys a kind cannot use fail validation; unconfirmed kinds are marked experimental and warned about
0067 A model below the subtype for products that share a wire type (amends 0059) Model rows below a (type, subtype) in the same table, declared with io_model: and never learned; model 0 is the (type, subtype) row; lookups take the model explicitly, without a default; a one-off model-name read and a WARN-only hint check the declaration; the first model is the towel dryer under 52/1
0068 A domestic hot water device is a water heater (amended by 0081) An ESPHome water_heater entity; heater modes 0/1/2 map to heat pump / eco / performance, no off; the target is the status main parameter over 20–62 °C; a family-0x00 big-endian codec of its own; the WATER_HEATER bit, kept only on a device bound to a water_heater: entry; row 51/0 experimental; target and mode writes experimental
0060 A state-changing diagnostic probe (amends 0024; amended by 0064) subscribe_event is allowlisted to two events, not sweepable, re-sent at most once after the device accepted it (0064); reports are recorded on a diagnostic sensor and never applied to device state
0061 A moving status report opens the follow-up window (amended by 0079) A report that reads as moving opens the window when none is open; a report that finds it expired ends it instead of reopening it; only kinds that report motion through status may open one
0062 A read-only data-transport probe (amends 0024) read_object opens, reads and closes five allowlisted objects over 0x46/0x4A; read direction only by construction, capped, raw output never applied
0063 A read-only register probe (amends 0024) register_00 / register_01 read one register over 0x20; the read direction byte is a constant no input changes; refused for every kind but register heaters and hot water
0064 A sensor reading is drawn by a re-sent event subscription (amends 0060, 0050) sun_level_poll_interval / battery_level_poll_interval re-send a subscription on a jittered interval and publish the value its answer carries; the answer is taken as the reply, one re-send after a lost answer, one retry a minute after an unanswered poll (rain too), strict decode, experimental on one RS100 Solar io
0065 A cover's travel speed is a three-way choice (amended by 0069) travel_speed: default / silent / fast with a select; default sends the 6-byte frame a VELUX pad sends; without the key nothing changes; silent: stays as the two-way form
0069 A config key is retired in two stages Warn with the exact replacement, then a config error about two months later; silent: enters the warning stage, amending 0065

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 ADR 0059: Device capabilities come from the type table ADR 0060: A state-changing diagnostic probe ADR 0061: A moving status report opens the follow-up window ADR 0062: A read-only data-transport probe ADR 0063: A read-only register probe ADR 0064: A sensor reading is drawn by a re-sent event subscription ADR 0065: A cover's travel speed is a three-way choice ADR 0066: A cover is read at rest by an opt-in status refresh ADR 0067: A model below the subtype for products that share a wire type ADR 0068: A domestic hot water device is a water heater ADR 0069: A config key is retired in two stages ADR 0070: A preamble on air keeps the software-PHY receiver running ADR 0071: A pulled key without an answer is proven by the devices that hold it ADR 0072: Diagnostic probes are their own collaborator ADR 0073: A read plans its attempts, and an attempt must fit the budget with its windows ADR 0074: Settling reads take the attempt plan, and progress reads stay single tries ADR 0075: Commands and the key pull take the attempt plan, and early attempts to a mains device listen short ADR 0076: A moving cover is read once at its predicted end of travel ADR 0077: A moving cover stays awake-believed until its remaining time runs out ADR 0078: Discovery follows the start preamble ADR 0079: A cover is followed by its execution state ADR 0080: Linked-remote activity is followed by an opt-in catch-up read ADR 0081: A water heater's boost and away travel in the operating-mode word

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.