|
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.
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 four 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 they're only reachable via Developer Tools or an automation.
| 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. | No — nothing here is read back either |
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 and identify_device only, when the device replies CMD_ERROR_RESP | Decoded protocol result code. |
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
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. A paired device only answers if it happens to be awake and listening on the channel the hub transmits on at that instant, 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. That covers most cases, but a device's own listen schedule is outside the hub's control, so it is still possible for a device to be asleep — or simply still composing its reply — 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.
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.
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:
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:
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:
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 — for example, pressing "open" on an awning during high wind now shows LIMITATION_BY_WIND in Home Assistant instead of only a log line.
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.
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 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. |
| 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/hop/phase event, in order) plus, when applicable, one or more pairing advisor WARN lines that turn overheard radio traffic into an actionable diagnosis:
| 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) to the same device you're trying to pair. | 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. |
| 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, but not yet against a third-party hub. 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. That confirms the crypto, state machine, and radio wiring are correct. It does not confirm compatibility with a genuine third-party hub (Somfy TaHoma/ Smoove, Velux KLF200, etc.) — the discovery-response field guesses and IV-derivation assumption were reverse-engineered from this project's own encoder and a small number of captures, and a real hub's exact requirements may differ.
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).
⚠️ 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.
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.
This is receive-only. The hub reacts to physical 1W remotes and sensors it overhears — decoding intent, firing events, updating linked devices — but it does not transmit 1W commands itself, and it does not control 1W-only devices (devices that have no 2W/authenticated protocol at all). Everything on this page assumes the devices you control are 2W, and that linked_remotes / exposed_senders only change how the hub reacts to radio traffic it overhears from other transmitters.
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 confirming poll/response always still runs and overwrites the optimistic value with the device's real reported position. 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 }}"