|
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 Types | |
| enum class | KeyPullResult : uint8_t { FAILED , OUR_KEY , OTHER_KEY } |
| What run_key_pull_phase_() ended with. More... | |
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. | |
| bool | verify_device_holds_key_ (pairing::PairingContext &context) |
| Check whether the device took our key although the key exchange did not confirm it. | |
| KeyPullResult | run_key_pull_phase_ (pairing::PairingContext &context) |
| Key pull (pairing_key_transfer: pull, or auto for a remote controller): 0x38 -> device's 0x32 -> our 0x3C -> device's 0x3D. | |
| bool | key_pull_selected_ (const pairing::PairingContext &context) const |
| Which direction this attempt moves the key in. | |
| uint8_t | roll_call_and_register_ (pairing::PairingContext &context, const uint8_t *key, bool device_key) |
| Find the devices that hold key, log one report, register the ones that can be controlled. | |
| bool | probe_heater_at_next_address_ (const pairing::PairingContext &context, const uint8_t *key, bool device_key, ScanState &state) |
| Ask the heater at the address after an Atlantic pairing node for its maximum setpoint. | |
| void | finish_paired_by_roll_call_ (pairing::PairingContext &context) |
| Record a pairing that ends with devices found by roll-call: paired, node= unchanged. | |
| bool | run_pull_and_finish_ (pairing::PairingContext &context, bool &paired) |
| Pull the key and end the attempt wherever the pull decides it. | |
| bool | register_pulled_key_devices_ (pairing::PairingContext &context) |
| After a proven pull of a different key: find the devices that hold it, then end the attempt. | |
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 83 of file pairing_engine.h.
|
strong |
What run_key_pull_phase_() ended with.
Definition at line 86 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 72 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 683 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 662 of file pairing_engine.cpp.
|
protected |
Record a pairing that ends with devices found by roll-call: paired, node= unchanged.
Definition at line 1050 of file pairing_engine.cpp.
|
nodiscardprotected |
Which direction this attempt moves the key in.
pull and push force it; auto pulls when the device answered discovery as a remote controller (node type 1023) and pushes otherwise. Logs the auto decision.
| context | Pairing context populated by run_discovery_phase_(). |
Definition at line 898 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 371 of file pairing_engine.cpp.
|
protected |
Ask the heater at the address after an Atlantic pairing node for its maximum setpoint.
Runs only for a node decisions::pairing_node_has_heater_at_next_address() names, and only when the YAML declares no device at that address. One authenticated register read under key (the full retry budget); an answer means the electrical heater with an adjustable setpoint is there, and it becomes the only entry of state, as if a roll-call had found it. Leaves the registry as it was.
| context | Pairing context of the attempt (the discovered node and its manufacturer). | |
| key | AES_KEY_SIZE bytes the read is signed with. | |
| device_key | true when key is a key the device handed over (the entry uses key slot 1). | |
| [out] | state | Filled with the heater when it answered; untouched otherwise. |
Definition at line 980 of file pairing_engine.cpp.
|
protected |
After a proven pull of a different key: find the devices that hold it, then end the attempt.
At least one registered responder ends the attempt as paired; none ends it as key_received. Zeroes the pulled key afterwards.
| context | Pairing context holding the pulled key (has_pulled_key). |
Definition at line 1092 of file pairing_engine.cpp.
|
protected |
Find the devices that hold key, log one report, register the ones that can be controlled.
Asks the heater at the next address first (probe_heater_at_next_address_()); otherwise runs one roll-call sweep (run_roll_call_sweep()) with key. Registers every device found that has a platform and no YAML entry, until reboot (IoDevice::registered_by_pairing); a record an earlier pairing in this boot added is replaced. With a device key the report is build_pulled_key_report() (device log only, one banner, the responders carry the key as their own system_key); with the hub key it is build_hub_key_pull_report() (no key, no banner, the responders use the hub key).
| context | Pairing context of the attempt. |
| key | AES_KEY_SIZE bytes the roll-call is built with. |
| device_key | true when key is a key the device handed over, false for the hub's own. |
Definition at line 916 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 501 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 426 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 595 of file pairing_engine.cpp.
|
protected |
Key pull (pairing_key_transfer: pull, or auto for a remote controller): 0x38 -> device's 0x32 -> our 0x3C -> device's 0x3D.
Sends a key transfer request with 6 random bytes, waits for the device's 0x32 and decrypts the key (recover_key_from_pull()). The log gets one "Key pull check" line with the key's check value, never the key, so two attempts or two devices can be compared from the log alone.
The hub's own key is accepted as is: no decrypt produces it by chance. A different key must be proven by the device's 0x3D to our challenge (ExchangeEngine::authenticate_request() under that key, up to EXCHANGE_RETRY_COUNT challenges while no 0x3D comes back; a 0x3D that does not verify ends it); only then does it become context.pulled_key, for register_pulled_key_devices_(). The hub's own key is never replaced. An unproven key is not printed. Hardware-validated on an Atlantic AGILIA IO radiator (issue #165).
| context | Pairing context populated by run_discovery_phase_(). |
Definition at line 1104 of file pairing_engine.cpp.
|
protected |
Pull the key and end the attempt wherever the pull decides it.
A foreign key goes to register_pulled_key_devices_(); a failed pull ends the attempt as refused or failed. The hub's own key from a node no platform drives looks for the devices that hold it and ends as paired, or as hub_key_returned with none found. The hub's own key from a device with a platform is left to the caller, which pairs it as after a push.
| context | Pairing context populated by run_discovery_phase_(). | |
| [out] | paired | The attempt's result when it ended here. |
Definition at line 1060 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 336 of file pairing_engine.cpp.
|
protected |
Check whether the device took our key although the key exchange did not confirm it.
A device can accept a key transfer whose 0x33 we never hear, then refuse the next one, so an unconfirmed key exchange is not proof of failure. Sends one authenticated name read (0x50). Only a device that challenges the read and then answers it with its name (0x51) has proven it verifies our MAC with our key. A device that answers without challenging proves nothing, and counts as "not held".
| context | Pairing context; context.req/context.resp are reused as scratch. |
Definition at line 832 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 194 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 103 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 154 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 299 of file pairing_engine.cpp.