|
Home IO Control
ESPHome add-on for IO-Homecontrol devices
|
Owns and drives all three phases of the IO-Homecontrol device pairing flow. More...
#include <pairing_engine.h>
Public Member Functions | |
| PairingEngine (RadioDriver **radio_ptr, const uint8_t *node_id, const uint8_t *system_key, const TuningConfig *tuning, ExchangeEngine &engine, DeviceRegistry ®istry, PairingTelemetry &telemetry, const RecentOneWayPairingSighting &recent_oneway_sighting) | |
| Construct the engine with all required collaborators. | |
| PairingEngine (const PairingEngine &)=delete | |
| Non-copyable — stores double-pointer and references into hub member addresses. | |
| PairingEngine & | operator= (const PairingEngine &)=delete |
| bool | discover_and_pair () |
| Discover and pair a device currently in pairing mode (three-phase orchestrator). | |
Static Public Member Functions | |
| static DiscoveryResponseInfo | parse_device_from_discovery (const IoFrame &frame, IoDevice &device, std::string &device_id) |
| Extract node ID, device type, and subtype from a CMD_DISCOVER_RESP frame. | |
Protected Member Functions | |
| decisions::PairingDiscoveryDisposition | run_discovery_phase_ (pairing::PairingContext &context) |
| Phase 1: broadcast discovery command(s) and wait for a device response (0x29). | |
| pairing::DiscoverConfirmResult | run_discover_confirm_step_ (pairing::PairingContext &context) |
| Discover-confirm step (0x2C → 0x2D): sent directly to the just-discovered device, once per discover_and_pair() attempt, between discovery and the key-exchange retry loop. | |
| bool | run_key_exchange_phase_ (pairing::PairingContext &context) |
| Phase 2: authenticated key exchange (0x31 → 0x3C → 0x32 → 0x33). | |
| void | finalize_pairing_configuration_ (pairing::PairingContext &context) |
| Phase 3: send SetConfig1 (0x6F) once; optional. | |
| decisions::PairingDiscoveryDisposition | wait_for_discovery_response_ (uint32_t timeout_ms, RadioRxPacket &packet, IoFrame &response_frame) |
| Wait for a discovery response (0x29) within timeout_ms with per-chip frequency hopping. | |
| bool | wait_for_key_challenge_ (uint32_t timeout_ms, RadioRxPacket &packet, IoFrame &challenge_frame, const uint8_t device_node_id[NODE_ID_SIZE]) |
| Wait for a key-challenge (0x3C) or direct key-confirm (0x33) from the target device. | |
| decisions::PairingDiscoverConfirmDisposition | wait_for_discover_confirm_ack_ (uint8_t try_index, pairing::PairingContext &context) |
| Wait for one discover-confirm (0x2C) try's answer: a matching 0x2D, a matching CMD_ERROR_RESP, or nothing recognisable before the window closes. | |
| bool | wait_for_key_confirm_ (pairing::PairingContext &context) |
| Transmit the 0x32 key transfer and wait for the 0x33 key confirm with retry. | |
| bool | transfer_key_and_wait_confirm_ (pairing::PairingContext &context) |
| Build CMD_KEY_TRANSFER against the current challenge and wait for the 0x33 confirm; see run_key_exchange_phase_()'s doc comment for why this is a separate, replayable step. | |
Owns and drives all three phases of the IO-Homecontrol device pairing flow.
Constructed once by IOHomeControlComponent; collaborators (radio, exchange engine, device registry) are injected as pointers/references so the engine never outlives them.
Definition at line 75 of file pairing_engine.h.
| esphome::home_io_control::PairingEngine::PairingEngine | ( | RadioDriver ** | radio_ptr, |
| const uint8_t * | node_id, | ||
| const uint8_t * | system_key, | ||
| const TuningConfig * | tuning, | ||
| ExchangeEngine & | engine, | ||
| DeviceRegistry & | registry, | ||
| PairingTelemetry & | telemetry, | ||
| const RecentOneWayPairingSighting & | recent_oneway_sighting ) |
Construct the engine with all required collaborators.
| radio_ptr | Double pointer into the hub's radio_ member — survives driver replacement in tests. |
| node_id | Controller 3-byte node ID buffer, owned by the hub. |
| system_key | 16-byte AES system key buffer, owned by the hub. |
| tuning | Tuning configuration, owned by the hub. |
| engine | Shared exchange engine for transmit/receive operations. |
| registry | Device registry where paired devices are permanently registered. |
| telemetry | Per-attempt telemetry recorder, owned by the hub. |
| recent_oneway_sighting | Most recent 1W pairing-gesture sighting from the hub's normal passive RX path, owned by the hub; see RecentOneWayPairingSighting. |
Definition at line 66 of file pairing_engine.cpp.
|
delete |
Non-copyable — stores double-pointer and references into hub member addresses.
| bool esphome::home_io_control::PairingEngine::discover_and_pair | ( | ) |
Discover and pair a device currently in pairing mode (three-phase orchestrator).
Pairing orchestrator — high-level three-phase flow.
Phase 1: run_discovery_phase_() finds a device in pairing mode. Phase 2: run_key_exchange_phase_() performs authenticated key establishment. Phase 3: finalize_pairing_configuration_() sends the optional SetConfig1 once.
On success the device is added to the registry and a YAML snippet is printed to the log. The hub's thin wrapper manages the busy_ flag before and after this call.
Definition at line 649 of file pairing_engine.cpp.
|
protected |
Phase 3: send SetConfig1 (0x6F) once; optional.
Phase 3: send SetConfig1 (0x6F) once.
The key exchange's 0x33 already completed the pairing, so neither the attempt's outcome nor its telemetry depends on this step: no device on record accepts the frame (see PAIRING_SET_CONFIG1_MAX_TRIES). An unanswered try is logged as harmless so the preceding "no first response" line isn't read as a failure.
| context | Pairing context with device information from phases 1 and 2. |
Optional — see the header. Its preamble follows pairing_start_preamble_(), like the other directed pairing start frames.
Definition at line 628 of file pairing_engine.cpp.
|
delete |
|
static |
Extract node ID, device type, and subtype from a CMD_DISCOVER_RESP frame.
Parse a discovery response frame into device metadata and ID.
Decodes node ID, device type, subtype, and the extended fields (manufacturer, backbone, Multi Information Byte) via decode_discovery_response(), then emits pairing's diagnostic log lines for whichever extended fields the payload actually included. The inversion flag is derived from the type via default_inverted_for_type().
Definition at line 360 of file pairing_engine.cpp.
|
protected |
Discover-confirm step (0x2C → 0x2D): sent directly to the just-discovered device, once per discover_and_pair() attempt, between discovery and the key-exchange retry loop.
Discover-confirm step (0x2C → 0x2D): sent directly to the device discovery just found, once per discover_and_pair() attempt, before the key-exchange retry loop begins (so a retry of that loop never repeats this step).
Every real controller in this project's corpus sends 0x2C here. The step never fails a pairing attempt: a refusal, timeout, or skip tuning mode all still let the caller proceed to run_key_exchange_phase_() — only the result and log line differ. Applies the pairing_key_init_delay_ms pause afterward for every outcome except SKIPPED (see tuning_config.h's pairing_discover_confirm/pairing_key_init_delay_ms doc for the modes and defaults).
| context | Pairing context populated by run_discovery_phase_(); context.req/context.rx are reused as scratch space for the 0x2C/0x2D exchange, same as the other phases. |
Never fails a pairing attempt: skip mode, a timeout, and an explicit CMD_ERROR_RESP all still let discover_and_pair() proceed to run_key_exchange_phase_() — this function only decides how long that takes and what gets logged. CTRL1_ACK follows the target's power class, not the mode alone: a low-power target's 0x2C carries CTRL1_LOW_POWER only in both send and send_with_ack (every corpus hub's own shape for that class); only an always-alive target's ACK bit changes between the two modes (see create_discover_confirm()'s doxygen for the byte-shape cross-check against real captures).
set_phase() fires at most once per state for the whole step (not per try): the confirm step can spend up to PAIRING_DISCOVER_CONFIRM_TRIES transmits, and PAIRING_TELEMETRY_MAX_EVENTS is a fixed 32-event budget the pairing advisor scans — a per-try phase would spend events on exactly the failure paths it is meant to diagnose. record_debug() (not telemetry) still runs every try.
Definition at line 471 of file pairing_engine.cpp.
|
protected |
Phase 1: broadcast discovery command(s) and wait for a device response (0x29).
| context | Pairing context updated on success. |
Sends each configured discovery command in order, waiting up to pairing_discovery_wait_ms for a valid response after each TX. Retries up to PAIRING_DISCOVERY_MAX_ATTEMPTS times per command.
Definition at line 399 of file pairing_engine.cpp.
|
protected |
Phase 2: authenticated key exchange (0x31 → 0x3C → 0x32 → 0x33).
| context | Pairing context populated by run_discovery_phase_(). |
Steps:
Step 4 depends on the driver's TX→RX turnaround: fast-turnaround radios await the 0x33 through the standard send_and_receive() exchange; slow-turnaround radios use the dedicated wait_for_key_confirm_() path with a key-init re-trigger, because the 0x33 would otherwise arrive while the receiver is still settling (see RadioDriver::has_fast_tx_rx_turnaround()).
The slow-turnaround re-trigger loop below (for (int re = 0; ...)) has two distinct outcomes for its re-sent CMD_KEY_INIT, and they are handled differently:
That replay roughly doubles this function's worst-case blocking time when every wait times out (approximately 3.5s -> 6.6s: two of the loop's iterations can now each wait out a full key-transfer-and-confirm cycle instead of returning immediately on a missed 0x33). This is a known, accepted trade-off: pairing already tolerates multi-second blocking exchanges (see EXCHANGE_TOTAL_BUDGET_MS's own reasoning, proto_timing.h), and the alternative — leaving a slow-turnaround device that never got its key stuck retrying forever — is worse than the occasional slower failure path.
Definition at line 565 of file pairing_engine.cpp.
|
protected |
Build CMD_KEY_TRANSFER against the current challenge and wait for the 0x33 confirm; see run_key_exchange_phase_()'s doc comment for why this is a separate, replayable step.
Build CMD_KEY_TRANSFER against the challenge currently held in context.rx.data and wait for the 0x33 confirm, routing through the fast- or slow-turnaround path.
| context | Pairing context; context.rx.data supplies the challenge bytes, context.req is filled with the outbound 0x32, context.resp with the inbound 0x33 on success. |
Shared by run_key_exchange_phase_()'s first attempt and its slow-turnaround retry loop, which calls this again against a freshly re-issued challenge (see that function's doc comment) rather than discarding it.
Definition at line 328 of file pairing_engine.cpp.
|
protected |
Wait for one discover-confirm (0x2C) try's answer: a matching 0x2D, a matching CMD_ERROR_RESP, or nothing recognisable before the window closes.
Wait for one discover-confirm (0x2C) try's answer.
Listen policy alternates by try: discover_confirm_try_rotates(try_index) selects ListenPolicy::ROTATE_ALL_CHANNELS for the one try that hedges against an off-channel 0x2D, and ListenPolicy::HOLD_REQUEST_CHANNEL (matching every other unicast pairing wait) for the rest — see that decision's doc for why only one try rotates.
| try_index | 1-based try number, used only to pick the listen policy. |
| context | Pairing context; context.req is the 0x2C just transmitted, context.rx/ context.packet receive the candidate reply. |
Listen policy alternates per try (decisions::discover_confirm_try_rotates()): the one rotating try hedges against a 0x2D landing off the request channel — Somfy answers on the request channel, VELUX is unmeasured — while the rest hold, matching every other unicast pairing wait in this file (a 0x2D is a unicast reply to a unicast 0x2C). Only parsed frames are recorded to telemetry, same as the other pairing waits.
Definition at line 188 of file pairing_engine.cpp.
|
protected |
Wait for a discovery response (0x29) within timeout_ms with per-chip frequency hopping.
Wait for a valid discovery response (0x29) within timeout_ms.
| timeout_ms | Maximum wait window. |
| packet | Output: raw RadioRxPacket of the accepted discovery frame. |
| response_frame | Output: parsed IoFrame of the accepted discovery frame. |
Listens with per-chip frequency hopping between slices. Distinguishes between NO_RESPONSE (no packets at all) and INVALID (packets seen but none valid).
Frequency hopping: hops between the 2 non-request IO-homecontrol channels after each slice — the request always goes out on FREQ_CH2 (see run_discovery_phase_()), and a discovery reply essentially never lands back on that channel (see ListenPolicy's own doc comment for why), so dwelling there is wasted listening time. Same policy as collect_broadcast_responses()'s default. The slice length comes from RadioDriver::hop_dwell_ms() (spec.dwell_ms left at 0, so listen() asks the driver). When preamble or sync detection fires, the dwell extends by PREAMBLE_LINGER_DWELL_MS so the incoming frame can complete without interruption.
Definition at line 97 of file pairing_engine.cpp.
|
protected |
Wait for a key-challenge (0x3C) or direct key-confirm (0x33) from the target device.
Wait for a key-challenge (0x3C) or direct key-confirm (0x33) from target device.
| timeout_ms | Maximum wait window. |
| packet | Output: raw RadioRxPacket of the accepted frame. |
| challenge_frame | Output: parsed IoFrame. |
| device_node_id | Expected source node ID (devices paired to). |
During key exchange the device typically responds to 0x31 with a random 6-byte challenge (0x3C). Some devices skip the challenge and send 0x33 directly — indicating immediate key acceptance (observed mostly when the controller's TX→RX turnaround is slow enough that the 0x3C is missed). Both are accepted.
Uses ExchangeEngine::listen() with ListenPolicy::HOLD_REQUEST_CHANNEL: this is a unicast reply to a unicast request (the 0x31 key-init), and every measured unicast pairing reply came back on the request channel, so there is nothing here to hop for — same reasoning as listen_for_key_confirm_(). This loop runs on all three chips (it is called before the has_fast_tx_rx_turnaround() branch in run_key_exchange_phase_()), unlike the dedicated confirm wait, which only slow-turnaround radios reach.
Definition at line 148 of file pairing_engine.cpp.
|
protected |
Transmit the 0x32 key transfer and wait for the 0x33 key confirm with retry.
Transmit the 0x32 key transfer and wait for 0x33 key confirm (with retry).
A device that challenges the key transfer (slow-turnaround chips answering with a fresh 0x3C instead of confirming directly) is handled inline via listen_for_key_confirm_() and ExchangeEngine::answer_challenge() — see their docs. An explicit refusal (REFUSE: a wrong reply shape, or CMD_ERROR_RESP) must not spend the remaining retries; a challenge is not a refusal, so it does not return false either — the try only ends without confirming.
Only reached on slow-turnaround radios (RadioDriver::has_fast_tx_rx_turnaround() == false, i.e. SX1262/LR1121): fast-turnaround radios (SX1276) catch the 0x33 through the standard ExchangeEngine::send_and_receive_() / wait_for_first_response_() path instead and never call this function — see run_key_exchange_phase_(). Uses the driver's response_preamble() (drivers whose TX waveform needs more lock-on margin return a longer preamble). Retries up to EXCHANGE_RETRY_COUNT times on timeout.
Definition at line 291 of file pairing_engine.cpp.