|
Home IO Control
ESPHome add-on for IO-Homecontrol devices
|
This page documents the YAML configuration for the home_io_control external component and its ESPHome platforms.
IO-Homecontrol is a proprietary 868 MHz radio protocol used by Somfy, Velux, and other manufacturers for motorized shutters, blinds, awnings, and related home devices. This component implements the 2-Way (2W) variant, which means the controller sends commands and devices reply with position feedback.
Key concepts:
api: external_components: - source: github://laberning/home_io_control spi: clk_pin: number: 5 ignore_strapping_warning: true mosi_pin: 27 miso_pin: 19 home_io_control: cs_pin: 18 rst_pin: 14 dio0_pin: 26 radio_type: sx1276 node_id: "C0FFEE" system_key: "00112233445566778899AABBCCDDEEFF" cover: - platform: home_io_control name: "Patio Awning" device_class: awning io_device_id: "FEEB1E" button: - platform: home_io_control name: "Discover & Pair"
The home_io_control: block defines the shared radio/controller hub. All cover, light, lock, switch, and button entities attach to this hub.
home_io_control: cs_pin: 18 rst_pin: 14 dio0_pin: 26 radio_type: sx1276 node_id: "C0FFEE" system_key: "00112233445566778899AABBCCDDEEFF"
Configuration variables:
Notes:
| Radio | Required hub pins | Optional hub pins | Typical extra setting |
|---|---|---|---|
| SX1276 | cs_pin, rst_pin, dio0_pin | dio4_pin | radio_type: sx1276, pa_pin: BOOST |
| SX1262 | cs_pin, rst_pin, dio1_pin, busy_pin | fem_en_pin, vfem_pin, fem_pa_pin | radio_type: sx1262, tcxo_voltage: 1_8V |
| LR1121 | cs_pin, rst_pin, dio1_pin, busy_pin | — | radio_type: lr1121, tcxo_voltage: 3_0V |
SX1276, SX1262, and LR1121 are all confirmed and validated on real hardware (see the README hardware table). radio_type must always be set explicitly — there is no chip auto-detection, so an ambiguous or mis-wired chip can never be silently probed into the wrong SPI command set.
The cover, light, switch, lock, and button platforms accept ESPHome's own device_id: key to group an entity — and every companion entity it automatically generates — under a distinct Home Assistant device, instead of everything landing on the single physical ESPHome node. This is unrelated to io_device_id: (the IO-homecontrol protocol address below); device_id: is purely an ESPHome/Home Assistant UI-grouping concept.
esphome: devices: - id: patio_awning_device name: "Patio Awning" cover: - platform: home_io_control device_id: patio_awning_device id: patio_awning name: "" io_device_id: "FEEB1E" io_device_type: "awning"
Use the cover platform for position-capable IO-homecontrol devices such as shutters, awnings, blinds, openers, curtains, and related families.
cover: - platform: home_io_control id: patio_awning name: "Patio Awning" device_class: awning io_device_id: "FEEB1E" io_device_type: "awning" invert_position: true status_poll_interval: 2s linked_remotes: - "ABCDEF" - "class:awning" optimistic_state: true
Configuration variables:
Notes:
Beyond the entities generated from your cover:/light:/lock:/switch: YAML, Home IO Control exposes six hub-level actions through ESPHome's native API. These are one-off/advanced operations that would clutter the entity UI if they were always-visible buttons, so most are only reachable via Developer Tools or an automation — scan_paired_devices is the exception, with an optional one-tap button (see "Button Platform" below).
| Action | What it does | verified can be true? |
|---|---|---|
| rename_device | Renames a paired actuator and reads the name back to confirm the write. | Yes |
| identify_device | Makes a device physically identify itself (brief jog/flash) so you can tell which physical motor a device ID belongs to. | No — no readback exists for a jog |
| force_open_device ⚠️ experimental | Requests a fully-open move at elevated protocol priority, intended to override wind/rain soft locks. Confirmed to move the device correctly; not yet confirmed to actually override an active lock — see the warning below. | No — the outcome is asynchronous |
| scan_paired_devices | Broadcasts a roll-call and reports every already-paired device that answers — no target device_id, no arguments at all. Also reachable as a one-tap button via scan_paired_devices_button: true. | No — nothing here is read back either |
| oneway_set_position | Sends a numeric position as a configured 1W controller identity. Takes controller_id and position, not a device_id — 1W addresses a device class. See the "Sending 1W Commands" section. | No — 1W has no reply at all |
| heating_control ⚠️ experimental | Sends one heating/climate function (CMD_WRITE_PRIVATE 0x20) to a climate device. Takes device_id, function, value. See the "Heating / Climate (experimental)" section. | No — set_* are write-only; the power_on / midnight_sync reads are logged, not decoded |
Two more actions, probe_device and probe_sweep, exist behind a separate opt-in (diagnostic_probes: true) for sending opcodes this project hasn't fully decoded yet — see Diagnostic probes in the tuning guide.
Requires a normal api: block — Home IO Control enables the extra native-API feature flags this needs internally, so no custom_services: or homeassistant_services: is needed:
api: encryption: key: !secret api_key
Each action becomes a node-scoped ESPHome action named esphome.<node_name>_<action_name>. <node_name> comes from esphome.name (not friendly_name), which Home Assistant normalizes to snake_case — e.g. the sample V2 config uses name: hioc-heltec-v2, so rename_device becomes esphome.hioc_heltec_v2_rename_device.
From Home Assistant Developer Tools -> Actions, use the direct action block (no alias:/sequence:):
action: esphome.hioc_heltec_v2_identify_device data: device_id: "FEEB1E"
From an automation or script, wrap the same block in a normal step:
alias: Identify the Patio Awning sequence: - action: esphome.hioc_heltec_v2_identify_device data: device_id: "FEEB1E"
Every action except scan_paired_devices takes device_id: the 6-hex-character IO-homecontrol device ID — the same value you set as io_device_id in the entity's YAML, and the same ID the pairing log prints. This is the protocol-level actuator ID, not the Home Assistant entity ID. scan_paired_devices takes no data: at all — it isn't aimed at one device.
Every action fires the same Home Assistant event, esphome.home_io_control_action_result, so one automation trigger can react to any of them:
| Field | When present | Meaning |
|---|---|---|
| action | always | Action name, e.g. rename_device. |
| device_id | always | Target device ID — empty for scan_paired_devices, which has no single target. |
| success | always | Whether the action succeeded. For scan_paired_devices, true whenever the broadcast went out — a scan that heard nothing back is a successful scan, not a failure. |
| verified | always | Whether a follow-up readback confirmed the result — see the table above for which actions can ever set this true. |
| message | always | Human-readable outcome summary. For scan_paired_devices this is the full multi-line report (see below), not a one-line summary. |
| requested_name, applied_name | rename_device only | Requested vs. verified device name. |
| result_code, result_code_name | rename_device, identify_device, probe_device, and heating_control, when the device replies CMD_ERROR_RESP | Decoded protocol result code. |
| probe, index | probe_device and probe_sweep only | Probe name and the requested index (or swept range). |
| response_cmd, response_cmd_name, response_hex | probe_device only — a sweep's per-index replies are in message | The reply's command byte, decoded command name, and full raw wire hex. |
Fields: device_id (required), new_name (required — UTF-8, ASCII whitespace trimmed, must fit the protocol's 15-character Latin-1 write limit).
Sends the authenticated SET_NAME write, then immediately sends GET_NAME to read it back — verified is true only if the readback matches the requested name exactly. An explicit device refusal (CMD_ERROR_RESP) surfaces its decoded result code in both the logs and the event.
action: esphome.hioc_heltec_v2_rename_device data: device_id: "FEEB1E" new_name: "Patio Awning"
Fields: device_id (required).
Sends the authenticated CMD_IDENTIFY command. No device-type gating beyond "is it registered" — identify exists specifically to help you work out what an unknown or unrecognized device physically is. A CMD_ERROR_RESP reply still counts as success: some devices answer that way to an identify request and jog anyway (confirmed on real hardware — the awning jogged every time despite the error reply).
action: esphome.hioc_heltec_v2_identify_device data: device_id: "FEEB1E"
Fields: device_id (required).
Queued through the same dispatch path as the cover entity and its buttons (capability gating, poll tracking, settle handling, backoff) rather than sent directly, so a non-cover device is rejected the same way any other cover command would be. The result event only confirms the command was queued — the actual movement shows up later through the device's normal cover-state/polling pipeline, the same as any other cover command.
action: esphome.hioc_heltec_v2_force_open_device data: device_id: "FEEB1E"
No fields — call it with an empty data: (or omit data: entirely). Like the other actions it becomes esphome.<node_name>_scan_paired_devices; for the sample V2 config (name: hioc-heltec-v2) that's esphome.hioc_heltec_v2_scan_paired_devices.
Broadcasts a CMD_DISCOVER_SPE_REQ (0x2A) roll-call and listens for replies from every device that already holds this hub's system key — not just the ones you have YAML entities for. Each responder is looked up against the hub's device registry and grouped into a Known: section and an Unknown: section; unknown responders additionally get a lead-in line and a ready-to-paste YAML block, the same one a successful pairing prints.
action: esphome.hioc_heltec_v2_scan_paired_devices
The same operation is also available as a one-tap button — see "Button Platform" below — by setting scan_paired_devices_button: true in the home_io_control: block, so you don't have to reach for Developer Tools every time.
A realistic report, one already-configured device and one that isn't:
A single scan reports at most 24 devices. If more than that answer, the report says so explicitly with a NOTE: more than 24 devices answered; the list below is truncated. line rather than quietly listing a subset — so a large install can never look like devices have gone missing. Raising the limit means changing SCAN_MAX_REPLIES in management_actions.cpp; each additional slot costs 12 bytes of stack, so there is plenty of headroom if you need it.
Known devices are always listed before unknown ones, and either the Known: or Unknown: section is omitted entirely when it would be empty (e.g. a scan where every responder is already known prints no Unknown: header at all).
success is true whenever the broadcast went out, including when nothing answers — a scan that hears nothing is a valid result, not a failure. device_id on the result event is always empty (there is no single target); the full report above is the event's message.
A single scan may still miss devices — run it again if one you expect is absent. Paired devices duty-cycle across the three radio channels independently of the hub, so the hub retries the broadcast on all three IO-homecontrol channels (CH2, then CH1, then CH3), each with its own full pairing_discovery_wait_ms listen window, before returning the merged report. A missed reply is uncommon, but it is still possible for a device to be missed through all three attempts. This makes a single scan take a while: three full-length windows plus transmit time, roughly 3 × pairing_discovery_wait_ms (~6 seconds at the 2000 ms default). It will also log an ESPHome "operation took a long time" warning on every run — a known, accepted tradeoff. That warning stops repeating for anything that blocks under ~2.5 s, and a shorter window was tried for exactly that reason; it was reverted because it made devices' replies land just after the window closed, where they are dropped, so scans started missing devices. A recurring log line is the lesser problem. If a device you know is paired doesn't show up, just trigger the action again — it costs nothing else, since the action has no side effects to worry about repeating.
This cannot help you pair a new device. A device only answers 0x2A if it already holds this hub's system key — a device sitting in learning mode, waiting to be paired, holds no key yet and stays silent. See docs/radio_diagnostics.md's pairing_discovery_commands section for why 0x2A is deliberately excluded from the pairing discovery command list. Use the "Discover & Pair" button for pairing a new device; use scan_paired_devices to check in on devices you have already paired.
If you already hold a system key recovered from a hub that has paired its devices (see "Key Extraction (Accept Foreign Pairing)" below), use scan_paired_devices — the action, or the button if you've set scan_paired_devices_button: true — instead of Discover & Pair. It performs no pairing handshake at all — it only roll-calls devices that already trust your key, and prints a ready-to-paste YAML block for every one you have no entity for yet. Field reports repeatedly find this faster and more reliable than discover-and-pair for that situation (GitHub issues #27, #98, #103) — see the "already paired to a real hub" tip in the "Pairing Workflow" section below.
An unknown responder in the report is not, on its own, a sign of an intruder — it almost always means a device you paired earlier whose YAML entry never got saved (or got lost), not a foreign controller. The reply itself proves nothing more than "this device once received your system key"; see the roll-call's protocol notes for why the reply carries no per-transaction proof.
Fields: device_id (required), probe (required probe name), index (probe_device) or first_index + last_index (probe_sweep).
Behind the separate diagnostic_probes: true opt-in — sends opcodes this project has observed on the wire but not fully decoded, and reports the raw reply rather than an interpretation: probe_device in the structured response_cmd/response_cmd_name/response_hex event fields, a sweep's per-index replies inline in message instead. See Diagnostic probes for the available probe names, argument shapes, worked examples, and safety notes.
action: esphome.hioc_heltec_v2_probe_device data: device_id: "FEEB1E" probe: "private_fn" index: "0x06"
Use the light platform for IO-homecontrol light devices. Defaults to binary on/off; set dimmable: true for brightness control on devices that support intermediate positions.
light: - platform: home_io_control id: garden_light name: "Garden Light" io_device_id: "D15C05" - platform: home_io_control id: dimmable_light name: "Dimmable Light" io_device_id: "D15C06" dimmable: true
Configuration variables:
Notes:
Use the lock platform for IO-homecontrol lock devices that should appear in Home Assistant as native ESPHome locks.
lock: - platform: home_io_control id: front_door_lock name: "Front Door Lock" io_device_id: "D0A9C0" io_device_type: "lock" status_poll_interval: 2s
Configuration variables:
Notes:
Use the switch platform for binary on/off IO-homecontrol switch devices.
switch: - platform: home_io_control id: irrigation_switch name: "Irrigation Switch" io_device_id: "112233"
Configuration variables:
Notes:
⚠️ Unvalidated on real hardware. This support is transcribed from the rspaargaren/iohomecontrol project (a downstream ESP32 project which builds on Velocet/iown-homecontrol) and its "Cozytouch" radiator support (Atlantic, Thermor, Sauter — primarily French-market heating). That project's own README cautions that its modifications have focused on 1W and have made "the 2W part of the code 'unstable'", so it should be treated cautiously as a base. The maintainer here has none of these devices. Every payload byte is cited to that project's iohcCozyDevice2W.cpp, but nothing here has been confirmed against a real radiator. If you own an Atlantic / Thermor / Sauter IO-homecontrol heater, please try it and report what happens on the issue tracker — success, silence, or a wrong setpoint are all useful.
It is also unknown whether set_temperature requires a prior power_on or set_mode manual. This component deliberately does not synthesize either — each heating_control call sends exactly one write. Field testers: please report whether a bare set_temperature takes effect.
2W heating devices are driven with CMD_WRITE_PRIVATE (0x20). The set_* functions are write-only — nothing decodes what the radiator actually did back into an entity, and the hub only learns whether the device acknowledged the write. (power_on and midnight_sync are register reads; their ACK payload is logged at DEBUG but not decoded — see below.) State shown in Home Assistant is "last commanded, never confirmed".
Supported device types: heating_temperature_interface (0x0E) is the one selectable from YAML today. exterior_heating (0x15) and heat_pump (0x16) also map to the climate capability class but are not yet YAML-selectable — open an issue if you have one.
The wire format carries the setpoint as a 16-bit little-endian value in tenths of a degree (round(10 × °C)), per the iown-homecontrol project's Atlantic/Thermor register map, whose 0x0130 block shows 18 01 = 0x0118 = 280 = 28.0 °C as a live setpoint. This component accepts 7.0–28.0 °C (28.0 is the ceiling Atlantic radiator manuals document) and rejects anything outside that range rather than clamping or truncating. The encoding above 25.5 °C is corroborated by the register map but still unverified on real hardware. This component writes the full 16-bit value; a naive low-byte-only write would silently truncate 28 °C to 2.4 °C. If your radiator's own panel allows a different maximum, that is worth reporting.
7.0–12.0 °C is the frost-protection (hors-gel) band on these radiators, not a comfort setpoint; the panel's own comfort range starts at 12 °C. The codec still accepts 7–12 °C so a frost-protection setpoint can be commanded directly.
Takes device_id, function, and value. verified is always false.
| function | value | Effect |
|---|---|---|
| power_on | (ignored) | Wake / retrieve paired devices. |
| set_temperature | float °C, 7.0–28.0 | Setpoint. |
| set_mode | auto | manual | prog | off | Operating mode. |
| set_presence | on | off | Presence / absence. |
| set_window | open | close | Open-window / frost protection. |
| midnight_sync | (ignored) | Reads register 0x0130 (the comfort/eco/auto setpoint block). Named "midnight" in the reference, but the payload is a 0x60 read, not a clock-set — the device's clock register is 0x010F and this component never writes it. Provided for protocol exploration; the ACK payload is logged at DEBUG. |
action: esphome.hioc_heltec_v2_heating_control data: device_id: "FEEB22" function: set_temperature value: "20.5"
An unknown function, an out-of-range temperature, or a non-climate device_id is reported in the result event's message and nothing is transmitted. A CMD_ERROR_RESP from the device surfaces its decoded result code in result_code / result_code_name. For power_on and midnight_sync (which are register reads) the 0x21 ACK payload is written to the DEBUG log — a midnight_sync read returns the ~17-byte setpoint block, which can help settle the 25.5-vs-28 °C question on real hardware.
climate: - platform: home_io_control name: "Living Room Radiator" io_device_id: "FEEB22" io_device_type: "heating_temperature_interface"
Use the button platform to expose a Home Assistant button that starts discovery and pairing.
button: - platform: home_io_control name: "Discover & Pair"
Configuration variables:
Notes:
Not a button: platform entry — enable it from the hub block instead:
home_io_control: # ... radio pins, node_id, system_key ... scan_paired_devices_button: true
A one-tap trigger for the scan_paired_devices action (see "Home Assistant Actions" above): the button and the native API action run the exact same roll-call, and the action stays available either way — this is an additional trigger, not a replacement. It's the fastest bring-up route once you already hold a system key recovered from another hub (see "Key Extraction (Accept Foreign Pairing)" below): every device that trusts the key answers with a ready-to-paste YAML snippet, with no pairing handshake involved at all.
Notes:
esphome: name: io-homecontrol-sx1276 esp32: variant: esp32 wifi: ssid: !secret wifi_ssid password: !secret wifi_password logger: api: ota: - platform: esphome spi: clk_pin: number: 5 ignore_strapping_warning: true mosi_pin: 27 miso_pin: 19 external_components: - source: github://laberning/home_io_control home_io_control: cs_pin: 18 rst_pin: 14 dio0_pin: 26 radio_type: sx1276 node_id: "C0FFEE" system_key: "00112233445566778899AABBCCDDEEFF" cover: - platform: home_io_control name: "Awning" device_class: awning io_device_id: "FEEB1E" io_device_type: "awning" io_subtype: 0 invert_position: true button: - platform: home_io_control name: "Discover & Pair"
With io_device_type: "awning", the example above also generates an Awning Favorite Position button automatically.
esphome: name: io-homecontrol-sx1262 esp32: variant: esp32s3 wifi: ssid: !secret wifi_ssid password: !secret wifi_password logger: api: ota: - platform: esphome spi: clk_pin: 9 mosi_pin: 10 miso_pin: 11 external_components: - source: github://laberning/home_io_control home_io_control: cs_pin: 8 rst_pin: 12 dio1_pin: 14 busy_pin: 13 radio_type: sx1262 tcxo_voltage: 1_8V node_id: "C0FFEE" system_key: "00112233445566778899AABBCCDDEEFF" cover: - platform: home_io_control name: "Awning" device_class: awning io_device_id: "FEEB1E" io_device_type: "awning" io_subtype: 0 invert_position: true button: - platform: home_io_control name: "Discover & Pair"
With io_device_type: "awning", the example above also generates an Awning Favorite Position button automatically.
esphome: name: io-homecontrol-lr1121 esp32: variant: esp32s3 wifi: ssid: !secret wifi_ssid password: !secret wifi_password logger: api: ota: - platform: esphome spi: clk_pin: 5 mosi_pin: 6 miso_pin: number: 3 ignore_strapping_warning: true external_components: - source: github://laberning/home_io_control home_io_control: cs_pin: 7 rst_pin: 8 dio1_pin: 36 # LR1121 DIO9 busy_pin: 34 radio_type: lr1121 tcxo_voltage: 3_0V node_id: "C0FFEE" system_key: "00112233445566778899AABBCCDDEEFF" cover: - platform: home_io_control name: "Awning" device_class: awning io_device_id: "FEEB1E" io_device_type: "awning" io_subtype: 0 invert_position: true button: - platform: home_io_control name: "Discover & Pair"
home_io_control: cs_pin: 18 rst_pin: 14 dio0_pin: 26 radio_type: sx1276 node_id: "C0FFEE" system_key: "00112233445566778899AABBCCDDEEFF" cover: - platform: home_io_control id: patio_awning name: "Patio Awning" device_class: awning io_device_id: "123ABC" io_device_type: "awning" io_subtype: 0 light: - platform: home_io_control id: garden_light name: "Garden Light" io_device_id: "D15C05" io_device_type: "light" io_subtype: 0 lock: - platform: home_io_control id: front_door_lock name: "Front Door Lock" io_device_id: "D0A9C0" io_device_type: "lock" io_subtype: 0 switch: - platform: home_io_control id: irrigation_switch name: "Irrigation Switch" io_device_id: "D0661E" io_device_type: "on_off_switch" io_subtype: 0 button: - platform: home_io_control name: "Discover & Pair"
For larger working examples, see the configs already in this repo:
These files are not standalone. Each one pulls its board's SPI bus and radio pin assignment from a package:
packages: board: !include boards/heltec-v3.yaml
The per-board pinouts live once in config/boards/ — heltec-v2.yaml, heltec-v3.yaml, t3s3.yaml. To reuse one of the configs above, copy the whole config/ directory (or at least the matching config/boards/*.yaml alongside the file you took), or replace the packages: line with the full inline pinout from the fenced block for your board earlier on this page.
Every device-bound platform (cover, light, switch, lock) automatically generates a companion diagnostic text sensor named <Entity Name> Active Issue. Unlike the Device Name sensor, it is enabled by default, since it turns a silent "nothing happened" command into a self-explained one — pressing "open" on an awning during high wind surfaces LIMITATION_BY_WIND in Home Assistant, not just in the log.
This is not a per-operation result — it does not get set on every command, only while an actual issue is outstanding.
Every device-bound platform also automatically generates three per-device diagnostic sensor: entities for radio and exchange health. Unlike the Active Issue sensor, all three are disabled by default — they are lower-level radio diagnostics, not everyday values, so they stay out of the way until explicitly enabled in Home Assistant.
Every device-bound platform also automatically generates two more text_sensor: companions, both disabled by default like the Link Health sensors above:
| Situation | Last Commanded By | Last Command Source |
|---|---|---|
| no record decoded yet | (empty) | (empty) |
| foreign controller 3B74DC | 3B74DC | user_remote(0x01) |
| this hub | C0FFEE (this hub) | user_remote(0x01) |
| device names its own ID | 2FE2D2 (this device) | local_user(0x00) |
| gate, undefined originator | 586E35 (this device) | unknown(0x0A) |
Free — no extra radio traffic, no probe. These bytes arrive in every status poll today and were previously discarded; the sensors just read what is already there.
Honest limits:
Use cases: "who moved my shutter" in a house with several remotes plus a hub; spotting a forgotten paired remote still commanding a device you don't recognise — see "Key Extraction (Accept Foreign Pairing)" below for the pairing-hygiene angle.
Example custom-lambda pattern:
lambda: |- const float position = id(patio_awning).position; if (position < 0.0f || position > 1.0f) { ESP_LOGI("example", "position is unknown"); } else { ESP_LOGI("example", "position %.0f%%", position * 100.0f); }
Tips:
Every home_io_control config with a button: entity automatically gets a companion "Last Pairing Result" diagnostic text sensor — no YAML configuration needed. It follows the button's own device_id:, if one is set (see "Grouping Entities into Home Assistant Devices" above). It updates after every "Discover & Pair" attempt with a frozen, machine-readable summary:
| Field | Meaning |
|---|---|
| outcome | paired, no_response, invalid_response, key_exchange_failed, or config_failed (key exchange succeeded but the best-effort SetConfig1 step failed — still counted as paired). |
| phase | The pairing state machine's furthest-reached stage. |
| node / type | The paired device's node ID and type, or - if nothing was paired. |
| attempts | Number of discovery command retries sent. |
| lbt | Listen-before-talk retries consumed across the whole attempt (channel-busy indicator). |
| dur_ms | Attempt duration in milliseconds. |
| heard | Total RX events seen, including ones rejected by the pairing classifiers and a 1W gesture seeded from just before the window opened (see the advisor section below). |
| advice | Comma-separated advisor codes (see below), or none. |
This is intentionally a stable, parseable format (the v1; prefix is versioned) so it can be scripted against — e.g. an automation that alerts if outcome isn't paired.
At the end of every attempt, the ESPHome log also gets a full human-readable summary (every TX/RX/RX_REJECT/LBT-defer/phase event, in order, plus a total channel-hop count — hops aren't itemized individually, since a multi-second attempt at a short hop slice can produce far more of them than there is room to log usefully) plus, when applicable, one or more pairing advisor WARN lines that turn overheard radio traffic into an actionable diagnosis. The advisor also considers a 1W pairing gesture the hub overheard in the 15 seconds before "Discover & Pair" was pressed — a PROG press completed just ahead of the button press is not RF silence, and is reported as such (1w_traffic, not rf_silent):
| Advice code | When it fires | What it means |
|---|---|---|
| 1w_traffic | A 1W remote is seen performing 1W pairing (CTRL0 1W bit set, broadcast to 00003F, with a 1W pairing command byte). Not necessarily your device — the broadcast doesn't identify a target, so this fires on any 1W pairing gesture in radio range. | The motor is not in 2W learning mode — a PROG press on a 1W remote does not enable 2W discovery. This is the issue #27 case: perform a Double Power Cut on the motor to force 2W learning mode, then retry. If DPC still doesn't get a 2W discovery response and the device already has a working hub, it's very likely already paired to that hub — see the "already paired to a real hub" tip above and use Key Extraction instead. |
| channel_busy | Listen-before-talk retries were exhausted (reached the configured max) and the same source was heard repeatedly during the wait. | The channel is being flooded by a repeating beacon (usually a nearby remote or sensor); discovery transmissions were delayed. Try again, or tune lbt_max_retries/lbt_rssi_threshold_dbm — see Radio Diagnostics Tuning. |
| foreign_controller | A discovery response (0x29) was seen addressed to a node ID that isn't this controller's. | Another controller (e.g. TaHoma) is pairing the same device right now. Wait for it to finish, or make sure you're the only controller with the device in pairing mode. |
| rf_silent | Nothing at all was heard on any channel during the whole discovery window. | Distinguishes "RF dead" (antenna, wiring, wrong channel/tuning) from "device just isn't in pairing mode" — check the antenna and radio tuning before re-pressing PROG. |
⚠️ Hardware-confirmed protocol, including against real third-party hubs. A full extraction (0x28 through 0x33) between two boards — one running this responder, the other running this project's own pairing flow as the "hub" — recovered the hub's node_id/system_key byte-for-byte on real RF hardware, confirming the crypto, state machine, and radio wiring are correct. It has since also completed successfully against genuine third-party hubs: a Velux KLR200 (GitHub issue #80) and a Somfy TaHoma Switch (GitHub issue #27) have both extracted first-try, no retries needed. That's still two hub families out of the whole IO-Homecontrol ecosystem — a different hub's exact requirements may still differ, so treat any specific model not listed here as unconfirmed until someone reports back.
⚠️ Use only on a hub and network you own or are authorized to modify — see the project Disclaimer.
If you already own a working IO-Homecontrol installation (a hub plus paired devices) and want to move it to this component, you normally need to extract that installation's node_id/ system_key — which otherwise requires resetting a device and sniffing a re-pair with an external tool. This feature avoids that: it makes the ESP32 emulate an unpaired device so your existing hub can pair to it directly, the same way it would pair to a real shutter. During that pairing handshake your hub hands over its node_id and system_key; this feature recovers both and prints a ready-to-paste YAML block, with no separate hardware and no device reset.
home_io_control: # ... rst_pin / node_id / system_key / etc. as usual ... accept_foreign_pairing: true
Configuration variable:
This lives directly under home_io_control:, alongside options like tuning: and exposed_senders:. The generated switch always boots off (restore_mode: ALWAYS_OFF) so a reboot can never leave it armed, and its name is fixed to "Accept Foreign Pairing (Key Extraction)" (not configurable).
⚠️ Receive-only, but it recovers a real secret. This feature never transmits anything. It listens for a frame a 1W remote broadcasts during its key-copy gesture and decrypts it. The decryption needs no secret of its own — see the security note below — so treat the recovered key exactly as you would treat your system_key. Recover a key only from a remote or network you own or are authorized to access — see the project Disclaimer.
One-way (1W) installations — a handheld remote driving a shutter or awning directly, with no hub — have their own network key. This feature recovers it by overhearing a single 0x30 "add controller" broadcast, which is what a 1W remote sends while its remote-to-remote key-copy mode is active in order to hand its network key to a new remote.
For controlling a device, you almost never need this. A device accepts whichever key arrives during its own association-mode window — enrolling this hub as a controller (see "Sending 1W Commands" below) works just as well with a freshly generated key as with a recovered one, so there is nothing to gain from recovering a key just to enroll with it. Recover a key only when you specifically need to become an existing, already-registered identity rather than add a new one:
Never reuse a recovered key under the original remote's address while that remote is still in use. A device tracks one rolling-sequence high-water mark per source address, fed by whichever transmitter used it most recently. Two independent transmitters sharing one address — the original remote and this hub — cannot coordinate that counter: the hub's own persisted value only reflects what it has sent, so its next frame is very likely to land at or below what the device already accepted from the real remote and be silently rejected as a replay. Reuse an identity's address only once its original transmitter will never transmit again.
home_io_control: # ... rst_pin / node_id / system_key / etc. as usual ... recover_oneway_key: true
Configuration variable:
Like accept_foreign_pairing, this lives directly under home_io_control:, the generated switch always boots off (restore_mode: ALWAYS_OFF) so a reboot can never leave it armed, and its name is fixed (not configurable). The two features are independent — arming one never arms the other.
The report tells you the MAC status, which is your on-the-spot evidence that the recovered key is correct:
Two fields in the emitted block deserve a note:
Anyone within radio range of a key-copy gesture can recover the network key this way. The wrapped key in that broadcast is protected only by a publicly-known transfer key, using an initialisation vector derived from the sender's own address — which is in the same frame's header, in plaintext. There is no secret involved in the unwrap.
That is a property of io-homecontrol, not something this project introduces; the same framing applies as to the Key Extraction section above. The practical advice is the same as for any secret: perform the key copy once, indoors, and treat the recovered key as the credential it is. Raw 0x30 payloads are masked in this component's own frame logs for that reason — the recovered key is printed in exactly one deliberate place, the adoption report.
The hub can act as a 1W controller — the kind of thing a wall remote is — and drive devices by transmitting. This is off unless you configure it, and it signs with a key you already hold: the same authorisation as any 2W command this component sends.
⚠️ Read this before configuring: a device only obeys a controller it has been taught. Real-hardware testing established that 1W actuators keep a table of registered controllers. A frame that is correctly built, correctly addressed and signed with a key the device accepts is still ignored if this hub's source address is not in that table. Enrollment (below) is what registers it — do that first, for every identity, before expecting any command to move a device.
Everything about this feature follows from two properties of the protocol:
The second one shapes the whole feature. There is no failure you can be notified about, so the "Last 1W Command" sensor and the section below are the diagnostic tools.
Because nothing on the wire names a device, what distinguishes one 1W control surface from another is the controller doing the transmitting. That triple — source address, network key, device class — is a controller identity, and it takes the place node addressing has for 2W (ADR 0027).
Minimal example — the fewest fields that generate a working set of buttons:
home_io_control: # ... radio pins, node_id, system_key ... oneway_controllers: - id: velux_windows io_device_type: window_opener commands: [open, close, stop]
Full example — every optional key at once, so you can see the whole shape in one place:
home_io_control: oneway_controllers: - id: velux_windows io_device_type: window_opener # see "Named device types" below commands: [open, close, stop, favorite] node_id: A11CE0 # optional -- overrides the derived address system_key: FEDCBA98765432100123456789ABCDEF # optional -- reuse a recovered network's key initial_sequence: 4000 # optional -- seed the rolling counter manufacturer: somfy # required only because enrollment: true, below enrollment: true # generates the "Enroll 1W Controller" button enrollment_with_mac: false # optional -- see the table below
| Key | Required | Meaning |
|---|---|---|
| id | yes | Handle the generated entities are named and ID'd from. |
| io_device_type | yes | The device class this identity commands — see the "Named device types" table under Device Type and Capability Notes below for the full list. |
| node_id | no | Source address to transmit as. Derived from your hub's node_id and this id when omitted, deterministically at compile time — a derived address takes part in the same collision checks as an explicit one (a clash fails the build), and is printed at boot marked (derived), since nothing in your YAML shows it otherwise. Asking you to invent a 3-byte radio address instead would be an unanswerable question: nothing tells you which addresses are safe, and colliding with a real remote in range silently desyncs both transmitters' counters. |
| system_key | no | Network key for this identity. Defaults to the hub's own — every new identity works fine with the default. Only set this to a recovered key when reusing an existing, already-registered identity (see "Recovering a 1W Controller Key"). |
| initial_sequence | no | Seeds the rolling counter. The day-one remedy for a desynced device — see troubleshooting. |
| commands | no | Which buttons to generate: open, close, stop, vent, favorite. stop is pinned by a published reference vector; vent matches the reference remote's source but is unconfirmed by any capture; favorite is extrapolated with no reference support and is directly contradicted by this project's own capture of a real My/favorite button press, which encodes it a different way entirely — see create_1w_execute_command() in proto_commands.h. Treat favorite as untested. |
| manufacturer | conditional | The manufacturer ID byte an enrollment frame carries on air — a named value such as somfy, or a raw integer; see the "Named manufacturers" table under Device Type and Capability Notes below for the full list. Required whenever enrollment: true is set — the build fails otherwise, rather than silently broadcasting 0. Also selects the 1W wire profile (see "Matching your remote's vendor" below): velux sends CMD_EXECUTE frames with a different priority byte than somfy. Find the value from a "Recovering a 1W Controller Key" report for this network, or the device's own documentation. |
| execute_broadcast | no | typed (default) addresses your io_device_type class; all addresses the all-devices broadcast, which is what a handheld cover remote of either vendor sends for open/close/stop. Set all to mimic a real remote. |
| execute_acei | no | Raw override of the priority byte (payload[1]) in a 1W CMD_EXECUTE frame, e.g. 0x61. Must be 1–0xFF — 0x00 is rejected, since 0 is the sentinel for "not overridden". Normally left unset — it is derived from manufacturer. Use it only for a vendor this project has no profile for. |
| enrollment | no | Build flag (default false) for this identity's "Enroll 1W Controller" button — see "Enrolling this hub as a controller" below. |
| enrollment_with_mac | no | Whether the 0x30 half of the enroll button's press carries a trailing MAC (default false, meaning no MAC at all — there is no in-band form for this frame, see below). Real hardware disagrees on this byte: most captures this project holds carry no MAC (the default), but a real Somfy Izymo has separately been shown to accept the MAC-bearing form too. Untested manufacturers may need either — try flipping this before assuming enrollment doesn't work at all. |
| enrollment_classes | no | Which device classes a VELUX enrollment 0x30 sweep targets, as a list of io_device_type names (max 3). Unset → the profile default [roller_shutter, awning, dual_shutter] — the exact set a real KLI PROG press sweeps. Set it (e.g. [awning]) to narrow the sweep once you know which class your actuator listens on. Ignored by the Somfy gesture, which always uses io_device_type. See "Enrolling this hub as a controller" below. |
A 1W CMD_EXECUTE frame (open/close/stop) differs between vendors in exactly one byte: the priority/ACEI byte at payload position 1. Somfy remotes send 0x43; VELUX KLI-class remotes send 0x61. The hub picks this from manufacturer: — somfy (or omitted) gives the historical 0x43; velux gives 0x61. Any other vendor falls back to 0x43, since this project has only verified the two; if that vendor was set explicitly (not defaulted) on an identity that actually transmits — one with commands: or enrollment: true — the build also logs a warning.
To mimic a handheld remote, also set execute_broadcast: all — real remotes broadcast open/close/ stop to every device, not to one class. The boot log prints the resolved acei and broadcast for each identity; frame-log a press of your real remote and compare cmd 0x00 payload[1] and the destination address to confirm.
The VELUX profile is verified against KLI-class exterior-shading remotes only (awnings, screens, roller shutters). A VELUX window-opener or KLR-class remote may differ — use execute_acei: if so.
There is no 1W force_open button. The only wire byte this project ever associated with a "force open" label, 0x64, decodes to an ordinary numeric position (50%) when tested against real hardware as an outbound command — see the "Numeric positions" section below. The hub's force-open (force_open_device) is a separate, 2W, per-device action; see that section above.
This is the step that makes every command above actually move something. A 1W device ignores any frame from a source it has not been taught, no matter how correctly it is built or signed — see the warning at the top of this section. Enrollment is what teaches it.
Add manufacturer: and enrollment: true to an identity — see the full example above. enrollment: true is the build flag: its presence is the whole gate, and adding or removing the line and reflashing is the feature's entire lifecycle. Setting it creates one more entity for that identity: "\<Identity\> Enroll 1W Controller", entity_category: config.
Enrolling is additive, not destructive. A device's controller table holds more than one entry, each with its own key — enrolling this hub alongside an already-registered remote leaves that remote working exactly as before; the device answers commands from either.
The gesture is two-sided, and only one half is a button press.
| Half | Who does it | What it is |
|---|---|---|
| Receiver enters association mode | you, physically | 2 second hold on the actuator's PROG button, confirmed by its own indicator |
| Controller offers its credential | the hub | one short press of the "Enroll 1W Controller" entity |
Get the order and the durations right: 2 seconds on the receiver, then one press on the hub — not the other way around, and not a long hold on the hub's entity (there is nothing to hold; a press is a press). Getting the two halves' timing backwards is the most common failure mode here, not a protocol problem.
The press sends two bursts back to back — 0x39 (self-directed; carries only this identity's own address, so it can only ever clear its own prior entry, never a different controller's), immediately followed by 0x30 (the credential itself) — each ~125 ms, ~4 copies, no gap beyond the bursts' own airtime. This is the documented 1W pairing handshake (a real Somfy Smoove remote does exactly this, corpus-captured), not two independent actions. There is no "learn window" on the hub's side beyond that, because the device owns its own timeout and there is nothing further to wait for. Only one device should be in association mode at a time: the frames reach every device of that class in range that is currently listening, so a second actuator in learn mode nearby would be taught too.
The 0x30 half's MAC trailer is controlled by enrollment_with_mac: (default false, no MAC at all) — see the option table above if enrollment doesn't take with the default shape.
A real VELUX KLI 310/311/312/313 PROG press does not enroll the way a Somfy Smoove does, so with manufacturer: velux the Enroll button emits the KLI gesture instead (ADR 0032):
So for a VELUX exterior-shading device, set io_device_type: to whatever the device actually is (it drives the control-frame destination), and leave enrollment_classes: unset unless you need to narrow the sweep — the enroll gesture ignores io_device_type and uses the three-class list. The Enroll button blocks for up to ~6 seconds while it sends all six bursts.
⚠️ Pressing Enroll physically moves your covers. Step 3 is a real STOP then a real DOWN broadcast to every 1W-enrolled cover in range that holds the key — they will close. That is the KLI registration gesture, not a side effect to fix, but it is worth knowing before you press the button.
⚠️ VELUX 1W enrollment is unconfirmed. No hub has been shown to 1W-enroll on any VELUX actuator (issue #74). The frame shapes are reconstructed from a real KLI 310 capture and samr037/iohc-flipper; the STOP+DOWN step in particular is not confirmed against a VELUX capture. Two likely blockers: the actuator's own 2-second PROG window must be open at the moment the 0x30 sweep transmits (hold PROG on the actuator, then press Enroll); and the sweep + STOP + DOWN may not fit the manual's own 3-second window at this radio cadence (ADR 0032).
A hub cannot enroll into a device nobody has walked up to. The receiver's physical PROG hold is the real safety interlock here, stronger than any software confirmation could be — it is why this feature has no separate arming switch the way the (irreversible) LR1121 bootloader rewrite does.
Un-enrolling without re-enrolling is reached through its own explicitly-named action — the Enroll button's 0x39 above always re-registers via the 0x30 that follows it, so this is the path when you want the removal without the re-add:
- action: esphome.<device_name>_oneway_remove_controller data: controller_id: velux_windows
This sends 0x39 alone, nothing else. It carries only this identity's own source address, so it cannot remove a different remote's registration — the same property that makes the Enroll button's 0x39 prelude safe to send automatically.
⚠️ Un-enrollment is unconfirmed on real hardware. This action has not been shown to have any effect on real hardware — the hub keeps controlling the device afterwards regardless. The most likely explanation, by analogy with enrollment itself, is that a device only acts on 0x39 while its receiver is in the same 2 second PROG association mode enrollment needs. Treat "un-enroll" as the documented design intent, not a confirmed rollback, until this is retested with that gesture.
When you are done enrolling, remove enrollment: true from the identity and reflash. A build that can put a device into someone else's controller table should not be the build that runs permanently — the same reasoning as removing recover_oneway_key: true after a key recovery.
Each name in commands: generates a button. Their IDs follow <identity_id>_<command> — velux_windows + open → velux_windows_open — and that rule is a documented contract, because you cannot compose against IDs you cannot predict. Entity names derive from the same pair ("Velux Windows Open").
These are created from the oneway_controllers: block rather than declared as button: - platform: home_io_control entries, deliberately. A platform entry would have to infer what the button is from which keys are present, so a device-bound entry that merely omitted its io_device_id could be misread as the security-sensitive 1W kind instead of failing validation. Creating these from the hub block makes that class of mistake structurally impossible.
**1W support is still very early: there currently is no 1W cover entity. What this component gives you natively is buttons and the oneway_set_position action below. But you can build a slider on your own with ESPHome:
Covers: time_based. ESPHome's time_based cover platform already does exactly this, with asymmetric durations and endstop handling, and it is a standalone platform rather than a mixin — so it composes rather than inherits:
cover: - platform: time_based name: "Roof Window" open_action: {then: [button.press: velux_windows_open]} close_action: {then: [button.press: velux_windows_close]} stop_action: {then: [button.press: velux_windows_stop]} open_duration: 30s close_duration: 28s has_built_in_endstop: true
Note the estimate is exactly that: pressing the physical remote moves the device without telling Home Assistant, so the position drifts until the next full open or close re-synchronises it.
For anything other than the generated buttons there is an action (ADR 0006):
- action: esphome.<device_name>_oneway_set_position data: controller_id: velux_windows position: "40"
position runs 0 (fully open) to 100 (fully closed) and is passed as a string, like every argument on this component's action surface.
The result event reports only that the command was queued. Nothing downstream can ever upgrade that to "the device moved".
Every identity gets one, and it is the only feedback this feature can produce:
It reports what the hub transmitted — never that a device acted, because that is not knowable. It also shows the sequence used, which is the number you need for the troubleshooting below.
With no reply frames, this ladder is the diagnostic. Work down it in order.
Nothing appears in "Last 1W Command" after a press. The command never reached the transmitter. Check that the button you pressed belongs to the identity you think it does (<identity_id>_<command>), and look for no controller identity in the log.
The sensor says not sent (no sequence reserved). The identity resolved but its counter could not be written to flash, so nothing was built — the hub refuses to transmit a sequence it has not durably reserved, because reusing one is unrecoverable. This is a storage failure, not a radio one.
The sensor updates but the device does not move. In order of likelihood:
The rolling sequence counter, and nothing else. It is the one exception to this component's otherwise absolute rule that YAML is the only source of truth, because the counter is neither configuration nor re-learnable from the air — a 1W device never transmits, so nothing reports the high-water mark your counter has to stay ahead of. ADR 0025 records the exception and its cost; the practical consequence is that replacing your board loses the counters, and the identities on the new board will need initial_sequence: raised once.
⚠️ Give this the same care as any firmware update. Make sure the device is on stable mains power that nobody is going to unplug, and don't power-cycle it while a flash is running. Most of what's described here is recoverable if it goes wrong; one part of it is not, and that part is clearly marked.
The LR1121 is a separate chip from the ESP32, and it runs its own software — nothing to do with this ESPHome component or the firmware you flash over USB/OTA. That software comes in two pieces, and it helps to know which is which before you update anything:
Both come from Semtech's image repository, and this component downloads whichever you point it at when you build — nothing is bundled here.
The two are linked: a newer transceiver firmware can require a newer bootloader underneath it. Today that matters in exactly one place — firmware 1.4 requires bootloader 0x2101, while 1.1 through 1.3 run on 0x2100. Most boards shipped with 0x2100, so reaching 1.4 means updating the bootloader first. Your device reports both versions in its startup log, so you never have to guess which situation you're in.
| Updating | Risk | If it fails |
|---|---|---|
| Transceiver firmware | Low | Recoverable. The bootloader is untouched, so the chip can always be reached again — press the button once more and re-flash. Worst case the radio is silent until you do. |
| Bootloader | High — not reversible | The bootloader is what makes recovery possible. If a bootloader write is interrupted, there is nothing left to recover with, and this project cannot fix the chip. |
So: updating transceiver firmware is routine and safe to try. Updating the bootloader is a deliberate, one-way operation that you opt into separately, and it is gated behind its own switch so you cannot trigger it by accident. Both are described below.
Whichever you do, the single most useful precaution is boring: stable power throughout. A flash blocks the device for a few seconds and it will go briefly unavailable in Home Assistant — that's normal, and reaching for the plug at that moment is the one reliable way to turn a safe operation into a broken chip.
Everything on this page has been run end to end on real hardware (a LilyGO T3-S3), including the bootloader update.
Triggered from a Home Assistant button, straight from your existing build — no extra hardware, no vendor tooling.
home_io_control: radio_type: lr1121 # required busy_pin: 34 # required lr1121_firmware_update: source: github://Lora-net/radio_firmware_images/lr1121/transceiver/lr1121_transceiver_0103.bin # ref: optional branch/tag/commit; defaults to HEAD # checksum_md5: optional 32-hex-char hash; also the fallback when no `.bin.md5` sibling exists # target_version: optional hex (e.g. 0x0103); only needed when the filename carries no version
Configuration variables (all nested under lr1121_firmware_update:):
The block's mere presence is the build flag: adding it recompiles the flash button and a boot-time bootloader-version read into the firmware; removing it takes them back out. There is no runtime toggle — entering and leaving flash mode is a recompile + OTA each way. All of your normal cover/light/switch/lock entities keep working throughout, unaffected.
Without the bootloader: sub-block below, the answer depends on the bootloader your board already has. On the common 0x2100 that means firmware 1.1 through 1.3; 1.4 needs 0x2101 and stays out of reach until you deliberately opt into the bootloader update.
You don't have to look this up. Every boot reads the bootloader version — a free chip reset, before anything else is configured — and prints it alongside a verdict for whatever you configured:
That verdict is computed once at startup, so pressing the button when it can't succeed costs nothing — it refuses immediately without touching the radio.
If you point source: at a firmware version newer than this component knows about, it is not refused: this build's compatibility table is a point-in-time snapshot, not an authority, so an unfamiliar version is routed through the two-press confirmation instead. The feature keeps working on releases that didn't exist when it was written.
⚠️ This one is a one-way door. A failed bootloader write has no recovery path in this project — see the risk table above for why. Mains power only, and don't interrupt it. Read ADR 0021 before configuring this.
Why you might want to. Semtech's advisory SEM-PSA-2026-001 lists three CVEs affecting the LR1121, and names bootloader 0x2101 as a threshold alongside firmware 1.4. The most severe of them is a weakness in secure boot — which is the bootloader's own job — so updating only the transceiver firmware most likely does not close it. Worth knowing before you rush: all three require physical access to the chip's SPI pins, so this is defence in depth for a hub sitting inside your house, not an emergency.
What actually happens. It's three steps, not one, and the radio has no working firmware in between: a small loader image is written first, that loader rewrites the bootloader, and then your chosen transceiver image is written on top. The middle step is the one that cannot be retried. The whole sequence takes about 10 seconds, during which the device is unresponsive.
Opt in with a nested bootloader: block, pointing at the matching loader image (not a transceiver image) Semtech publishes alongside the transceiver firmware:
home_io_control: radio_type: lr1121 # required busy_pin: 34 # required lr1121_firmware_update: source: github://Lora-net/radio_firmware_images/lr1121/transceiver/lr1121_transceiver_0104.bin bootloader: source: github://Lora-net/radio_firmware_images/lr1121/loader/lr1121_loader_2100.bin # ref / checksum_md5: same meaning as the outer block's
Pairing this block with an outer source: that's known to require the existing bootloader 0x2100 is a build-time error, not merely discouraged: after the rewrite that image would be unflashable, so ESPHome refuses to compile the config at all rather than let it arm a trap. This guarantees the recovery transceiver image is already in ESP32 flash before anything is erased, so a config mistake fails the build rather than bricking a board.
If the outer source: names a firmware version this build's compatibility table has never heard of (the table's advisory, not exhaustive — see the "Which images can I flash?" section above), that's only a build-time warning, not an error: the build will not gamble an irreversible write on a requirement it doesn't know, so the bootloader: block is accepted but the rewrite path stays inert at runtime (the boot-time log explains why, and the flash button refuses without touching the chip). Extending the compatibility table once Semtech publishes the pairing is a one-line edit — see LR1121_KNOWN_BOOTLOADER_REQUIREMENTS in lr1121_firmware_decisions.h.
Configuring the block also creates an "Allow LR1121 Bootloader Rewrite (Irreversible)" switch on the hub, defaulting off and never auto-arming after a reboot. The existing flash button performs the three-stage rewrite only while that switch is on and the cached verdict says the upgrade applies; with the switch off, a press refuses immediately without touching the chip. There is no separate two-press confirmation for this path — the switch is the confirmation, and it's visible in Home Assistant so "is this armed?" is answerable by looking rather than remembering.
A completed (or failed) rewrite always ends in an ESP32 reboot, which clears the switch again — the window is armed-until-next-flash, not indefinitely armed. Once the bootloader is 0x2101, the ordinary transceiver path above becomes the recovery mechanism for any future re-flash: the bootloader: block goes inert (a same-bootloader chip has nothing left to upgrade) and every press uses the plain flash sequence.
What a successful run looks like. Turn the switch on, press the button once, then leave it alone. Expect roughly:
The Stage 1b checkpoint line is the meaningful one to watch for: everything up to that point is still fully recoverable, and it is what confirms the loader really is running before the irreversible step begins. After it, the device goes quiet for a few seconds — that is the flash, not a crash. Once it reboots, the startup log should report bootloader 0x2101 and firmware 1.4.
If anything goes wrong the log says which stage failed and whether it is recoverable, in plain terms, rather than leaving you to work it out. A stage 3 failure in particular is not serious: the bootloader is already updated, and the ordinary flash button (switch off) finishes the job.
Configuring the block creates a "Flash LR1121 Radio Firmware" button, bound directly to the hub. Pressing it:
Once a flash starts, the chip is erased and rewritten, with progress logged periodically (Flashing LR1121: 1630/16304 words (10%)) on both the serial console and the API connection — on a 1.3 image this takes a few seconds total. The ESP32 reboots on its own once the sequence finishes, including after a failure. A failed erase or write is recoverable: press the button again after the reboot to retry.
Both io_device_type and the class:<device_type> form of linked_remotes (see Linked Remotes below) accept these named values:
| Name | Hex ID | Name | Hex ID |
|---|---|---|---|
| venetian_blind | 0x01 | on_off_switch | 0x0F |
| roller_shutter | 0x02 | horizontal_awning | 0x10 |
| awning | 0x03 | external_venetian_blind | 0x11 |
| window_opener | 0x04 | louvre_blind | 0x12 |
| garage_opener | 0x05 | curtain_track | 0x13 |
| light | 0x06 | intrusion_alarm | 0x17 |
| gate_opener | 0x07 | swinging_shutter | 0x18 |
| rolling_door_opener | 0x08 | ||
| lock | 0x09 | ||
| blind | 0x0A | ||
| screen | 0x0B | ||
| dual_shutter | 0x0D | ||
| heating_temperature_interface | 0x0E |
A device type not in this table can still be declared as a raw hex ID, in either place: io_device_type: 0x14 or linked_remotes: ["class:0x14"]. Named and raw-hex entries can be mixed freely within the same linked_remotes list.
Finding your device's type: the surest way is to pair it through Home Assistant and check the "Last Pairing Result" diagnostic sensor — its type= field reports the device's actual type name (see "Pairing Workflow" above, e.g. type=awning). If you already know the device (e.g. you're configuring a Somfy awning), just use the matching name from the table above.
The manufacturer key on a 1W oneway_controllers: identity (see "Sending 1W Commands" above) accepts these named values, the IO-Homecontrol alliance's own manufacturer IDs:
| Name | Hex ID | 1W profile | Name | Hex ID | 1W profile |
|---|---|---|---|---|---|
| velux | 0x01 | ✅ | window_master | 0x07 | — |
| somfy | 0x02 | ✅ | renson | 0x08 | — |
| honeywell | 0x03 | — | ciat | 0x09 | — |
| hormann | 0x04 | — | secuyou | 0x0A | — |
| assa_abloy | 0x05 | — | overkiz | 0x0B | — |
| niko | 0x06 | — | atlantic_group | 0x0C | — |
A manufacturer not in this table can still be declared as a raw hex ID, e.g. manufacturer: 0x0D.
1W profile marks the two vendors this project has a verified 1W CMD_EXECUTE wire profile for (the priority byte — somfy 0x43, velux 0x61; see "Matching your remote's vendor" above). Any other value — named or raw — transmits with the somfy-shaped byte and, if it is an explicitly-set manufacturer on a transmitting identity, logs a build-time warning. Use execute_acei: to pin the byte yourself for an unprofiled vendor.
Physical IO-Homecontrol remotes (wall switches, handheld remotes, wind sensors) use the 1W (one-way) protocol to send commands. Unlike 2W devices that address a specific device ID, 1W remotes broadcast to a type-class address (e.g., "all awning devices"). This means the controller cannot automatically detect which of your devices a particular remote controls — you need to configure the link explicitly.
linked_remotes and exposed_senders are receive-only. They only change how the hub reacts to 1W radio traffic it overhears from other transmitters — decoding intent, firing events, updating linked devices — and neither one transmits anything. That is a separate capability: the hub can also transmit 1W commands of its own, including to 1W-only devices with no 2W/authenticated protocol at all. See "Sending 1W Commands (Controller Identities)" above for the transmit side.
Without linked_remotes, when someone presses a wall remote, the device moves but the controller doesn't know about it until the next scheduled poll. With linked_remotes configured, the controller overhears the remote's radio traffic and immediately polls the device for its new position (with a configurable poll interval).
logger: level: DEBUG
Notes:
Add the remote's node ID to the linked_remotes list on the entity it controls:
cover: - platform: home_io_control name: "Patio Awning" io_device_id: "30E1F2" io_device_type: "awning" linked_remotes: - "9D6085"
Multiple remotes can be linked to the same device, and the same remote can be linked to multiple devices if it controls more than one:
cover: - platform: home_io_control name: "Patio Awning" io_device_id: "30E1F2" linked_remotes: - "9D6085" - "A0A9A1" - platform: home_io_control name: "Bedroom Shutter" io_device_id: "054E17" linked_remotes: - "9D6085"
Instead of (or alongside) individual remote node IDs, a linked_remotes entry can be class:<device_type>, matching how 1W remotes actually address a device — a typed broadcast such as "all awnings" rather than a single node. This links every typed broadcast targeting that class to the device, without enumerating each remote's node ID:
cover: - platform: home_io_control name: "Patio Awning" io_device_id: "30E1F2" io_device_type: "awning" linked_remotes: - "class:awning"
<device_type> accepts the same named values as io_device_type — see the "Named device types" table under Device Type and Capability Notes above for the full list (e.g. awning, roller_shutter, venetian_blind) — or a raw hex ID such as class:0x14 for a type without a named alias yet. Bare node-ID entries, named class: entries, and raw-hex class: entries can all be mixed freely in the same list, and a device linked both ways (by ID and by class) is only updated once per press — the class form is purely a convenience for "any remote that presses this device's type," it does not change how bare node IDs behave.
By default (optimistic_state: true), a linked remote's press — or an HA-issued open/close/stop/set-position command — shows the requested direction in Home Assistant immediately, before the confirming poll (linked remote) or device response (HA command) arrives. This is a pure UX bridge: the prediction is held in its own layer, never written over the device's last reported values, and the confirming poll/response supersedes it with real data as soon as that arrives. Set optimistic_state: false on a device to disable this and fall back to polling only.
Notes:
| Field | Meaning |
|---|---|
| 1W remote XXXXXX | The remote's 6-character node ID |
| targets all / targets awning | The broadcast device-type class the remote addresses |
| EXECUTE(0x00) CLOSE | The command: OPEN, CLOSE, STOP, FAVORITE, VENT, or a numeric position |
| originator=user_remote | Who triggered the command (user, wind sensor, rain sensor, timer, etc.) |
| priority=user_high | The ACEI priority level of the command |
| (linked → 30E1F2) | Shown when the remote is already configured — confirms the link is active |
Every decoded 1W transmission is DEBUG-logged (see the Linked Remotes section above), regardless of configuration. Additionally, senders on the exposed_senders allowlist fire an esphome.home_io_control_sender_event event to Home Assistant, so you can trigger automations directly from a physical remote press — including a remote that doesn't control any device you own an entity for (e.g. a spare remote you want to repurpose as an HA trigger).
"Sender" is deliberately not "remote": handheld/wall remotes and wind/rain sensors use the exact same 1W broadcast mechanism and the same node-ID addressing — a wind sensor is just another device that broadcasts a command (e.g. "close due to wind") with originator=wind_sensor instead of originator=user_remote. From the radio's perspective they're indistinguishable except for that one payload byte, so exposed_senders (like linked_remotes) works identically for either: a wind or rain sensor's node ID can go in this list the same way a handheld remote's can.
1W frames are unencrypted broadcasts to a type-class address (e.g., "all awning devices") — there is no ownership marker on the radio protocol. Your controller can overhear a neighbor's remote (or sensor) as easily as your own if it's in range. Firing an event to Home Assistant for every overheard transmission could mean HA sees a neighbor's button presses, so exposed_senders defaults to an empty list: nothing fires an event until you explicitly add its node ID.
This is a separate mechanism from linked_remotes (which drives optimistic status polling for a device you own):
Find the sender's node ID the same way as for linked_remotes (see "Finding your remote's node ID" above — the same log line works for a wind/rain sensor's node ID too), then add it to the hub-level exposed_senders list:
home_io_control: cs_pin: 18 rst_pin: 14 dio0_pin: 26 radio_type: sx1276 node_id: "C0FFEE" system_key: "00112233445566778899AABBCCDDEEFF" exposed_senders: - "9D6085"
| Field | Meaning |
|---|---|
| remote_id | The sender's 6-character node ID |
| target_class | Address classification: unicast, broadcast_all, broadcast_type, discovery, or unknown_broadcast |
| target_type | The broadcast device-type class the sender addresses (e.g. awning, or unknown for an all-devices broadcast) |
| cmd | The command name and hex code, e.g. execute(0x00) |
| intent | The decoded command: OPEN, CLOSE, STOP, FAVORITE, VENT, or a numeric position |
| originator | Who triggered the command (user_remote, wind_sensor, rain_sensor, timer, etc.) |
| acei_level | The ACEI priority level of the command |
| linked | "true" if this sender is also in some entity's linked_remotes list, "false" otherwise |
Before wiring up an automation, confirm the event actually reaches Home Assistant:
If nothing appears, check the ESPHome DEBUG log (logger: level: DEBUG) for one of these lines, logged right after the rx 1W remote ... decode line:
| Log line | Meaning |
|---|---|
| Firing esphome.home_io_control_sender_event for sender XXXXXX | The event was sent — if Developer Tools still shows nothing, check the Home Assistant API connection instead (api: block, encryption key, network). |
| 1W sender XXXXXX has intent but is not in exposed_senders, skipping ... | The sender ID isn't on the allowlist (or doesn't match — check exact casing/value). |
| 1W sender XXXXXX has intent but the API is not connected, skipping ... | Home Assistant hasn't got an active connection to this device yet. |
| (no line at all, just the rx 1W remote ... decode) | This particular frame carried no decodable command intent — see below. |
Not every button press fires the event. Only frames decoded as CMD_EXECUTE or CMD_ACTIVATE_MODE carry a command intent (OPEN/CLOSE/STOP/etc.); other 1W traffic from the same remote — for example a WRITE_PRIVATE(0x20) frame, which some remotes send as part of the same button press — is still DEBUG-logged but never fires the event, because there is nothing decodable to put in intent. If you only ever see WRITE_PRIVATE lines and never an EXECUTE line for a press that should have moved something, the EXECUTE frame itself was likely never received (radio timing/contention), not silently dropped by this component.
automation: - alias: "Awning remote pressed" trigger: - platform: event event_type: esphome.home_io_control_sender_event event_data: remote_id: "9D6085" action: - service: notify.mobile_app data: message: "Awning remote: {{ trigger.event.data.intent }}"