Home IO Control
ESPHome add-on for IO-Homecontrol devices
Loading...
Searching...
No Matches
pairing_engine.cpp
Go to the documentation of this file.
1/// @file pairing_engine.cpp
2/// @brief Device pairing orchestration — discovery, key exchange, and finalization.
3/// @ingroup hioc_hub
4///
5/// Implements PairingEngine's three-phase pairing procedure and the low-level waiters.
6/// All radio operations delegate to ExchangeEngine (which owns LBT and hop timing);
7/// the PairingEngine focuses purely on protocol sequencing.
8///
9/// @todo Validate the full discovery and re-pair flow on freshly reset SX1276-backed devices.
10/// @todo Validate the full discovery and re-pair flow on freshly reset SX1262-backed devices.
11/// @todo Add first-class platform coverage for additional paired device classes once hardware is available.
12
13#include "pairing_engine.h"
14
15#include "hub_decisions.h"
16#include "proto_commands.h"
17#include "tuning_config.h"
18#include "esphome/core/application.h"
19#include "esphome/core/log.h"
20
21#include <algorithm>
22#include <cinttypes>
23#include <cstring>
24
25namespace esphome {
26namespace home_io_control {
27
28namespace {
29
30const char *const TAG = "home_io_control";
31
32/// Check if frame is a 0x33 key-confirm message.
33bool frame_is_key_confirm(const IoFrame &frame) { return frame.cmd == CMD_KEY_CONFIRM; }
34
35/// Log discovery-phase failure based on disposition.
36void log_discovery_diagnostic(decisions::PairingDiscoveryDisposition disp) {
37 switch (disp) {
39 ESP_LOGW(TAG, "No device responded to discovery");
40 break;
42 ESP_LOGW(TAG, "No valid discovery response received");
43 break;
45 break;
46 }
47}
48
49/// Block for `ms`, feeding the watchdog between chunks of at most 1000 ms each — a single
50/// multi-second delay() would trip the loop-task watchdog, and `pairing_key_init_delay_ms` allows
51/// up to 10000. Used only by the discover-confirm step's post-step pause: every other wait in this
52/// file is short enough (a few hundred ms) that App.feed_wdt() inside the retry loop is enough.
53void delay_feeding_wdt(uint32_t ms) {
54 while (ms > 0) {
55 App.feed_wdt();
56 const uint32_t chunk = ms < 1000 ? ms : 1000;
57 delay(chunk);
58 ms -= chunk;
59 }
60}
61
62} // namespace
63
64// --- Constructor ---
65
66PairingEngine::PairingEngine(RadioDriver **radio_ptr, const uint8_t *node_id, const uint8_t *system_key,
67 const TuningConfig *tuning, ExchangeEngine &engine, DeviceRegistry &registry,
68 PairingTelemetry &telemetry, const RecentOneWayPairingSighting &recent_oneway_sighting)
69 : radio_ptr_(radio_ptr),
70 node_id_(node_id),
71 system_key_(system_key),
72 tuning_(tuning),
73 engine_(engine),
74 registry_(registry),
75 telemetry_(telemetry),
76 recent_oneway_sighting_(recent_oneway_sighting) {}
77
78uint16_t PairingEngine::pairing_start_preamble_(const IoFrame &frame) const {
79 return std::min(engine_.request_preamble_for(frame), tuning_->pairing_discovery_preamble);
80}
81
82// --- Low-level waiters ---
83
84/// Wait for a valid discovery response (0x29) within timeout_ms.
85///
86/// Listens with per-chip frequency hopping between slices. Distinguishes between
87/// NO_RESPONSE (no packets at all) and INVALID (packets seen but none valid).
88///
89/// Frequency hopping: hops between the 2 non-request IO-homecontrol channels after each slice —
90/// the request always goes out on FREQ_CH2 (see run_discovery_phase_()), and a discovery reply
91/// essentially never lands back on that channel (see ListenPolicy's own doc comment for why), so
92/// dwelling there is wasted listening time. Same policy as collect_broadcast_responses()'s
93/// default. The slice length comes from
94/// RadioDriver::hop_dwell_ms() (spec.dwell_ms left at 0, so listen() asks the driver). When
95/// preamble or sync detection fires, the dwell extends by PREAMBLE_LINGER_DWELL_MS so the
96/// incoming frame can complete without interruption.
98 RadioRxPacket &packet,
99 IoFrame &response_frame) {
100 ListenSpec spec;
101 spec.window_ms = timeout_ms;
102 // Skipping the request channel is the default because a broadcast's answers are almost never
103 // on it (ListenPolicy's own doc has the 1-in-149 measurement). `all` is the escape hatch for a
104 // device that has never answered at all: that measurement is drawn from responders we already
105 // hear, so it cannot speak for one whose replies we might be missing entirely.
106 spec.policy = tuning_->pairing_discovery_listen_channels == DiscoveryListenChannels::ALL
109 spec.request_freq = FREQ_CH2;
110 // dwell_ms left at 0: no measured reason to dwell differently from the roll-call, so listen()
111 // asks the driver (radio_()->hop_dwell_ms(*tuning_)) the same way collect_broadcast_responses()
112 // does.
113 spec.linger_on_preamble = true;
114 spec.linger_dwell_ms = PREAMBLE_LINGER_DWELL_MS;
115 spec.on_hop = [this]() { this->telemetry_.record_hop(); };
116
117 bool saw_traffic = false;
118 auto outcome =
119 engine_.listen(spec, packet, response_frame, [&](const IoFrame *parsed, const RadioRxPacket & /*packet*/) {
120 saw_traffic = true;
121 if (parsed == nullptr)
123 const bool accepted = decisions::classify_pairing_discovery_response(*parsed, node_id_) ==
125 this->record_discovery_rx_telemetry_(*parsed, accepted, radio_()->get_last_capture().rssi_dbm);
127 });
128
129 if (outcome == ListenOutcome::ACCEPTED)
133}
134
135/// Wait for a key-challenge (0x3C) or direct key-confirm (0x33) from target device.
136///
137/// During key exchange the device typically responds to 0x31 with a random 6-byte
138/// challenge (0x3C). Some devices skip the challenge and send 0x33 directly —
139/// indicating immediate key acceptance (observed mostly when the controller's
140/// TX→RX turnaround is slow enough that the 0x3C is missed). Both are accepted.
141///
142/// Uses `ExchangeEngine::listen()` with `ListenPolicy::HOLD_REQUEST_CHANNEL`: this is a unicast
143/// reply to a unicast request (the 0x31 key-init), and every measured unicast pairing reply came
144/// back on the request channel, so there is nothing here to hop for — same reasoning as
145/// `listen_for_key_confirm_()`. This loop runs on all three chips (it is called before the
146/// `has_fast_tx_rx_turnaround()` branch in `run_key_exchange_phase_()`), unlike the dedicated
147/// confirm wait, which only slow-turnaround radios reach.
148bool PairingEngine::wait_for_key_challenge_(uint32_t timeout_ms, RadioRxPacket &packet, IoFrame &challenge_frame,
149 const uint8_t device_node_id[NODE_ID_SIZE]) {
150 ListenSpec spec;
151 spec.window_ms = timeout_ms;
153
154 bool saw_traffic = false;
155 auto outcome =
156 engine_.listen(spec, packet, challenge_frame, [&](const IoFrame *parsed, const RadioRxPacket & /*packet*/) {
157 saw_traffic = true;
158 if (parsed == nullptr)
160 const int16_t rssi = radio_()->get_last_capture().rssi_dbm;
161 if (parsed->cmd == CMD_KEY_CONFIRM && memcmp(parsed->src, device_node_id, NODE_ID_SIZE) == 0 &&
162 memcmp(parsed->dst, node_id_, NODE_ID_SIZE) == 0) {
163 this->telemetry_.record_rx(*parsed, rssi);
165 }
166 if (decisions::classify_pairing_key_challenge(*parsed, device_node_id, node_id_) !=
168 this->telemetry_.record_rx_reject(*parsed, rssi);
170 }
171 this->telemetry_.record_rx(*parsed, rssi);
173 });
174
175 if (outcome == ListenOutcome::ACCEPTED)
176 return true;
177 ESP_LOGW(TAG, saw_traffic ? "Key exchange: no valid challenge received" : "Key exchange: no challenge received");
178 return false;
179}
180
181/// Wait for one discover-confirm (0x2C) try's answer.
182///
183/// Listen policy alternates per try (`decisions::discover_confirm_try_rotates()`): the one
184/// rotating try hedges against a 0x2D landing off the request channel — Somfy answers on the
185/// request channel, VELUX is unmeasured — while the rest hold, matching every other unicast
186/// pairing wait in this file (a 0x2D is a unicast reply to a unicast 0x2C). Only parsed frames are
187/// recorded to telemetry, same as the other pairing waits.
189 uint8_t try_index, pairing::PairingContext &context) {
190 ListenSpec spec;
194 // Same shape as the discovery listen's rotating spec: a short rotating dwell needs the
195 // preamble/sync linger guard so an incoming reply is not cut off mid-retune.
196 spec.linger_on_preamble = true;
197 spec.linger_dwell_ms = PREAMBLE_LINGER_DWELL_MS;
198 spec.on_hop = [this]() { this->telemetry_.record_hop(); };
199 } else {
201 }
202
204 auto outcome =
205 engine_.listen(spec, context.packet, context.rx, [&](const IoFrame *parsed, const RadioRxPacket & /*packet*/) {
206 if (parsed == nullptr)
207 return ReplyDisposition::IGNORE;
208 const int16_t rssi = radio_()->get_last_capture().rssi_dbm;
209 disposition = decisions::classify_pairing_discover_confirm_reply(*parsed, context.device.node_id, node_id_);
210 if (disposition == decisions::PairingDiscoverConfirmDisposition::IGNORE) {
211 this->telemetry_.record_rx_reject(*parsed, rssi);
212 return ReplyDisposition::IGNORE;
213 }
214 this->telemetry_.record_rx(*parsed, rssi);
216 });
217
218 if (outcome != ListenOutcome::ACCEPTED)
219 return decisions::PairingDiscoverConfirmDisposition::IGNORE; // Timeout: no reply this try.
220 return disposition;
221}
222
223/// One HOLD_REQUEST_CHANNEL listen for the key-transfer confirm wait — see the header doc for why
224/// this is a shared helper rather than an inline lambda repeated at each of its two call sites.
225///
226/// Deliberately holds the request channel rather than hopping: a key confirm is a unicast reply to
227/// a unicast request, and every measured unicast pairing reply — the 0x33 in the corpus pairing
228/// captures on all three chips, every 0x3C, field-logged 0xFE error replies — came back on the
229/// channel the request went out on. Only replies to *broadcasts* are measured off the request
230/// channel, so there is nothing here to hop for, and hopping loses any device that answers later
231/// than one slice (real devices answer some requests at 246+ ms). HOLD also does not slice the
232/// wait: slicing exists so a hopping loop gets a chance to hop between dwells, and to keep the
233/// watchdog fed during a long silent wait; neither applies here, and wait_for_packet() already
234/// feeds the watchdog internally while it blocks.
235decisions::PairingKeyConfirmDisposition PairingEngine::listen_for_key_confirm_(pairing::PairingContext &context,
236 uint8_t try_number,
237 bool after_challenge) {
238 ListenSpec spec;
239 spec.window_ms = PAIRING_KEY_CONFIRM_TIMEOUT_MS;
240 spec.policy = ListenPolicy::HOLD_REQUEST_CHANNEL;
241
242 bool saw_any = false;
243 auto disposition = decisions::PairingKeyConfirmDisposition::IGNORE;
244 auto outcome =
245 engine_.listen(spec, context.packet, context.resp, [&](const IoFrame *parsed, const RadioRxPacket &packet) {
246 saw_any = true;
247 ESP_LOGD(TAG, "Key confirm wait: got %u bytes on freq=%" PRIu32, packet.len, packet.freq_hz);
248 if (parsed == nullptr) {
249 ESP_LOGD(TAG, "Key confirm wait: parse failed");
250 return ReplyDisposition::IGNORE;
251 }
252 ESP_LOGD(TAG, "Key confirm wait: parsed cmd=0x%02X src=%02X%02X%02X dst=%02X%02X%02X", parsed->cmd,
253 parsed->src[0], parsed->src[1], parsed->src[2], parsed->dst[0], parsed->dst[1], parsed->dst[2]);
254 disposition = decisions::classify_pairing_key_confirm_reply(context.req, *parsed);
255 if (disposition == decisions::PairingKeyConfirmDisposition::IGNORE)
256 return ReplyDisposition::IGNORE;
257
258 const int16_t rssi = radio_()->get_last_capture().rssi_dbm;
259 if (disposition == decisions::PairingKeyConfirmDisposition::REFUSE) {
260 this->telemetry_.record_rx_reject(*parsed, rssi);
261 ESP_LOGW(TAG, "Key transfer: device responded with cmd=%s(0x%02X) (expected KEY_CONFIRM 0x33)",
262 command_name(parsed->cmd), parsed->cmd);
263 if (parsed->cmd == CMD_ERROR_RESP && parsed->data_len > 0)
264 ESP_LOGW(TAG, "Key transfer: error code=0x%02X", parsed->data[0]);
265 return ReplyDisposition::ABORT;
266 }
267 // CONFIRM (0x33) or CHALLENGE (a well-formed 0x3C): both are legitimate answers, not
268 // rejections. No TX happens in this callback — a CHALLENGE is answered by the caller
269 // after this listen ends, per the header doc.
270 this->telemetry_.record_rx(*parsed, rssi);
271 return disposition == decisions::PairingKeyConfirmDisposition::CONFIRM ? ReplyDisposition::ACCEPT
272 : ReplyDisposition::ABORT;
273 });
274
275 if (outcome == ListenOutcome::TIMED_OUT) {
276 ESP_LOGI(TAG, "Try %u%s: no response for key transfer (0x32) within %" PRIu32 " ms (saw_any=%d)", try_number,
277 after_challenge ? " (post-challenge)" : "", PAIRING_KEY_CONFIRM_TIMEOUT_MS, saw_any);
278 return decisions::PairingKeyConfirmDisposition::IGNORE;
279 }
280 return disposition;
281}
282
283/// Transmit the 0x32 key transfer and wait for 0x33 key confirm (with retry).
284///
285/// Only reached on slow-turnaround radios (`RadioDriver::has_fast_tx_rx_turnaround() == false`,
286/// i.e. SX1262/LR1121): fast-turnaround radios (SX1276) catch the 0x33 through the standard
287/// `ExchangeEngine::send_and_receive_()` / `wait_for_first_response_()` path instead and never
288/// call this function — see `run_key_exchange_phase_()`. Uses the driver's response_preamble()
289/// (drivers whose TX waveform needs more lock-on margin return a longer preamble). Retries up to
290/// EXCHANGE_RETRY_COUNT times on timeout.
292 for (uint8_t tries = 0; tries < EXCHANGE_RETRY_COUNT; tries++) {
293 if (tries > 0) {
294 App.feed_wdt();
295 delay(EXCHANGE_RETRY_DELAY_MS);
296 }
297 if (!engine_.transmit_frame(context.req, FREQ_CH2, radio_()->response_preamble()))
298 continue;
299
300 const uint8_t try_number = tries + 1;
301 decisions::PairingKeyConfirmDisposition disposition = listen_for_key_confirm_(context, try_number, false);
303 ESP_LOGI(TAG, "Key transfer: device challenged the key transfer, answering 0x3D");
304 // "Rest of the window" would leave almost nothing after a late challenge plus the 0x3D TX,
305 // so this is a fresh full window, same as ExchangeEngine::wait_for_final_response_() gets
306 // after handle_authentication_(). A failed 0x3D build/TX leaves `disposition` at CHALLENGE,
307 // which the checks below treat the same as a second challenge: this try ends without
308 // confirming, and the next try re-sends 0x32.
309 if (engine_.answer_challenge(context.req, context.resp, FREQ_CH2))
310 disposition = listen_for_key_confirm_(context, try_number, true);
311 }
312
314 return true;
316 return false; // An explicit refusal must not spend the remaining retries; a challenge is
317 // not a refusal, so neither branch above returns false for it.
318 // IGNORE (timeout) or a second CHALLENGE in the same try: spend the next try.
319 }
320 return false;
321}
322
323/// Build CMD_KEY_TRANSFER against the challenge currently held in `context.rx.data` and wait for
324/// the 0x33 confirm, routing through the fast- or slow-turnaround path. Shared by
325/// run_key_exchange_phase_()'s first attempt and its slow-turnaround retry loop, which calls this
326/// again against a freshly re-issued challenge (see that function's doc comment) rather than
327/// discarding it.
330 engine_.record_debug(pairing_stage_name(context.state), 1, true);
331 this->telemetry_.set_phase(context.state);
332 if (!create_key_transfer(context.req, context.key_init, context.device.node_id, node_id_, system_key_,
333 context.rx.data)) {
334 return false;
335 }
336
338 engine_.record_debug(pairing_stage_name(context.state), 1, true);
339 this->telemetry_.set_phase(context.state);
340 // Fast-turnaround radios catch the 0x33 through the standard exchange wait. Slow-turnaround
341 // radios miss it while re-entering RX, so run_key_exchange_phase_()'s retry loop calls this
342 // helper again instead, rather than using the dedicated wait loop directly.
343 if (radio_()->has_fast_tx_rx_turnaround()) {
344 // Key exchange is the one caller that genuinely needs the payload: without the 0x33 there is
345 // no confirmation the device took the key, so an unconfirmed acceptance is not good enough.
346 return engine_.send_and_receive(context.req, context.resp, FREQ_CH2) == ExchangeOutcome::SUCCESS_WITH_RESPONSE &&
347 frame_is_key_confirm(context.resp);
348 }
349 return wait_for_key_confirm_(context);
350}
351
352// --- Discovery metadata ---
353
354/// Parse a discovery response frame into device metadata and ID.
355///
356/// Decodes node ID, device type, subtype, and the extended fields (manufacturer, backbone,
357/// Multi Information Byte) via decode_discovery_response(), then emits pairing's diagnostic log
358/// lines for whichever extended fields the payload actually included.
359/// The inversion flag is derived from the type via `default_inverted_for_type()`.
361 std::string &device_id) {
362 const DiscoveryResponseInfo info = decode_discovery_response(frame, device, device_id);
363
364 if (frame.data_len > DISCOVERY_RESP_MANUFACTURER_OFFSET) {
365 const char *mfr_name = manufacturer_name(info.manufacturer);
366 ESP_LOGI(TAG, "Discovery: device %s manufacturer=%u (%s)", device_id.c_str(), info.manufacturer, mfr_name);
367 if (info.manufacturer == 0 || info.manufacturer > MANUFACTURER_ID_MAX) {
368 ESP_LOGW(TAG,
369 "Unknown manufacturer ID %u reported by device %s. "
370 "Please file a GitHub issue with this ID and your device model so support can be added.",
371 info.manufacturer, device_id.c_str());
372 }
373 }
374 if (frame.data_len > DISCOVERY_RESP_BACKBONE_OFFSET + NODE_ID_SIZE - 1) {
375 ESP_LOGD(TAG, "Discovery: backbone=%02X%02X%02X", info.backbone[0], info.backbone[1], info.backbone[2]);
376 }
377 if (frame.data_len > DISCOVERY_RESP_FLAGS_OFFSET) {
378 uint8_t const att = discovery_att_class(info.flags);
379 uint8_t const power_save = discovery_power_save_mode(info.flags);
380 ESP_LOGI(TAG, "Discovery: device %s turnaround=%s power_save=%s flags=0x%02X", device_id.c_str(),
381 att_class_name(att), power_save_mode_name(power_save), info.flags);
382 if (power_save == POWER_SAVE_LOW_POWER) {
383 ESP_LOGI(TAG,
384 "Device %s reports low-power mode: add 'low_power: true' to its YAML entry. "
385 "The pairing snippet below pre-fills it when one is printed.",
386 device_id.c_str());
387 }
388 }
389 return info;
390}
391
392// --- Phase helpers ---
393
394/// Phase 1: broadcast discovery command(s) and wait for a device response (0x29).
395///
396/// Sends each configured discovery command in order, waiting up to
397/// `pairing_discovery_wait_ms` for a valid response after each TX.
398/// Retries up to PAIRING_DISCOVERY_MAX_ATTEMPTS times per command.
400 if (tuning_->pairing_discovery_initial_dwell_ms > 0) {
401 ESP_LOGD(TAG, "Discovery: initial dwell %u ms", tuning_->pairing_discovery_initial_dwell_ms);
402 delay(tuning_->pairing_discovery_initial_dwell_ms);
403 }
404
405 // Tracks whether any single attempt saw traffic that failed to classify as a valid discovery
406 // response, so the final "gave up after retries" return can distinguish INVALID (something was
407 // heard, just not a valid response) from NO_RESPONSE (nothing heard at all) instead of always
408 // collapsing to NO_RESPONSE.
409 bool saw_invalid = false;
410
411 for (size_t command_index = 0; command_index < tuning_->pairing_discovery_commands.size(); ++command_index) {
412 auto command = static_cast<uint8_t>(tuning_->pairing_discovery_commands[command_index]);
413 const uint8_t *destination = resolve_discovery_destination(command, tuning_->pairing_discovery_destination_auto,
414 tuning_->pairing_discovery_destination.data());
415 ESP_LOGD(TAG, "Discovery command %zu/%zu: cmd=0x%02X dst=%02X%02X%02X", command_index + 1,
416 tuning_->pairing_discovery_commands.size(), command, destination[0], destination[1], destination[2]);
417
418 for (uint8_t attempt = 1; attempt <= PAIRING_DISCOVERY_MAX_ATTEMPTS; ++attempt) {
419 this->telemetry_.increment_discovery_attempt();
421 engine_.record_debug(pairing_stage_name(context.state), attempt, false);
422 this->telemetry_.set_phase(context.state);
423 if (!create_discovery_request(context.req, node_id_, command, destination, tuning_->pairing_discovery_low_power,
424 tuning_->pairing_discovery_ack_capable, tuning_->pairing_discovery_payload_enabled,
425 tuning_->pairing_discovery_payload, system_key_) ||
426 !engine_.transmit_frame(context.req, FREQ_CH2, tuning_->pairing_discovery_preamble)) {
428 }
429
431 engine_.record_debug(pairing_stage_name(context.state), attempt, false);
432 this->telemetry_.set_phase(context.state);
433 auto result = wait_for_discovery_response_(tuning_->pairing_discovery_wait_ms, context.packet, context.rx);
435 const DiscoveryResponseInfo info = parse_device_from_discovery(context.rx, context.device, context.device_id);
436 context.discovery_metadata_complete = context.rx.data_len >= DEVICE_METADATA_SIZE;
437 context.discovery_low_power =
438 info.has_extended && discovery_power_save_mode(info.flags) == POWER_SAVE_LOW_POWER;
439 return result;
440 }
442 saw_invalid = true;
443 }
444
445 if (attempt < PAIRING_DISCOVERY_MAX_ATTEMPTS) {
446 ESP_LOGI(TAG, "Discovery attempt %u/%u for cmd=0x%02X: no response, retrying...", attempt,
448 }
449 }
450 }
453}
454
455/// Discover-confirm step (0x2C → 0x2D): sent directly to the device discovery just found, once
456/// per discover_and_pair() attempt, before the key-exchange retry loop begins (so a retry of that
457/// loop never repeats this step).
458///
459/// Never fails a pairing attempt: `skip` mode, a timeout, and an explicit CMD_ERROR_RESP all still
460/// let discover_and_pair() proceed to run_key_exchange_phase_() — this function only decides how
461/// long that takes and what gets logged. CTRL1_ACK follows the *target's* power class, not the
462/// mode alone: a low-power target's 0x2C carries `CTRL1_LOW_POWER` only in both `send` and
463/// `send_with_ack` (every corpus hub's own shape for that class); only an always-alive target's
464/// ACK bit changes between the two modes (see create_discover_confirm()'s doxygen for the
465/// byte-shape cross-check against real captures).
466///
467/// `set_phase()` fires at most once per state for the whole step (not per try): the confirm step
468/// can spend up to PAIRING_DISCOVER_CONFIRM_TRIES transmits, and PAIRING_TELEMETRY_MAX_EVENTS is a
469/// fixed 32-event budget the pairing advisor scans — a per-try phase would spend events on exactly
470/// the failure paths it is meant to diagnose. record_debug() (not telemetry) still runs every try.
472 if (tuning_->pairing_discover_confirm == DiscoverConfirmMode::SKIP)
474
475 const bool ack =
476 tuning_->pairing_discover_confirm == DiscoverConfirmMode::SEND_WITH_ACK && !context.discovery_low_power;
477
479 this->telemetry_.set_phase(context.state);
480
481 bool wait_phase_started = false;
483 for (uint8_t try_index = 1; try_index <= PAIRING_DISCOVER_CONFIRM_TRIES; try_index++) {
484 if (try_index > 1)
485 App.feed_wdt(); // No extra retry gap: the 1.5 s windows already space the retransmissions.
486
487 engine_.record_debug(pairing_stage_name(pairing::PairingState::TX_DISCOVER_CONFIRM), try_index, false);
488 if (!create_discover_confirm(context.req, node_id_, context.device.node_id, context.discovery_low_power, ack) ||
489 !engine_.transmit_frame(context.req, FREQ_CH2, pairing_start_preamble_(context.req))) {
490 continue; // A failed build/TX counts as a silent try, like wait_for_key_confirm_() does for 0x32.
491 }
492
494 if (!wait_phase_started) {
495 this->telemetry_.set_phase(context.state);
496 wait_phase_started = true;
497 }
498 engine_.record_debug(pairing_stage_name(context.state), try_index, false);
499
500 const uint32_t wait_start_ms = millis();
502
504 ESP_LOGI(TAG, "Discover confirm: 0x2D from %s on freq=%" PRIu32 " after %" PRIu32 " ms (try %u/%u)",
505 context.device_id.c_str(), context.packet.freq_hz, millis() - wait_start_ms, try_index,
508 break;
509 }
511 const uint8_t error_code = context.rx.data_len > 0 ? context.rx.data[0] : 0;
512 ESP_LOGW(TAG, "Discover confirm: device %s answered 0x2C with error 0x%02X, continuing with key exchange",
513 context.device_id.c_str(), error_code);
515 break;
516 }
517 // IGNORE: timeout or an unrelated frame this try — spend the next try.
518 }
519
521 ESP_LOGI(TAG, "Discover confirm: no 0x2D from %s after %u tries, continuing with key exchange",
523 }
524
525 // Applied for every result reaching here (i.e. every result except SKIPPED, which already
526 // returned above), including NO_REPLY — one simple rule instead of a per-outcome one.
527 if (tuning_->pairing_key_init_delay_ms > 0)
528 delay_feeding_wdt(tuning_->pairing_key_init_delay_ms);
529
530 return result;
531}
532
533/// Phase 2: authenticated key exchange (0x31 → 0x3C → 0x32 → 0x33).
534///
535/// Steps:
536/// 1. Transmit CMD_KEY_INIT (0x31), preamble per pairing_start_preamble_()
537/// 2. Wait for device challenge (0x3C)
538/// 3. Transmit CMD_KEY_TRANSFER (0x32) with encrypted system key
539/// 4. Wait for CMD_KEY_CONFIRM (0x33)
540///
541/// Step 4 depends on the driver's TX→RX turnaround: fast-turnaround radios await
542/// the 0x33 through the standard send_and_receive() exchange; slow-turnaround
543/// radios use the dedicated wait_for_key_confirm_() path with a key-init
544/// re-trigger, because the 0x33 would otherwise arrive while the receiver is
545/// still settling (see RadioDriver::has_fast_tx_rx_turnaround()).
546///
547/// The slow-turnaround re-trigger loop below (`for (int re = 0; ...)`) has two distinct outcomes
548/// for its re-sent CMD_KEY_INIT, and they are handled differently:
549/// - The device replies with CMD_KEY_CONFIRM (0x33) directly — it already had the key from the
550/// first 0x32 and is just auto-confirming again. Nothing more to send.
551/// - The device replies with a *fresh* CMD_CHALLENGE_REQ (0x3C) instead — proof it never received
552/// the first 0x32 at all (a device that already holds the key doesn't re-challenge), so there
553/// is no key transfer for it to confirm yet. The loop calls transfer_key_and_wait_confirm_()
554/// again here, replaying CMD_KEY_TRANSFER against this new challenge, rather than discarding
555/// the 0x3C and burning the retry for nothing — the device's next 0x31 would just produce
556/// another fresh challenge either way, so retrying without resending 0x32 could never succeed.
557///
558/// That replay roughly doubles this function's worst-case blocking time when every wait times out
559/// (approximately 3.5s -> 6.6s: two of the loop's iterations can now each wait out a full
560/// key-transfer-and-confirm cycle instead of returning immediately on a missed 0x33). This is a
561/// known, accepted trade-off: pairing already tolerates multi-second blocking exchanges (see
562/// EXCHANGE_TOTAL_BUDGET_MS's own reasoning, proto_timing.h), and the alternative — leaving a
563/// slow-turnaround device that never got its key stuck retrying forever — is worse than the
564/// occasional slower failure path.
567 engine_.record_debug(pairing_stage_name(context.state), 1, false);
568 this->telemetry_.set_phase(context.state);
569 if (!create_key_init(context.key_init, node_id_, context.device.node_id) ||
570 !engine_.transmit_frame(context.key_init, FREQ_CH2, pairing_start_preamble_(context.key_init))) {
571 return false;
572 }
573
575 engine_.record_debug(pairing_stage_name(context.state), 1, true);
576 this->telemetry_.set_phase(context.state);
578 return false;
579 }
580
581 // Some devices send 0x33 directly after 0x31 without requiring 0x32.
582 if (context.rx.cmd == CMD_KEY_CONFIRM) {
583 ESP_LOGI(TAG, "Device accepted key immediately (0x33 without 0x32 exchange)");
584 context.resp = context.rx;
585 return true;
586 }
587
588 // No challenge bytes here: the raw 0x3C payload plus the 0x3D response it provokes is a
589 // known-plaintext/known-ciphertext pair under the system key (see redaction.h). The generic
590 // frame-log helpers (log_frame()/log_component_capture()) already mask both commands.
591 ESP_LOGI(TAG, "Challenge (0x3C) received: data_len=%u freq=%" PRIu32 " rssi=%d", context.rx.data_len,
592 context.packet.freq_hz, radio_()->get_last_capture().rssi_dbm);
593
594 bool key_ok = transfer_key_and_wait_confirm_(context);
595 // Fast-turnaround radios catch the 0x33 through the standard exchange wait inside the helper
596 // above and never reach this loop. Slow-turnaround radios miss it while re-entering RX, so on a
597 // miss they re-send the key-init to trigger the device's auto-confirm.
598 if (!radio_()->has_fast_tx_rx_turnaround()) {
599 for (int re = 0; !key_ok && re < 2; re++) {
600 ESP_LOGI(TAG, "Key confirm missed, re-sending key-init to trigger auto-confirm (attempt %d/2)", re + 1);
601 App.feed_wdt();
602 delay(EXCHANGE_RETRY_DELAY_MS);
603 if (!engine_.transmit_frame(context.key_init, FREQ_CH2, pairing_start_preamble_(context.key_init)))
604 continue;
606 context.device.node_id))
607 continue;
608 if (context.rx.cmd == CMD_KEY_CONFIRM) {
609 key_ok = true;
610 continue;
611 }
612 // A fresh CHALLENGE_REQ (0x3C), not a direct KEY_CONFIRM (0x33): the device never received
613 // our first 0x32 in the first place, so there is no key for it to auto-confirm yet -- a
614 // fresh 0x3C is exactly the signal that the right response is "resend 0x32 against this new
615 // challenge", not "give up and burn the retry for nothing".
616 key_ok = transfer_key_and_wait_confirm_(context);
617 }
618 }
619 if (!key_ok) {
620 ESP_LOGW(TAG, "Key exchange failed");
621 return false;
622 }
623 return true;
624}
625
626/// Phase 3: send SetConfig1 (0x6F) once. Optional — see the header. Its preamble follows
627/// pairing_start_preamble_(), like the other directed pairing start frames.
629 if (!create_set_config1(context.req, node_id_, context.device.node_id))
630 return;
631 // Its reply is never read: an explicit refusal and silence are equally harmless here.
632 if (engine_.send_and_receive(context.req, context.resp, FREQ_CH2, PAIRING_SET_CONFIG1_MAX_TRIES,
633 pairing_start_preamble_(context.req)) == ExchangeOutcome::FAILED) {
634 ESP_LOGI(TAG, "Pairing: no reply from %s to the optional SetConfig1 (0x6F); the pairing is complete",
635 context.device_id.c_str());
636 }
637}
638
639// --- Orchestrator ---
640
641/// Pairing orchestrator — high-level three-phase flow.
642///
643/// Phase 1: run_discovery_phase_() finds a device in pairing mode.
644/// Phase 2: run_key_exchange_phase_() performs authenticated key establishment.
645/// Phase 3: finalize_pairing_configuration_() sends the optional SetConfig1 once.
646///
647/// On success the device is added to the registry and a YAML snippet is printed to the log.
648/// The hub's thin wrapper manages the busy_ flag before and after this call.
650 this->telemetry_.begin();
651 this->engine_.set_transmit_observer(&this->telemetry_);
652
653 // Seed telemetry with a 1W pairing-gesture frame the hub overheard shortly before this call
654 // (issue #27/#65): without this, a PROG press completed a few seconds before "Discover & Pair"
655 // is pressed in the app is invisible to the advisor purely because the telemetry window opens
656 // here, even though the radio heard it just fine — the false rf_silent advice this produced was
657 // field-confirmed against a real trace (issue #27, miljaar).
658 if (this->recent_oneway_sighting_.seen_ms != 0 &&
659 (millis() - this->recent_oneway_sighting_.seen_ms) < PAIRING_RECENT_ONE_WAY_SIGHTING_WINDOW_MS) {
660 this->telemetry_.record_recent_one_way_sighting(this->recent_oneway_sighting_);
661 }
662
663 ESP_LOGI(TAG, "Starting device discovery...");
664 ESP_LOGI(TAG, "%s", tuning_config_full_snapshot(*tuning_).c_str());
665
667
668 // Phase 1: Discovery
669 auto disc_disp = run_discovery_phase_(context);
671 log_discovery_diagnostic(disc_disp);
672 this->finish_pairing_attempt_(disc_disp == decisions::PairingDiscoveryDisposition::INVALID
675 return false;
676 }
677
678 if (tuning_->pairing_discovery_preamble < LONG_PREAMBLE) {
679 ESP_LOGI(TAG, "Pairing: directed frames to %s use the %u-byte discovery preamble it just answered",
680 context.device_id.c_str(), tuning_->pairing_discovery_preamble);
681 }
682
683 // Discover-confirm (0x2C -> 0x2D): once per attempt, before the key-exchange retry loop below,
684 // so a retry of that loop never repeats it. Never fails the attempt — see the function's doc.
685 this->run_discover_confirm_step_(context);
686
687 // Phase 2: Key exchange — retry up to the configured number of times.
688 bool key_exchanged = false;
689 for (uint8_t ke_attempt = 0; ke_attempt < tuning_->pairing_key_exchange_retries; ke_attempt++) {
690 if (ke_attempt > 0) {
691 ESP_LOGI(TAG, "Retrying key exchange (attempt %d/%u)...", ke_attempt + 1, tuning_->pairing_key_exchange_retries);
692 App.feed_wdt();
693 delay(EXCHANGE_RETRY_DELAY_MS);
694 }
695 if (run_key_exchange_phase_(context)) {
696 key_exchanged = true;
697 break;
698 }
699 }
700 if (!key_exchanged) {
701 this->finish_pairing_attempt_(PairingOutcome::KEY_EXCHANGE_FAILED);
702 return false;
703 }
704
705 // Phase 3: optional SetConfig1. The 0x33 above completed the pairing.
707
709 engine_.record_debug(pairing_stage_name(context.state), 1, true);
710 this->telemetry_.set_phase(context.state);
711
712 // The device just sent its 0x33, so it counts as heard from: its wake belief starts at "maybe
713 // awake" for the follow-up exchanges rather than "asleep" (same stamp update_link_health() sets).
714 context.device.last_seen_ms = millis();
715 registry_.put(context.device_id, context.device);
716 this->telemetry_.set_paired_device(context.device.node_id, context.device.type);
717
718 const std::string type_diag = format_device_type_diagnostic(context.device.type);
719 const std::string type_yaml = format_device_type_for_yaml(context.device.type);
720 const std::string snippet = build_device_yaml_snippet(context.device.type, context.device.subtype, context.device_id,
722 context.discovery_low_power);
723
724 if (snippet.empty()) {
725 // metadata_complete is true here (build_device_yaml_snippet() never returns empty for
726 // metadata_complete == false), so this is specifically "we know the type, but there's no
727 // ESPHome platform for it yet".
728 ESP_LOGW(TAG,
729 "Device %s paired successfully, but this repo does not yet expose an ESPHome platform for type=%s "
730 "class=%s subtype=%u.",
731 context.device_id.c_str(), type_diag.c_str(), device_capability_class_name(context.device.type),
732 context.device.subtype);
733 ESP_LOGW(TAG,
734 "No ready-to-paste YAML was generated. If you want to experiment manually, choose the most likely "
735 "platform and set io_device_type: %s.",
736 type_yaml.c_str());
737 ESP_LOGW(TAG, "Please file a GitHub issue with this device type, subtype, model, and the pairing log so support "
738 "can be added.");
739
741 engine_.record_debug(pairing_stage_name(context.state), 1, true);
742 this->telemetry_.set_phase(context.state);
743 this->finish_pairing_attempt_(PairingOutcome::PAIRED);
744 return true;
745 }
746
747 if (!context.discovery_metadata_complete) {
748 ESP_LOGW(TAG,
749 "Device %s paired successfully, but the discovery response did not include type/subtype "
750 "metadata, so the platform (cover/light/switch/lock) can't be determined automatically.",
751 context.device_id.c_str());
752 ESP_LOGI(TAG, "Add this to your YAML once you know what kind of device it is:\n%s", snippet.c_str());
753 ESP_LOGW(TAG, "Please file a GitHub issue with the pairing log and device model so this discovery edge case can "
754 "be investigated.");
755
757 engine_.record_debug(pairing_stage_name(context.state), 1, true);
758 this->telemetry_.set_phase(context.state);
759 this->finish_pairing_attempt_(PairingOutcome::PAIRED);
760 return true;
761 }
762
763 ESP_LOGI(TAG, "Device %s paired successfully! Add this to your YAML:\n%s", context.device_id.c_str(),
764 snippet.c_str());
765
766 if (yaml_device_type_name(context.device.type) == nullptr) {
767 ESP_LOGW(TAG,
768 "This snippet uses the raw device type %s because the project does not yet expose a named YAML alias "
769 "for %s.",
770 type_yaml.c_str(), type_diag.c_str());
771 ESP_LOGW(TAG, "Please file a GitHub issue with this type, subtype, device model, and the pairing log so support "
772 "can be added.");
773 }
774
776 engine_.record_debug(pairing_stage_name(context.state), 1, true);
777 this->telemetry_.set_phase(context.state);
778 this->finish_pairing_attempt_(PairingOutcome::PAIRED);
779 return true;
780}
781
782void PairingEngine::finish_pairing_attempt_(PairingOutcome outcome) {
783 this->telemetry_.set_outcome(outcome);
784 this->engine_.set_transmit_observer(nullptr);
785 this->telemetry_.log_summary();
786
787 advisor::PairingAdvice advice[advisor::PAIRING_ADVICE_MAX];
788 const uint8_t advice_count = advisor::analyze_pairing_telemetry(this->telemetry_, this->node_id_, advice);
789 std::string advice_codes;
790 for (uint8_t i = 0; i < advice_count; i++) {
791 ESP_LOGW(TAG, "Pairing advisor: %s", advisor::pairing_advice_message(advice[i]).c_str());
792 if (!advice_codes.empty())
793 advice_codes += ',';
794 advice_codes += advisor::pairing_advice_code_name(advice[i].code);
795 }
796 this->telemetry_.set_advice_codes(advice_codes);
797}
798
799void PairingEngine::record_discovery_rx_telemetry_(const IoFrame &frame, bool accepted, int16_t rssi) {
800 if (accepted) {
801 this->telemetry_.record_rx(frame, rssi);
802 } else {
803 this->telemetry_.record_rx_reject(frame, rssi);
804 }
805}
806
807} // namespace home_io_control
808} // namespace esphome
Owns the per-hub device table, update callbacks, and linked-remote associations.
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).
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.
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_key_exchange_phase_(pairing::PairingContext &context)
Phase 2: authenticated key exchange (0x31 → 0x3C → 0x32 → 0x33).
bool wait_for_key_confirm_(pairing::PairingContext &context)
Transmit the 0x32 key transfer and wait for the 0x33 key confirm with retry.
bool wait_for_key_challenge_(uint32_t timeout_ms, RadioRxPacket &packet, IoFrame &challenge_frame, const uint8_t device_node_id[NODE_ID_SIZE])
Wait for a key-challenge (0x3C) or direct key-confirm (0x33) from the target device.
bool discover_and_pair()
Discover and pair a device currently in pairing mode (three-phase orchestrator).
bool transfer_key_and_wait_confirm_(pairing::PairingContext &context)
Build CMD_KEY_TRANSFER against the current challenge and wait for the 0x33 confirm; see run_key_excha...
decisions::PairingDiscoveryDisposition wait_for_discovery_response_(uint32_t timeout_ms, RadioRxPacket &packet, IoFrame &response_frame)
Wait for a discovery response (0x29) within timeout_ms with per-chip frequency hopping.
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...
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.
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.
PairingDiscoveryDisposition
Disposition during pairing discovery phase.
@ NO_RESPONSE
No packets received on the channel within timeout.
@ INVALID
Packets seen but none were valid discovery (0x29) frames.
PairingKeyChallengeDisposition classify_pairing_key_challenge(const IoFrame &candidate, const uint8_t device_id[NODE_ID_SIZE], const uint8_t controller_id[NODE_ID_SIZE])
Decide if a frame is a valid key-challenge (0x3C) during pairing key exchange.
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 ...
DiscoverConfirmResult
What PairingEngine::run_discover_confirm_step_() actually observed.
Definition hub_pairing.h:82
@ ERROR_REPLY
The device answered with CMD_ERROR_RESP.
Definition hub_pairing.h:85
@ SKIPPED
pairing_discover_confirm tuning mode is skip — no 0x2C was sent.
Definition hub_pairing.h:83
@ NO_REPLY
Every try was silent (or every transmit failed).
Definition hub_pairing.h:86
@ ACKED
The device answered with CMD_DISCOVER_CONFIRM_ACK (0x2D).
Definition hub_pairing.h:84
@ REGISTER_DEVICE
Registering device in the runtime registry for the current boot.
Definition hub_pairing.h:58
@ TX_DISCOVER
Discovery broadcast (0x28) sent; awaiting device response.
Definition hub_pairing.h:50
@ 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:59
@ 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
@ 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
const char * manufacturer_name(uint8_t id)
Get a human-readable manufacturer name from the protocol manufacturer byte.
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.
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.
static constexpr const char * TAG
const char * power_save_mode_name(uint8_t mode)
Get a human-readable power save mode name.
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...
PairingOutcome
Final disposition of a pairing attempt, used by the result sensor string.
@ INVALID_RESPONSE
Discovery saw traffic but nothing valid.
@ KEY_EXCHANGE_FAILED
Discovery succeeded but the key exchange did not complete.
@ PAIRED
The key exchange completed and the device is registered.
@ NO_RESPONSE
No device responded to discovery.
@ 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...
bool create_key_init(IoFrame &f, const uint8_t *own, const uint8_t *dst)
Build a key-init request (0x31) to start the pairing key exchange with a discovered device.
@ SUCCESS_WITH_RESPONSE
Device replied; the caller's response frame is populated.
@ FAILED
No usable reply; the device may never have heard the request.
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.
Definition hub_pairing.h:94
constexpr uint8_t PAIRING_DISCOVER_CONFIRM_TRIES
Max tries for the discover-confirm (0x2C) step.
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.
std::string build_device_yaml_snippet(DeviceType type, uint8_t subtype, const std::string &device_id, bool metadata_complete, bool inverted, bool low_power)
Build the ready-to-paste YAML block describing a device, for both a fully-decoded device and one whos...
constexpr uint32_t PAIRING_RECENT_ONE_WAY_SIGHTING_WINDOW_MS
How recent a RecentOneWayPairingSighting has to be, relative to discover_and_pair() starting,...
@ 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).
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_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 format_device_type_diagnostic(DeviceType type)
Human-readable device type string for diagnostics, including the raw numeric value.
const uint8_t * resolve_discovery_destination(uint8_t command, bool destination_auto, const uint8_t destination[NODE_ID_SIZE])
Resolve the destination address for a discovery command.
DiscoveryResponseInfo decode_discovery_response(const IoFrame &frame, IoDevice &device, std::string &device_id)
Decode a discovery-response payload (CMD_DISCOVER_RESP 0x29 or CMD_DISCOVER_SPE_RESP 0x2B — both carr...
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.
Extended discovery-response fields (manufacturer, Multi Information Byte, backbone address,...
bool has_extended
data_len >= DISCOVERY_RESP_FULL_SIZE (mfr/flags/timestamp present).
uint8_t backbone[NODE_ID_SIZE]
Backbone address as reported by the device.
uint8_t manufacturer
Raw manufacturer ID; name via manufacturer_name().
uint8_t flags
Multi Information Byte; decode with DISCOVERY_FLAGS_* masks.
Runtime state of a paired IO‑Homecontrol device.
bool inverted
True if open/close positions are swapped (e.g., horizontal awning).
uint8_t subtype
Device subtype (manufacturer‑specific).
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.
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...
Raw packet received from the radio.
uint32_t freq_hz
Frequency the packet was received on (Hz).
A 1W pairing-gesture frame observed on the hub's normal passive RX path, remembered so a fresh discov...
All runtime tunable parameters for pairing and radio diagnostics.
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:64
IoFrame req
Outbound frame buffer (reused across all phases).
Definition hub_pairing.h:67
bool discovery_low_power
True when discovery reported POWER_SAVE_LOW_POWER.
Definition hub_pairing.h:74
PairingState state
Current state machine state.
Definition hub_pairing.h:65
IoFrame resp
Inbound frame buffer (holds key‑confirm response).
Definition hub_pairing.h:68
RadioRxPacket packet
Raw radio capture for the current phase.
Definition hub_pairing.h:71
IoDevice device
Resolved device metadata after discovery (node_id, type, subtype, etc.).
Definition hub_pairing.h:66
IoFrame key_init
Key‑init frame retained for key‑transfer IV derivation.
Definition hub_pairing.h:70
std::string device_id
Hex string representation of the paired node ID.
Definition hub_pairing.h:72
bool discovery_metadata_complete
True when discovery carried type/subtype bytes.
Definition hub_pairing.h:73
IoFrame rx
Raw RX frame during waiting phases (discovery, challenge, confirm).
Definition hub_pairing.h:69
Runtime tuning configuration for pairing and radio diagnostics.