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 "log_helpers.h"
17#include "proto_commands.h"
18#include "proto_crypto.h"
19#include "proto_device_types.h"
20#include "proto_heating.h"
21#include "roll_call.h"
22#include "tuning_config.h"
23#include "esphome/core/application.h"
24#include "esphome/core/log.h"
25
26#include <algorithm>
27#include <cinttypes>
28#include <cmath>
29#include <cstring>
30
31namespace esphome {
32namespace home_io_control {
33
34namespace {
35
36const char *const TAG = "home_io_control";
37
38/// Check if frame is a 0x33 key-confirm message.
39bool frame_is_key_confirm(const IoFrame &frame) { return frame.cmd == CMD_KEY_CONFIRM; }
40
41/// Log discovery-phase failure based on disposition.
42void log_discovery_diagnostic(decisions::PairingDiscoveryDisposition disp) {
43 switch (disp) {
45 ESP_LOGW(TAG, "No device responded to discovery");
46 break;
48 ESP_LOGW(TAG, "No valid discovery response received");
49 break;
51 break;
52 }
53}
54
55/// Block for `ms`, feeding the watchdog between chunks of at most 1000 ms each — a single
56/// multi-second delay() would trip the loop-task watchdog, and `pairing_key_init_delay_ms` allows
57/// up to 10000. Used only by the discover-confirm step's post-step pause: every other wait in this
58/// file is short enough (a few hundred ms) that App.feed_wdt() inside the retry loop is enough.
59void delay_feeding_wdt(uint32_t ms) {
60 while (ms > 0) {
61 App.feed_wdt();
62 const uint32_t chunk = ms < 1000 ? ms : 1000;
63 delay(chunk);
64 ms -= chunk;
65 }
66}
67
68} // namespace
69
70// --- Constructor ---
71
72PairingEngine::PairingEngine(RadioDriver **radio_ptr, const uint8_t *node_id, const uint8_t *system_key,
73 const TuningConfig *tuning, ExchangeEngine &engine, DeviceRegistry &registry,
74 PairingTelemetry &telemetry, const RecentOneWayPairingSighting &recent_oneway_sighting)
75 : radio_ptr_(radio_ptr),
76 node_id_(node_id),
77 system_key_(system_key),
78 tuning_(tuning),
79 engine_(engine),
80 registry_(registry),
81 telemetry_(telemetry),
82 recent_oneway_sighting_(recent_oneway_sighting) {}
83
84uint16_t PairingEngine::pairing_start_preamble_(const IoFrame &frame) const {
85 return std::min(engine_.request_preamble_for(frame), effective_discovery_preamble(*tuning_));
86}
87
88// --- Low-level waiters ---
89
90/// Wait for a valid discovery response (0x29) within timeout_ms.
91///
92/// Listens with per-chip frequency hopping between slices. Distinguishes between
93/// NO_RESPONSE (no packets at all) and INVALID (packets seen but none valid).
94///
95/// Frequency hopping: hops between the 2 non-request IO-homecontrol channels after each slice —
96/// the request always goes out on FREQ_CH2 (see run_discovery_phase_()), and a discovery reply
97/// essentially never lands back on that channel (see ListenPolicy's own doc comment for why), so
98/// dwelling there is wasted listening time. Same policy as collect_broadcast_responses()'s
99/// default. The slice length comes from
100/// RadioDriver::hop_dwell_ms() (spec.dwell_ms left at 0, so listen() asks the driver). When
101/// preamble or sync detection fires, the dwell extends by PREAMBLE_LINGER_DWELL_MS so the
102/// incoming frame can complete without interruption.
104 RadioRxPacket &packet,
105 IoFrame &response_frame) {
106 ListenSpec spec;
107 spec.window_ms = timeout_ms;
108 // Skipping the request channel is the default because a broadcast's answers are almost never
109 // on it (ListenPolicy's own doc has the 1-in-149 measurement). `all` is the escape hatch for a
110 // device that has never answered at all: that measurement is drawn from responders we already
111 // hear, so it cannot speak for one whose replies we might be missing entirely.
112 spec.policy = tuning_->pairing_discovery_listen_channels == DiscoveryListenChannels::ALL
115 spec.request_freq = FREQ_CH2;
116 // dwell_ms left at 0: no measured reason to dwell differently from the roll-call, so listen()
117 // asks the driver (radio_()->hop_dwell_ms(*tuning_)) the same way collect_broadcast_responses()
118 // does.
119 spec.linger_on_preamble = true;
120 spec.linger_dwell_ms = PREAMBLE_LINGER_DWELL_MS;
121 spec.on_hop = [this]() { this->telemetry_.record_hop(); };
122
123 bool saw_traffic = false;
124 auto outcome =
125 engine_.listen(spec, packet, response_frame, [&](const IoFrame *parsed, const RadioRxPacket & /*packet*/) {
126 saw_traffic = true;
127 if (parsed == nullptr)
129 const bool accepted = decisions::classify_pairing_discovery_response(*parsed, node_id_) ==
131 this->record_discovery_rx_telemetry_(*parsed, accepted, radio_()->get_last_capture().rssi_dbm);
133 });
134
135 if (outcome == ListenOutcome::ACCEPTED)
139}
140
141/// Wait for a key-challenge (0x3C) or direct key-confirm (0x33) from target device.
142///
143/// During key exchange the device typically responds to 0x31 with a random 6-byte
144/// challenge (0x3C). Some devices skip the challenge and send 0x33 directly —
145/// indicating immediate key acceptance (observed mostly when the controller's
146/// TX→RX turnaround is slow enough that the 0x3C is missed). Both are accepted.
147///
148/// Uses `ExchangeEngine::listen()` with `ListenPolicy::HOLD_REQUEST_CHANNEL`: this is a unicast
149/// reply to a unicast request (the 0x31 key-init), and every measured unicast pairing reply came
150/// back on the request channel, so there is nothing here to hop for — same reasoning as
151/// `listen_for_key_confirm_()`. This loop runs on all three chips (it is called before the
152/// `has_fast_tx_rx_turnaround()` branch in `run_key_exchange_phase_()`), unlike the dedicated
153/// confirm wait, which only slow-turnaround radios reach.
154bool PairingEngine::wait_for_key_challenge_(uint32_t timeout_ms, RadioRxPacket &packet, IoFrame &challenge_frame,
155 const uint8_t device_node_id[NODE_ID_SIZE]) {
156 ListenSpec spec;
157 spec.window_ms = timeout_ms;
159
160 bool saw_traffic = false;
161 auto outcome =
162 engine_.listen(spec, packet, challenge_frame, [&](const IoFrame *parsed, const RadioRxPacket & /*packet*/) {
163 saw_traffic = true;
164 if (parsed == nullptr)
166 const int16_t rssi = radio_()->get_last_capture().rssi_dbm;
167 if (parsed->cmd == CMD_KEY_CONFIRM && memcmp(parsed->src, device_node_id, NODE_ID_SIZE) == 0 &&
168 memcmp(parsed->dst, node_id_, NODE_ID_SIZE) == 0) {
169 this->telemetry_.record_rx(*parsed, rssi);
171 }
172 if (decisions::classify_pairing_key_challenge(*parsed, device_node_id, node_id_) !=
174 this->telemetry_.record_rx_reject(*parsed, rssi);
176 }
177 this->telemetry_.record_rx(*parsed, rssi);
179 });
180
181 if (outcome == ListenOutcome::ACCEPTED)
182 return true;
183 ESP_LOGW(TAG, saw_traffic ? "Key exchange: no valid challenge received" : "Key exchange: no challenge received");
184 return false;
185}
186
187/// Wait for one discover-confirm (0x2C) try's answer.
188///
189/// Listen policy alternates per try (`decisions::discover_confirm_try_rotates()`): the one
190/// rotating try hedges against a 0x2D landing off the request channel — Somfy answers on the
191/// request channel, VELUX is unmeasured — while the rest hold, matching every other unicast
192/// pairing wait in this file (a 0x2D is a unicast reply to a unicast 0x2C). Only parsed frames are
193/// recorded to telemetry, same as the other pairing waits.
195 uint8_t try_index, pairing::PairingContext &context) {
196 ListenSpec spec;
200 // Same shape as the discovery listen's rotating spec: a short rotating dwell needs the
201 // preamble/sync linger guard so an incoming reply is not cut off mid-retune.
202 spec.linger_on_preamble = true;
203 spec.linger_dwell_ms = PREAMBLE_LINGER_DWELL_MS;
204 spec.on_hop = [this]() { this->telemetry_.record_hop(); };
205 } else {
207 }
208
210 auto outcome =
211 engine_.listen(spec, context.packet, context.rx, [&](const IoFrame *parsed, const RadioRxPacket & /*packet*/) {
212 if (parsed == nullptr)
213 return ReplyDisposition::IGNORE;
214 const int16_t rssi = radio_()->get_last_capture().rssi_dbm;
215 disposition = decisions::classify_pairing_discover_confirm_reply(*parsed, context.device.node_id, node_id_);
216 if (disposition == decisions::PairingDiscoverConfirmDisposition::IGNORE) {
217 this->telemetry_.record_rx_reject(*parsed, rssi);
218 return ReplyDisposition::IGNORE;
219 }
220 this->telemetry_.record_rx(*parsed, rssi);
222 });
223
224 if (outcome != ListenOutcome::ACCEPTED)
225 return decisions::PairingDiscoverConfirmDisposition::IGNORE; // Timeout: no reply this try.
226 return disposition;
227}
228
229/// One HOLD_REQUEST_CHANNEL listen for the key-transfer confirm wait — see the header doc for why
230/// this is a shared helper rather than an inline lambda repeated at each of its two call sites.
231///
232/// Deliberately holds the request channel rather than hopping: a key confirm is a unicast reply to
233/// a unicast request, and every measured unicast pairing reply — the 0x33 in the corpus pairing
234/// captures on all three chips, every 0x3C, field-logged 0xFE error replies — came back on the
235/// channel the request went out on. Only replies to *broadcasts* are measured off the request
236/// channel, so there is nothing here to hop for, and hopping loses any device that answers later
237/// than one slice (real devices answer some requests at 246+ ms). HOLD also does not slice the
238/// wait: slicing exists so a hopping loop gets a chance to hop between dwells, and to keep the
239/// watchdog fed during a long silent wait; neither applies here, and wait_for_packet() already
240/// feeds the watchdog internally while it blocks.
241decisions::PairingKeyConfirmDisposition PairingEngine::listen_for_key_confirm_(pairing::PairingContext &context,
242 uint8_t try_number,
243 bool after_challenge) {
244 ListenSpec spec;
245 spec.window_ms = PAIRING_KEY_CONFIRM_TIMEOUT_MS;
246 spec.policy = ListenPolicy::HOLD_REQUEST_CHANNEL;
247
248 bool saw_any = false;
249 auto disposition = decisions::PairingKeyConfirmDisposition::IGNORE;
250 auto outcome =
251 engine_.listen(spec, context.packet, context.resp, [&](const IoFrame *parsed, const RadioRxPacket &packet) {
252 saw_any = true;
253 ESP_LOGD(TAG, "Key confirm wait: got %u bytes on freq=%" PRIu32, packet.len, packet.freq_hz);
254 if (parsed == nullptr) {
255 ESP_LOGD(TAG, "Key confirm wait: parse failed");
256 return ReplyDisposition::IGNORE;
257 }
258 ESP_LOGD(TAG, "Key confirm wait: parsed cmd=0x%02X src=%02X%02X%02X dst=%02X%02X%02X", parsed->cmd,
259 parsed->src[0], parsed->src[1], parsed->src[2], parsed->dst[0], parsed->dst[1], parsed->dst[2]);
260 disposition = decisions::classify_pairing_key_confirm_reply(context.req, *parsed);
261 if (disposition == decisions::PairingKeyConfirmDisposition::IGNORE)
262 return ReplyDisposition::IGNORE;
263
264 const int16_t rssi = radio_()->get_last_capture().rssi_dbm;
265 if (disposition == decisions::PairingKeyConfirmDisposition::REFUSE) {
266 this->telemetry_.record_rx_reject(*parsed, rssi);
267 if (parsed->cmd == CMD_ERROR_RESP) {
268 this->note_key_refusal_(context, *parsed, "Key transfer");
269 } else {
270 ESP_LOGW(TAG, "Key transfer: device responded with cmd=%s(0x%02X) (expected KEY_CONFIRM 0x33)",
271 command_name(parsed->cmd), parsed->cmd);
272 }
273 return ReplyDisposition::ABORT;
274 }
275 // CONFIRM (0x33) or CHALLENGE (a well-formed 0x3C): both are legitimate answers, not
276 // rejections. No TX happens in this callback — a CHALLENGE is answered by the caller
277 // after this listen ends, per the header doc.
278 this->telemetry_.record_rx(*parsed, rssi);
279 return disposition == decisions::PairingKeyConfirmDisposition::CONFIRM ? ReplyDisposition::ACCEPT
280 : ReplyDisposition::ABORT;
281 });
282
283 if (outcome == ListenOutcome::TIMED_OUT) {
284 ESP_LOGI(TAG, "Try %u%s: no response for key transfer (0x32) within %" PRIu32 " ms (saw_any=%d)", try_number,
285 after_challenge ? " (post-challenge)" : "", PAIRING_KEY_CONFIRM_TIMEOUT_MS, saw_any);
286 return decisions::PairingKeyConfirmDisposition::IGNORE;
287 }
288 return disposition;
289}
290
291/// Transmit the 0x32 key transfer and wait for 0x33 key confirm (with retry).
292///
293/// Only reached on slow-turnaround radios (`RadioDriver::has_fast_tx_rx_turnaround() == false`,
294/// i.e. SX1262/LR1121): fast-turnaround radios (SX1276) catch the 0x33 through the standard
295/// `ExchangeEngine::send_and_receive_()` / `wait_for_first_response_()` path instead and never
296/// call this function — see `run_key_exchange_phase_()`. Uses the driver's response_preamble()
297/// (drivers whose TX waveform needs more lock-on margin return a longer preamble). Retries up to
298/// EXCHANGE_RETRY_COUNT times on timeout.
300 for (uint8_t tries = 0; tries < EXCHANGE_RETRY_COUNT; tries++) {
301 if (tries > 0) {
302 App.feed_wdt();
303 delay(EXCHANGE_RETRY_DELAY_MS);
304 }
305 if (!engine_.transmit_frame(context.req, FREQ_CH2, radio_()->response_preamble()))
306 continue;
307
308 const uint8_t try_number = tries + 1;
309 decisions::PairingKeyConfirmDisposition disposition = listen_for_key_confirm_(context, try_number, false);
311 // "Rest of the window" would leave almost nothing after a late challenge plus the 0x3D TX,
312 // so this is a fresh full window, same as ExchangeEngine::wait_for_final_response_() gets
313 // after handle_authentication_(). A failed 0x3D build/TX leaves `disposition` at CHALLENGE,
314 // which the checks below treat the same as a second challenge: this try ends without
315 // confirming, and the next try re-sends 0x32.
316 // Logged after the 0x3D, not before: a log line between challenge and answer delays the
317 // answer, and some receivers drop a late one (issue #163). RX is already re-armed by then.
318 const bool answered = engine_.answer_challenge(context.req, context.resp, FREQ_CH2);
319 ESP_LOGI(TAG, "Key transfer: device challenged the key transfer, %s", answered ? "answered 0x3D" : "0x3D failed");
320 if (answered)
321 disposition = listen_for_key_confirm_(context, try_number, true);
322 }
323
325 return true;
327 return false; // An explicit refusal must not spend the remaining retries; a challenge is
328 // not a refusal, so neither branch above returns false for it.
329 // IGNORE (timeout) or a second CHALLENGE in the same try: spend the next try.
330 }
331 return false;
332}
333
334/// Build CMD_KEY_TRANSFER against the challenge currently held in `context.rx.data` and wait for
335/// the 0x33 confirm, routing through the fast- or slow-turnaround path. Shared by
336/// run_key_exchange_phase_()'s first attempt and its slow-turnaround retry loop, which calls this
337/// again against a freshly re-issued challenge (see that function's doc comment) rather than
338/// discarding it.
341 engine_.record_debug(pairing_stage_name(context.state), 1, true);
342 this->telemetry_.set_phase(context.state);
343 if (!create_key_transfer(context.req, context.key_init, context.device.node_id, node_id_, system_key_,
344 context.rx.data)) {
345 return false;
346 }
347
349 engine_.record_debug(pairing_stage_name(context.state), 1, true);
350 this->telemetry_.set_phase(context.state);
351 // Fast-turnaround radios catch the 0x33 through the standard exchange wait. Slow-turnaround
352 // radios miss it while re-entering RX, so run_key_exchange_phase_()'s retry loop calls this
353 // helper again instead, rather than using the dedicated wait loop directly.
354 if (radio_()->has_fast_tx_rx_turnaround()) {
355 // Key exchange is the one caller that genuinely needs the payload: without the 0x33 there is
356 // no confirmation the device took the key, so an unconfirmed acceptance is not good enough.
357 if (engine_.send_and_receive(context.req, context.resp, FREQ_CH2) != ExchangeOutcome::SUCCESS_WITH_RESPONSE)
358 return false;
359 if (context.resp.cmd == CMD_ERROR_RESP)
360 this->note_key_refusal_(context, context.resp, "Key transfer");
361 return frame_is_key_confirm(context.resp);
362 }
363 return wait_for_key_confirm_(context);
364}
365
366// --- Discovery metadata ---
367
368/// Parse a discovery response frame into device metadata and ID.
369///
370/// Decodes node ID, device type, subtype, and the extended fields (manufacturer, backbone,
371/// Multi Information Byte) via decode_discovery_response(), then emits pairing's diagnostic log
372/// lines for whichever extended fields the payload actually included.
374 std::string &device_id) {
375 const DiscoveryResponseInfo info = decode_discovery_response(frame, device, device_id);
376
377 if (frame.data_len > DISCOVERY_RESP_MANUFACTURER_OFFSET) {
378 const char *mfr_name = manufacturer_name(info.manufacturer);
379 ESP_LOGI(TAG, "Discovery: device %s manufacturer=%u (%s)", device_id.c_str(), info.manufacturer, mfr_name);
380 if (info.manufacturer == 0 || info.manufacturer > MANUFACTURER_ID_MAX) {
381 ESP_LOGW(TAG,
382 "Unknown manufacturer ID %u reported by device %s. "
383 "Please file a GitHub issue with this ID and your device model so support can be added.",
384 info.manufacturer, device_id.c_str());
385 }
386 }
387 if (frame.data_len > DISCOVERY_RESP_BACKBONE_OFFSET + NODE_ID_SIZE - 1) {
388 ESP_LOGD(TAG, "Discovery: backbone=%02X%02X%02X", info.backbone[0], info.backbone[1], info.backbone[2]);
389 }
390 if (frame.data_len > DISCOVERY_RESP_FLAGS_OFFSET) {
391 uint8_t const att = discovery_att_class(info.flags);
392 uint8_t const power_save = discovery_power_save_mode(info.flags);
393 ESP_LOGI(TAG, "Discovery: device %s turnaround=%s power_save=%s flags=0x%02X", device_id.c_str(),
394 att_class_name(att), power_save_mode_name(power_save), info.flags);
395 if (power_save == POWER_SAVE_LOW_POWER) {
396 ESP_LOGI(TAG,
397 "Device %s reports low-power mode: add 'low_power: true' to its YAML entry. "
398 "The pairing snippet below pre-fills it when one is printed.",
399 device_id.c_str());
400 }
401 }
402 return info;
403}
404
405// --- Phase helpers ---
406
407/// Suffix for a log line that names a device kind no capture or report has confirmed on a real device.
408static const char *experimental_note(DeviceType type, uint8_t subtype) {
409 return device_is_experimental(type, subtype) ? " (experimental)" : "";
410}
411
412/// Log what discovery accepted: the device kind, and a warning when it answered as a remote controller.
414 if (context.discovery_metadata_complete) {
415 ESP_LOGI(TAG, "Discovery: device %s: %s (%s)%s%s", context.device_id.c_str(),
416 format_device_kind(context.device.type, context.device.subtype).c_str(),
417 format_device_ids(context.device.type, context.device.subtype).c_str(),
418 experimental_note(context.device.type, context.device.subtype),
419 device_has_platform(context.device.type, context.device.subtype) ? "" : " (not supported yet)");
420 }
422 ESP_LOGW(TAG,
423 "Discovery: device %s answered as a remote controller (node type %u), not as an actuator. "
424 "It may not accept a pushed key; the pairing advisor says what to try if the key step fails.",
425 context.device_id.c_str(), static_cast<unsigned>(context.device.type));
426 }
427}
428
429/// Phase 1: broadcast discovery command(s) and wait for a device response (0x29).
430///
431/// Sends each configured discovery command in order, waiting up to
432/// `pairing_discovery_wait_ms` for a valid response after each TX.
433/// Retries up to PAIRING_DISCOVERY_MAX_ATTEMPTS times per command.
435 if (tuning_->pairing_discovery_initial_dwell_ms > 0) {
436 ESP_LOGD(TAG, "Discovery: initial dwell %u ms", tuning_->pairing_discovery_initial_dwell_ms);
437 delay(tuning_->pairing_discovery_initial_dwell_ms);
438 }
439
440 // Tracks whether any single attempt saw traffic that failed to classify as a valid discovery
441 // response, so the final "gave up after retries" return can distinguish INVALID (something was
442 // heard, just not a valid response) from NO_RESPONSE (nothing heard at all) instead of always
443 // collapsing to NO_RESPONSE.
444 bool saw_invalid = false;
445
446 for (size_t command_index = 0; command_index < tuning_->pairing_discovery_commands.size(); ++command_index) {
447 auto command = static_cast<uint8_t>(tuning_->pairing_discovery_commands[command_index]);
448 const uint8_t *destination = resolve_discovery_destination(command, tuning_->pairing_discovery_destination_auto,
449 tuning_->pairing_discovery_destination.data());
450 ESP_LOGD(TAG, "Discovery command %zu/%zu: cmd=0x%02X dst=%02X%02X%02X", command_index + 1,
451 tuning_->pairing_discovery_commands.size(), command, destination[0], destination[1], destination[2]);
452
453 for (uint8_t attempt = 1; attempt <= PAIRING_DISCOVERY_MAX_ATTEMPTS; ++attempt) {
454 this->telemetry_.increment_discovery_attempt();
456 engine_.record_debug(pairing_stage_name(context.state), attempt, false);
457 this->telemetry_.set_phase(context.state);
458 if (!create_discovery_request(context.req, node_id_, command, destination, tuning_->pairing_discovery_low_power,
459 tuning_->pairing_discovery_ack_capable, tuning_->pairing_discovery_payload_enabled,
460 tuning_->pairing_discovery_payload, system_key_) ||
461 !engine_.transmit_frame(context.req, FREQ_CH2, effective_discovery_preamble(*tuning_))) {
463 }
464
466 engine_.record_debug(pairing_stage_name(context.state), attempt, false);
467 this->telemetry_.set_phase(context.state);
468 auto result = wait_for_discovery_response_(tuning_->pairing_discovery_wait_ms, context.packet, context.rx);
470 const DiscoveryResponseInfo info = parse_device_from_discovery(context.rx, context.device, context.device_id);
471 context.discovery_metadata_complete = context.rx.data_len >= DEVICE_METADATA_SIZE;
472 context.discovery_low_power =
473 info.has_extended && discovery_power_save_mode(info.flags) == POWER_SAVE_LOW_POWER;
474 context.discovery_manufacturer = info.has_extended ? info.manufacturer : 0;
475 this->telemetry_.set_discovered_device(context.device.node_id, context.device.type);
476 log_discovered_device(context);
477 return result;
478 }
480 saw_invalid = true;
481 }
482
483 if (attempt < PAIRING_DISCOVERY_MAX_ATTEMPTS) {
484 ESP_LOGI(TAG, "Discovery attempt %u/%u for cmd=0x%02X: no response, retrying...", attempt,
486 }
487 }
488 }
491}
492
493/// Discover-confirm step (0x2C → 0x2D): sent directly to the device discovery just found, once
494/// per discover_and_pair() attempt, before the key-exchange retry loop begins (so a retry of that
495/// loop never repeats this step).
496///
497/// Never fails a pairing attempt: `skip` mode, a timeout, and an explicit CMD_ERROR_RESP all still
498/// let discover_and_pair() proceed to run_key_exchange_phase_() — this function only decides how
499/// long that takes and what gets logged. CTRL1_ACK follows the *target's* power class, not the
500/// mode alone: a low-power target's 0x2C carries `CTRL1_LOW_POWER` only in both `send` and
501/// `send_with_ack` (every corpus hub's own shape for that class); only an always-alive target's
502/// ACK bit changes between the two modes (see create_discover_confirm()'s doxygen for the
503/// byte-shape cross-check against real captures).
504///
505/// `set_phase()` fires at most once per state for the whole step (not per try): the confirm step
506/// can spend up to PAIRING_DISCOVER_CONFIRM_TRIES transmits, and PAIRING_TELEMETRY_MAX_EVENTS is a
507/// fixed 32-event budget the pairing advisor scans — a per-try phase would spend events on exactly
508/// the failure paths it is meant to diagnose. record_debug() (not telemetry) still runs every try.
510 if (tuning_->pairing_discover_confirm == DiscoverConfirmMode::SKIP)
512
513 const bool ack =
514 tuning_->pairing_discover_confirm == DiscoverConfirmMode::SEND_WITH_ACK && !context.discovery_low_power;
515
517 this->telemetry_.set_phase(context.state);
518
519 bool wait_phase_started = false;
521 for (uint8_t try_index = 1; try_index <= PAIRING_DISCOVER_CONFIRM_TRIES; try_index++) {
522 if (try_index > 1)
523 App.feed_wdt(); // No extra retry gap: the 1.5 s windows already space the retransmissions.
524
525 engine_.record_debug(pairing_stage_name(pairing::PairingState::TX_DISCOVER_CONFIRM), try_index, false);
526 if (!create_discover_confirm(context.req, node_id_, context.device.node_id, context.discovery_low_power, ack) ||
527 !engine_.transmit_frame(context.req, FREQ_CH2, pairing_start_preamble_(context.req))) {
528 continue; // A failed build/TX counts as a silent try, like wait_for_key_confirm_() does for 0x32.
529 }
530
532 if (!wait_phase_started) {
533 this->telemetry_.set_phase(context.state);
534 wait_phase_started = true;
535 }
536 engine_.record_debug(pairing_stage_name(context.state), try_index, false);
537
538 const uint32_t wait_start_ms = millis();
540
542 ESP_LOGI(TAG, "Discover confirm: 0x2D from %s on freq=%" PRIu32 " after %" PRIu32 " ms (try %u/%u)",
543 context.device_id.c_str(), context.packet.freq_hz, millis() - wait_start_ms, try_index,
546 break;
547 }
549 const uint8_t error_code = context.rx.data_len > 0 ? context.rx.data[0] : 0;
550 ESP_LOGW(TAG, "Discover confirm: device %s answered 0x2C with error 0x%02X, continuing with key exchange",
551 context.device_id.c_str(), error_code);
553 break;
554 }
555 // IGNORE: timeout or an unrelated frame this try — spend the next try.
556 }
557
559 ESP_LOGI(TAG, "Discover confirm: no 0x2D from %s after %u tries, continuing with key exchange",
561 }
562
563 // Applied for every result reaching here (i.e. every result except SKIPPED, which already
564 // returned above), including NO_REPLY — one simple rule instead of a per-outcome one.
565 if (tuning_->pairing_key_init_delay_ms > 0)
566 delay_feeding_wdt(tuning_->pairing_key_init_delay_ms);
567
568 return result;
569}
570
571/// Phase 2: authenticated key exchange (0x31 → 0x3C → 0x32 → 0x33).
572///
573/// Steps:
574/// 1. Transmit CMD_KEY_INIT (0x31), preamble per pairing_start_preamble_()
575/// 2. Wait for device challenge (0x3C)
576/// 3. Transmit CMD_KEY_TRANSFER (0x32) with encrypted system key
577/// 4. Wait for CMD_KEY_CONFIRM (0x33)
578///
579/// Step 4 depends on the driver's TX→RX turnaround: fast-turnaround radios await
580/// the 0x33 through the standard send_and_receive() exchange; slow-turnaround
581/// radios use the dedicated wait_for_key_confirm_() path with a key-init
582/// re-trigger, because the 0x33 would otherwise arrive while the receiver is
583/// still settling (see RadioDriver::has_fast_tx_rx_turnaround()).
584///
585/// The slow-turnaround re-trigger loop below (`for (int re = 0; ...)`) has two distinct outcomes
586/// for its re-sent CMD_KEY_INIT, and they are handled differently:
587/// - The device replies with CMD_KEY_CONFIRM (0x33) directly — it already had the key from the
588/// first 0x32 and is just auto-confirming again. Nothing more to send.
589/// - The device replies with a *fresh* CMD_CHALLENGE_REQ (0x3C) instead — proof it never received
590/// the first 0x32 at all (a device that already holds the key doesn't re-challenge), so there
591/// is no key transfer for it to confirm yet. The loop calls transfer_key_and_wait_confirm_()
592/// again here, replaying CMD_KEY_TRANSFER against this new challenge, rather than discarding
593/// the 0x3C and burning the retry for nothing — the device's next 0x31 would just produce
594/// another fresh challenge either way, so retrying without resending 0x32 could never succeed.
595///
596/// That replay roughly doubles this function's worst-case blocking time when every wait times out
597/// (approximately 3.5s -> 6.6s: two of the loop's iterations can now each wait out a full
598/// key-transfer-and-confirm cycle instead of returning immediately on a missed 0x33). This is a
599/// known, accepted trade-off: pairing already tolerates multi-second blocking exchanges (see
600/// EXCHANGE_TOTAL_BUDGET_MS's own reasoning, proto_timing.h), and the alternative — leaving a
601/// slow-turnaround device that never got its key stuck retrying forever — is worse than the
602/// occasional slower failure path.
605 engine_.record_debug(pairing_stage_name(context.state), 1, false);
606 this->telemetry_.set_phase(context.state);
607 if (!create_key_init(context.key_init, node_id_, context.device.node_id) ||
608 !engine_.transmit_frame(context.key_init, FREQ_CH2, pairing_start_preamble_(context.key_init))) {
609 return false;
610 }
611
613 engine_.record_debug(pairing_stage_name(context.state), 1, true);
614 this->telemetry_.set_phase(context.state);
616 return false;
617 }
618
619 // Some devices send 0x33 directly after 0x31 without requiring 0x32.
620 if (context.rx.cmd == CMD_KEY_CONFIRM) {
621 ESP_LOGI(TAG, "Device accepted key immediately (0x33 without 0x32 exchange)");
622 context.resp = context.rx;
623 return true;
624 }
625
626 context.saw_key_challenge = true;
627 // No challenge bytes here: the raw 0x3C payload plus the 0x3D response it provokes is a
628 // known-plaintext/known-ciphertext pair under the system key (see redaction.h). The generic
629 // frame-log helpers (log_frame()/log_component_capture()) already mask both commands.
630 ESP_LOGI(TAG, "Challenge (0x3C) received: data_len=%u freq=%" PRIu32 " rssi=%d", context.rx.data_len,
631 context.packet.freq_hz, radio_()->get_last_capture().rssi_dbm);
632
633 bool key_ok = transfer_key_and_wait_confirm_(context);
634 // Fast-turnaround radios catch the 0x33 through the standard exchange wait inside the helper
635 // above and never reach this loop. Slow-turnaround radios miss it while re-entering RX, so on a
636 // miss they re-send the key-init to trigger the device's auto-confirm. An explicit refusal is
637 // the device's answer, not a missed confirm: re-sending the key-init after it only draws the same
638 // refusal again (or, seen in the field, a refusal of a second key the device did not need).
639 if (!radio_()->has_fast_tx_rx_turnaround()) {
640 for (int re = 0; !key_ok && !context.key_refused && re < 2; re++) {
641 ESP_LOGI(TAG, "Key confirm missed, re-sending key-init to trigger auto-confirm (attempt %d/2)", re + 1);
642 App.feed_wdt();
643 delay(EXCHANGE_RETRY_DELAY_MS);
644 if (!engine_.transmit_frame(context.key_init, FREQ_CH2, pairing_start_preamble_(context.key_init)))
645 continue;
647 context.device.node_id))
648 continue;
649 if (context.rx.cmd == CMD_KEY_CONFIRM) {
650 key_ok = true;
651 continue;
652 }
653 context.saw_key_challenge = true;
654 // A fresh CHALLENGE_REQ (0x3C), not a direct KEY_CONFIRM (0x33): the device never received
655 // our first 0x32 in the first place, so there is no key for it to auto-confirm yet -- a
656 // fresh 0x3C is exactly the signal that the right response is "resend 0x32 against this new
657 // challenge", not "give up and burn the retry for nothing".
658 key_ok = transfer_key_and_wait_confirm_(context);
659 }
660 }
661 if (!key_ok) {
662 ESP_LOGW(TAG, "Key exchange failed");
663 return false;
664 }
665 return true;
666}
667
668/// Phase 3: send SetConfig1 (0x6F) once. Optional — see the header. Its preamble follows
669/// pairing_start_preamble_(), like the other directed pairing start frames.
671 if (!create_set_config1(context.req, node_id_, context.device.node_id))
672 return;
673 // Its reply is never read: an explicit refusal and silence are equally harmless here.
674 if (engine_.send_and_receive(context.req, context.resp, FREQ_CH2, PAIRING_SET_CONFIG1_MAX_TRIES,
675 pairing_start_preamble_(context.req)) == ExchangeOutcome::FAILED) {
676 ESP_LOGI(TAG, "Pairing: no reply from %s to the optional SetConfig1 (0x6F); the pairing is complete",
677 context.device_id.c_str());
678 }
679}
680
681// --- Orchestrator ---
682
683/// Pairing orchestrator — high-level three-phase flow.
684///
685/// Phase 1: run_discovery_phase_() finds a device in pairing mode.
686/// Phase 2: run_key_exchange_phase_() performs authenticated key establishment.
687/// Phase 3: finalize_pairing_configuration_() sends the optional SetConfig1 once.
688///
689/// On success the device is added to the registry and a YAML snippet is printed to the log.
690/// The hub's thin wrapper manages the busy_ flag before and after this call.
692 this->telemetry_.begin();
693 this->engine_.set_transmit_observer(&this->telemetry_);
694
695 // Seed telemetry with a 1W pairing-gesture frame the hub overheard shortly before this call
696 // (issue #27/#65): without this, a PROG press completed a few seconds before "Discover & Pair"
697 // is pressed in the app is invisible to the advisor purely because the telemetry window opens
698 // here, even though the radio heard it just fine — the false rf_silent advice this produced was
699 // field-confirmed against a real trace (issue #27, miljaar).
700 if (this->recent_oneway_sighting_.seen_ms != 0 &&
701 (millis() - this->recent_oneway_sighting_.seen_ms) < PAIRING_RECENT_ONE_WAY_SIGHTING_WINDOW_MS) {
702 this->telemetry_.record_recent_one_way_sighting(this->recent_oneway_sighting_);
703 }
704
705 ESP_LOGI(TAG, "Starting device discovery...");
706 ESP_LOGI(TAG, "%s", tuning_config_full_snapshot(*tuning_).c_str());
707
709
710 // Phase 1: Discovery
711 auto disc_disp = run_discovery_phase_(context);
713 log_discovery_diagnostic(disc_disp);
714 this->finish_pairing_attempt_(disc_disp == decisions::PairingDiscoveryDisposition::INVALID
717 return false;
718 }
719
720 if (tuning_->pairing_discovery_preamble_set) {
721 ESP_LOGI(TAG, "Pairing: discovery preamble %u B (set in tuning); directed frames to %s are capped to it",
722 effective_discovery_preamble(*tuning_), context.device_id.c_str());
723 }
724
725 // Discover-confirm (0x2C -> 0x2D): once per attempt, before the key-exchange retry loop below,
726 // so a retry of that loop never repeats it. Never fails the attempt — see the function's doc.
727 this->run_discover_confirm_step_(context);
728
729 // Phase 2: move the key, in the direction `pairing_key_transfer` selects (auto: the discovery
730 // answer decides).
731 if (key_pull_selected_(context)) {
732 bool paired = false;
733 if (this->run_pull_and_finish_(context, paired))
734 return paired;
735 } else if (!run_push_key_step_(context)) {
736 this->finish_pairing_attempt_(context.key_refused ? PairingOutcome::KEY_TRANSFER_REFUSED
738 return false;
739 }
740
741 // Phase 3: optional SetConfig1. The 0x33 above completed the pairing.
743
745 engine_.record_debug(pairing_stage_name(context.state), 1, true);
746 this->telemetry_.set_phase(context.state);
747
748 // The device just sent its 0x33, so it counts as heard from: its wake belief starts at "maybe
749 // awake" for the follow-up exchanges rather than "asleep" (same stamp update_link_health() sets).
750 context.device.last_seen_ms = millis();
751 // A YAML entry for this node keeps its declared status; anything else lives until reboot.
752 context.device.registered_by_pairing = registry_.get_declared(context.device_id) == nullptr;
753 registry_.put(context.device_id, context.device);
754 this->telemetry_.set_paired_device(context.device.node_id, context.device.type);
755
756 const std::string type_diag = format_device_type_diagnostic(context.device.type);
757 const std::string type_yaml = format_device_type_for_yaml(context.device.type);
758 const std::string snippet =
761
762 if (snippet.empty()) {
763 // metadata_complete is true here (build_device_yaml_snippet() never returns empty for
764 // metadata_complete == false), so this is specifically "we know the type, but there's no
765 // ESPHome platform for it yet" (device_has_platform()).
766 ESP_LOGW(TAG,
767 "Device %s paired successfully (%s), but no ESPHome platform drives it yet: type=%s "
768 "class=%s subtype=%u.",
769 context.device_id.c_str(), format_device_kind(context.device.type, context.device.subtype).c_str(),
770 type_diag.c_str(), device_capability_class_name(context.device.type), context.device.subtype);
771 ESP_LOGW(TAG,
772 "No ready-to-paste YAML was generated. If you want to experiment manually, choose the most likely "
773 "platform and set io_device_type: %s.",
774 type_yaml.c_str());
775 ESP_LOGW(TAG, "Please file a GitHub issue with this device type, subtype, model, and the pairing log so support "
776 "can be added.");
777
779 engine_.record_debug(pairing_stage_name(context.state), 1, true);
780 this->telemetry_.set_phase(context.state);
781 this->finish_pairing_attempt_(PairingOutcome::PAIRED);
782 return true;
783 }
784
785 if (!context.discovery_metadata_complete) {
786 ESP_LOGW(TAG,
787 "Device %s paired successfully, but the discovery response did not include type/subtype "
788 "metadata, so the platform (cover/light/switch/lock) can't be determined automatically.",
789 context.device_id.c_str());
790 ESP_LOGI(TAG, "Add this to your YAML once you know what kind of device it is:\n%s", snippet.c_str());
791 ESP_LOGW(TAG, "Please file a GitHub issue with the pairing log and device model so this discovery edge case can "
792 "be investigated.");
793
795 engine_.record_debug(pairing_stage_name(context.state), 1, true);
796 this->telemetry_.set_phase(context.state);
797 this->finish_pairing_attempt_(PairingOutcome::PAIRED);
798 return true;
799 }
800
801 ESP_LOGI(TAG, "Device %s (%s%s) paired successfully! Add this to your YAML:\n%s", context.device_id.c_str(),
802 format_device_kind(context.device.type, context.device.subtype).c_str(),
803 experimental_note(context.device.type, context.device.subtype), snippet.c_str());
804
805 if (yaml_device_type_name(context.device.type) == nullptr) {
806 ESP_LOGW(TAG,
807 "This snippet uses the raw device type %s because the project does not yet expose a named YAML alias "
808 "for %s.",
809 type_yaml.c_str(), type_diag.c_str());
810 ESP_LOGW(TAG, "Please file a GitHub issue with this type, subtype, device model, and the pairing log so support "
811 "can be added.");
812 }
813
815 engine_.record_debug(pairing_stage_name(context.state), 1, true);
816 this->telemetry_.set_phase(context.state);
817 this->finish_pairing_attempt_(PairingOutcome::PAIRED);
818 return true;
819}
820
821bool PairingEngine::run_push_key_step_(pairing::PairingContext &context) {
822 for (uint8_t ke_attempt = 0; ke_attempt < tuning_->pairing_key_exchange_retries; ke_attempt++) {
823 if (ke_attempt > 0) {
824 ESP_LOGI(TAG, "Retrying key exchange (attempt %d/%u)...", ke_attempt + 1, tuning_->pairing_key_exchange_retries);
825 App.feed_wdt();
826 delay(EXCHANGE_RETRY_DELAY_MS);
827 }
828 if (run_key_exchange_phase_(context))
829 return true;
830 // A refusal is final for this attempt: retrying the same push only repeats the question.
831 if (context.key_refused)
832 break;
833 }
834 // The device challenged our key-init, so it was talking to us; it may still have taken a key
835 // whose confirm we lost. Without a challenge there is nothing to check.
836 if (!context.saw_key_challenge)
837 return false;
838 return verify_device_holds_key_(context);
839}
840
843 engine_.record_debug(pairing_stage_name(context.state), 1, false);
844 this->telemetry_.set_phase(context.state);
845 ESP_LOGI(TAG, "Key exchange: not confirmed; checking whether %s took the key anyway (authenticated name read)",
846 context.device_id.c_str());
847 if (!create_get_name(context.req, node_id_, context.device.node_id, context.discovery_low_power))
848 return false;
849 const ExchangeOutcome outcome = engine_.send_and_receive(
850 context.req, context.resp, FREQ_CH2, PAIRING_KEY_VERIFY_MAX_TRIES, pairing_start_preamble_(context.req));
851 const bool held = outcome == ExchangeOutcome::SUCCESS_WITH_RESPONSE && engine_.get_debug().saw_challenge &&
852 context.resp.cmd == CMD_GET_NAME_RESP;
853 if (held) {
854 ESP_LOGW(TAG,
855 "Key exchange: %s never confirmed the key, but it answered an authenticated request with it. "
856 "Treating it as paired.",
857 context.device_id.c_str());
858 } else if (outcome == ExchangeOutcome::SUCCESS_WITH_RESPONSE && context.resp.cmd == CMD_ERROR_RESP &&
859 context.resp.data_len > 0) {
860 // Refused outright: the device never got as far as checking a key.
861 const uint8_t code = context.resp.data[0];
862 ESP_LOGW(TAG,
863 "Key exchange failed: %s refused the name read with 0x%02X %s (%s), so it does not act as a "
864 "paired device",
865 context.device_id.c_str(), code, command_result_name(code), command_result_description(code));
866 } else {
867 ESP_LOGW(TAG, "Key exchange failed: %s does not answer authenticated requests with our key",
868 context.device_id.c_str());
869 }
870 return held;
871}
872
873void PairingEngine::note_key_refusal_(pairing::PairingContext &context, const IoFrame &reply, const char *step) {
874 const uint8_t code = reply.data_len > 0 ? reply.data[0] : 0;
875 context.key_refused = true;
876 context.key_refusal_code = code;
877 this->telemetry_.set_key_refusal(code);
878 ESP_LOGW(TAG, "%s: device %s refused with 0x%02X %s (%s; code %u, state %u)", step, context.device_id.c_str(), code,
880}
881
882bool PairingEngine::wait_for_pulled_key_(pairing::PairingContext &context, uint32_t window_ms) {
883 ListenSpec spec;
884 spec.window_ms = window_ms;
885 spec.policy = ListenPolicy::HOLD_REQUEST_CHANNEL; // Unicast reply to a unicast request, as for 0x33.
886
887 auto outcome =
888 engine_.listen(spec, context.packet, context.rx, [&](const IoFrame *parsed, const RadioRxPacket & /*packet*/) {
889 if (parsed == nullptr || memcmp(parsed->src, context.device.node_id, NODE_ID_SIZE) != 0 ||
890 memcmp(parsed->dst, node_id_, NODE_ID_SIZE) != 0)
891 return ReplyDisposition::IGNORE;
892 const int16_t rssi = radio_()->get_last_capture().rssi_dbm;
893 if (parsed->cmd == CMD_KEY_TRANSFER && parsed->data_len >= AES_KEY_SIZE) {
894 this->telemetry_.record_rx(*parsed, rssi);
895 return ReplyDisposition::ACCEPT;
896 }
897 this->telemetry_.record_rx_reject(*parsed, rssi);
898 if (parsed->cmd == CMD_ERROR_RESP) {
899 this->note_key_refusal_(context, *parsed, "Key pull");
900 return ReplyDisposition::ABORT;
901 }
902 return ReplyDisposition::IGNORE;
903 });
904 return outcome == ListenOutcome::ACCEPTED;
905}
906
908 switch (tuning_->pairing_key_transfer) {
910 return true;
912 return false;
914 break;
915 }
916 // A device that answers discovery as a remote controller (node type 1023) hands out its own key and
917 // refuses a pushed one; every other device takes a push. No switch to the other direction inside the
918 // attempt: a refusal repeats deterministically, and a retry only costs pairing time.
919 const bool pull = context.device.type == DeviceType::REMOTE_CONTROLLER;
920 ESP_LOGI(TAG, "Key transfer: auto -> %s (%s answered discovery as %s)", pull ? "pull" : "push",
921 context.device_id.c_str(), format_device_kind(context.device.type, context.device.subtype).c_str());
922 return pull;
923}
924
926 const uint8_t *key, bool device_key,
927 bool key_proven) {
928 // Every device that holds the key answers a roll-call built with it. For an Atlantic AGILIA radiator
929 // that is the heater at the next address up, not the node that handed the key over.
931 engine_.record_debug(pairing_stage_name(context.state), 1, true);
932 this->telemetry_.set_phase(context.state);
933 ScanState state{};
934 // An Atlantic radiator's heater answers a direct read every time but a roll-call only now and then
935 // (about one request in four), so it is asked first; the roll-call is the fallback, and the only
936 // search for any other device.
937 if (!this->probe_heater_at_next_address_(context, key, device_key, state)) {
938 uint8_t attempt = 0;
939 const uint8_t total_attempts = count_enabled_passes(tuning_->scan_power_classes) * SCAN_CHANNEL_COUNT;
940 ESP_LOGI(TAG, "Roll-call with key check value %s (%s %s handed over)", detail::format_key_check_value(key).c_str(),
941 device_key ? "the key" : "the hub key, which", context.device_id.c_str());
942 if (run_roll_call_sweep(engine_, node_id_, key, /*key_slot=*/device_key ? 1 : 0, *tuning_, state, attempt,
943 total_attempts) == RollCallAttemptResult::BUILD_FAILED) {
944 ESP_LOGW(TAG, "Could not build the roll-call under the pulled key");
945 }
946 }
947
948 // One report for the device log only: with a device key it carries the key, so the pairing summary, the
949 // Last Pairing Result sensor and every Home Assistant event keep to the key check value. A hub-key report
950 // carries no key and has no banner.
951 KeyHolderSearch found{};
952 found.answered = state.count;
953 // An unproven key that no device answered under proves nothing: no report, so it is never printed.
954 if (!key_proven && found.answered == 0)
955 return found;
956
957 const PulledKeyReportInfo info{context.device_id, context.device.type, context.device.subtype, key,
958 system_key_, key_proven};
959 if (device_key) {
960 ESP_LOGW(TAG, "========================================");
961 detail::log_multiline_result(TAG, /*is_warning=*/true, /*prefix=*/"",
962 build_pulled_key_report(info, state, registry_));
963 ESP_LOGW(TAG, "========================================");
964 } else if (state.count != 0) {
965 detail::log_multiline_result(TAG, /*is_warning=*/false, /*prefix=*/"",
966 build_hub_key_pull_report(info, state, registry_));
967 }
968
969 // Register each device the report offers a block for, so it can be controlled until the YAML is
970 // flashed: with the key it answered under when that is a device key, with the hub key otherwise. A
971 // device the YAML declares keeps its record; one an earlier pairing in this boot added is replaced, so
972 // pairing it again counts as paired. One of a type no platform drives yet is reported but not registered.
973 for (uint8_t i = 0; i < state.count; i++) {
974 const ScanResponder &responder = state.responders[i];
975 const std::string responder_id = node_id_to_string(responder.src);
976 if (registry_.get_declared(responder_id) != nullptr || !device_has_platform(responder.type, responder.subtype))
977 continue;
978 IoDevice device{};
979 memcpy(device.node_id, responder.src, NODE_ID_SIZE);
980 device.type = responder.type;
981 device.subtype = responder.subtype;
982 device.low_power = responder.has_extended && discovery_power_save_mode(responder.flags) == POWER_SAVE_LOW_POWER;
983 device.has_system_key = device_key;
984 if (device_key)
985 memcpy(device.system_key, key, AES_KEY_SIZE);
986 device.last_seen_ms = millis();
987 device.registered_by_pairing = true;
988 registry_.put(responder_id, device);
989 found.registered++;
990 ESP_LOGI(TAG, "Pairing: registered %s (%s) until reboot; paste its block above to keep it", responder_id.c_str(),
991 format_device_kind(responder.type, responder.subtype).c_str());
992 }
993 return found;
994}
995
997 bool device_key, ScanState &state) {
998 uint8_t heater[NODE_ID_SIZE];
1000 !decisions::next_node_id(context.device.node_id, heater))
1001 return false;
1002 const std::string heater_id = node_id_to_string(heater);
1003 // A YAML entry keeps its own key; the roll-call reports it against that.
1004 if (registry_.get_declared(heater_id) != nullptr)
1005 return false;
1006
1007 // The exchange picks its key from the registry, so the candidate is in it for the read only; the
1008 // caller registers it properly if it answered. A record an earlier pairing in this boot left there is
1009 // put back afterwards.
1010 const IoDevice *earlier = registry_.get(heater_id);
1011 const bool had_earlier = earlier != nullptr;
1012 const IoDevice previous = had_earlier ? *earlier : IoDevice{};
1013 IoDevice candidate{};
1014 memcpy(candidate.node_id, heater, NODE_ID_SIZE);
1016 candidate.subtype = SUBTYPE_HEATER_WITH_SETPOINT;
1017 candidate.has_system_key = device_key;
1018 if (device_key)
1019 memcpy(candidate.system_key, key, AES_KEY_SIZE);
1020 candidate.registered_by_pairing = true;
1021 registry_.put(heater_id, candidate);
1022
1023 // The maximum setpoint is a read-only register every such heater holds, so reading it changes nothing.
1024 uint8_t payload[HEATING_PAYLOAD_MAX_SIZE];
1025 const size_t payload_len = encode_heating_read(HeatingFunction::MAX_SETPOINT, payload);
1026 IoFrame request{};
1027 IoFrame response{};
1028 float max_setpoint_c = NAN;
1029 ESP_LOGI(TAG, "Heater probe: reading %s, the address after %s, under key check value %s", heater_id.c_str(),
1030 context.device_id.c_str(), detail::format_key_check_value(key).c_str());
1031 const bool answered =
1032 payload_len != 0 && create_write_private(request, node_id_, heater, /*low_power=*/false, payload, payload_len) &&
1033 engine_.send_and_receive(request, response, FREQ_CH2) == ExchangeOutcome::SUCCESS_WITH_RESPONSE &&
1034 decode_heating_reply(response, HeatingFunction::MAX_SETPOINT, max_setpoint_c);
1035 const int16_t rssi = radio_()->get_last_capture().rssi_dbm;
1036 if (had_earlier) {
1037 registry_.put(heater_id, previous);
1038 } else {
1039 registry_.remove_registered_by_pairing(heater_id);
1040 }
1041 if (!answered) {
1042 ESP_LOGI(TAG, "Heater probe: %s did not answer; looking for devices with a roll-call instead", heater_id.c_str());
1043 return false;
1044 }
1045 ESP_LOGI(TAG, "Heater probe: %s answered (maximum setpoint %.1f C); no roll-call needed", heater_id.c_str(),
1046 max_setpoint_c);
1047
1048 // Only the register protocol answers this read, and only the heater with an adjustable setpoint
1049 // speaks it; manufacturer and flags come from no reply of its own, so the report omits them.
1050 ScanResponder &responder = state.responders[0];
1051 memcpy(responder.src, heater, NODE_ID_SIZE);
1053 responder.subtype = SUBTYPE_HEATER_WITH_SETPOINT;
1054 responder.rssi_dbm = rssi;
1055 responder.manufacturer = context.discovery_manufacturer;
1056 responder.flags = 0;
1057 responder.has_extended = false;
1058 responder.metadata_complete = true;
1059 responder.key_slot = device_key ? 1 : 0;
1060 state.count = 1;
1061 state.truncated = false;
1062 return true;
1063}
1064
1066 // `node=` in the frozen result string stays the node that was discovered; the responders appear in
1067 // the log report only.
1068 this->telemetry_.set_paired_device(context.device.node_id, context.device.type);
1070 engine_.record_debug(pairing_stage_name(context.state), 1, true);
1071 this->telemetry_.set_phase(context.state);
1072 this->finish_pairing_attempt_(PairingOutcome::PAIRED);
1073}
1074
1076 this->telemetry_.set_key_pull_attempted();
1077 const KeyPullResult pulled = run_key_pull_phase_(context);
1078 if (pulled == KeyPullResult::OTHER_KEY || pulled == KeyPullResult::UNPROVEN) {
1079 paired = this->register_pulled_key_devices_(context, /*key_proven=*/pulled == KeyPullResult::OTHER_KEY);
1080 return true;
1081 }
1082 if (pulled == KeyPullResult::FAILED) {
1083 this->finish_pairing_attempt_(context.key_refused ? PairingOutcome::KEY_TRANSFER_REFUSED
1085 paired = false;
1086 return true;
1087 }
1088 // The device shares the hub's key. A device with a platform of its own is paired as after a push.
1089 if (device_has_platform(context.device.type, context.device.subtype))
1090 return false;
1091 // A node with no platform of its own (an AGILIA's remote-controller node): the devices that hold the
1092 // key are elsewhere, so look for them, as after a foreign key, and register the ones found with the
1093 // hub key. With none found the attempt ends here: the node itself cannot be controlled, and it
1094 // refuses the SetConfig1 the push path sends.
1095 if (this->roll_call_and_register_(context, system_key_, /*device_key=*/false, /*key_proven=*/true).registered > 0) {
1096 this->finish_paired_by_roll_call_(context);
1097 paired = true;
1098 return true;
1099 }
1100 ESP_LOGW(TAG, "Pairing: %s holds this hub's key, but no device that holds it answered; nothing registered",
1101 context.device_id.c_str());
1102 this->finish_pairing_attempt_(PairingOutcome::HUB_KEY_RETURNED);
1103 paired = false;
1104 return true;
1105}
1106
1108 const KeyHolderSearch found =
1109 this->roll_call_and_register_(context, context.pulled_key, /*device_key=*/true, key_proven);
1110 memset(context.pulled_key, 0, AES_KEY_SIZE);
1111 context.has_pulled_key = false;
1112 if (!key_proven && found.answered == 0) {
1113 ESP_LOGW(TAG, "Key pull: no device answered under the unproven key from %s; it is not printed and must not be used",
1114 context.device_id.c_str());
1115 this->finish_pairing_attempt_(PairingOutcome::KEY_EXCHANGE_FAILED);
1116 return false;
1117 }
1118 if (found.registered == 0) {
1119 this->finish_pairing_attempt_(PairingOutcome::KEY_RECEIVED);
1120 return false;
1121 }
1122 this->finish_paired_by_roll_call_(context);
1123 return true;
1124}
1125
1127 ESP_LOGW(TAG, "Key pull: asking %s for its key", context.device_id.c_str());
1128 // One challenge for every try: the IV of the device's 0x32 is built from the request it answers, so
1129 // with the same request bytes a 0x32 that answers an earlier try late still decrypts to the key.
1130 uint8_t challenge[HMAC_SIZE];
1131 crypto::generate_challenge(challenge);
1132 // The request is the same on every try (one challenge), so it is built once and its transmit time
1133 // estimated once for the budget rule.
1134 if (!create_launch_key_transfer(context.req, node_id_, context.device.node_id, challenge)) {
1135 ESP_LOGW(TAG, "Key pull failed: could not build the key transfer request for %s", context.device_id.c_str());
1136 return KeyPullResult::FAILED;
1137 }
1138 const uint16_t preamble = pairing_start_preamble_(context.req);
1139 const decisions::AttemptPlan plan =
1140 engine_.plan_attempts(decisions::ReadKind::COMMAND, context.discovery_low_power, context.req, FREQ_CH2, preamble,
1141 static_cast<uint16_t>(PAIRING_KEY_PULL_REPLY_TIMEOUT_MS));
1142 const uint32_t tx_ms = engine_.tx_time_ms(context.req, FREQ_CH2, preamble);
1143 const uint32_t begin_ms = millis();
1144 bool got_key = false;
1145 for (uint8_t tries = 0; tries < plan.max_attempts && !got_key && !context.key_refused; tries++) {
1146 const uint16_t window_ms = plan.window_for_attempt(tries + 1, plan.max_attempts);
1147 if (tries > 0) {
1148 if (!decisions::attempt_fits(millis() - begin_ms, plan.gap_ms, tx_ms, window_ms, 0,
1149 tuning_->exchange_total_budget_ms)) {
1150 ESP_LOGI(TAG, "Key pull budget exhausted after %u tries (%" PRIu32 " of %u ms)", tries, millis() - begin_ms,
1151 tuning_->exchange_total_budget_ms);
1152 break;
1153 }
1154 App.feed_wdt();
1155 delay(plan.gap_ms); // 0 for a mains plan: a no-op that still yields
1156 }
1158 engine_.record_debug(pairing_stage_name(context.state), tries + 1, false);
1159 this->telemetry_.set_phase(context.state);
1160 if (!engine_.transmit_frame(context.req, FREQ_CH2, preamble))
1161 continue;
1163 this->telemetry_.set_phase(context.state);
1164 got_key = wait_for_pulled_key_(context, window_ms);
1165 if (!got_key && !context.key_refused) {
1166 ESP_LOGI(TAG, "Key pull try %u: no key transfer (0x32) from %s within %u ms", tries + 1,
1167 context.device_id.c_str(), window_ms);
1168 }
1169 }
1170 if (!got_key) {
1171 ESP_LOGW(TAG, "Key pull failed: %s sent no key", context.device_id.c_str());
1172 return KeyPullResult::FAILED;
1173 }
1174
1175 uint8_t key[AES_KEY_SIZE];
1176 if (!recover_key_from_pull(context.req, context.rx.data, key))
1177 return KeyPullResult::FAILED;
1178 // The key check value names the key without revealing it, so two attempts or two devices can be
1179 // compared from the log alone.
1180 const std::string check_value = detail::format_key_check_value(key);
1181 // Our own key cannot come out of the decrypt by chance, so a match proves the device holds it.
1182 if (memcmp(key, system_key_, AES_KEY_SIZE) == 0) {
1183 ESP_LOGI(TAG, "Key pull check: %s -> key check value %s, the hub's own key", context.device_id.c_str(),
1184 check_value.c_str());
1186 }
1187
1189 this->telemetry_.set_phase(context.state);
1190 // The device's 0x3D over its own 0x32, under the key it sent, proves the decrypt and that it holds
1191 // the key (a chance match is 1 in 2^48). A lost answer is challenged again; a wrong one is final.
1192 // Every challenge repeats the same bytes, so a 0x3D that arrives in the next try's window still
1193 // verifies instead of reading as a wrong answer. The first challenge answers the 0x32 at once and
1194 // goes out with the response preamble; a repeat follows a pause, by which time a device that hops or
1195 // sleeps between frames may have left the channel, so it gets the 0x38's start preamble.
1196 uint8_t verify_challenge[HMAC_SIZE];
1197 crypto::generate_challenge(verify_challenge);
1198 const uint16_t repeat_preamble = pairing_start_preamble_(context.req);
1200 uint8_t challenges = 0;
1201 while (challenges < EXCHANGE_RETRY_COUNT && proof == ExchangeEngine::ChallengeResult::NO_ANSWER) {
1202 if (challenges > 0) {
1203 App.feed_wdt();
1204 delay(EXCHANGE_RETRY_DELAY_MS);
1205 }
1206 const uint16_t preamble = challenges == 0 ? radio_()->response_preamble() : repeat_preamble;
1207 challenges++;
1208 proof = engine_.challenge_sender(context.rx, FREQ_CH2, key, verify_challenge, preamble);
1209 }
1211 ESP_LOGI(TAG, "Key pull check: %s -> key check value %s, proven by its 0x3D", context.device_id.c_str(),
1212 check_value.c_str());
1213 memcpy(context.pulled_key, key, AES_KEY_SIZE);
1214 context.has_pulled_key = true;
1216 }
1217 this->telemetry_.set_key_pull_unproven();
1219 ESP_LOGW(TAG,
1220 "Key pull check: %s -> key check value %s, not proven: its 0x3D does not match. The key is not "
1221 "printed and must not be used",
1222 context.device_id.c_str(), check_value.c_str());
1223 return KeyPullResult::FAILED;
1224 }
1225 // No answer at all is not a wrong answer: devices that answer a roll-call under the key can still
1226 // prove it (register_pulled_key_devices_()).
1227 ESP_LOGW(TAG,
1228 "Key pull check: %s -> key check value %s, not proven: no 0x3D received after %u challenges. The key "
1229 "is not printed; a roll-call under it decides whether devices hold it",
1230 context.device_id.c_str(), check_value.c_str(), challenges);
1231 memcpy(context.pulled_key, key, AES_KEY_SIZE);
1232 context.has_pulled_key = true;
1234}
1235
1236void PairingEngine::finish_pairing_attempt_(PairingOutcome outcome) {
1237 this->telemetry_.set_outcome(outcome);
1238 this->engine_.set_transmit_observer(nullptr);
1239 this->telemetry_.log_summary();
1240
1241 advisor::PairingAdvice advice[advisor::PAIRING_ADVICE_MAX];
1242 const uint8_t advice_count = advisor::analyze_pairing_telemetry(this->telemetry_, this->node_id_, advice);
1243 std::string advice_codes;
1244 for (uint8_t i = 0; i < advice_count; i++) {
1245 ESP_LOGW(TAG, "Pairing advisor: %s", advisor::pairing_advice_message(advice[i]).c_str());
1246 if (!advice_codes.empty())
1247 advice_codes += ',';
1248 advice_codes += advisor::pairing_advice_code_name(advice[i].code);
1249 }
1250 this->telemetry_.set_advice_codes(advice_codes);
1251}
1252
1253void PairingEngine::record_discovery_rx_telemetry_(const IoFrame &frame, bool accepted, int16_t rssi) {
1254 if (accepted) {
1255 this->telemetry_.record_rx(frame, rssi);
1256 } else {
1257 this->telemetry_.record_rx_reject(frame, rssi);
1258 }
1259}
1260
1261} // namespace home_io_control
1262} // namespace esphome
Owns the per-hub device table, update callbacks, and linked-remote associations.
ChallengeResult
What one challenge (our 0x3C, the device's 0x3D) came to.
@ MISMATCH
A 0x3D came back, but it does not verify under the key.
@ VERIFIED
The device's 0x3D verified under the key.
@ NO_ANSWER
Our 0x3C could not be sent, or no 0x3D came back within the response window.
uint16_t request_preamble_for(const IoFrame &request) const
Preamble length for an outbound request frame.
decisions::PairingDiscoveryDisposition run_discovery_phase_(pairing::PairingContext &context)
Phase 1: broadcast discovery command(s) and wait for a device response (0x29).
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.
bool key_pull_selected_(const pairing::PairingContext &context) const
Which direction this attempt moves the key in.
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.
KeyHolderSearch roll_call_and_register_(pairing::PairingContext &context, const uint8_t *key, bool device_key, bool key_proven)
Find the devices that hold key, log one report, register the ones that can be controlled.
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,...
bool run_pull_and_finish_(pairing::PairingContext &context, bool &paired)
Pull the key and end the attempt wherever the pull decides it.
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).
void finish_paired_by_roll_call_(pairing::PairingContext &context)
Record a pairing that ends with devices found by roll-call: paired, node= unchanged.
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.
KeyPullResult
What run_key_pull_phase_() ended with.
@ UNPROVEN
The device sent a different key and no 0x3D to our challenges (context.pulled_key,...
@ OTHER_KEY
The device sent a different key and proved it holds it (context.pulled_key).
@ OUR_KEY
The device sent the hub's own key: it is paired with this hub.
@ FAILED
No key received, refused, or a different key the device did not prove (not printed).
bool register_pulled_key_devices_(pairing::PairingContext &context, bool key_proven)
After a pull of a different key: find the devices that hold it, then end the attempt.
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 ...
pairing::DiscoverConfirmResult run_discover_confirm_step_(pairing::PairingContext &context)
Discover-confirm step (0x2C → 0x2D): sent directly to the just-discovered device, once per discover_a...
bool verify_device_holds_key_(pairing::PairingContext &context)
Check whether the device took our key although the key exchange did not confirm it.
void finalize_pairing_configuration_(pairing::PairingContext &context)
Phase 3: send SetConfig1 (0x6F) once; optional.
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.
Hub-layer log tag and log/format helpers shared by the hub and its collaborators.
std::string pairing_advice_message(const PairingAdvice &advice)
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.
void generate_challenge(uint8_t out[HMAC_SIZE])
Generate 6 random bytes for a challenge using the ESP32 hardware RNG.
bool pairing_node_has_heater_at_next_address(DeviceType type, uint8_t manufacturer)
Does a pairing node that answered discovery this way control a heater at the next address?
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.
bool next_node_id(const uint8_t node[NODE_ID_SIZE], uint8_t next[NODE_ID_SIZE])
The node address one above node, as a 24-bit number (B6BDFB -> B6BDFC).
bool attempt_fits(uint32_t elapsed_ms, uint32_t gap_ms, uint32_t tx_ms, uint32_t window_ms, uint32_t auth_tail_ms, uint32_t budget_ms)
The attempt budget rule: an attempt starts only if the whole attempt, up to the end of the last wait ...
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.
PairingKeyConfirmDisposition
Disposition for a candidate reply to the key-transfer (0x32) confirm wait — the slow-turnaround path'...
@ REFUSE
Anything else (including CMD_ERROR_RESP) — an explicit or implicit refusal; ends the whole wait,...
@ CHALLENGE
A fresh CMD_CHALLENGE_REQ (0x3C) — the device may challenge the key transfer before confirming it; an...
@ CONFIRM
CMD_KEY_CONFIRM (0x33) — the device accepted the key.
@ COMMAND
A request that is harmless to repeat (is_repeat_safe_command()): to a mains device as many attempts a...
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.
PairingDiscoverConfirmDisposition
Disposition for a candidate reply to a discovery-confirm request (0x2C).
@ ACK
CMD_DISCOVER_CONFIRM_ACK (0x2D) from the device — it wants to proceed.
@ IGNORE
Not from/to the expected endpoints, or a frame the step does not act on (e.g.
@ ERROR
CMD_ERROR_RESP from the device — an explicit, named refusal.
bool discover_confirm_try_rotates(uint8_t try_index)
Whether discover-confirm try try_index (1-based) should rotate channels rather than hold the request ...
void log_multiline_result(const char *tag, bool is_warning, const std::string &prefix, const std::string &message)
Log prefix followed by message, one line per log call rather than one call for the whole (possibly mu...
std::string format_key_check_value(const uint8_t key[AES_KEY_SIZE])
A key check value: the first three bytes of the key's AES encryption of a zero block.
Definition log_helpers.h:80
DiscoverConfirmResult
What PairingEngine::run_discover_confirm_step_() actually observed.
Definition hub_pairing.h:94
@ ERROR_REPLY
The device answered with CMD_ERROR_RESP.
Definition hub_pairing.h:97
@ SKIPPED
pairing_discover_confirm tuning mode is skip — no 0x2C was sent.
Definition hub_pairing.h:95
@ NO_REPLY
Every try was silent (or every transmit failed).
Definition hub_pairing.h:98
@ ACKED
The device answered with CMD_DISCOVER_CONFIRM_ACK (0x2D).
Definition hub_pairing.h:96
@ REGISTER_DEVICE
Registering device in the runtime registry for the current boot.
Definition hub_pairing.h:64
@ TX_DISCOVER
Discovery broadcast (0x28) sent; awaiting device response.
Definition hub_pairing.h:50
@ WAIT_KEY_PULL_REPLY
Waiting for the device's key transfer (0x32) in answer to our 0x38.
Definition hub_pairing.h:61
@ TX_KEY_PULL
Key transfer request (0x38) sent: pairing_key_transfer: pull.
Definition hub_pairing.h:60
@ WAIT_DISCOVER_RESPONSE
Listening for discovery response (0x29) from a device in pairing mode.
Definition hub_pairing.h:51
@ COMPLETE
Pairing completed successfully; device ready for use.
Definition hub_pairing.h:65
@ TX_DISCOVER_CONFIRM
Discovery-confirm (0x2C) sent to the discovered device.
Definition hub_pairing.h:52
@ WAIT_KEY_CHALLENGE
Waiting for challenge (0x3C) from device as part of key transfer.
Definition hub_pairing.h:55
@ WAIT_KEY_CONFIRM
Waiting for key‑confirm (0x33) from device (key receipt acknowledgement).
Definition hub_pairing.h:57
@ VERIFY_KEY_HELD
Key exchange unconfirmed: checking with an authenticated read whether the device took our key anyway.
Definition hub_pairing.h:58
@ SCAN_PULLED_KEY
Roll-call under the pulled key: finding the devices that hold it.
Definition hub_pairing.h:63
@ VERIFY_PULLED_KEY
Challenging the device to prove it holds the key it sent.
Definition hub_pairing.h:62
@ TX_KEY_INIT
Key‑init (0x31) sent to the discovered device.
Definition hub_pairing.h:54
@ TX_KEY_TRANSFER
Key‑transfer (0x32) sent with encrypted system key.
Definition hub_pairing.h:56
@ WAIT_DISCOVER_CONFIRM
Listening for the device's discovery-confirm ack (0x2D).
Definition hub_pairing.h:53
RollCallAttemptResult run_roll_call_sweep(ExchangeEngine &engine, const uint8_t *node_id, const uint8_t *key, uint8_t key_slot, const TuningConfig &tuning, ScanState &state, uint8_t &attempt, uint8_t total_attempts)
Run the full roll-call sweep under one key: every enabled pass on every channel.
const char * manufacturer_name(uint8_t id)
Get a human-readable manufacturer name from the protocol manufacturer byte.
bool create_get_name(IoFrame &f, const uint8_t *own, const uint8_t *dst, bool low_power)
Build a get-name request (0x50).
uint8_t count_enabled_passes(ScanPowerClasses selection)
How many of ROLL_CALL_PASSES selection actually calls.
Definition roll_call.cpp:52
uint8_t discovery_power_save_mode(uint8_t flags)
Extract the power save mode field from a discovery response's Multi Information Byte.
@ ALL
CH1->CH2->CH3, including the request channel.
std::string format_device_type_for_yaml(DeviceType type)
Build the YAML value for a device's io_device_type key.
bool decode_heating_reply(const IoFrame &reply, HeatingFunction fn, float &value)
Decode the value a heater answered a register read or write with.
constexpr size_t HEATING_PAYLOAD_MAX_SIZE
Largest payload any function produces: a set of a 16-bit value (4-byte header + 2 value bytes).
bool create_write_private(IoFrame &f, const uint8_t *own, const uint8_t *dst, bool low_power, const uint8_t *payload, size_t payload_len)
Build a generic CMD_WRITE_PRIVATE (0x20) frame around a caller-supplied payload — the one builder beh...
constexpr uint8_t discovery_att_class(uint8_t flags)
Extract the ATT class field from a discovery response's Multi Information Byte.
constexpr uint8_t error_resp_code(uint8_t result)
Result code (bits [7:3]) of a CMD_ERROR_RESP byte.
@ MAX_SETPOINT
Register 0x0102, read-only: highest setpoint the heater accepts, tenths of a degree.
const char * att_class_name(uint8_t att_class)
Get a human-readable turnaround time string for an ATT class value.
constexpr uint8_t PAIRING_DISCOVERY_MAX_ATTEMPTS
Retry discovery TX up to this many times.
std::string format_device_ids(DeviceType type, uint8_t subtype)
Wire values of a (type, subtype), for logs that must stay readable without the table.
static constexpr const char * TAG
const char * power_save_mode_name(uint8_t mode)
Get a human-readable power save mode name.
constexpr uint8_t PAIRING_KEY_VERIFY_MAX_TRIES
Tries for the authenticated name read that checks whether a device took our key although the key exch...
static void log_discovered_device(const pairing::PairingContext &context)
Log what discovery accepted: the device kind, and a warning when it answered as a remote controller.
bool create_discover_confirm(IoFrame &f, const uint8_t *own, const uint8_t *dst, bool low_power, bool ack)
Build a discovery-confirm request (0x2C) — controller side, sent directly to a freshly-discovered dev...
constexpr uint8_t SCAN_CHANNEL_COUNT
Definition roll_call.h:71
DeviceType
IO-Homecontrol node types.
@ REMOTE_CONTROLLER
Remote controller (all ten type bits set, packed bytes FF C0).
PairingOutcome
Final disposition of a pairing attempt, used by the result sensor string.
@ HUB_KEY_RETURNED
A pull returned the hub's own key from a node no platform drives (an Atlantic radiator's pairing node...
@ INVALID_RESPONSE
Discovery saw traffic but nothing valid.
@ KEY_EXCHANGE_FAILED
Discovery succeeded but the key exchange did not complete.
@ PAIRED
The key exchange completed and the device is registered.
@ NO_RESPONSE
No device responded to discovery.
@ KEY_TRANSFER_REFUSED
The device answered our key transfer with CMD_ERROR_RESP; the code is in PairingTelemetry::key_refusa...
@ KEY_RECEIVED
pairing_key_transfer: pull received a key that differs from ours and the device
@ ACCEPT
This is the frame the caller was waiting for — stop listening, return ACCEPTED.
@ 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...
@ PULL
Ask the device for its key, then run a roll-call under it to find the devices that hold it.
@ PUSH
Send our system key to the device.
@ AUTO
Default. Pull from a device that answered discovery as a remote controller, push to any other.
const char * command_result_description(uint8_t result)
Return a human-readable explanation for a CMD_ERROR_RESP result code.
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.
const char * command_result_name(uint8_t result)
Return a stable symbolic name for a CMD_ERROR_RESP result code.
ExchangeOutcome
Authenticated exchange engine — outbound and inbound protocol flows.
@ SUCCESS_WITH_RESPONSE
Device replied; the caller's response frame is populated.
@ FAILED
No usable reply; the device may never have heard the request.
constexpr uint32_t PAIRING_DISCOVER_CONFIRM_ACK_TIMEOUT_MS
Wait window per discover-confirm try.
const char * pairing_stage_name(pairing::PairingState state)
Get a short, log/telemetry-friendly name for a pairing state.
constexpr uint8_t PAIRING_DISCOVER_CONFIRM_TRIES
Max tries for the discover-confirm (0x2C) step.
@ BUILD_FAILED
create_discovery_request() failed; the caller should abort the whole scan.
Definition roll_call.h:124
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.
@ HOLD_REQUEST_CHANNEL
Never retunes, never slices. Unicast replies.
@ SKIP
Kill switch: sends no 0x2C and applies no post-step pause.
@ SEND_WITH_ACK
Like SEND, but also sets CTRL1_ACK for an always-alive target (0x10) — the shape every corpus hub (VE...
std::string build_device_yaml_snippet(DeviceType type, uint8_t subtype, const std::string &device_id, bool metadata_complete, bool low_power, const std::string &system_key_hex)
Build the ready-to-paste YAML block describing a device, for both a fully-decoded device and one whos...
const char * device_capability_class_name(DeviceType type)
Get a human‑readable name for a capability class.
bool recover_key_from_pull(const IoFrame &request, const uint8_t transfer_payload[AES_KEY_SIZE], uint8_t out_key[AES_KEY_SIZE])
Recover a device's key from its answer to our 0x38.
bool device_has_platform(DeviceType type, uint8_t subtype)
Can an ESPHome platform drive this (type, subtype) today?
constexpr uint32_t PAIRING_RECENT_ONE_WAY_SIGHTING_WINDOW_MS
How recent a RecentOneWayPairingSighting has to be, relative to discover_and_pair() starting,...
bool device_is_experimental(DeviceType type, uint8_t subtype)
Is this (type, subtype) a kind no capture or report has confirmed on a real device?
std::string node_id_to_string(const uint8_t id[NODE_ID_SIZE])
Format a 3‑byte node ID as a 6‑character uppercase hex string.
@ ACCEPTED
The handler returned ReplyDisposition::ACCEPT for some received frame.
bool create_discovery_request(IoFrame &f, const uint8_t *own, uint8_t command, const uint8_t *dst, bool low_power, bool ack_capable, bool payload_enabled, uint8_t payload, const uint8_t *system_key)
Build a configurable discovery request command (0x28, 0x2A, or 0x2E).
constexpr uint8_t error_resp_state(uint8_t result)
Execution state (bits [2:0]) of a CMD_ERROR_RESP byte.
constexpr uint32_t PAIRING_KEY_PULL_REPLY_TIMEOUT_MS
Wait for the device's key transfer (0x32) after our key transfer request (0x38).
std::string build_hub_key_pull_report(const PulledKeyReportInfo &info, const ScanState &state, DeviceRegistry &registry)
Build the report for a pull that returned the hub's own key from a node no platform drives.
uint16_t effective_discovery_preamble(const TuningConfig &cfg)
The preamble length (bytes) of the pairing discovery broadcast (0x28, 0x2E).
bool create_launch_key_transfer(IoFrame &f, const uint8_t *own, const uint8_t *dst, const uint8_t challenge[HMAC_SIZE])
Build a key transfer request (0x38). See proto_commands.h for the frame shape.
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.
std::string build_pulled_key_report(const PulledKeyReportInfo &info, const ScanState &state, DeviceRegistry &registry)
Build the one report a proven key pull prints after looking for the devices that hold the key.
std::string format_device_type_diagnostic(DeviceType type)
Human-readable device type string for diagnostics, including the raw numeric value.
size_t encode_heating_read(HeatingFunction fn, uint8_t out[HEATING_PAYLOAD_MAX_SIZE])
Encode a read ("get") of one register into a CMD_WRITE_PRIVATE (0x20) payload.
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...
std::string format_device_kind(DeviceType type, uint8_t subtype)
Human-readable description of a (type, subtype) for logs and reports.
static const char *const TAG
static const char * experimental_note(DeviceType type, uint8_t subtype)
Suffix for a log line that names a device kind no capture or report has confirmed on a real device.
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.
Cryptographic helpers for the IO‑Homecontrol protocol.
The IO node-type and subtype table: names, entity class, platform and behaviour flags.
Pure codec for the private register protocol of 2W heaters (CMD_WRITE_PRIVATE 0x20).
The roll-call sweep: one keyed 0x2A broadcast, repeated over channels and power classes.
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 low_power
YAML-declared: this target is a low-power / duty-cycled receiver, so directed frames set CTRL1_LOW_PO...
bool registered_by_pairing
Added by a pairing in this boot, not declared in YAML.
uint8_t subtype
Device subtype (manufacturer‑specific).
uint32_t last_seen_ms
millis() of the last frame received from this device (any command), 0 = never.
uint8_t node_id[NODE_ID_SIZE]
Device's 3‑byte radio address.
uint8_t system_key[AES_KEY_SIZE]
The device's own key; meaningful only when has_system_key.
bool has_system_key
YAML-declared (system_key:): this device keeps a key of its own.
DeviceType type
Device type (shutter, awning, etc.).
Parsed IO‑Homecontrol frame (CTRL0/1 + addresses + command + data).
Definition proto_frame.h:93
uint8_t data[FRAME_MAX_DATA_SIZE]
Command parameters (0–23 bytes). Never includes mac.
Definition proto_frame.h:99
uint8_t src[NODE_ID_SIZE]
Source node ID (3 bytes).
Definition proto_frame.h:97
uint8_t dst[NODE_ID_SIZE]
Destination node ID (3 bytes).
Definition proto_frame.h:96
uint8_t data_len
Actual length of data.
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...
What roll_call_and_register_() found under a key.
uint8_t answered
Devices that answered under the key (registered or not).
uint8_t registered
Of those, the ones registered until reboot.
What build_pulled_key_report() says about the device that handed over a key.
Definition roll_call.h:219
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...
One roll-call responder, decoded on arrival.
Definition roll_call.h:42
uint8_t src[NODE_ID_SIZE]
Responder's node ID; also the dedup key.
Definition roll_call.h:43
uint8_t manufacturer
Raw manufacturer ID; name via manufacturer_name().
Definition roll_call.h:47
DeviceType type
Decoded device type.
Definition roll_call.h:44
bool has_extended
Whether manufacturer/flags above are present.
Definition roll_call.h:49
int16_t rssi_dbm
RSSI of the reply that produced this entry.
Definition roll_call.h:46
bool metadata_complete
Whether type/subtype were present in the payload.
Definition roll_call.h:50
uint8_t key_slot
Which key of the sweep it answered under: 0 is the hub key, n the n-th per-device key (RollCallKeys).
Definition roll_call.h:51
uint8_t flags
Multi Information Byte; decode with DISCOVERY_FLAGS_* masks.
Definition roll_call.h:48
uint8_t subtype
Decoded device subtype.
Definition roll_call.h:45
Accumulated state of one scan, threaded through every sweep.
Definition roll_call.h:128
uint8_t count
Entries in use.
Definition roll_call.h:130
bool truncated
A responder was dropped because the array was full.
Definition roll_call.h:131
ScanResponder responders[SCAN_MAX_REPLIES]
Distinct responders, in arrival order.
Definition roll_call.h:129
All runtime tunable parameters for pairing and radio diagnostics.
One piece of advice, with the node/RSSI it pertains to (if any).
How one exchange spends its attempts. Handed to ExchangeEngine::send_and_receive().
uint16_t gap_ms
Wait before each attempt after the first; 0 for none.
uint8_t max_attempts
Most transmissions of the request, [1, EXCHANGE_MAX_ATTEMPTS].
uint16_t window_for_attempt(uint8_t attempt, uint8_t attempts) const
Context object that lives for the duration of a single pairing attempt.
Definition hub_pairing.h:70
uint8_t discovery_manufacturer
Manufacturer byte of the discovery reply; 0 when it carried none.
Definition hub_pairing.h:81
bool saw_key_challenge
True once the device challenged our key-init this attempt.
Definition hub_pairing.h:82
IoFrame req
Outbound frame buffer (reused across all phases).
Definition hub_pairing.h:73
bool discovery_low_power
True when discovery reported POWER_SAVE_LOW_POWER.
Definition hub_pairing.h:80
bool has_pulled_key
True once a pull produced a different key (proven or not).
Definition hub_pairing.h:85
PairingState state
Current state machine state.
Definition hub_pairing.h:71
IoFrame resp
Inbound frame buffer (holds key‑confirm response).
Definition hub_pairing.h:74
RadioRxPacket packet
Raw radio capture for the current phase.
Definition hub_pairing.h:77
IoDevice device
Resolved device metadata after discovery (node_id, type, subtype, etc.).
Definition hub_pairing.h:72
uint8_t key_refusal_code
That CMD_ERROR_RESP byte; only meaningful if key_refused.
Definition hub_pairing.h:84
uint8_t pulled_key[AES_KEY_SIZE]
That key. Key material: never logged outside the pull report.
Definition hub_pairing.h:86
IoFrame key_init
Key‑init frame retained for key‑transfer IV derivation.
Definition hub_pairing.h:76
std::string device_id
Hex string representation of the paired node ID.
Definition hub_pairing.h:78
bool key_refused
True once the device answered the key step with CMD_ERROR_RESP.
Definition hub_pairing.h:83
bool discovery_metadata_complete
True when discovery carried type/subtype bytes.
Definition hub_pairing.h:79
IoFrame rx
Raw RX frame during waiting phases (discovery, challenge, confirm).
Definition hub_pairing.h:75
Runtime tuning configuration for pairing and radio diagnostics.