|
Home IO Control
ESPHome add-on for IO-Homecontrol devices
|
This project is still experimental. IO-Homecontrol covers a wide range of motors and actuators, and every model can behave slightly differently during pairing — different discovery commands, timings, or radio settings. It is not possible to test against every device that exists in the field, so getting pairing to work reliably on a specific device sometimes needs a bit of per-device fine-tuning.
The tuning: block exists for exactly that. It exposes the pairing and radio parameters that are normally fixed, so you can experiment on a stubborn device and find a working combination — then copy that combination back into your configuration.
Add a tuning: block under home_io_control: and set ui_controls: true:
home_io_control: node_id: "C0FFEE" system_key: "..." tuning: ui_controls: true
ui_controls: true exposes every parameter as a Home Assistant number or select entity, so you can change values live from the UI without reflashing for each experiment. The entities appear under the device's Configuration section, grouped into Radio … and Pairing … names.
Turn on verbose logging. Set the logger to DEBUG so you can see the pairing exchange and the raw packet capture:
At boot and at the start of every pairing attempt, the component logs the active overrides:
or, when nothing is overridden:
Each UI change is also logged in YAML-compatible form, ready to paste back:
Parameters fall into two groups: the radio / physical layer (how the chip transmits and receives) and the pairing protocol (which frames are sent and how the hub waits for answers). The Radio column shows which chip a parameter affects — a chip-specific parameter (SX1262, SX1276, or LR1121) is ignored on any other chip's boards; both applies everywhere.
Each parameter below has a short What we've seen note. These summarise concrete behaviour observed while getting pairing to work — treat them as starting hints, not guarantees, because your device may differ.
| Parameter | Radio | Default | Range / options | What it does |
|---|---|---|---|---|
| sx1262_rx_bandwidth | SX1262 | 58.6 | 39.0 / 46.9 / 58.6 / 78.2 / 117.3 / 156.2 / 187.2 (kHz) | Receiver bandwidth; narrower rejects more noise. |
| sx1262_response_preamble | SX1262 | 8 | 8–256 B | Preamble length on reply frames, for the peer to lock on. |
| sx1262_post_tx_settle_us | SX1262 | 500 | 0–2000 µs | Settling delay after TX before switching back to RX. |
| sx1276_rx_bandwidth | SX1276 | 41.7 | 20.8 / 41.7 / 62.5 / 83.3 / 125.0 (kHz) | Receiver bandwidth; wider tolerates LO offset, narrower rejects more noise. |
| sx1276_response_preamble | SX1276 | 12 | 8–256 B | Preamble length on reply frames, for the peer to lock on. |
| sx1276_discovery_hop_slice_ms | SX1276 | 5 | 5–200 ms | Per-channel dwell for any hopping listen — discovery and the scan_paired_devices roll-call alike. |
| sx1262_discovery_hop_slice_ms | SX1262 | 7 | 0–500 ms | Per-channel dwell for any hopping listen — discovery and the scan_paired_devices roll-call alike. |
| exchange_start_response_wait_ms | both | 400 | 200–4000 ms | How long to listen for a reply to a start frame (the first frame of a command). |
| exchange_response_wait_ms | both | 500 | 200–4000 ms | How long to listen for a reply to a continuation frame, and for the post-auth final response. |
| exchange_total_budget_ms | both | 2500 | 500–12000 ms | Wall-clock ceiling on one whole exchange, including retries. |
| lr1121_rx_bandwidth | LR1121 | 117.3 | 39.0 / 46.9 / 58.6 / 78.2 / 117.3 / 156.2 / 187.2 (kHz) | Receiver bandwidth. Still 117.3 by default — untested on LR1121, but the SX1262 result below suggests trying narrower. |
| lr1121_response_preamble | LR1121 | 8 | 8–256 B | Preamble length on reply frames, for the peer to lock on. |
| lr1121_post_tx_settle_us | LR1121 | 500 | 0–2000 µs | Settling delay after TX before switching back to RX. |
| lr1121_discovery_hop_slice_ms | LR1121 | 7 | 0–500 ms | Per-channel dwell for any hopping listen — discovery and the scan_paired_devices roll-call alike. |
| cold_broadcast_reply_preamble | both | 80 | 8–256 B | Preamble length for the key-extraction responder's discovery reply (0x29) — the one reply a hopping peer has to catch cold. |
| normal_start_preamble | both | 32 | 8–256 B | Preamble length for a directed start frame to a device not declared low_power: — an always-alive receiver that does not need the 1024-byte wake-up burst. Low-power devices still get LONG_PREAMBLE. |
| lbt_max_retries | both | 5 | 0–10 | Listen-before-talk carrier-sense attempts before TX. |
| lbt_rssi_threshold_dbm | both | -90 | -95 to -70 dBm | RSSI below which the channel counts as free. |
| pairing_discovery_commands | both | ["0x28"] | ordered list of 0x28 / 0x2E | Which discovery command(s) to send, and in what order. |
| pairing_discovery_destination | both | auto | auto / 0x00003B / 0x00003F | Address the discovery frames are sent to. |
| pairing_discovery_payload | both | none | none / 0x00 | Optional payload byte (used by the alternate command). |
| pairing_discovery_low_power | both | false | true / false | Sets the LOW_POWER flag in discovery frames. |
| pairing_discovery_wait_ms | both | 2000 | 500–5000 ms | How long to wait for a response after each discovery TX. Also the per-attempt listen window for each of the three roll-call attempts the scan_paired_devices action makes. |
| pairing_discovery_initial_dwell_ms | both | 300 | 0–500 ms | Settle delay before the first discovery TX. |
| pairing_key_exchange_retries | both | 3 | 1–5 | Retries for the authenticated key-exchange phase. |
ui_controls itself is a feature toggle (default false) that exposes these as entities; it is not a tunable.
GFSK receiver bandwidth on the SX1262. Change it when frames arrive but fail to decode — the did not parse as a frame warnings in the log are a direct count of that.
Observations: 58.6 kHz is the default — narrower rejects more out-of-band noise, and reception on this waveform improves as the filter narrows. It also brings the SX1262 into line with the SX1276's long-validated 41.7 kHz default on the identical waveform. A wide default would exist only to tolerate local-oscillator offset across the TX→RX turnaround, but that turnaround is now a measured ~390 µs plus a 500 µs settle, well within what the narrow filter tolerates.
39.0 and 46.9 bracket the SX1276's 41.7 — worth trying if 58.6 still shows decode failures.
How long a preamble the SX1262 puts in front of its reply frames (the key-transfer and authentication responses), giving the peer time to lock on after the hub transmits. Raise it when key exchange stalls right after discovery on an SX1262 board, especially with a device that has never been paired before.
Observations: the SX1262's modulation is marginal for short preambles on the fast post-TX turnaround. Already-paired devices lock onto an on-air preamble of 8 bytes reliably. Brand-new devices often fail to decode on-air preambles of 1/4/8 bytes and need something substantially longer — exactly how much, up to the protocol's full 1024-byte long preamble, has not yet been independently validated on this chip, so treat any specific number above 8 as untested rather than known-good. The same "not below the tens of bytes" caution is why normal_start_preamble (below) defaults to 32 rather than 8.
How long the SX1262 waits after transmitting before switching back to receive. Increase it when fast replies (the challenge or key frames) arrive corrupted.
Observations: experiments here established ~500 µs as the minimum that keeps the demodulator from mangling the first bytes of a quick reply after the transmit→standby→receive transition. It works hand-in-hand with bandwidth — a narrower bandwidth generally wants a longer settle.
How long the hub listens for a device's reply before giving up on a try. Raise exchange_start_response_wait_ms when a device ignores commands but is known to be in range and correctly paired.
Observations: for this device class, reply latency is fast-or-never rather than variably slow — a device answers within a few milliseconds of the carrier dropping, or it does not answer at all. A failure therefore shows up as no frame received rather than as a late arrival, so raising this value cannot fix a device that genuinely fails to respond; check wait_ms in the logs first. The 400 ms default sits comfortably above every directly measured reply from this device class while keeping a failed exchange inside exchange_total_budget_ms. Raise it only for a device you have confirmed genuinely answers late.
Every millisecond here is loop-blocking time on a failed exchange only (a successful one returns as soon as the reply lands, see docs/adr/0013-blocking-exchange-on-the-esphome-loop.md), and a failure costs this window once per retry. Raise it for a stubborn device; lower it if slow failures are worse for you than missed commands.
Wall-clock ceiling on one whole exchange, including retries. exchange_start_response_wait_ms and exchange_response_wait_ms set how long each try waits; this caps how long all of them together may run, so a try only starts if there is still budget left for it.
Observations: this exists to keep a failing command from blocking the ESPHome loop past its own "took a long time for an operation" warning threshold (2550 ms — see docs/adr/0013-blocking-exchange-on-the-esphome-loop.md). If you raise either response-wait parameter, raise this too, or later retries within the same command will silently be skipped once the budget runs out.
GFSK receiver bandwidth on the SX1276, written to both the RX and AFC bandwidth registers. Change it when discovery or key-exchange replies fail to decode cleanly on an SX1276 board.
Observations: the default 41.7 kHz is the long-standing fixed value — tighter than the ~77 kHz Carson-rule figure, chosen to maximise sensitivity by rejecting out-of-band noise, and validated against real devices. Unlike the SX1262, the SX1276 has a fast TX→RX turnaround and has worked reliably at this narrow default across the devices tested here, so this knob is exposed for marginal-range or drifting installs rather than because a change was needed. Widen it (62.5/83.3/125.0) when a device's transmitter drifts more than the controller's radio, at the cost of admitting more noise; narrow to 20.8 for maximum noise rejection on a clean signal.
How long a preamble the SX1276 puts in front of its reply frames (the key-transfer and authentication responses), giving the peer time to lock on after the hub transmits. Raise it if key exchange stalls right after discovery on an SX1276 board.
Observations: the default here is 12 bytes — longer than the protocol's 8-byte short preamble, which on hardware measurably improves the peer's lock-on at no timing cost. That is also longer than the SX1262's 8 (see the SX1262 section above: brand-new devices needed noticeably more than that on the SX1262, so don't read the SX1276's smaller number as evidence 12 is generous; it has simply never needed raising in testing here). Lengthen it further for a stubborn or marginal-range device.
How long the receiver dwells on each channel while hopping. This governs every rotating listen in the project, not just discovery: pairing discovery and the scan_paired_devices broadcast roll-call both rotate across channels and both fall back to this value when they have no loop-specific reason to dwell differently (neither does today). Change it when a device is clearly present but its discovery response, or its roll-call reply, is never caught.
Observations: because a device often answers on a different channel than the request was sent on, hopping during the wait is essential — a hop slice that never rotates would miss it. Both chips dwell only a few milliseconds per channel and extend the dwell when a preamble or sync word is detected, so a caught reply is not cut off mid-retune: the SX1276 uses a naturally fast hop cycle, and a short SX1262 dwell fits more retunes into the same window, so the receiver is more often already on the right channel when a reply starts. The floor of 0 is not a physically meaningful minimum for the chip — coverage degrades gradually below the low single digits and only collapses at the literal 0, where wait_for_packet(..., 0) returns before anything can be observed at all — so it is left open as an experimentally checkable floor rather than assumed. Values near that floor are for probing it, not for everyday tuning.
The LR1121 equivalents of the four SX1262 knobs above — same meaning (lr1121_discovery_hop_slice_ms governs the roll-call as well as discovery, same as its SX1262/SX1276 counterparts), same register-level reasoning (the LR1121's GFSK bandwidth encoding is register-identical to the SX1262's, and it needs the same standby→retune→RX hop cycle, no fast hop). Three of the four share SX1262's default values (lr1121_response_preamble, lr1121_post_tx_settle_us, lr1121_discovery_hop_slice_ms); lr1121_rx_bandwidth instead keeps its own wider default until a narrower one is validated on this chip. lr1121_discovery_hop_slice_ms is measured independently on LR1121 rather than merely inherited, since the two chips are validated separately and could in principle diverge.
Observations: the defaults were seeded from SX1262's validated values and are confirmed working on real LR1121 hardware — authenticated open/close/stop exchanges complete reliably against a real awning at the stock settings. Two of lr1121_rx_bandwidth's enum values (39.0/46.9 kHz) had the wrong register encoding when first borrowed directly from SX1262 and were corrected — the full, corrected option set is 39.0 / 46.9 / 58.6 / 78.2 / 117.3 / 156.2 / 187.2 kHz. The main variable that still matters in practice is RF link quality (RSSI) rather than these timing/bandwidth knobs — weak signal shows up as intermittent frame loss on either leg of an exchange, which the existing per-command retry already absorbs.
Sized independently of response_preamble(): it governs only the "Recover System Key" feature's discovery reply (0x29), the sole device-role reply that is start=true and therefore the one a hopping/scanning peer has to catch cold, rather than a peer already parked on a held channel.
The preamble in front of a directed start frame (EXECUTE, status poll, GET_NAME, rename, identify, probe) whose target is not declared low_power:. An always-listening receiver does not need the ~213 ms 1024-byte wake-up burst, and some receivers never lock onto one that long — so a normal start frame gets this shorter preamble, matching what a reference hub sends to an always-alive device. A device declared low_power: true still gets LONG_PREAMBLE on its start frames, unchanged.
Sized independently of response_preamble() (that knob is a per-chip TX→RX turnaround property; this is a cold-peer property). The 32-byte default is 256 bits, inside the preamble band the protocol reference documents; drop it toward 8 only if a start frame is still not being heard and raise it toward LONG_PREAMBLE if a marginal always-alive link needs more. Because it is a live tuning knob, bisecting the right value needs no rebuild.
Listen-Before-Talk: before transmitting, the hub checks the channel is quieter than the threshold, retrying up to max_retries times, then transmits anyway. Loosen them when transmissions are delayed on a channel that only looks busy.
Observations: a device's own pairing-mode beacons made the channel read as busy (around -77 to -83 dBm), which tripped every LBT retry before each discovery transmit and slowed pairing noticeably. Raising the threshold (less sensitive) or lowering the retry count lets the hub transmit through that.
Which discovery command(s) the hub broadcasts, and in what order; it sends them in sequence and stops at the first device response.
0x2A (SPE roll-call) is not an option here, on purpose. It looks superficially similar — authenticated with the configured system_key, broadcast to 0x00003B — but only devices that already hold your key answer it. A device sitting in learning mode, waiting to be paired, holds no key yet and stays silent. So adding it to this list cannot help a pairing attempt succeed; it would only add replies from devices you have already paired. It is a "who is still out there" roll-call over your existing devices — a different question entirely from "who wants to pair".
If a "who is still out there" roll-call over your already-paired devices is what you actually want, that is exactly what the scan_paired_devices hub action does — see docs/home_io_control.md's "Home Assistant Actions" section.
Observations: plain 0x28 to 0x00003B is what works. Broadcast 0x2E has drawn no response on every device this project has real hardware evidence for — a Somfy Izymo dimmer and a Velux KLR200/KUX100 pair both went unanswered (see the CMD_DISCOVER_ALT_REQ doc comment in proto_constants.h, and GitHub issue #27 where two independent reporters also tried it with no result). It only draws a response when addressed directly to an already-known node ID, which is no help for first-contact pairing where the node ID is exactly what you don't have yet. Real full-featured controllers (Tahoma) are seen broadcasting both 0x28 and 0x2E during pairing, which is why the combined preset (0x28,0x2E) still exists in the Home Assistant selector — but on the evidence gathered so far, that's the controller hedging its bets, not 0x2E actually reaching a device 0x28 couldn't. Don't reach for 0x2E as a fix for a stuck device; see "A suggested tuning plan" below for what to try instead.
The address the discovery frames are sent to. With auto each command uses its conventional address (above). An explicit 0x00003B / 0x00003F forces every configured command to that address — useful for deliberately sending 0x28 to the alternate address, or vice-versa.
An optional single 0x00 payload byte, and the LOW_POWER frame flag.
Observations: the alternate discovery path is conventionally sent with the 0x00 payload and LOW_POWER set, and some devices filter on them; they have no effect on the plain 0x28 path, so only enable them alongside 0x2E.
The preamble length (in bytes) on the discovery broadcast itself (0x28/0x2E). Defaults to 1024 (LONG_PREAMBLE), unchanged from historical behavior — a factory-fresh device in learning mode is exactly the kind of duty-cycled receiver that wake-up burst exists for.
Observations: issue #87 hardware-proved that some always-alive receivers never lock onto a preamble this long on directed commands, which is why every other directed start frame now derives its preamble from the target's power class instead of paying the full 1024 bytes unconditionally (see normal_start_preamble above). The discovery broadcast can't be made power-class-aware the same way — discovery exists to learn a device before anything is known about it — so it still always pays the ~213 ms cost. Issue #27 (Somfy Sunea IO devices repeatedly failing to answer discovery) raised this as a plausible, unconfirmed contributor. If a device is confirmed to be genuinely unpaired (not already claimed by another hub — see "A suggested tuning plan" below) and still doesn't answer 0x28 at any of the settings above, this is worth trying next:
tuning: ui_controls: true pairing_discovery_preamble: 32 # NORMAL_START_PREAMBLE's value; try 8 (SHORT_PREAMBLE) too
Not yet hardware-confirmed as a fix for any specific device — report back either way if you try it. Scope: this only covers phase 1 (discovery, 0x28/0x2E). If a shortened preamble gets you a discovery response but the attempt then stalls at key exchange (outcome=key_exchange_failed), that is very likely the same underlying limitation biting phase 2 — CMD_KEY_INIT (0x31) is still transmitted at a hardcoded LONG_PREAMBLE, and the best-effort SetConfig1 in phase 3 is built low_power=true, which also resolves to LONG_PREAMBLE. Neither is tunable yet; this is a known, not-yet-closed gap (ADR 0029's Consequences section already flags phase 3 specifically).
How long to wait for a response after each discovery transmit, and an initial settle before the first transmit. pairing_discovery_wait_ms also sets the per-attempt listen window for the scan_paired_devices Home Assistant action (see docs/home_io_control.md's "Home Assistant Actions" section); since that action makes three attempts, one per channel, its total runtime is roughly 3x this value.
Observations: the 300 ms initial dwell mirrors the conventional wait after a start frame. Lengthening the wait only helps when responses are intermittent — merely extending the dwell without hopping across channels did not help in testing; the fix was the hopping itself.
How many times to retry the authenticated key exchange, each attempt using a fresh challenge.
Observations: the key-transfer step is the most fragile part of the flow on SX1262 — a device may acknowledge the challenge yet never confirm the key. That is usually a preamble/turnaround problem, so if extra retries alone don't help, address sx1262_response_preamble and sx1262_post_tx_settle_us first. Retries mainly guard against occasional transient decode failures.
Every home_io_control config with a button: entity gets a companion "Last Pairing Result" diagnostic text sensor that publishes a frozen, machine-readable summary after each pairing attempt (leading v1; is a version tag — any later format change bumps it):
lbt (LBT retries consumed) and advice (see below) are the two fields most useful while tuning: a high lbt count with a channel_busy advice code means the channel — not the tuning parameters above — is the bottleneck. See docs/home_io_control.md's "Diagnosing a failed pairing attempt" section for the full field reference and the pairing-window traffic advisor's advice codes (1w_traffic, channel_busy, foreign_controller, rf_silent).
If a device that should be in pairing mode does not pair with the defaults, work through the following in order, changing one thing at a time and checking the logs after each step. This is a general starting point, not a guarantee — different devices need different combinations.
After each step, record the tuning snapshot line and the outcome. When a combination works, paste that snapshot into your permanent tuning: block. If you find a combination that makes an otherwise-unsupported device work, it is worth sharing via an issue on the project's GitHub page so the defaults can improve.
lbt_max_retries and lbt_rssi_threshold_dbm exist for diagnostics only. Setting the threshold too high (e.g. -45 dBm) or the retry count too low can force transmissions on a busy channel and may violate local regulations for the 868 MHz SRD band. Do not leave aggressive LBT values in a production configuration.
home_io_control.diagnostic_probes: true enables two Home Assistant actions, probe_device and probe_sweep, for sending a handful of opcodes this project has observed on the wire but never fully decoded, and for reading back the raw, uninterpreted reply. This is protocol-research tooling for closing exactly that kind of open question on hardware you own — see ADR 0024 for the full reasoning behind how it's gated and isolated from the rest of this component.
home_io_control: node_id: "C0FFEE" system_key: "..." diagnostic_probes: true
It only ever targets a device already paired to this hub. probe_device/probe_sweep resolve their target the same way every other management action does — there is no path from this instrumentation to a device this hub has not already paired with and does not already hold a key for. Every probe additionally refuses while the target device's last known state is "moving" — an unknown frame is never sent into a device state machine that is already mid-transaction. This is the device's last reported movement state, not a check on anything in flight: it never applies to a light/switch, and it can be stale if the device was last moved from a physical remote the hub never saw.
Same node-scoped naming as every other action in this component — see Home Assistant Actions for the full explanation of how <node_name> is derived from esphome.name. For a config with name: hioc-heltec-v2:
action: esphome.hioc_heltec_v2_probe_device data: device_id: "FEEB1E" probe: "private2" index: "0x09"
probe_sweep takes a range instead of a single index:
action: esphome.hioc_heltec_v2_probe_sweep data: device_id: "FEEB1E" probe: "status_ext" first_index: "0x00" last_index: "0x01"
| probe | Sends | index selects | Start with | Evidence for the starting values |
|---|---|---|---|---|
| private_fn | CMD_PRIVATE (0x03) with a chosen function ID | The function ID | 0x06 or 0x09 | Not field-observed on our own wire — every CMD_PRIVATE frame captured here uses function ID 0x03. 0x06/0x09 are known from production software elsewhere, where they are described as a battery read. On real hardware (17 solar devices + our own mains motors) they returned a stored/target position, not a battery value — 0x06 is always 00 00; 0x09 tracked shutters closing. Not a battery probe. |
| private_fn_sub | CMD_PRIVATE (0x03) at function ID 0x09, with a chosen second payload byte | The second payload byte (data[1]) | 0x01 | Not field-observed. Every CMD_PRIVATE frame ever captured — by this project or by a real hub — has 0x00 in this byte, so nothing on air pins what it means. Reference material describes a two-field parameter address here, but the field-to-byte mapping is unknown: at least three encodings fit every observed payload equally well, because all of them have both fields zero. Treat any reply as uninterpreted. |
| status_ext | Extended CMD_PRIVATE at selector 0x80 | The block/N value | 0x00 and 0x01 | Field-observed: real hubs send exactly these two values to real motors. |
| status_ext_fn6 | Extended CMD_PRIVATE at selector 0x80, function ID 0x06 | The block/N value | 0x00, then 0x80 | The 4-byte extended shape is field-observed: a real hub sends 03 80 00 00 and 03 80 01 00 to real motors (40 frames across two logs), and the 0x80 at data[1] is what makes the 0x04 reply carry its trailing extended block. The function ID 0x06 in that shape is not field-observed — no hub has ever been seen sending it. probe_sweep caps a run at 16 consecutive indices, so 0x80 is out of reach of a sweep starting at 0 — send it with probe_device (or sweep 0x80–0x8F explicitly). |
| status_ext_fn9 | Extended CMD_PRIVATE at selector 0x80, function ID 0x09 | The block/N value | 0x00, then 0x01 | Same shape evidence as status_ext_fn6; the function ID 0x09 in this shape is not field-observed. On the ordinary 3-byte form, 0x09 returned a stored position on every device tested — not a battery value. |
| get_info1 | CMD_GET_INFO1 (0x54), no payload | — (probe_device only; probe_sweep rejects it) | — | Field-observed: a real hub sends 0x54 on air. No 0x55 answer has ever been captured, so this probe may well draw an 0xFE "opcode not supported" or nothing at all — that is itself a result worth recording. |
| get_info2 | CMD_GET_INFO2 (0x56), no payload | — (probe_device only; probe_sweep rejects it) | — | The request has never been captured; it rests on the protocol's even=request / odd=answer pairing rule. The answer 0x57 is captured and carries a leading ASCII reference string followed by the packed type/subtype bytes this component already decodes. Reply strings are wire-observable and citable as-is; do not attempt to resolve one to a model name from any non-public source. |
| general_info3 | CMD_GET_GENERAL_INFO3 (0x58), no payload | — (probe_device only; probe_sweep rejects it) | — | — |
| private2 | CMD_PRIVATE2 (0x0C), long wire form | The modifier byte | 0x06, then 0x05, 0x09 | Field-observed: a real hub sends exactly these modifier bytes in the long form to real motors (e.g. request data D4 00 80 D8 06 00). Earlier revisions of this table suggested 0x00/0x03 — those were taken from this component's own POS_FAVORITE/POS_VENT_MODIFIER constants, not from the wire, and never drew the extended block described below. |
| private2_short | CMD_PRIVATE2 (0x0C), short wire form | Same as private2 | 0x03, then 0x09 | Field-observed: a real hub sends the short form with these modifiers (e.g. request data D8 03 00 00). |
There is deliberately no probe for 0x4A. Its leading published interpretation is a destructive file-management operation, and no reference this project has consulted has ever transmitted it. See ADR 0024 for the reasoning.
A get_info2 reply (0x57) leads with printable ASCII: paste the raw hex into the corpus and read the string off it, but record only what the wire shows — do not resolve it to a model name from any non-public source.
A running record of what these frames actually draw back — as much as is understood so far.
| probe / index | what came back |
|---|---|
| private_fn fn 0x06 | data[2..3] = 00 00 on every device probed — two mains motors of ours plus 17 solar shutters via a field reporter. No content. |
| private_fn fn 0x09 | data[2..3] = a stored position: D8 0A on the dimmer (0xD8 == POS_FAVORITE), 58 22 on the awning; solar shutters in the field data tracked their real position as they closed. |
| status_ext (fn 0x03), index = block | Field-observed selector. Device-dependent framing (see status_ext_fn9). Blocks 0x00/0x01 are what real hubs send. |
| status_ext_fn9, index = block | Reply framing is device-dependent: some devices answer data[0] = the 0x04/0x05 stopped-flag byte with an 0x80-tagged block, others answer data[0] = 0x2D with no block. On the dimmer the 0x80 block tail changes with the index (… 80 00 00 00 at 0x00 → … 80 D8 06 00 at 0x01) — first time one of our own probes drew a non-empty, index-selected block; content is position family (D8 06). The awning ignores the index. A Velux window kept data[2..3] = its live position and appended an index-selected 0x80 tail (78 00 / 50 00 / C8 00 at different blocks — position-family values, meaning undecoded). |
| status_ext_fn6, index = block | data[2..3] zeroed, same as private_fn fn 0x06. Walking past the last block the device implements draws ERROR_RESP result code 0x58, now mapped as INVALID_FUNCTION_INDEX — seen cross-vendor (Somfy Sunea awning + dimmer at block 0x80; a Velux window at block 0x0F and every block 0x80–0x8F). Not in any reference error table; treat purely as "that index does not exist on this device". |
| private_fn_sub (fn 0x09), index = data[1] | Byte-identical to the private_fn fn 0x09 reply on both mains devices — the non-zero second payload byte changed nothing. On a Velux window a non-zero data[1] flipped the reply's data[0] to 0x2D and, at an irregular set of sub-indices, appended a 00 20 field — both undecoded. |
| get_info2 (0x56) | 0x57 reply: 10 printable ASCII bytes (5143802A06, 5071662B09, 5165948A01) — a Somfy-internal reference / sw-version code, not a public catalogue number. The next two bytes (data[10..11]) are the packed device type/subtype this component already decodes via decode_packed_device_type() — verified: dimmer → LIGHT, awning → HORIZONTAL_AWNING, Velux window → WINDOW_OPENER. data[12..15] undecoded. |
| get_info1 (0x54) | Not yet sent to a device by this project. No 0x55 answer has ever been captured from anything. |
| general_info3 (0x58) | Dimmer answered a real 0x59; a Somfy awning and a Velux window both replied ERROR_RESP result 0x08 (ERROR_DURING_EXECUTION, "opcode not supported"). Device-dependent. |
| private2 / private2_short (0x0C), index = modifier | 0x0D reply; D4 00 is the request's own leading bytes echoed back; optional 0x80 block is position family. Byte-identical day vs night across 17 solar devices, and byte-identical between two window positions on a Velux window (a stored parameter, not a live reading) — its short form at modifier 0x03 read back the stored ventilation position (BA 00). See "Reading a `private2` reply" below. |
What the replies do and don't carry. Across every probe and device tried so far — mains and solar, day and night — the only things recovered are position/target data and, via status_ext / private_fn data[8..10], the last commanding node ID. No reply has carried a per-device sensor value (battery, charge, luminance, temperature). Out-of-range indices answer result code 0x58 (INVALID_FUNCTION_INDEX — self-derived from the cross-vendor pattern, no external source). The remaining non-position unknowns — data[0] = 0x2D framing, the 00 20 private_fn_sub field, get_info2 data[12..15] — have no decode and no external source.
The reply command byte is CMD_PRIVATE2_RESP (0x0D), not 0x04. In every reply captured so far the two bytes right after the flags byte are D4 00 — this is the request's own leading payload bytes echoed back, not a POS_UNKNOWN position reading. The per-device content, when there is any, rides in an optional 0x80-tagged block after that echo:
The block content observed to date is position-family (C8 00 00, 72 49 00) and matches the status_ext last-command tail — it is not battery/charge telemetry, and it does not vary over a day/night cycle (checked on 17 solar devices). 0x0C is not a live-telemetry read.
The starting values above are the safest available for each probe — for status_ext and private2/private2_short because a real hub sends exactly those bytes to real motors routinely; for private_fn because, absent an on-air observation of our own, production software using this exact command is the next-best evidence available. Widening beyond them is a separate, deliberate decision, not something to do by default — and when you do, prefer the dimmer/light over a motor: a wrong write-shaped result on a light is visible and trivially reversible, while a motor's stored configuration is not.
private_fn_sub, status_ext_fn6 and status_ext_fn9 differ from status_ext/private_fn in that only their shape, not their function ID or sub-index, is field-observed — so they are one byte further from known-safe traffic than anything else in this table. The same "prefer the dimmer/light over a motor" advice applies, with more reason.
A full-range sweep can block the ESPHome loop (API, other components, OTA) for on the order of a minute at default tuning, longer if a device never answers or if exchange_start_response_wait_ms has been raised — up to 16 indices, each up to 3 retries at the configured response-wait time, plus a spacing delay between indices. This is accepted deliberately for this maintainer-triggered, explicitly-opted-in diagnostic rather than restructured into scheduled steps — expect a warning about a long-blocking operation, and expect other Home Assistant traffic against this device to stall for the duration. probe_device (a single index) does not have this problem; reach for probe_sweep only when you actually need the range in one gesture.