Home IO Control
ESPHome add-on for IO-Homecontrol devices
Loading...
Searching...
No Matches
esphome::home_io_control::PairingEngine Class Reference

Owns and drives all three phases of the IO-Homecontrol device pairing flow. More...

#include <pairing_engine.h>

Collaboration diagram for esphome::home_io_control::PairingEngine:

Public Member Functions

 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.
 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.

Detailed Description

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.

Constructor & Destructor Documentation

◆ PairingEngine() [1/2]

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.

Parameters
radio_ptrDouble pointer into the hub's radio_ member — survives driver replacement in tests.
node_idController 3-byte node ID buffer, owned by the hub.
system_key16-byte AES system key buffer, owned by the hub.
tuningTuning configuration, owned by the hub.
engineShared exchange engine for transmit/receive operations.
registryDevice registry where paired devices are permanently registered.
telemetryPer-attempt telemetry recorder, owned by the hub.
recent_oneway_sightingMost 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.

◆ PairingEngine() [2/2]

esphome::home_io_control::PairingEngine::PairingEngine ( const PairingEngine & )
delete

Non-copyable — stores double-pointer and references into hub member addresses.

Here is the call graph for this function:

Member Function Documentation

◆ discover_and_pair()

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.

Returns
true if all three phases completed successfully; false otherwise.

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.

Here is the call graph for this function:

◆ finalize_pairing_configuration_()

void esphome::home_io_control::PairingEngine::finalize_pairing_configuration_ ( pairing::PairingContext & context)
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.

Parameters
contextPairing 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.

Here is the call graph for this function:

◆ operator=()

PairingEngine & esphome::home_io_control::PairingEngine::operator= ( const PairingEngine & )
delete
Here is the call graph for this function:

◆ parse_device_from_discovery()

DiscoveryResponseInfo esphome::home_io_control::PairingEngine::parse_device_from_discovery ( const IoFrame & frame,
IoDevice & device,
std::string & device_id )
static

Extract node ID, device type, and subtype from a CMD_DISCOVER_RESP frame.

Parse a discovery response frame into device metadata and ID.

Returns
The decoded extended discovery fields (manufacturer / Multi Information Byte / length flags), so a caller can read the self-reported power class without decoding twice.

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.

Here is the call graph for this function:

◆ run_discover_confirm_step_()

pairing::DiscoverConfirmResult esphome::home_io_control::PairingEngine::run_discover_confirm_step_ ( pairing::PairingContext & context)
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).

Parameters
contextPairing 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.
Returns
What the step actually observed — see pairing::DiscoverConfirmResult. Not stored in context: nothing downstream reads it.

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.

Here is the call graph for this function:

◆ run_discovery_phase_()

decisions::PairingDiscoveryDisposition esphome::home_io_control::PairingEngine::run_discovery_phase_ ( pairing::PairingContext & context)
protected

Phase 1: broadcast discovery command(s) and wait for a device response (0x29).

Parameters
contextPairing context updated on success.
Returns
ACCEPT on success; NO_RESPONSE or INVALID otherwise.

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.

Here is the call graph for this function:

◆ run_key_exchange_phase_()

bool esphome::home_io_control::PairingEngine::run_key_exchange_phase_ ( pairing::PairingContext & context)
protected

Phase 2: authenticated key exchange (0x31 → 0x3C → 0x32 → 0x33).

Parameters
contextPairing context populated by run_discovery_phase_().
Returns
true if key exchange completes; false on any failure.

Steps:

  1. Transmit CMD_KEY_INIT (0x31), preamble per pairing_start_preamble_()
  2. Wait for device challenge (0x3C)
  3. Transmit CMD_KEY_TRANSFER (0x32) with encrypted system key
  4. Wait for CMD_KEY_CONFIRM (0x33)

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:

  • The device replies with CMD_KEY_CONFIRM (0x33) directly — it already had the key from the first 0x32 and is just auto-confirming again. Nothing more to send.
  • The device replies with a fresh CMD_CHALLENGE_REQ (0x3C) instead — proof it never received the first 0x32 at all (a device that already holds the key doesn't re-challenge), so there is no key transfer for it to confirm yet. The loop calls transfer_key_and_wait_confirm_() again here, replaying CMD_KEY_TRANSFER against this new challenge, rather than discarding the 0x3C and burning the retry for nothing — the device's next 0x31 would just produce another fresh challenge either way, so retrying without resending 0x32 could never succeed.

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.

Here is the call graph for this function:

◆ transfer_key_and_wait_confirm_()

bool esphome::home_io_control::PairingEngine::transfer_key_and_wait_confirm_ ( pairing::PairingContext & context)
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.

Parameters
contextPairing context; context.rx.data supplies the challenge bytes, context.req is filled with the outbound 0x32, context.resp with the inbound 0x33 on success.
Returns
true if the device confirmed the key.

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.

Here is the call graph for this function:

◆ wait_for_discover_confirm_ack_()

decisions::PairingDiscoverConfirmDisposition esphome::home_io_control::PairingEngine::wait_for_discover_confirm_ack_ ( uint8_t try_index,
pairing::PairingContext & context )
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.

Parameters
try_index1-based try number, used only to pick the listen policy.
contextPairing context; context.req is the 0x2C just transmitted, context.rx/ context.packet receive the candidate reply.
Returns
ACK or ERROR for a matching reply; IGNORE only once the window closes with nothing recognised — an unrelated frame arriving mid-window does not end the try, it keeps listening.

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.

Here is the call graph for this function:

◆ wait_for_discovery_response_()

decisions::PairingDiscoveryDisposition esphome::home_io_control::PairingEngine::wait_for_discovery_response_ ( uint32_t timeout_ms,
RadioRxPacket & packet,
IoFrame & response_frame )
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.

Parameters
timeout_msMaximum wait window.
packetOutput: raw RadioRxPacket of the accepted discovery frame.
response_frameOutput: parsed IoFrame of the accepted discovery frame.
Returns
ACCEPT on success; NO_RESPONSE (no traffic) or INVALID (wrong frames) otherwise.

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.

Here is the call graph for this function:

◆ wait_for_key_challenge_()

bool esphome::home_io_control::PairingEngine::wait_for_key_challenge_ ( uint32_t timeout_ms,
RadioRxPacket & packet,
IoFrame & challenge_frame,
const uint8_t device_node_id[NODE_ID_SIZE] )
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.

Parameters
timeout_msMaximum wait window.
packetOutput: raw RadioRxPacket of the accepted frame.
challenge_frameOutput: parsed IoFrame.
device_node_idExpected source node ID (devices paired to).
Returns
true if a valid challenge or confirm was received; false on timeout.

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.

Here is the call graph for this function:

◆ wait_for_key_confirm_()

bool esphome::home_io_control::PairingEngine::wait_for_key_confirm_ ( pairing::PairingContext & context)
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.

Here is the call graph for this function:

The documentation for this class was generated from the following files: