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), tuning_->pairing_discovery_preamble);
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 ESP_LOGI(TAG, "Key transfer: device challenged the key transfer, answering 0x3D");
312 // "Rest of the window" would leave almost nothing after a late challenge plus the 0x3D TX,
313 // so this is a fresh full window, same as ExchangeEngine::wait_for_final_response_() gets
314 // after handle_authentication_(). A failed 0x3D build/TX leaves `disposition` at CHALLENGE,
315 // which the checks below treat the same as a second challenge: this try ends without
316 // confirming, and the next try re-sends 0x32.
317 if (engine_.answer_challenge(context.req, context.resp, FREQ_CH2))
318 disposition = listen_for_key_confirm_(context, try_number, true);
319 }
320
322 return true;
324 return false; // An explicit refusal must not spend the remaining retries; a challenge is
325 // not a refusal, so neither branch above returns false for it.
326 // IGNORE (timeout) or a second CHALLENGE in the same try: spend the next try.
327 }
328 return false;
329}
330
331/// Build CMD_KEY_TRANSFER against the challenge currently held in `context.rx.data` and wait for
332/// the 0x33 confirm, routing through the fast- or slow-turnaround path. Shared by
333/// run_key_exchange_phase_()'s first attempt and its slow-turnaround retry loop, which calls this
334/// again against a freshly re-issued challenge (see that function's doc comment) rather than
335/// discarding it.
338 engine_.record_debug(pairing_stage_name(context.state), 1, true);
339 this->telemetry_.set_phase(context.state);
340 if (!create_key_transfer(context.req, context.key_init, context.device.node_id, node_id_, system_key_,
341 context.rx.data)) {
342 return false;
343 }
344
346 engine_.record_debug(pairing_stage_name(context.state), 1, true);
347 this->telemetry_.set_phase(context.state);
348 // Fast-turnaround radios catch the 0x33 through the standard exchange wait. Slow-turnaround
349 // radios miss it while re-entering RX, so run_key_exchange_phase_()'s retry loop calls this
350 // helper again instead, rather than using the dedicated wait loop directly.
351 if (radio_()->has_fast_tx_rx_turnaround()) {
352 // Key exchange is the one caller that genuinely needs the payload: without the 0x33 there is
353 // no confirmation the device took the key, so an unconfirmed acceptance is not good enough.
354 if (engine_.send_and_receive(context.req, context.resp, FREQ_CH2) != ExchangeOutcome::SUCCESS_WITH_RESPONSE)
355 return false;
356 if (context.resp.cmd == CMD_ERROR_RESP)
357 this->note_key_refusal_(context, context.resp, "Key transfer");
358 return frame_is_key_confirm(context.resp);
359 }
360 return wait_for_key_confirm_(context);
361}
362
363// --- Discovery metadata ---
364
365/// Parse a discovery response frame into device metadata and ID.
366///
367/// Decodes node ID, device type, subtype, and the extended fields (manufacturer, backbone,
368/// Multi Information Byte) via decode_discovery_response(), then emits pairing's diagnostic log
369/// lines for whichever extended fields the payload actually included.
370/// The inversion flag is derived from the type via `default_inverted_for_type()`.
372 std::string &device_id) {
373 const DiscoveryResponseInfo info = decode_discovery_response(frame, device, device_id);
374
375 if (frame.data_len > DISCOVERY_RESP_MANUFACTURER_OFFSET) {
376 const char *mfr_name = manufacturer_name(info.manufacturer);
377 ESP_LOGI(TAG, "Discovery: device %s manufacturer=%u (%s)", device_id.c_str(), info.manufacturer, mfr_name);
378 if (info.manufacturer == 0 || info.manufacturer > MANUFACTURER_ID_MAX) {
379 ESP_LOGW(TAG,
380 "Unknown manufacturer ID %u reported by device %s. "
381 "Please file a GitHub issue with this ID and your device model so support can be added.",
382 info.manufacturer, device_id.c_str());
383 }
384 }
385 if (frame.data_len > DISCOVERY_RESP_BACKBONE_OFFSET + NODE_ID_SIZE - 1) {
386 ESP_LOGD(TAG, "Discovery: backbone=%02X%02X%02X", info.backbone[0], info.backbone[1], info.backbone[2]);
387 }
388 if (frame.data_len > DISCOVERY_RESP_FLAGS_OFFSET) {
389 uint8_t const att = discovery_att_class(info.flags);
390 uint8_t const power_save = discovery_power_save_mode(info.flags);
391 ESP_LOGI(TAG, "Discovery: device %s turnaround=%s power_save=%s flags=0x%02X", device_id.c_str(),
392 att_class_name(att), power_save_mode_name(power_save), info.flags);
393 if (power_save == POWER_SAVE_LOW_POWER) {
394 ESP_LOGI(TAG,
395 "Device %s reports low-power mode: add 'low_power: true' to its YAML entry. "
396 "The pairing snippet below pre-fills it when one is printed.",
397 device_id.c_str());
398 }
399 }
400 return info;
401}
402
403// --- Phase helpers ---
404
405/// Log what discovery accepted: the device kind, and a warning when it answered as a remote controller.
407 if (context.discovery_metadata_complete) {
408 ESP_LOGI(TAG, "Discovery: device %s: %s (%s)%s", context.device_id.c_str(),
409 format_device_kind(context.device.type, context.device.subtype).c_str(),
410 format_device_ids(context.device.type, context.device.subtype).c_str(),
411 device_has_platform(context.device.type, context.device.subtype) ? "" : " (not supported yet)");
412 }
414 ESP_LOGW(TAG,
415 "Discovery: device %s answered as a remote controller (node type %u), not as an actuator. "
416 "It may not accept a pushed key; the pairing advisor says what to try if the key step fails.",
417 context.device_id.c_str(), static_cast<unsigned>(context.device.type));
418 }
419}
420
421/// Phase 1: broadcast discovery command(s) and wait for a device response (0x29).
422///
423/// Sends each configured discovery command in order, waiting up to
424/// `pairing_discovery_wait_ms` for a valid response after each TX.
425/// Retries up to PAIRING_DISCOVERY_MAX_ATTEMPTS times per command.
427 if (tuning_->pairing_discovery_initial_dwell_ms > 0) {
428 ESP_LOGD(TAG, "Discovery: initial dwell %u ms", tuning_->pairing_discovery_initial_dwell_ms);
429 delay(tuning_->pairing_discovery_initial_dwell_ms);
430 }
431
432 // Tracks whether any single attempt saw traffic that failed to classify as a valid discovery
433 // response, so the final "gave up after retries" return can distinguish INVALID (something was
434 // heard, just not a valid response) from NO_RESPONSE (nothing heard at all) instead of always
435 // collapsing to NO_RESPONSE.
436 bool saw_invalid = false;
437
438 for (size_t command_index = 0; command_index < tuning_->pairing_discovery_commands.size(); ++command_index) {
439 auto command = static_cast<uint8_t>(tuning_->pairing_discovery_commands[command_index]);
440 const uint8_t *destination = resolve_discovery_destination(command, tuning_->pairing_discovery_destination_auto,
441 tuning_->pairing_discovery_destination.data());
442 ESP_LOGD(TAG, "Discovery command %zu/%zu: cmd=0x%02X dst=%02X%02X%02X", command_index + 1,
443 tuning_->pairing_discovery_commands.size(), command, destination[0], destination[1], destination[2]);
444
445 for (uint8_t attempt = 1; attempt <= PAIRING_DISCOVERY_MAX_ATTEMPTS; ++attempt) {
446 this->telemetry_.increment_discovery_attempt();
448 engine_.record_debug(pairing_stage_name(context.state), attempt, false);
449 this->telemetry_.set_phase(context.state);
450 if (!create_discovery_request(context.req, node_id_, command, destination, tuning_->pairing_discovery_low_power,
451 tuning_->pairing_discovery_ack_capable, tuning_->pairing_discovery_payload_enabled,
452 tuning_->pairing_discovery_payload, system_key_) ||
453 !engine_.transmit_frame(context.req, FREQ_CH2, tuning_->pairing_discovery_preamble)) {
455 }
456
458 engine_.record_debug(pairing_stage_name(context.state), attempt, false);
459 this->telemetry_.set_phase(context.state);
460 auto result = wait_for_discovery_response_(tuning_->pairing_discovery_wait_ms, context.packet, context.rx);
462 const DiscoveryResponseInfo info = parse_device_from_discovery(context.rx, context.device, context.device_id);
463 context.discovery_metadata_complete = context.rx.data_len >= DEVICE_METADATA_SIZE;
464 context.discovery_low_power =
465 info.has_extended && discovery_power_save_mode(info.flags) == POWER_SAVE_LOW_POWER;
466 context.discovery_manufacturer = info.has_extended ? info.manufacturer : 0;
467 this->telemetry_.set_discovered_device(context.device.node_id, context.device.type);
468 log_discovered_device(context);
469 return result;
470 }
472 saw_invalid = true;
473 }
474
475 if (attempt < PAIRING_DISCOVERY_MAX_ATTEMPTS) {
476 ESP_LOGI(TAG, "Discovery attempt %u/%u for cmd=0x%02X: no response, retrying...", attempt,
478 }
479 }
480 }
483}
484
485/// Discover-confirm step (0x2C → 0x2D): sent directly to the device discovery just found, once
486/// per discover_and_pair() attempt, before the key-exchange retry loop begins (so a retry of that
487/// loop never repeats this step).
488///
489/// Never fails a pairing attempt: `skip` mode, a timeout, and an explicit CMD_ERROR_RESP all still
490/// let discover_and_pair() proceed to run_key_exchange_phase_() — this function only decides how
491/// long that takes and what gets logged. CTRL1_ACK follows the *target's* power class, not the
492/// mode alone: a low-power target's 0x2C carries `CTRL1_LOW_POWER` only in both `send` and
493/// `send_with_ack` (every corpus hub's own shape for that class); only an always-alive target's
494/// ACK bit changes between the two modes (see create_discover_confirm()'s doxygen for the
495/// byte-shape cross-check against real captures).
496///
497/// `set_phase()` fires at most once per state for the whole step (not per try): the confirm step
498/// can spend up to PAIRING_DISCOVER_CONFIRM_TRIES transmits, and PAIRING_TELEMETRY_MAX_EVENTS is a
499/// fixed 32-event budget the pairing advisor scans — a per-try phase would spend events on exactly
500/// the failure paths it is meant to diagnose. record_debug() (not telemetry) still runs every try.
502 if (tuning_->pairing_discover_confirm == DiscoverConfirmMode::SKIP)
504
505 const bool ack =
506 tuning_->pairing_discover_confirm == DiscoverConfirmMode::SEND_WITH_ACK && !context.discovery_low_power;
507
509 this->telemetry_.set_phase(context.state);
510
511 bool wait_phase_started = false;
513 for (uint8_t try_index = 1; try_index <= PAIRING_DISCOVER_CONFIRM_TRIES; try_index++) {
514 if (try_index > 1)
515 App.feed_wdt(); // No extra retry gap: the 1.5 s windows already space the retransmissions.
516
517 engine_.record_debug(pairing_stage_name(pairing::PairingState::TX_DISCOVER_CONFIRM), try_index, false);
518 if (!create_discover_confirm(context.req, node_id_, context.device.node_id, context.discovery_low_power, ack) ||
519 !engine_.transmit_frame(context.req, FREQ_CH2, pairing_start_preamble_(context.req))) {
520 continue; // A failed build/TX counts as a silent try, like wait_for_key_confirm_() does for 0x32.
521 }
522
524 if (!wait_phase_started) {
525 this->telemetry_.set_phase(context.state);
526 wait_phase_started = true;
527 }
528 engine_.record_debug(pairing_stage_name(context.state), try_index, false);
529
530 const uint32_t wait_start_ms = millis();
532
534 ESP_LOGI(TAG, "Discover confirm: 0x2D from %s on freq=%" PRIu32 " after %" PRIu32 " ms (try %u/%u)",
535 context.device_id.c_str(), context.packet.freq_hz, millis() - wait_start_ms, try_index,
538 break;
539 }
541 const uint8_t error_code = context.rx.data_len > 0 ? context.rx.data[0] : 0;
542 ESP_LOGW(TAG, "Discover confirm: device %s answered 0x2C with error 0x%02X, continuing with key exchange",
543 context.device_id.c_str(), error_code);
545 break;
546 }
547 // IGNORE: timeout or an unrelated frame this try — spend the next try.
548 }
549
551 ESP_LOGI(TAG, "Discover confirm: no 0x2D from %s after %u tries, continuing with key exchange",
553 }
554
555 // Applied for every result reaching here (i.e. every result except SKIPPED, which already
556 // returned above), including NO_REPLY — one simple rule instead of a per-outcome one.
557 if (tuning_->pairing_key_init_delay_ms > 0)
558 delay_feeding_wdt(tuning_->pairing_key_init_delay_ms);
559
560 return result;
561}
562
563/// Phase 2: authenticated key exchange (0x31 → 0x3C → 0x32 → 0x33).
564///
565/// Steps:
566/// 1. Transmit CMD_KEY_INIT (0x31), preamble per pairing_start_preamble_()
567/// 2. Wait for device challenge (0x3C)
568/// 3. Transmit CMD_KEY_TRANSFER (0x32) with encrypted system key
569/// 4. Wait for CMD_KEY_CONFIRM (0x33)
570///
571/// Step 4 depends on the driver's TX→RX turnaround: fast-turnaround radios await
572/// the 0x33 through the standard send_and_receive() exchange; slow-turnaround
573/// radios use the dedicated wait_for_key_confirm_() path with a key-init
574/// re-trigger, because the 0x33 would otherwise arrive while the receiver is
575/// still settling (see RadioDriver::has_fast_tx_rx_turnaround()).
576///
577/// The slow-turnaround re-trigger loop below (`for (int re = 0; ...)`) has two distinct outcomes
578/// for its re-sent CMD_KEY_INIT, and they are handled differently:
579/// - The device replies with CMD_KEY_CONFIRM (0x33) directly — it already had the key from the
580/// first 0x32 and is just auto-confirming again. Nothing more to send.
581/// - The device replies with a *fresh* CMD_CHALLENGE_REQ (0x3C) instead — proof it never received
582/// the first 0x32 at all (a device that already holds the key doesn't re-challenge), so there
583/// is no key transfer for it to confirm yet. The loop calls transfer_key_and_wait_confirm_()
584/// again here, replaying CMD_KEY_TRANSFER against this new challenge, rather than discarding
585/// the 0x3C and burning the retry for nothing — the device's next 0x31 would just produce
586/// another fresh challenge either way, so retrying without resending 0x32 could never succeed.
587///
588/// That replay roughly doubles this function's worst-case blocking time when every wait times out
589/// (approximately 3.5s -> 6.6s: two of the loop's iterations can now each wait out a full
590/// key-transfer-and-confirm cycle instead of returning immediately on a missed 0x33). This is a
591/// known, accepted trade-off: pairing already tolerates multi-second blocking exchanges (see
592/// EXCHANGE_TOTAL_BUDGET_MS's own reasoning, proto_timing.h), and the alternative — leaving a
593/// slow-turnaround device that never got its key stuck retrying forever — is worse than the
594/// occasional slower failure path.
597 engine_.record_debug(pairing_stage_name(context.state), 1, false);
598 this->telemetry_.set_phase(context.state);
599 if (!create_key_init(context.key_init, node_id_, context.device.node_id) ||
600 !engine_.transmit_frame(context.key_init, FREQ_CH2, pairing_start_preamble_(context.key_init))) {
601 return false;
602 }
603
605 engine_.record_debug(pairing_stage_name(context.state), 1, true);
606 this->telemetry_.set_phase(context.state);
608 return false;
609 }
610
611 // Some devices send 0x33 directly after 0x31 without requiring 0x32.
612 if (context.rx.cmd == CMD_KEY_CONFIRM) {
613 ESP_LOGI(TAG, "Device accepted key immediately (0x33 without 0x32 exchange)");
614 context.resp = context.rx;
615 return true;
616 }
617
618 context.saw_key_challenge = true;
619 // No challenge bytes here: the raw 0x3C payload plus the 0x3D response it provokes is a
620 // known-plaintext/known-ciphertext pair under the system key (see redaction.h). The generic
621 // frame-log helpers (log_frame()/log_component_capture()) already mask both commands.
622 ESP_LOGI(TAG, "Challenge (0x3C) received: data_len=%u freq=%" PRIu32 " rssi=%d", context.rx.data_len,
623 context.packet.freq_hz, radio_()->get_last_capture().rssi_dbm);
624
625 bool key_ok = transfer_key_and_wait_confirm_(context);
626 // Fast-turnaround radios catch the 0x33 through the standard exchange wait inside the helper
627 // above and never reach this loop. Slow-turnaround radios miss it while re-entering RX, so on a
628 // miss they re-send the key-init to trigger the device's auto-confirm. An explicit refusal is
629 // the device's answer, not a missed confirm: re-sending the key-init after it only draws the same
630 // refusal again (or, seen in the field, a refusal of a second key the device did not need).
631 if (!radio_()->has_fast_tx_rx_turnaround()) {
632 for (int re = 0; !key_ok && !context.key_refused && re < 2; re++) {
633 ESP_LOGI(TAG, "Key confirm missed, re-sending key-init to trigger auto-confirm (attempt %d/2)", re + 1);
634 App.feed_wdt();
635 delay(EXCHANGE_RETRY_DELAY_MS);
636 if (!engine_.transmit_frame(context.key_init, FREQ_CH2, pairing_start_preamble_(context.key_init)))
637 continue;
639 context.device.node_id))
640 continue;
641 if (context.rx.cmd == CMD_KEY_CONFIRM) {
642 key_ok = true;
643 continue;
644 }
645 context.saw_key_challenge = true;
646 // A fresh CHALLENGE_REQ (0x3C), not a direct KEY_CONFIRM (0x33): the device never received
647 // our first 0x32 in the first place, so there is no key for it to auto-confirm yet -- a
648 // fresh 0x3C is exactly the signal that the right response is "resend 0x32 against this new
649 // challenge", not "give up and burn the retry for nothing".
650 key_ok = transfer_key_and_wait_confirm_(context);
651 }
652 }
653 if (!key_ok) {
654 ESP_LOGW(TAG, "Key exchange failed");
655 return false;
656 }
657 return true;
658}
659
660/// Phase 3: send SetConfig1 (0x6F) once. Optional — see the header. Its preamble follows
661/// pairing_start_preamble_(), like the other directed pairing start frames.
663 if (!create_set_config1(context.req, node_id_, context.device.node_id))
664 return;
665 // Its reply is never read: an explicit refusal and silence are equally harmless here.
666 if (engine_.send_and_receive(context.req, context.resp, FREQ_CH2, PAIRING_SET_CONFIG1_MAX_TRIES,
667 pairing_start_preamble_(context.req)) == ExchangeOutcome::FAILED) {
668 ESP_LOGI(TAG, "Pairing: no reply from %s to the optional SetConfig1 (0x6F); the pairing is complete",
669 context.device_id.c_str());
670 }
671}
672
673// --- Orchestrator ---
674
675/// Pairing orchestrator — high-level three-phase flow.
676///
677/// Phase 1: run_discovery_phase_() finds a device in pairing mode.
678/// Phase 2: run_key_exchange_phase_() performs authenticated key establishment.
679/// Phase 3: finalize_pairing_configuration_() sends the optional SetConfig1 once.
680///
681/// On success the device is added to the registry and a YAML snippet is printed to the log.
682/// The hub's thin wrapper manages the busy_ flag before and after this call.
684 this->telemetry_.begin();
685 this->engine_.set_transmit_observer(&this->telemetry_);
686
687 // Seed telemetry with a 1W pairing-gesture frame the hub overheard shortly before this call
688 // (issue #27/#65): without this, a PROG press completed a few seconds before "Discover & Pair"
689 // is pressed in the app is invisible to the advisor purely because the telemetry window opens
690 // here, even though the radio heard it just fine — the false rf_silent advice this produced was
691 // field-confirmed against a real trace (issue #27, miljaar).
692 if (this->recent_oneway_sighting_.seen_ms != 0 &&
693 (millis() - this->recent_oneway_sighting_.seen_ms) < PAIRING_RECENT_ONE_WAY_SIGHTING_WINDOW_MS) {
694 this->telemetry_.record_recent_one_way_sighting(this->recent_oneway_sighting_);
695 }
696
697 ESP_LOGI(TAG, "Starting device discovery...");
698 ESP_LOGI(TAG, "%s", tuning_config_full_snapshot(*tuning_).c_str());
699
701
702 // Phase 1: Discovery
703 auto disc_disp = run_discovery_phase_(context);
705 log_discovery_diagnostic(disc_disp);
706 this->finish_pairing_attempt_(disc_disp == decisions::PairingDiscoveryDisposition::INVALID
709 return false;
710 }
711
712 if (tuning_->pairing_discovery_preamble < LONG_PREAMBLE) {
713 ESP_LOGI(TAG, "Pairing: directed frames to %s use the %u-byte discovery preamble it just answered",
714 context.device_id.c_str(), tuning_->pairing_discovery_preamble);
715 }
716
717 // Discover-confirm (0x2C -> 0x2D): once per attempt, before the key-exchange retry loop below,
718 // so a retry of that loop never repeats it. Never fails the attempt — see the function's doc.
719 this->run_discover_confirm_step_(context);
720
721 // Phase 2: move the key, in the direction `pairing_key_transfer` selects (auto: the discovery
722 // answer decides).
723 if (key_pull_selected_(context)) {
724 bool paired = false;
725 if (this->run_pull_and_finish_(context, paired))
726 return paired;
727 } else if (!run_push_key_step_(context)) {
728 this->finish_pairing_attempt_(context.key_refused ? PairingOutcome::KEY_TRANSFER_REFUSED
730 return false;
731 }
732
733 // Phase 3: optional SetConfig1. The 0x33 above completed the pairing.
735
737 engine_.record_debug(pairing_stage_name(context.state), 1, true);
738 this->telemetry_.set_phase(context.state);
739
740 // The device just sent its 0x33, so it counts as heard from: its wake belief starts at "maybe
741 // awake" for the follow-up exchanges rather than "asleep" (same stamp update_link_health() sets).
742 context.device.last_seen_ms = millis();
743 // A YAML entry for this node keeps its declared status; anything else lives until reboot.
744 context.device.registered_by_pairing = registry_.get_declared(context.device_id) == nullptr;
745 registry_.put(context.device_id, context.device);
746 this->telemetry_.set_paired_device(context.device.node_id, context.device.type);
747
748 const std::string type_diag = format_device_type_diagnostic(context.device.type);
749 const std::string type_yaml = format_device_type_for_yaml(context.device.type);
750 const std::string snippet = build_device_yaml_snippet(context.device.type, context.device.subtype, context.device_id,
752 context.discovery_low_power);
753
754 if (snippet.empty()) {
755 // metadata_complete is true here (build_device_yaml_snippet() never returns empty for
756 // metadata_complete == false), so this is specifically "we know the type, but there's no
757 // ESPHome platform for it yet" (device_has_platform()).
758 ESP_LOGW(TAG,
759 "Device %s paired successfully (%s), but no ESPHome platform drives it yet: type=%s "
760 "class=%s subtype=%u.",
761 context.device_id.c_str(), format_device_kind(context.device.type, context.device.subtype).c_str(),
762 type_diag.c_str(), device_capability_class_name(context.device.type), context.device.subtype);
763 ESP_LOGW(TAG,
764 "No ready-to-paste YAML was generated. If you want to experiment manually, choose the most likely "
765 "platform and set io_device_type: %s.",
766 type_yaml.c_str());
767 ESP_LOGW(TAG, "Please file a GitHub issue with this device type, subtype, model, and the pairing log so support "
768 "can be added.");
769
771 engine_.record_debug(pairing_stage_name(context.state), 1, true);
772 this->telemetry_.set_phase(context.state);
773 this->finish_pairing_attempt_(PairingOutcome::PAIRED);
774 return true;
775 }
776
777 if (!context.discovery_metadata_complete) {
778 ESP_LOGW(TAG,
779 "Device %s paired successfully, but the discovery response did not include type/subtype "
780 "metadata, so the platform (cover/light/switch/lock) can't be determined automatically.",
781 context.device_id.c_str());
782 ESP_LOGI(TAG, "Add this to your YAML once you know what kind of device it is:\n%s", snippet.c_str());
783 ESP_LOGW(TAG, "Please file a GitHub issue with the pairing log and device model so this discovery edge case can "
784 "be investigated.");
785
787 engine_.record_debug(pairing_stage_name(context.state), 1, true);
788 this->telemetry_.set_phase(context.state);
789 this->finish_pairing_attempt_(PairingOutcome::PAIRED);
790 return true;
791 }
792
793 ESP_LOGI(TAG, "Device %s paired successfully! Add this to your YAML:\n%s", context.device_id.c_str(),
794 snippet.c_str());
795
796 if (yaml_device_type_name(context.device.type) == nullptr) {
797 ESP_LOGW(TAG,
798 "This snippet uses the raw device type %s because the project does not yet expose a named YAML alias "
799 "for %s.",
800 type_yaml.c_str(), type_diag.c_str());
801 ESP_LOGW(TAG, "Please file a GitHub issue with this type, subtype, device model, and the pairing log so support "
802 "can be added.");
803 }
804
806 engine_.record_debug(pairing_stage_name(context.state), 1, true);
807 this->telemetry_.set_phase(context.state);
808 this->finish_pairing_attempt_(PairingOutcome::PAIRED);
809 return true;
810}
811
812bool PairingEngine::run_push_key_step_(pairing::PairingContext &context) {
813 for (uint8_t ke_attempt = 0; ke_attempt < tuning_->pairing_key_exchange_retries; ke_attempt++) {
814 if (ke_attempt > 0) {
815 ESP_LOGI(TAG, "Retrying key exchange (attempt %d/%u)...", ke_attempt + 1, tuning_->pairing_key_exchange_retries);
816 App.feed_wdt();
817 delay(EXCHANGE_RETRY_DELAY_MS);
818 }
819 if (run_key_exchange_phase_(context))
820 return true;
821 // A refusal is final for this attempt: retrying the same push only repeats the question.
822 if (context.key_refused)
823 break;
824 }
825 // The device challenged our key-init, so it was talking to us; it may still have taken a key
826 // whose confirm we lost. Without a challenge there is nothing to check.
827 if (!context.saw_key_challenge)
828 return false;
829 return verify_device_holds_key_(context);
830}
831
834 engine_.record_debug(pairing_stage_name(context.state), 1, false);
835 this->telemetry_.set_phase(context.state);
836 ESP_LOGI(TAG, "Key exchange: not confirmed; checking whether %s took the key anyway (authenticated name read)",
837 context.device_id.c_str());
838 if (!create_get_name(context.req, node_id_, context.device.node_id, context.discovery_low_power))
839 return false;
840 const ExchangeOutcome outcome = engine_.send_and_receive(
841 context.req, context.resp, FREQ_CH2, PAIRING_KEY_VERIFY_MAX_TRIES, pairing_start_preamble_(context.req));
842 const bool held = outcome == ExchangeOutcome::SUCCESS_WITH_RESPONSE && engine_.get_debug().saw_challenge &&
843 context.resp.cmd == CMD_GET_NAME_RESP;
844 if (held) {
845 ESP_LOGW(TAG,
846 "Key exchange: %s never confirmed the key, but it answered an authenticated request with it. "
847 "Treating it as paired.",
848 context.device_id.c_str());
849 } else if (outcome == ExchangeOutcome::SUCCESS_WITH_RESPONSE && context.resp.cmd == CMD_ERROR_RESP &&
850 context.resp.data_len > 0) {
851 // Refused outright: the device never got as far as checking a key.
852 const uint8_t code = context.resp.data[0];
853 ESP_LOGW(TAG,
854 "Key exchange failed: %s refused the name read with 0x%02X %s (%s), so it does not act as a "
855 "paired device",
856 context.device_id.c_str(), code, command_result_name(code), command_result_description(code));
857 } else {
858 ESP_LOGW(TAG, "Key exchange failed: %s does not answer authenticated requests with our key",
859 context.device_id.c_str());
860 }
861 return held;
862}
863
864void PairingEngine::note_key_refusal_(pairing::PairingContext &context, const IoFrame &reply, const char *step) {
865 const uint8_t code = reply.data_len > 0 ? reply.data[0] : 0;
866 context.key_refused = true;
867 context.key_refusal_code = code;
868 this->telemetry_.set_key_refusal(code);
869 ESP_LOGW(TAG, "%s: device %s refused with 0x%02X %s (%s; code %u, state %u)", step, context.device_id.c_str(), code,
871}
872
873bool PairingEngine::wait_for_pulled_key_(pairing::PairingContext &context) {
874 ListenSpec spec;
875 spec.window_ms = PAIRING_KEY_PULL_REPLY_TIMEOUT_MS;
876 spec.policy = ListenPolicy::HOLD_REQUEST_CHANNEL; // Unicast reply to a unicast request, as for 0x33.
877
878 auto outcome =
879 engine_.listen(spec, context.packet, context.rx, [&](const IoFrame *parsed, const RadioRxPacket & /*packet*/) {
880 if (parsed == nullptr || memcmp(parsed->src, context.device.node_id, NODE_ID_SIZE) != 0 ||
881 memcmp(parsed->dst, node_id_, NODE_ID_SIZE) != 0)
882 return ReplyDisposition::IGNORE;
883 const int16_t rssi = radio_()->get_last_capture().rssi_dbm;
884 if (parsed->cmd == CMD_KEY_TRANSFER && parsed->data_len >= AES_KEY_SIZE) {
885 this->telemetry_.record_rx(*parsed, rssi);
886 return ReplyDisposition::ACCEPT;
887 }
888 this->telemetry_.record_rx_reject(*parsed, rssi);
889 if (parsed->cmd == CMD_ERROR_RESP) {
890 this->note_key_refusal_(context, *parsed, "Key pull");
891 return ReplyDisposition::ABORT;
892 }
893 return ReplyDisposition::IGNORE;
894 });
895 return outcome == ListenOutcome::ACCEPTED;
896}
897
899 switch (tuning_->pairing_key_transfer) {
901 return true;
903 return false;
905 break;
906 }
907 // A device that answers discovery as a remote controller (node type 1023) hands out its own key and
908 // refuses a pushed one; every other device takes a push. No switch to the other direction inside the
909 // attempt: a refusal repeats deterministically, and a retry only costs pairing time.
910 const bool pull = context.device.type == DeviceType::REMOTE_CONTROLLER;
911 ESP_LOGI(TAG, "Key transfer: auto -> %s (%s answered discovery as %s)", pull ? "pull" : "push",
912 context.device_id.c_str(), format_device_kind(context.device.type, context.device.subtype).c_str());
913 return pull;
914}
915
916uint8_t PairingEngine::roll_call_and_register_(pairing::PairingContext &context, const uint8_t *key, bool device_key) {
917 // Every device that holds the key answers a roll-call built with it. For an Atlantic AGILIA radiator
918 // that is the heater at the next address up, not the node that handed the key over.
920 engine_.record_debug(pairing_stage_name(context.state), 1, true);
921 this->telemetry_.set_phase(context.state);
922 ScanState state{};
923 // An Atlantic radiator's heater answers a direct read every time but a roll-call only now and then
924 // (about one request in four), so it is asked first; the roll-call is the fallback, and the only
925 // search for any other device.
926 if (!this->probe_heater_at_next_address_(context, key, device_key, state)) {
927 uint8_t attempt = 0;
928 const uint8_t total_attempts = count_enabled_passes(tuning_->scan_power_classes) * SCAN_CHANNEL_COUNT;
929 ESP_LOGI(TAG, "Roll-call with key check value %s (%s %s handed over)", detail::format_key_check_value(key).c_str(),
930 device_key ? "the key" : "the hub key, which", context.device_id.c_str());
931 if (run_roll_call_sweep(engine_, node_id_, key, /*key_slot=*/device_key ? 1 : 0, *tuning_, state, attempt,
932 total_attempts) == RollCallAttemptResult::BUILD_FAILED) {
933 ESP_LOGW(TAG, "Could not build the roll-call under the pulled key");
934 }
935 }
936
937 // One report for the device log only: with a device key it carries the key, so the pairing summary, the
938 // Last Pairing Result sensor and every Home Assistant event keep to the key check value. A hub-key report
939 // carries no key and has no banner.
940 const PulledKeyReportInfo info{context.device_id, context.device.type, context.device.subtype, key, system_key_};
941 if (device_key) {
942 ESP_LOGW(TAG, "========================================");
943 detail::log_multiline_result(TAG, /*is_warning=*/true, /*prefix=*/"",
944 build_pulled_key_report(info, state, registry_));
945 ESP_LOGW(TAG, "========================================");
946 } else if (state.count != 0) {
947 detail::log_multiline_result(TAG, /*is_warning=*/false, /*prefix=*/"",
948 build_hub_key_pull_report(info, state, registry_));
949 }
950
951 // Register each device the report offers a block for, so it can be controlled until the YAML is
952 // flashed: with the key it answered under when that is a device key, with the hub key otherwise. A
953 // device the YAML declares keeps its record; one an earlier pairing in this boot added is replaced, so
954 // pairing it again counts as paired. One of a type no platform drives yet is reported but not registered.
955 uint8_t registered = 0;
956 for (uint8_t i = 0; i < state.count; i++) {
957 const ScanResponder &responder = state.responders[i];
958 const std::string responder_id = node_id_to_string(responder.src);
959 if (registry_.get_declared(responder_id) != nullptr || !device_has_platform(responder.type, responder.subtype))
960 continue;
961 IoDevice device{};
962 memcpy(device.node_id, responder.src, NODE_ID_SIZE);
963 device.type = responder.type;
964 device.subtype = responder.subtype;
965 device.inverted = responder.inverted;
966 device.low_power = responder.has_extended && discovery_power_save_mode(responder.flags) == POWER_SAVE_LOW_POWER;
967 device.has_system_key = device_key;
968 if (device_key)
969 memcpy(device.system_key, key, AES_KEY_SIZE);
970 device.last_seen_ms = millis();
971 device.registered_by_pairing = true;
972 registry_.put(responder_id, device);
973 registered++;
974 ESP_LOGI(TAG, "Pairing: registered %s (%s) until reboot; paste its block above to keep it", responder_id.c_str(),
975 format_device_kind(responder.type, responder.subtype).c_str());
976 }
977 return registered;
978}
979
981 bool device_key, ScanState &state) {
982 uint8_t heater[NODE_ID_SIZE];
984 !decisions::next_node_id(context.device.node_id, heater))
985 return false;
986 const std::string heater_id = node_id_to_string(heater);
987 // A YAML entry keeps its own key; the roll-call reports it against that.
988 if (registry_.get_declared(heater_id) != nullptr)
989 return false;
990
991 // The exchange picks its key from the registry, so the candidate is in it for the read only; the
992 // caller registers it properly if it answered. A record an earlier pairing in this boot left there is
993 // put back afterwards.
994 const IoDevice *earlier = registry_.get(heater_id);
995 const bool had_earlier = earlier != nullptr;
996 const IoDevice previous = had_earlier ? *earlier : IoDevice{};
997 IoDevice candidate{};
998 memcpy(candidate.node_id, heater, NODE_ID_SIZE);
1000 candidate.subtype = SUBTYPE_HEATER_WITH_SETPOINT;
1001 candidate.has_system_key = device_key;
1002 if (device_key)
1003 memcpy(candidate.system_key, key, AES_KEY_SIZE);
1004 candidate.registered_by_pairing = true;
1005 registry_.put(heater_id, candidate);
1006
1007 // The maximum setpoint is a read-only register every such heater holds, so reading it changes nothing.
1008 uint8_t payload[HEATING_PAYLOAD_MAX_SIZE];
1009 const size_t payload_len = encode_heating_read(HeatingFunction::MAX_SETPOINT, payload);
1010 IoFrame request{};
1011 IoFrame response{};
1012 float max_setpoint_c = NAN;
1013 ESP_LOGI(TAG, "Heater probe: reading %s, the address after %s, under key check value %s", heater_id.c_str(),
1014 context.device_id.c_str(), detail::format_key_check_value(key).c_str());
1015 const bool answered =
1016 payload_len != 0 && create_write_private(request, node_id_, heater, /*low_power=*/false, payload, payload_len) &&
1017 engine_.send_and_receive(request, response, FREQ_CH2) == ExchangeOutcome::SUCCESS_WITH_RESPONSE &&
1018 decode_heating_reply(response, HeatingFunction::MAX_SETPOINT, max_setpoint_c);
1019 const int16_t rssi = radio_()->get_last_capture().rssi_dbm;
1020 if (had_earlier) {
1021 registry_.put(heater_id, previous);
1022 } else {
1023 registry_.remove_registered_by_pairing(heater_id);
1024 }
1025 if (!answered) {
1026 ESP_LOGI(TAG, "Heater probe: %s did not answer; looking for devices with a roll-call instead", heater_id.c_str());
1027 return false;
1028 }
1029 ESP_LOGI(TAG, "Heater probe: %s answered (maximum setpoint %.1f C); no roll-call needed", heater_id.c_str(),
1030 max_setpoint_c);
1031
1032 // Only the register protocol answers this read, and only the heater with an adjustable setpoint
1033 // speaks it; manufacturer and flags come from no reply of its own, so the report omits them.
1034 ScanResponder &responder = state.responders[0];
1035 memcpy(responder.src, heater, NODE_ID_SIZE);
1037 responder.subtype = SUBTYPE_HEATER_WITH_SETPOINT;
1038 responder.inverted = false;
1039 responder.rssi_dbm = rssi;
1040 responder.manufacturer = context.discovery_manufacturer;
1041 responder.flags = 0;
1042 responder.has_extended = false;
1043 responder.metadata_complete = true;
1044 responder.key_slot = device_key ? 1 : 0;
1045 state.count = 1;
1046 state.truncated = false;
1047 return true;
1048}
1049
1051 // `node=` in the frozen result string stays the node that was discovered; the responders appear in
1052 // the log report only.
1053 this->telemetry_.set_paired_device(context.device.node_id, context.device.type);
1055 engine_.record_debug(pairing_stage_name(context.state), 1, true);
1056 this->telemetry_.set_phase(context.state);
1057 this->finish_pairing_attempt_(PairingOutcome::PAIRED);
1058}
1059
1061 this->telemetry_.set_key_pull_attempted();
1062 const KeyPullResult pulled = run_key_pull_phase_(context);
1063 if (pulled == KeyPullResult::OTHER_KEY) {
1064 paired = this->register_pulled_key_devices_(context);
1065 return true;
1066 }
1067 if (pulled == KeyPullResult::FAILED) {
1068 this->finish_pairing_attempt_(context.key_refused ? PairingOutcome::KEY_TRANSFER_REFUSED
1070 paired = false;
1071 return true;
1072 }
1073 // The device shares the hub's key. A device with a platform of its own is paired as after a push.
1074 if (device_has_platform(context.device.type, context.device.subtype))
1075 return false;
1076 // A node with no platform of its own (an AGILIA's remote-controller node): the devices that hold the
1077 // key are elsewhere, so look for them, as after a foreign key, and register the ones found with the
1078 // hub key. With none found the attempt ends here: the node itself cannot be controlled, and it
1079 // refuses the SetConfig1 the push path sends.
1080 if (this->roll_call_and_register_(context, system_key_, /*device_key=*/false) > 0) {
1081 this->finish_paired_by_roll_call_(context);
1082 paired = true;
1083 return true;
1084 }
1085 ESP_LOGW(TAG, "Pairing: %s holds this hub's key, but no device that holds it answered; nothing registered",
1086 context.device_id.c_str());
1087 this->finish_pairing_attempt_(PairingOutcome::HUB_KEY_RETURNED);
1088 paired = false;
1089 return true;
1090}
1091
1093 const uint8_t registered = this->roll_call_and_register_(context, context.pulled_key, /*device_key=*/true);
1094 memset(context.pulled_key, 0, AES_KEY_SIZE);
1095 context.has_pulled_key = false;
1096 if (registered == 0) {
1097 this->finish_pairing_attempt_(PairingOutcome::KEY_RECEIVED);
1098 return false;
1099 }
1100 this->finish_paired_by_roll_call_(context);
1101 return true;
1102}
1103
1105 ESP_LOGW(TAG, "Key pull: asking %s for its key", context.device_id.c_str());
1106 uint8_t challenge[HMAC_SIZE];
1107 bool got_key = false;
1108 for (uint8_t tries = 0; tries < EXCHANGE_RETRY_COUNT && !got_key && !context.key_refused; tries++) {
1109 if (tries > 0) {
1110 App.feed_wdt();
1111 delay(EXCHANGE_RETRY_DELAY_MS);
1112 }
1114 engine_.record_debug(pairing_stage_name(context.state), tries + 1, false);
1115 this->telemetry_.set_phase(context.state);
1116 // Fresh random bytes per try: the IV of the device's answer is built from the request it answers.
1117 crypto::generate_challenge(challenge);
1118 if (!create_launch_key_transfer(context.req, node_id_, context.device.node_id, challenge) ||
1119 !engine_.transmit_frame(context.req, FREQ_CH2, pairing_start_preamble_(context.req)))
1120 continue;
1122 this->telemetry_.set_phase(context.state);
1123 got_key = wait_for_pulled_key_(context);
1124 if (!got_key && !context.key_refused) {
1125 ESP_LOGI(TAG, "Key pull try %u: no key transfer (0x32) from %s within %" PRIu32 " ms", tries + 1,
1127 }
1128 }
1129 if (!got_key) {
1130 ESP_LOGW(TAG, "Key pull failed: %s sent no key", context.device_id.c_str());
1131 return KeyPullResult::FAILED;
1132 }
1133
1134 uint8_t key[AES_KEY_SIZE];
1135 if (!recover_key_from_pull(context.req, context.rx.data, key))
1136 return KeyPullResult::FAILED;
1137 // The key check value names the key without revealing it, so two attempts or two devices can be
1138 // compared from the log alone.
1139 const std::string check_value = detail::format_key_check_value(key);
1140 // Our own key cannot come out of the decrypt by chance, so a match proves the device holds it.
1141 if (memcmp(key, system_key_, AES_KEY_SIZE) == 0) {
1142 ESP_LOGI(TAG, "Key pull check: %s -> key check value %s, the hub's own key", context.device_id.c_str(),
1143 check_value.c_str());
1145 }
1146
1148 this->telemetry_.set_phase(context.state);
1149 // The device's 0x3D over its own 0x32, under the key it sent, proves the decrypt and that it holds
1150 // the key (a chance match is 1 in 2^48). A lost answer is challenged again; a wrong one is final.
1152 uint8_t challenges = 0;
1153 while (challenges < EXCHANGE_RETRY_COUNT && proof == ExchangeEngine::ChallengeResult::NO_ANSWER) {
1154 if (challenges > 0) {
1155 App.feed_wdt();
1156 delay(EXCHANGE_RETRY_DELAY_MS);
1157 }
1158 challenges++;
1159 proof = engine_.challenge_sender(context.rx, FREQ_CH2, key);
1160 }
1162 ESP_LOGI(TAG, "Key pull check: %s -> key check value %s, proven by its 0x3D", context.device_id.c_str(),
1163 check_value.c_str());
1164 memcpy(context.pulled_key, key, AES_KEY_SIZE);
1165 context.has_pulled_key = true;
1167 }
1168 this->telemetry_.set_key_pull_unproven();
1170 ESP_LOGW(TAG,
1171 "Key pull check: %s -> key check value %s, not proven: its 0x3D does not match. The key is not "
1172 "printed and must not be used",
1173 context.device_id.c_str(), check_value.c_str());
1174 } else {
1175 ESP_LOGW(TAG,
1176 "Key pull check: %s -> key check value %s, not proven: no 0x3D received after %u challenges. The key "
1177 "is not printed and must not be used",
1178 context.device_id.c_str(), check_value.c_str(), challenges);
1179 }
1180 return KeyPullResult::FAILED;
1181}
1182
1183void PairingEngine::finish_pairing_attempt_(PairingOutcome outcome) {
1184 this->telemetry_.set_outcome(outcome);
1185 this->engine_.set_transmit_observer(nullptr);
1186 this->telemetry_.log_summary();
1187
1188 advisor::PairingAdvice advice[advisor::PAIRING_ADVICE_MAX];
1189 const uint8_t advice_count = advisor::analyze_pairing_telemetry(this->telemetry_, this->node_id_, advice);
1190 std::string advice_codes;
1191 for (uint8_t i = 0; i < advice_count; i++) {
1192 ESP_LOGW(TAG, "Pairing advisor: %s", advisor::pairing_advice_message(advice[i]).c_str());
1193 if (!advice_codes.empty())
1194 advice_codes += ',';
1195 advice_codes += advisor::pairing_advice_code_name(advice[i].code);
1196 }
1197 this->telemetry_.set_advice_codes(advice_codes);
1198}
1199
1200void PairingEngine::record_discovery_rx_telemetry_(const IoFrame &frame, bool accepted, int16_t rssi) {
1201 if (accepted) {
1202 this->telemetry_.record_rx(frame, rssi);
1203 } else {
1204 this->telemetry_.record_rx_reject(frame, rssi);
1205 }
1206}
1207
1208} // namespace home_io_control
1209} // 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.
bool register_pulled_key_devices_(pairing::PairingContext &context)
After a proven pull of a different key: find the devices that hold it, then end the attempt.
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.
uint8_t roll_call_and_register_(pairing::PairingContext &context, const uint8_t *key, bool device_key)
Find the devices that hold key, log one report, register the ones that can be controlled.
bool 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.
@ 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).
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).
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.
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:77
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:53
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 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:72
@ 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:125
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...
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?
std::string build_device_yaml_snippet(DeviceType type, uint8_t subtype, const std::string &device_id, bool metadata_complete, bool inverted, 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...
constexpr uint32_t PAIRING_RECENT_ONE_WAY_SIGHTING_WINDOW_MS
How recent a RecentOneWayPairingSighting has to be, relative to discover_and_pair() starting,...
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.
uint8_t discovery_att_class(uint8_t flags)
Extract the ATT class field from a discovery response's Multi Information 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.
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
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 inverted
True if open/close positions are swapped (e.g., horizontal awning).
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 build_pulled_key_report() says about the device that handed over a key.
Definition roll_call.h:220
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:48
bool inverted
Decoded position-inversion flag.
Definition roll_call.h:46
DeviceType type
Decoded device type.
Definition roll_call.h:44
bool has_extended
Whether manufacturer/flags above are present.
Definition roll_call.h:50
int16_t rssi_dbm
RSSI of the reply that produced this entry.
Definition roll_call.h:47
bool metadata_complete
Whether type/subtype were present in the payload.
Definition roll_call.h:51
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:52
uint8_t flags
Multi Information Byte; decode with DISCOVERY_FLAGS_* masks.
Definition roll_call.h:49
uint8_t subtype
Decoded device subtype.
Definition roll_call.h:45
Accumulated state of one scan, threaded through every sweep.
Definition roll_call.h:129
uint8_t count
Entries in use.
Definition roll_call.h:131
bool truncated
A responder was dropped because the array was full.
Definition roll_call.h:132
ScanResponder responders[SCAN_MAX_REPLIES]
Distinct responders, in arrival order.
Definition roll_call.h:130
All runtime tunable parameters for pairing and radio diagnostics.
uint16_t pairing_discovery_preamble
Preamble for the discovery broadcast (0x28/0x2E) start frame.
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: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 the device proved.
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.