Home IO Control
ESPHome add-on for IO-Homecontrol devices
Loading...
Searching...
No Matches
pairing_engine.cpp
Go to the documentation of this file.
1/// @file pairing_engine.cpp
2/// @brief Device pairing orchestration — discovery, key exchange, and finalization.
3/// @ingroup hioc_hub
4///
5/// Implements PairingEngine's three-phase pairing procedure and the low-level waiters.
6/// All radio operations delegate to ExchangeEngine (which owns LBT and hop timing);
7/// the PairingEngine focuses purely on protocol sequencing.
8///
9/// @todo Validate the full discovery and re-pair flow on freshly reset SX1276-backed devices.
10/// @todo Validate the full discovery and re-pair flow on freshly reset SX1262-backed devices.
11/// @todo Add first-class platform coverage for additional paired device classes once hardware is available.
12
13#include "pairing_engine.h"
14
15#include "hub_decisions.h"
16#include "proto_commands.h"
17#include "tuning_config.h"
18#include "esphome/core/application.h"
19#include "esphome/core/log.h"
20
21#include <cinttypes>
22#include <cstring>
23
24namespace esphome {
25namespace home_io_control {
26
27namespace {
28
29const char *const TAG = "home_io_control";
30
31/// Check if frame is a 0x33 key-confirm message.
32bool frame_is_key_confirm(const IoFrame &frame) { return frame.cmd == CMD_KEY_CONFIRM; }
33
34/// Log discovery-phase failure based on disposition.
35void log_discovery_diagnostic(decisions::PairingDiscoveryDisposition disp) {
36 switch (disp) {
38 ESP_LOGW(TAG, "No device responded to discovery");
39 break;
41 ESP_LOGW(TAG, "No valid discovery response received");
42 break;
44 break;
45 }
46}
47
48} // namespace
49
50// --- Constructor ---
51
52PairingEngine::PairingEngine(RadioDriver **radio_ptr, const uint8_t *node_id, const uint8_t *system_key,
53 const TuningConfig *tuning, ExchangeEngine &engine, DeviceRegistry &registry,
54 PairingTelemetry &telemetry, const RecentOneWayPairingSighting &recent_oneway_sighting)
55 : radio_ptr_(radio_ptr),
56 node_id_(node_id),
57 system_key_(system_key),
58 tuning_(tuning),
59 engine_(engine),
60 registry_(registry),
61 telemetry_(telemetry),
62 recent_oneway_sighting_(recent_oneway_sighting) {}
63
64// --- Low-level waiters ---
65
66/// Wait for a valid discovery response (0x29) within timeout_ms.
67///
68/// Listens with per-chip frequency hopping between slices. Distinguishes between
69/// NO_RESPONSE (no packets at all) and INVALID (packets seen but none valid).
70///
71/// Frequency hopping: hops between the 2 non-request IO-homecontrol channels after each slice —
72/// the request always goes out on FREQ_CH2 (see run_discovery_phase_()), and a broadcast reply
73/// essentially never lands back on that channel (see ListenPolicy's own doc comment for why), so
74/// dwelling there is wasted listening time. Same policy as collect_broadcast_responses() uses for
75/// its own broadcast wait (spec.request_freq below). The slice length comes from
76/// RadioDriver::hop_dwell_ms() (spec.dwell_ms left at 0, so listen() asks the driver). When
77/// preamble or sync detection fires, the dwell extends by PREAMBLE_LINGER_DWELL_MS so the
78/// incoming frame can complete without interruption.
80 RadioRxPacket &packet,
81 IoFrame &response_frame) {
82 ListenSpec spec;
83 spec.window_ms = timeout_ms;
86 // dwell_ms left at 0: no measured reason to dwell differently from the roll-call, so listen()
87 // asks the driver (radio_()->hop_dwell_ms(*tuning_)) the same way collect_broadcast_responses()
88 // does.
89 spec.linger_on_preamble = true;
91 spec.on_hop = [this]() { this->telemetry_.record_hop(); };
92
93 bool saw_traffic = false;
94 auto outcome =
95 engine_.listen(spec, packet, response_frame, [&](const IoFrame *parsed, const RadioRxPacket & /*packet*/) {
96 saw_traffic = true;
97 if (parsed == nullptr)
99 const bool accepted = decisions::classify_pairing_discovery_response(*parsed, node_id_) ==
101 this->record_discovery_rx_telemetry_(*parsed, accepted, radio_()->get_last_capture().rssi_dbm);
103 });
104
105 if (outcome == ListenOutcome::ACCEPTED)
109}
110
111/// Wait for a key-challenge (0x3C) or direct key-confirm (0x33) from target device.
112///
113/// During key exchange the device typically responds to 0x31 with a random 6-byte
114/// challenge (0x3C). Some devices skip the challenge and send 0x33 directly —
115/// indicating immediate key acceptance (observed mostly when the controller's
116/// TX→RX turnaround is slow enough that the 0x3C is missed). Both are accepted.
117///
118/// Uses `ExchangeEngine::listen()` with `ListenPolicy::HOLD_REQUEST_CHANNEL`: this is a unicast
119/// reply to a unicast request (the 0x31 key-init), and every measured unicast pairing reply came
120/// back on the request channel, so there is nothing here to hop for — same reasoning as
121/// `wait_for_key_confirm_()`. This loop runs on all three chips (it is called before the
122/// `has_fast_tx_rx_turnaround()` branch in `run_key_exchange_phase_()`), unlike the dedicated
123/// confirm wait, which only slow-turnaround radios reach.
124bool PairingEngine::wait_for_key_challenge_(uint32_t timeout_ms, RadioRxPacket &packet, IoFrame &challenge_frame,
125 const uint8_t device_node_id[NODE_ID_SIZE]) {
126 ListenSpec spec;
127 spec.window_ms = timeout_ms;
129
130 bool saw_traffic = false;
131 auto outcome =
132 engine_.listen(spec, packet, challenge_frame, [&](const IoFrame *parsed, const RadioRxPacket & /*packet*/) {
133 saw_traffic = true;
134 if (parsed == nullptr)
136 const int16_t rssi = radio_()->get_last_capture().rssi_dbm;
137 if (parsed->cmd == CMD_KEY_CONFIRM && memcmp(parsed->src, device_node_id, NODE_ID_SIZE) == 0 &&
138 memcmp(parsed->dst, node_id_, NODE_ID_SIZE) == 0) {
139 this->telemetry_.record_rx(*parsed, rssi);
141 }
142 if (decisions::classify_pairing_key_challenge(*parsed, device_node_id, node_id_) !=
144 this->telemetry_.record_rx_reject(*parsed, rssi);
146 }
147 this->telemetry_.record_rx(*parsed, rssi);
149 });
150
151 if (outcome == ListenOutcome::ACCEPTED)
152 return true;
153 ESP_LOGW(TAG, saw_traffic ? "Key exchange: no valid challenge received" : "Key exchange: no challenge received");
154 return false;
155}
156
157/// Transmit the 0x32 key transfer and wait for 0x33 key confirm (with retry).
158///
159/// Only reached on slow-turnaround radios (`RadioDriver::has_fast_tx_rx_turnaround() == false`,
160/// i.e. SX1262/LR1121): fast-turnaround radios (SX1276) catch the 0x33 through the standard
161/// `ExchangeEngine::send_and_receive_()` / `wait_for_first_response_()` path instead and never
162/// call this function — see `run_key_exchange_phase_()`. Uses `ExchangeEngine::listen()` with
163/// `ListenPolicy::HOLD_REQUEST_CHANNEL` (does not hop, does not slice, see below) and the driver's
164/// response_preamble() (drivers whose TX waveform needs more lock-on margin return a longer
165/// preamble). Retries up to EXCHANGE_RETRY_COUNT times on timeout.
167 for (uint8_t tries = 0; tries < EXCHANGE_RETRY_COUNT; tries++) {
168 if (tries > 0) {
169 App.feed_wdt();
171 }
172 if (!engine_.transmit_frame(context.req, FREQ_CH2, radio_()->response_preamble()))
173 continue;
174
175 // Deliberately holds the request channel rather than hopping: a key confirm is a unicast
176 // reply to a unicast request, and every measured unicast pairing reply — the 0x33 in the
177 // corpus pairing captures on all three chips, every 0x3C, field-logged 0xFE error replies —
178 // came back on the channel the request went out on. Only replies to *broadcasts* are measured
179 // off the request channel. So there is nothing here to hop for, and hopping loses any device
180 // that answers later than one slice (real devices answer some requests at 246+ ms). The
181 // challenge wait above holds still for the same reason; the broadcast roll-call is the
182 // opposite case and uses ListenPolicy::ROTATE_SKIPPING_REQUEST instead.
183 //
184 // HOLD also does not slice the wait: slicing exists so a hopping loop gets a chance to hop
185 // between dwells, and to keep the watchdog fed during a long silent wait. Neither applies
186 // here — there is nothing to hop for (above), and wait_for_packet() already feeds the
187 // watchdog internally while it waits. Waiting the full remaining window in one call means
188 // strictly fewer RX re-arm gaps than any slicing would, which matters most on exactly the
189 // radios that reach this loop: has_fast_tx_rx_turnaround() routes fast-turnaround chips
190 // (SX1276) through ExchangeEngine::wait_for_first_response_() instead, so only the
191 // slow-turnaround chips (SX1262, LR1121) — the ones where a re-arm is most expensive — ever
192 // wait here.
193 ListenSpec spec;
196
197 bool saw_any = false;
198 auto outcome =
199 engine_.listen(spec, context.packet, context.resp, [&](const IoFrame *parsed, const RadioRxPacket &packet) {
200 saw_any = true;
201 ESP_LOGD(TAG, "Key confirm wait: got %u bytes on freq=%" PRIu32, packet.len, packet.freq_hz);
202 if (parsed == nullptr) {
203 ESP_LOGD(TAG, "Key confirm wait: parse failed");
204 return ReplyDisposition::IGNORE;
205 }
206 ESP_LOGD(TAG, "Key confirm wait: parsed cmd=0x%02X src=%02X%02X%02X dst=%02X%02X%02X", parsed->cmd,
207 parsed->src[0], parsed->src[1], parsed->src[2], parsed->dst[0], parsed->dst[1], parsed->dst[2]);
210 const int16_t rssi = radio_()->get_last_capture().rssi_dbm;
211 if (frame_is_key_confirm(*parsed)) {
212 this->telemetry_.record_rx(*parsed, rssi);
214 }
215 this->telemetry_.record_rx_reject(*parsed, rssi);
216 ESP_LOGW(TAG, "Key transfer: device responded with cmd=%s(0x%02X) (expected KEY_CONFIRM 0x33)",
217 command_name(parsed->cmd), parsed->cmd);
218 if (parsed->cmd == CMD_ERROR_RESP && parsed->data_len > 0)
219 ESP_LOGW(TAG, "Key transfer: error code=0x%02X", parsed->data[0]);
221 });
222
223 if (outcome == ListenOutcome::ACCEPTED)
224 return true;
225 if (outcome == ListenOutcome::ABORTED)
226 return false; // An explicit refusal must not spend the remaining retries.
227
228 ESP_LOGI(TAG, "Try %d ended: no response for key transfer (0x32) within %" PRIu32 " ms (saw_any=%d)", tries + 1,
230 }
231 return false;
232}
233
234/// Build CMD_KEY_TRANSFER against the challenge currently held in `context.rx.data` and wait for
235/// the 0x33 confirm, routing through the fast- or slow-turnaround path. Shared by
236/// run_key_exchange_phase_()'s first attempt and its slow-turnaround retry loop, which calls this
237/// again against a freshly re-issued challenge (see that function's doc comment) rather than
238/// discarding it.
241 engine_.record_debug(pairing_stage_name(context.state), 1, true);
242 this->telemetry_.set_phase(context.state);
243 if (!create_key_transfer(context.req, context.key_init, context.device.node_id, node_id_, system_key_,
244 context.rx.data)) {
245 return false;
246 }
247
249 engine_.record_debug(pairing_stage_name(context.state), 1, true);
250 this->telemetry_.set_phase(context.state);
251 // Fast-turnaround radios catch the 0x33 through the standard exchange wait. Slow-turnaround
252 // radios miss it while re-entering RX, so run_key_exchange_phase_()'s retry loop calls this
253 // helper again instead, rather than using the dedicated wait loop directly.
254 if (radio_()->has_fast_tx_rx_turnaround()) {
255 // Key exchange is the one caller that genuinely needs the payload: without the 0x33 there is
256 // no confirmation the device took the key, so an unconfirmed acceptance is not good enough.
257 return engine_.send_and_receive(context.req, context.resp, FREQ_CH2) == ExchangeOutcome::SUCCESS_WITH_RESPONSE &&
258 frame_is_key_confirm(context.resp);
259 }
260 return wait_for_key_confirm_(context);
261}
262
263// --- Discovery metadata ---
264
265/// Parse a discovery response frame into device metadata and ID.
266///
267/// Decodes node ID, device type, subtype, and the extended fields (manufacturer, backbone,
268/// Multi Information Byte) via decode_discovery_response(), then emits pairing's diagnostic log
269/// lines for whichever extended fields the payload actually included.
270/// The inversion flag is derived from the type via `default_inverted_for_type()`.
272 std::string &device_id) {
273 const DiscoveryResponseInfo info = decode_discovery_response(frame, device, device_id);
274
276 const char *mfr_name = manufacturer_name(info.manufacturer);
277 ESP_LOGI(TAG, "Discovery: device %s manufacturer=%u (%s)", device_id.c_str(), info.manufacturer, mfr_name);
278 if (info.manufacturer == 0 || info.manufacturer > MANUFACTURER_ID_MAX) {
279 ESP_LOGW(TAG,
280 "Unknown manufacturer ID %u reported by device %s. "
281 "Please file a GitHub issue with this ID and your device model so support can be added.",
282 info.manufacturer, device_id.c_str());
283 }
284 }
286 ESP_LOGD(TAG, "Discovery: backbone=%02X%02X%02X", info.backbone[0], info.backbone[1], info.backbone[2]);
287 }
289 uint8_t const att = discovery_att_class(info.flags);
290 uint8_t const power_save = discovery_power_save_mode(info.flags);
291 ESP_LOGI(TAG, "Discovery: device %s turnaround=%s power_save=%s flags=0x%02X", device_id.c_str(),
292 att_class_name(att), power_save_mode_name(power_save), info.flags);
293 if (power_save == POWER_SAVE_LOW_POWER) {
294 ESP_LOGI(TAG,
295 "Device %s reports low-power mode: add 'low_power: true' to its YAML entry. "
296 "The pairing snippet below pre-fills it when one is printed.",
297 device_id.c_str());
298 }
299 }
300 return info;
301}
302
303// --- Phase helpers ---
304
305/// Phase 1: broadcast discovery command(s) and wait for a device response (0x29).
306///
307/// Sends each configured discovery command in order, waiting up to
308/// `pairing_discovery_wait_ms` for a valid response after each TX.
309/// Retries up to PAIRING_DISCOVERY_MAX_ATTEMPTS times per command.
311 if (tuning_->pairing_discovery_initial_dwell_ms > 0) {
312 ESP_LOGD(TAG, "Discovery: initial dwell %u ms", tuning_->pairing_discovery_initial_dwell_ms);
313 delay(tuning_->pairing_discovery_initial_dwell_ms);
314 }
315
316 // Tracks whether any single attempt saw traffic that failed to classify as a valid discovery
317 // response, so the final "gave up after retries" return can distinguish INVALID (something was
318 // heard, just not a valid response) from NO_RESPONSE (nothing heard at all) instead of always
319 // collapsing to NO_RESPONSE.
320 bool saw_invalid = false;
321
322 for (size_t command_index = 0; command_index < tuning_->pairing_discovery_commands.size(); ++command_index) {
323 auto command = static_cast<uint8_t>(tuning_->pairing_discovery_commands[command_index]);
324 const uint8_t *destination = resolve_discovery_destination(command, tuning_->pairing_discovery_destination_auto,
325 tuning_->pairing_discovery_destination.data());
326 ESP_LOGD(TAG, "Discovery command %zu/%zu: cmd=0x%02X dst=%02X%02X%02X", command_index + 1,
327 tuning_->pairing_discovery_commands.size(), command, destination[0], destination[1], destination[2]);
328
329 for (uint8_t attempt = 1; attempt <= PAIRING_DISCOVERY_MAX_ATTEMPTS; ++attempt) {
330 this->telemetry_.increment_discovery_attempt();
332 engine_.record_debug(pairing_stage_name(context.state), attempt, false);
333 this->telemetry_.set_phase(context.state);
334 if (!create_discovery_request(context.req, node_id_, command, destination, tuning_->pairing_discovery_low_power,
335 tuning_->pairing_discovery_payload_enabled, tuning_->pairing_discovery_payload,
336 system_key_) ||
337 !engine_.transmit_frame(context.req, FREQ_CH2, tuning_->pairing_discovery_preamble)) {
339 }
340
342 engine_.record_debug(pairing_stage_name(context.state), attempt, false);
343 this->telemetry_.set_phase(context.state);
344 auto result = wait_for_discovery_response_(tuning_->pairing_discovery_wait_ms, context.packet, context.rx);
346 const DiscoveryResponseInfo info = parse_device_from_discovery(context.rx, context.device, context.device_id);
348 context.discovery_low_power =
350 return result;
351 }
353 saw_invalid = true;
354 }
355
356 if (attempt < PAIRING_DISCOVERY_MAX_ATTEMPTS) {
357 ESP_LOGI(TAG, "Discovery attempt %u/%u for cmd=0x%02X: no response, retrying...", attempt,
359 }
360 }
361 }
364}
365
366/// Phase 2: authenticated key exchange (0x31 → 0x3C → 0x32 → 0x33).
367///
368/// Steps:
369/// 1. Transmit CMD_KEY_INIT (0x31)
370/// 2. Wait for device challenge (0x3C)
371/// 3. Transmit CMD_KEY_TRANSFER (0x32) with encrypted system key
372/// 4. Wait for CMD_KEY_CONFIRM (0x33)
373///
374/// Step 4 depends on the driver's TX→RX turnaround: fast-turnaround radios await
375/// the 0x33 through the standard send_and_receive() exchange; slow-turnaround
376/// radios use the dedicated wait_for_key_confirm_() path with a key-init
377/// re-trigger, because the 0x33 would otherwise arrive while the receiver is
378/// still settling (see RadioDriver::has_fast_tx_rx_turnaround()).
379///
380/// The slow-turnaround re-trigger loop below (`for (int re = 0; ...)`) has two distinct outcomes
381/// for its re-sent CMD_KEY_INIT, and they are handled differently:
382/// - The device replies with CMD_KEY_CONFIRM (0x33) directly — it already had the key from the
383/// first 0x32 and is just auto-confirming again. Nothing more to send.
384/// - The device replies with a *fresh* CMD_CHALLENGE_REQ (0x3C) instead — proof it never received
385/// the first 0x32 at all (a device that already holds the key doesn't re-challenge), so there
386/// is no key transfer for it to confirm yet. The loop calls transfer_key_and_wait_confirm_()
387/// again here, replaying CMD_KEY_TRANSFER against this new challenge, rather than discarding
388/// the 0x3C and burning the retry for nothing — the device's next 0x31 would just produce
389/// another fresh challenge either way, so retrying without resending 0x32 could never succeed.
390///
391/// That replay roughly doubles this function's worst-case blocking time when every wait times out
392/// (approximately 3.5s -> 6.6s: two of the loop's iterations can now each wait out a full
393/// key-transfer-and-confirm cycle instead of returning immediately on a missed 0x33). This is a
394/// known, accepted trade-off: pairing already tolerates multi-second blocking exchanges (see
395/// EXCHANGE_TOTAL_BUDGET_MS's own reasoning, proto_timing.h), and the alternative — leaving a
396/// slow-turnaround device that never got its key stuck retrying forever — is worse than the
397/// occasional slower failure path.
400 engine_.record_debug(pairing_stage_name(context.state), 1, false);
401 this->telemetry_.set_phase(context.state);
402 if (!create_key_init(context.key_init, node_id_, context.device.node_id) ||
403 !engine_.transmit_frame(context.key_init, FREQ_CH2, LONG_PREAMBLE)) {
404 return false;
405 }
406
408 engine_.record_debug(pairing_stage_name(context.state), 1, true);
409 this->telemetry_.set_phase(context.state);
411 return false;
412 }
413
414 // Some devices send 0x33 directly after 0x31 without requiring 0x32.
415 if (context.rx.cmd == CMD_KEY_CONFIRM) {
416 ESP_LOGI(TAG, "Device accepted key immediately (0x33 without 0x32 exchange)");
417 context.resp = context.rx;
418 return true;
419 }
420
421 // No challenge bytes here: the raw 0x3C payload plus the 0x3D response it provokes is a
422 // known-plaintext/known-ciphertext pair under the system key (see redaction.h). The generic
423 // frame-log helpers (log_frame()/log_component_capture()) already mask both commands.
424 ESP_LOGI(TAG, "Challenge (0x3C) received: data_len=%u freq=%" PRIu32 " rssi=%d", context.rx.data_len,
425 context.packet.freq_hz, radio_()->get_last_capture().rssi_dbm);
426
427 bool key_ok = transfer_key_and_wait_confirm_(context);
428 // Fast-turnaround radios catch the 0x33 through the standard exchange wait inside the helper
429 // above and never reach this loop. Slow-turnaround radios miss it while re-entering RX, so on a
430 // miss they re-send the key-init to trigger the device's auto-confirm.
431 if (!radio_()->has_fast_tx_rx_turnaround()) {
432 for (int re = 0; !key_ok && re < 2; re++) {
433 ESP_LOGI(TAG, "Key confirm missed, re-sending key-init to trigger auto-confirm (attempt %d/2)", re + 1);
434 App.feed_wdt();
436 if (!engine_.transmit_frame(context.key_init, FREQ_CH2, LONG_PREAMBLE))
437 continue;
439 context.device.node_id))
440 continue;
441 if (context.rx.cmd == CMD_KEY_CONFIRM) {
442 key_ok = true;
443 continue;
444 }
445 // A fresh CHALLENGE_REQ (0x3C), not a direct KEY_CONFIRM (0x33): the device never received
446 // our first 0x32 in the first place, so there is no key for it to auto-confirm yet -- a
447 // fresh 0x3C is exactly the signal that the right response is "resend 0x32 against this new
448 // challenge", not "give up and burn the retry for nothing".
449 key_ok = transfer_key_and_wait_confirm_(context);
450 }
451 }
452 if (!key_ok) {
453 ESP_LOGW(TAG, "Key exchange failed");
454 return false;
455 }
456 return true;
457}
458
459/// Phase 3: send SetConfig1 (0x6F) to enable automatic status updates. Best-effort.
461 if (!create_set_config1(context.req, node_id_, context.device.node_id))
462 return false;
463 // Best-effort, and its reply is never read — an unconfirmed acceptance is a success here.
464 return engine_.send_and_receive(context.req, context.resp, FREQ_CH2) != ExchangeOutcome::FAILED;
465}
466
467// --- Orchestrator ---
468
469/// Pairing orchestrator — high-level three-phase flow.
470///
471/// Phase 1: run_discovery_phase_() finds a device in pairing mode.
472/// Phase 2: run_key_exchange_phase_() performs authenticated key establishment.
473/// Phase 3: finalize_pairing_configuration_() sends SetConfig1 (best-effort).
474///
475/// On success the device is added to the registry and a YAML snippet is printed to the log.
476/// The hub's thin wrapper manages the busy_ flag before and after this call.
478 this->telemetry_.begin();
479 this->engine_.set_pairing_telemetry(&this->telemetry_);
480
481 // Seed telemetry with a 1W pairing-gesture frame the hub overheard shortly before this call
482 // (issue #27/#65): without this, a PROG press completed a few seconds before "Discover & Pair"
483 // is pressed in the app is invisible to the advisor purely because the telemetry window opens
484 // here, even though the radio heard it just fine — the false rf_silent advice this produced was
485 // field-confirmed against a real trace (issue #27, miljaar).
486 if (this->recent_oneway_sighting_.seen_ms != 0 &&
487 (millis() - this->recent_oneway_sighting_.seen_ms) < PAIRING_RECENT_ONE_WAY_SIGHTING_WINDOW_MS) {
488 this->telemetry_.record_recent_one_way_sighting(this->recent_oneway_sighting_);
489 }
490
491 ESP_LOGI(TAG, "Starting device discovery...");
492 ESP_LOGI(TAG, "%s", tuning_config_full_snapshot(*tuning_).c_str());
493
495
496 // Phase 1: Discovery
497 auto disc_disp = run_discovery_phase_(context);
499 log_discovery_diagnostic(disc_disp);
500 this->finish_pairing_attempt_(disc_disp == decisions::PairingDiscoveryDisposition::INVALID
503 return false;
504 }
505
506 // Phase 2: Key exchange — retry up to the configured number of times.
507 bool key_exchanged = false;
508 for (uint8_t ke_attempt = 0; ke_attempt < tuning_->pairing_key_exchange_retries; ke_attempt++) {
509 if (ke_attempt > 0) {
510 ESP_LOGI(TAG, "Retrying key exchange (attempt %d/%u)...", ke_attempt + 1, tuning_->pairing_key_exchange_retries);
511 App.feed_wdt();
513 }
514 if (run_key_exchange_phase_(context)) {
515 key_exchanged = true;
516 break;
517 }
518 }
519 if (!key_exchanged) {
520 this->finish_pairing_attempt_(PairingOutcome::KEY_EXCHANGE_FAILED);
521 return false;
522 }
523
524 // Phase 3: Final configuration — best-effort SetConfig1. Pairing proceeds either way; the
525 // result only affects which outcome telemetry reports (PAIRED vs. CONFIG_FAILED).
526 const bool config_ok = finalize_pairing_configuration_(context);
528
530 engine_.record_debug(pairing_stage_name(context.state), 1, true);
531 this->telemetry_.set_phase(context.state);
532
533 registry_.put(context.device_id, context.device);
534 this->telemetry_.set_paired_device(context.device.node_id, context.device.type);
535
536 const std::string type_diag = format_device_type_diagnostic(context.device.type);
537 const std::string type_yaml = format_device_type_for_yaml(context.device.type);
538 const std::string snippet = build_device_yaml_snippet(context.device.type, context.device.subtype, context.device_id,
540 context.discovery_low_power);
541
542 if (snippet.empty()) {
543 // metadata_complete is true here (build_device_yaml_snippet() never returns empty for
544 // metadata_complete == false), so this is specifically "we know the type, but there's no
545 // ESPHome platform for it yet".
546 ESP_LOGW(TAG,
547 "Device %s paired successfully, but this repo does not yet expose an ESPHome platform for type=%s "
548 "class=%s subtype=%u.",
549 context.device_id.c_str(), type_diag.c_str(), device_capability_class_name(context.device.type),
550 context.device.subtype);
551 ESP_LOGW(TAG,
552 "No ready-to-paste YAML was generated. If you want to experiment manually, choose the most likely "
553 "platform and set io_device_type: %s.",
554 type_yaml.c_str());
555 ESP_LOGW(TAG, "Please file a GitHub issue with this device type, subtype, model, and the pairing log so support "
556 "can be added.");
557
559 engine_.record_debug(pairing_stage_name(context.state), 1, true);
560 this->telemetry_.set_phase(context.state);
561 this->finish_pairing_attempt_(outcome);
562 return true;
563 }
564
565 if (!context.discovery_metadata_complete) {
566 ESP_LOGW(TAG,
567 "Device %s paired successfully, but the discovery response did not include type/subtype "
568 "metadata, so the platform (cover/light/switch/lock) can't be determined automatically.",
569 context.device_id.c_str());
570 ESP_LOGI(TAG, "Add this to your YAML once you know what kind of device it is:\n%s", snippet.c_str());
571 ESP_LOGW(TAG, "Please file a GitHub issue with the pairing log and device model so this discovery edge case can "
572 "be investigated.");
573
575 engine_.record_debug(pairing_stage_name(context.state), 1, true);
576 this->telemetry_.set_phase(context.state);
577 this->finish_pairing_attempt_(outcome);
578 return true;
579 }
580
581 ESP_LOGI(TAG, "Device %s paired successfully! Add this to your YAML:\n%s", context.device_id.c_str(),
582 snippet.c_str());
583
584 if (yaml_device_type_name(context.device.type) == nullptr) {
585 ESP_LOGW(TAG,
586 "This snippet uses the raw device type %s because the project does not yet expose a named YAML alias "
587 "for %s.",
588 type_yaml.c_str(), type_diag.c_str());
589 ESP_LOGW(TAG, "Please file a GitHub issue with this type, subtype, device model, and the pairing log so support "
590 "can be added.");
591 }
592
594 engine_.record_debug(pairing_stage_name(context.state), 1, true);
595 this->telemetry_.set_phase(context.state);
596 this->finish_pairing_attempt_(outcome);
597 return true;
598}
599
600void PairingEngine::finish_pairing_attempt_(PairingOutcome outcome) {
601 this->telemetry_.set_outcome(outcome);
602 this->engine_.set_pairing_telemetry(nullptr);
603 this->telemetry_.log_summary();
604
606 const uint8_t advice_count = advisor::analyze_pairing_telemetry(this->telemetry_, this->node_id_, advice);
607 std::string advice_codes;
608 for (uint8_t i = 0; i < advice_count; i++) {
609 ESP_LOGW(TAG, "Pairing advisor: %s", advisor::pairing_advice_message(advice[i]).c_str());
610 if (!advice_codes.empty())
611 advice_codes += ',';
612 advice_codes += advisor::pairing_advice_code_name(advice[i].code);
613 }
614 this->telemetry_.set_advice_codes(advice_codes);
615}
616
617void PairingEngine::record_discovery_rx_telemetry_(const IoFrame &frame, bool accepted, int16_t rssi) {
618 if (accepted) {
619 this->telemetry_.record_rx(frame, rssi);
620 } else {
621 this->telemetry_.record_rx_reject(frame, rssi);
622 }
623}
624
625} // namespace home_io_control
626} // namespace esphome
Owns the per-hub device table, update callbacks, and linked-remote associations.
decisions::PairingDiscoveryDisposition run_discovery_phase_(pairing::PairingContext &context)
Phase 1: broadcast discovery command(s) and wait for a device response (0x29).
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.
bool run_key_exchange_phase_(pairing::PairingContext &context)
Phase 2: authenticated key exchange (0x31 → 0x3C → 0x32 → 0x33).
bool wait_for_key_confirm_(pairing::PairingContext &context)
Transmit the 0x32 key transfer and wait for the 0x33 key confirm with retry.
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.
bool discover_and_pair()
Discover and pair a device currently in pairing mode (three-phase orchestrator).
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_excha...
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 finalize_pairing_configuration_(pairing::PairingContext &context)
Phase 3: send SetConfig1 (0x6F) to enable automatic status updates; best-effort.
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.
Fixed-size per-attempt telemetry recorder for the pairing flow.
Abstract radio driver for IO-Homecontrol.
Pure transition helpers for hub-owned exchange and pairing frame decisions.
std::string pairing_advice_message(const PairingAdvice &advice)
static constexpr uint8_t PAIRING_ADVICE_MAX
Maximum number of advice entries a single attempt can produce (one slot per non-NONE code).
const char * pairing_advice_code_name(PairingAdviceCode code)
uint8_t analyze_pairing_telemetry(const PairingTelemetry &telemetry, const uint8_t own_node_id[NODE_ID_SIZE], PairingAdvice out[PAIRING_ADVICE_MAX])
Inspect a completed pairing attempt's telemetry and produce actionable advice.
PairingDiscoveryDisposition
Disposition during pairing discovery phase.
@ NO_RESPONSE
No packets received on the channel within timeout.
@ INVALID
Packets seen but none were valid discovery (0x29) frames.
PairingKeyChallengeDisposition classify_pairing_key_challenge(const IoFrame &candidate, const uint8_t device_id[NODE_ID_SIZE], const uint8_t controller_id[NODE_ID_SIZE])
Decide if a frame is a valid key-challenge (0x3C) during pairing key exchange.
PairingDiscoveryDisposition classify_pairing_discovery_response(const IoFrame &candidate, const uint8_t controller_id[NODE_ID_SIZE])
Decide if a frame is a valid discovery response (0x29) during pairing.
bool frame_matches_exchange_endpoints(const IoFrame &request, const IoFrame &candidate)
Check if candidate frame endpoints are the reverse of the request (dst==request.src,...
@ REGISTER_DEVICE
Registering device in the runtime registry for the current boot.
Definition hub_pairing.h:53
@ TX_DISCOVER
Discovery broadcast (0x28) sent; awaiting device response.
Definition hub_pairing.h:47
@ WAIT_DISCOVER_RESPONSE
Listening for discovery response (0x29) from a device in pairing mode.
Definition hub_pairing.h:48
@ COMPLETE
Pairing completed successfully; device ready for use.
Definition hub_pairing.h:54
@ WAIT_KEY_CHALLENGE
Waiting for challenge (0x3C) from device as part of key transfer.
Definition hub_pairing.h:50
@ WAIT_KEY_CONFIRM
Waiting for key‑confirm (0x33) from device (key receipt acknowledgement).
Definition hub_pairing.h:52
@ TX_KEY_INIT
Key‑init (0x31) sent to the discovered device.
Definition hub_pairing.h:49
@ TX_KEY_TRANSFER
Key‑transfer (0x32) sent with encrypted system key.
Definition hub_pairing.h:51
const char * manufacturer_name(uint8_t id)
Get a human-readable manufacturer name from the protocol manufacturer byte.
static constexpr uint8_t DEVICE_METADATA_SIZE
Packed device metadata uses two bytes where the high 8 bits carry the upper type bits and the low byt...
uint8_t discovery_power_save_mode(uint8_t flags)
Extract the power save mode field from a discovery response's Multi Information Byte.
static constexpr uint8_t NODE_ID_SIZE
Device/node addresses are 3 bytes (e.g., "123ABC").
Definition proto_sizes.h:20
std::string format_device_type_for_yaml(DeviceType type)
Build the YAML value for a device's io_device_type key.
static constexpr uint8_t CMD_ERROR_RESP
Error response to any command.
const char * att_class_name(uint8_t att_class)
Get a human-readable turnaround time string for an ATT class value.
static constexpr uint8_t DISCOVERY_RESP_MANUFACTURER_OFFSET
Manufacturer ID at data[5].
constexpr uint8_t PAIRING_DISCOVERY_MAX_ATTEMPTS
Retry discovery TX up to this many times.
static constexpr const char * TAG
const char * power_save_mode_name(uint8_t mode)
Get a human-readable power save mode name.
constexpr uint32_t PAIRING_KEY_CONFIRM_TIMEOUT_MS
Wait for 0x33 key confirm after sending 0x32.
bool create_discovery_request(IoFrame &f, const uint8_t *own, uint8_t command, const uint8_t *dst, bool low_power, bool payload_enabled, uint8_t payload, const uint8_t *system_key)
Build a configurable discovery request command (0x28, 0x2A, or 0x2E).
const char * command_name(uint8_t cmd)
Get a human-readable name for any IO-Homecontrol command ID.
PairingOutcome
Final disposition of a pairing attempt, used by the result sensor string.
@ INVALID_RESPONSE
Discovery saw traffic but nothing valid.
@ KEY_EXCHANGE_FAILED
Discovery succeeded but the key exchange did not complete.
@ PAIRED
All three phases completed successfully.
@ CONFIG_FAILED
Key exchange succeeded but SetConfig1 failed (still counted as paired).
@ NO_RESPONSE
No device responded to discovery.
static constexpr uint8_t DISCOVERY_RESP_BACKBONE_OFFSET
Byte offsets within CMD_DISCOVER_RESP (0x29) payload data.
static constexpr uint8_t CMD_KEY_CONFIRM
Device confirms key was received.
static constexpr int32_t EXCHANGE_RETRY_DELAY_MS
Gap between retries within one HA command.
@ ACCEPT
This is the frame the caller was waiting for — stop listening, return ACCEPTED.
@ ABORT
An explicit refusal (e.g. CMD_ERROR_RESP) — stop listening, return ABORTED.
@ IGNORE
Unparsable / not ours / wrong exchange — keep listening.
bool create_set_config1(IoFrame &f, const uint8_t *own, const uint8_t *dst)
Build a set-config command (0x6F) to tell the device to automatically send status updates when contro...
bool create_key_init(IoFrame &f, const uint8_t *own, const uint8_t *dst)
Build a key-init request (0x31) to start the pairing key exchange with a discovered device.
@ SUCCESS_WITH_RESPONSE
Device replied; the caller's response frame is populated.
@ FAILED
No usable reply; the device may never have heard the request.
const char * pairing_stage_name(pairing::PairingState state)
Get a short, log/telemetry-friendly name for a pairing state.
Definition hub_pairing.h:77
static constexpr uint8_t DISCOVERY_RESP_FLAGS_OFFSET
Flags byte at data[6].
std::string tuning_config_full_snapshot(const TuningConfig &cfg)
Format the current tuning configuration as a full one-line snapshot.
@ ROTATE_SKIPPING_REQUEST
The two channels that are not the request channel. Roll-call.
@ HOLD_REQUEST_CHANNEL
Never retunes, never slices. Unicast replies.
static constexpr uint8_t MANUFACTURER_ID_MAX
Maximum manufacturer ID with a known name in the lookup table.
static constexpr uint8_t EXCHANGE_RETRY_COUNT
Attempts per command before reporting failure.
const char * device_capability_class_name(DeviceType type)
Get a human‑readable name for a capability class.
std::string build_device_yaml_snippet(DeviceType type, uint8_t subtype, const std::string &device_id, bool metadata_complete, bool inverted, bool low_power)
Build the ready-to-paste YAML block describing a device, for both a fully-decoded device and one whos...
static constexpr uint32_t FREQ_CH2
Channel 2: 868.95 MHz (1W and 2W, TX channel).
constexpr uint32_t PAIRING_RECENT_ONE_WAY_SIGHTING_WINDOW_MS
How recent a RecentOneWayPairingSighting has to be, relative to discover_and_pair() starting,...
@ ABORTED
The handler returned ReplyDisposition::ABORT for some received frame.
@ ACCEPTED
The handler returned ReplyDisposition::ACCEPT for some received frame.
uint8_t discovery_att_class(uint8_t flags)
Extract the ATT class field from a discovery response's Multi Information Byte.
static constexpr uint8_t POWER_SAVE_LOW_POWER
Device sleeps — needs long preamble to wake.
constexpr uint32_t PAIRING_KEY_CHALLENGE_TIMEOUT_MS
Wait window for the device's 0x3C challenge.
const char * yaml_device_type_name(DeviceType type)
Return the YAML-friendly device-type name for types exposed in the Python schema.
static constexpr uint32_t PREAMBLE_LINGER_DWELL_MS
Preamble/sync linger extension for a rotating listen (ListenSpec::linger_dwell_ms): how much longer t...
static constexpr uint16_t LONG_PREAMBLE
Preamble is a sequence of 0xAA bytes that precedes every frame.
std::string format_device_type_diagnostic(DeviceType type)
Human-readable device type string for diagnostics, including the raw numeric value.
const uint8_t * resolve_discovery_destination(uint8_t command, bool destination_auto, const uint8_t destination[NODE_ID_SIZE])
Resolve the destination address for a discovery command.
DiscoveryResponseInfo decode_discovery_response(const IoFrame &frame, IoDevice &device, std::string &device_id)
Decode a discovery-response payload (CMD_DISCOVER_RESP 0x29 or CMD_DISCOVER_SPE_RESP 0x2B — both carr...
bool create_key_transfer(IoFrame &f, IoFrame &old_frame, const uint8_t *dst, const uint8_t *src, const uint8_t key[AES_KEY_SIZE], const uint8_t challenge[HMAC_SIZE])
Build a key-transfer frame (0x32) containing the system key encrypted with the transfer key.
Device discovery and key-exchange engine for IO-Homecontrol pairing.
Command builders for the IO‑Homecontrol protocol.
Extended discovery-response fields (manufacturer, Multi Information Byte, backbone address,...
bool has_extended
data_len >= DISCOVERY_RESP_FULL_SIZE (mfr/flags/timestamp present).
uint8_t backbone[NODE_ID_SIZE]
Backbone address as reported by the device.
uint8_t manufacturer
Raw manufacturer ID; name via manufacturer_name().
uint8_t flags
Multi Information Byte; decode with DISCOVERY_FLAGS_* masks.
Runtime state of a paired IO‑Homecontrol device.
bool inverted
True if open/close positions are swapped (e.g., horizontal awning).
uint8_t subtype
Device subtype (manufacturer‑specific).
uint8_t node_id[NODE_ID_SIZE]
Device's 3‑byte radio address.
DeviceType type
Device type (shutter, awning, etc.).
Parsed IO‑Homecontrol frame (CTRL0/1 + addresses + command + data).
Definition proto_frame.h:88
uint8_t data[FRAME_MAX_DATA_SIZE]
Command parameters (0–23 bytes). Never includes mac.
Definition proto_frame.h:94
uint8_t src[NODE_ID_SIZE]
Source node ID (3 bytes).
Definition proto_frame.h:92
uint8_t dst[NODE_ID_SIZE]
Destination node ID (3 bytes).
Definition proto_frame.h:91
uint8_t data_len
Actual length of data.
Definition proto_frame.h:95
How one listen window is to be spent — everything ExchangeEngine::listen() needs; everything else is ...
uint32_t request_freq
Channel the request went out on (Hz).
uint32_t window_ms
Total time budget for this listen, in milliseconds.
uint32_t linger_dwell_ms
Length of the preamble/sync extension, in milliseconds.
ListenPolicy policy
Which channels this listen covers.
bool linger_on_preamble
Extend the listen instead of hopping while the chip reports a preamble or sync word,...
std::function< void()> on_hop
Called on every hop, for callers that count hops in their own telemetry (pairing does; the exchange l...
Raw packet received from the radio.
uint32_t freq_hz
Frequency the packet was received on (Hz).
A 1W pairing-gesture frame observed on the hub's normal passive RX path, remembered so a fresh discov...
All runtime tunable parameters for pairing and radio diagnostics.
One piece of advice, with the node/RSSI it pertains to (if any).
Context object that lives for the duration of a single pairing attempt.
Definition hub_pairing.h:59
IoFrame req
Outbound frame buffer (reused across all phases).
Definition hub_pairing.h:62
bool discovery_low_power
True when discovery reported POWER_SAVE_LOW_POWER.
Definition hub_pairing.h:69
PairingState state
Current state machine state.
Definition hub_pairing.h:60
IoFrame resp
Inbound frame buffer (holds key‑confirm response).
Definition hub_pairing.h:63
RadioRxPacket packet
Raw radio capture for the current phase.
Definition hub_pairing.h:66
IoDevice device
Resolved device metadata after discovery (node_id, type, subtype, etc.).
Definition hub_pairing.h:61
IoFrame key_init
Key‑init frame retained for key‑transfer IV derivation.
Definition hub_pairing.h:65
std::string device_id
Hex string representation of the paired node ID.
Definition hub_pairing.h:67
bool discovery_metadata_complete
True when discovery carried type/subtype bytes.
Definition hub_pairing.h:68
IoFrame rx
Raw RX frame during waiting phases (discovery, challenge, confirm).
Definition hub_pairing.h:64
Runtime tuning configuration for pairing and radio diagnostics.