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} // namespace
50
51// --- Constructor ---
52
53PairingEngine::PairingEngine(RadioDriver **radio_ptr, const uint8_t *node_id, const uint8_t *system_key,
54 const TuningConfig *tuning, ExchangeEngine &engine, DeviceRegistry &registry,
55 PairingTelemetry &telemetry)
56 : radio_ptr_(radio_ptr),
57 node_id_(node_id),
58 system_key_(system_key),
59 tuning_(tuning),
60 engine_(engine),
61 registry_(registry),
62 telemetry_(telemetry) {}
63
64// --- Low-level waiters ---
65
66/// Wait for a valid discovery response (0x29) within timeout_ms.
67///
68/// Listens with per-chip frequency hopping between slices. Distinguishes between
69/// NO_RESPONSE (no packets at all) and INVALID (packets seen but none valid).
70///
71/// Frequency hopping: hops between the 3 IO-homecontrol channels after each slice.
72/// The slice length comes from RadioDriver::discovery_hop_slice_ms() — chips that
73/// retune slowly need a much longer dwell than fast-hopping chips. When preamble or
74/// sync detection fires, the dwell extends by PREAMBLE_DWELL_MS so the incoming
75/// frame can complete without interruption.
77 RadioRxPacket &packet,
78 IoFrame &response_frame) {
79 const uint32_t hop_slice_ms = radio_()->discovery_hop_slice_ms(*tuning_);
80 static constexpr uint32_t PREAMBLE_DWELL_MS = 15;
81
82 auto try_accept = [&]() {
83 if (!parse(packet.data, packet.len, response_frame))
84 return false;
85 const bool accepted = decisions::classify_pairing_discovery_response(response_frame, node_id_) ==
87 this->record_discovery_rx_telemetry_(response_frame, accepted, radio_()->get_last_capture().rssi_dbm);
88 return accepted;
89 };
90
91 bool saw_traffic = false;
92 const uint32_t deadline = millis() + timeout_ms;
93 while ((int32_t) (deadline - millis()) > 0) {
94 const uint32_t slice = std::min((uint32_t) (deadline - millis()), hop_slice_ms);
95 if (radio_()->wait_for_packet(packet, slice)) {
96 saw_traffic = true;
97 if (try_accept())
99 if ((int32_t) (deadline - millis()) > 0 && !radio_()->is_preamble_detected() && !radio_()->is_sync_detected()) {
100 engine_.hop_frequency();
101 this->telemetry_.record_hop();
102 }
103 continue;
104 }
105 if ((int32_t) (deadline - millis()) <= 0)
106 break;
107 if (!radio_()->is_preamble_detected() && !radio_()->is_sync_detected()) {
108 engine_.hop_frequency();
109 this->telemetry_.record_hop();
110 continue;
111 }
112 const uint32_t ext = std::min((uint32_t) (deadline - millis()), PREAMBLE_DWELL_MS);
113 if (radio_()->wait_for_packet(packet, ext)) {
114 saw_traffic = true;
115 if (try_accept())
117 }
118 }
121}
122
123/// Wait for a key-challenge (0x3C) or direct key-confirm (0x33) from target device.
124///
125/// During key exchange the device typically responds to 0x31 with a random 6-byte
126/// challenge (0x3C). Some devices skip the challenge and send 0x33 directly —
127/// indicating immediate key acceptance (observed mostly when the controller's
128/// TX→RX turnaround is slow enough that the 0x3C is missed). Both are accepted.
129bool PairingEngine::wait_for_key_challenge_(uint32_t timeout_ms, RadioRxPacket &packet, IoFrame &challenge_frame,
130 const uint8_t device_node_id[NODE_ID_SIZE]) {
131 bool saw_traffic = false;
132 const uint32_t deadline = millis() + timeout_ms;
133 while ((int32_t) (deadline - millis()) > 0) {
134 const uint32_t remaining_ms = deadline - millis();
135 const uint32_t slice = decisions::response_wait_slice_ms(remaining_ms);
136 if (!radio_()->wait_for_packet(packet, slice))
137 continue;
138 saw_traffic = true;
139 if (!parse(packet.data, packet.len, challenge_frame))
140 continue;
141 const int16_t rssi = radio_()->get_last_capture().rssi_dbm;
142 if (challenge_frame.cmd == CMD_KEY_CONFIRM && memcmp(challenge_frame.src, device_node_id, NODE_ID_SIZE) == 0 &&
143 memcmp(challenge_frame.dst, node_id_, NODE_ID_SIZE) == 0) {
144 this->telemetry_.record_rx(challenge_frame, rssi);
145 return true;
146 }
147 if (decisions::classify_pairing_key_challenge(challenge_frame, device_node_id, node_id_) !=
149 this->telemetry_.record_rx_reject(challenge_frame, rssi);
150 continue;
151 }
152 this->telemetry_.record_rx(challenge_frame, rssi);
153 return true;
154 }
155 ESP_LOGW(TAG, saw_traffic ? "Key exchange: no valid challenge received" : "Key exchange: no challenge received");
156 return false;
157}
158
159/// Transmit the 0x32 key transfer and wait for 0x33 key confirm (with retry).
160///
161/// Uses a dedicated wait loop with frequency hopping and the driver's
162/// response_preamble() (drivers whose TX waveform needs more lock-on margin
163/// return a longer preamble). Retries up to EXCHANGE_RETRY_COUNT times on timeout.
164// NOLINTNEXTLINE(readability-function-cognitive-complexity)
166 for (uint8_t tries = 0; tries < EXCHANGE_RETRY_COUNT; tries++) {
167 if (tries > 0) {
168 App.feed_wdt();
170 }
171 if (!engine_.transmit_frame(context.req, FREQ_CH2, radio_()->response_preamble()))
172 continue;
173
174 const uint32_t deadline = millis() + PAIRING_KEY_CONFIRM_TIMEOUT_MS;
175 bool saw_any = false;
176 while ((int32_t) (deadline - millis()) > 0) {
177 const uint32_t remaining = deadline - millis();
178 const uint32_t slice = std::min<uint32_t>(remaining, PAIRING_KEY_CONFIRM_SLICE_MS);
179 if (!radio_()->wait_for_packet(context.packet, slice)) {
180 if ((int32_t) (deadline - millis()) > 0) {
181 engine_.hop_frequency();
182 this->telemetry_.record_hop();
183 }
184 continue;
185 }
186 saw_any = true;
187 ESP_LOGD(TAG, "Key confirm wait: got %u bytes on freq=%" PRIu32, context.packet.len, context.packet.freq_hz);
188 if (!parse(context.packet.data, context.packet.len, context.resp)) {
189 ESP_LOGD(TAG, "Key confirm wait: parse failed");
190 continue;
191 }
192 ESP_LOGD(TAG, "Key confirm wait: parsed cmd=0x%02X src=%02X%02X%02X dst=%02X%02X%02X", context.resp.cmd,
193 context.resp.src[0], context.resp.src[1], context.resp.src[2], context.resp.dst[0], context.resp.dst[1],
194 context.resp.dst[2]);
196 continue;
197 const int16_t rssi = radio_()->get_last_capture().rssi_dbm;
198 if (frame_is_key_confirm(context.resp)) {
199 this->telemetry_.record_rx(context.resp, rssi);
200 return true;
201 }
202 this->telemetry_.record_rx_reject(context.resp, rssi);
203 ESP_LOGW(TAG, "Key transfer: device responded with cmd=%s(0x%02X) (expected KEY_CONFIRM 0x33)",
204 command_name(context.resp.cmd), context.resp.cmd);
205 if (context.resp.cmd == CMD_ERROR_RESP && context.resp.data_len > 0)
206 ESP_LOGW(TAG, "Key transfer: error code=0x%02X", context.resp.data[0]);
207 return false;
208 }
209 ESP_LOGI(TAG, "Try %d ended: no response for key transfer (0x32) within %" PRIu32 " ms (saw_any=%d)", tries + 1,
211 }
212 return false;
213}
214
215// --- Discovery metadata ---
216
217/// Parse a discovery response frame into device metadata and ID.
218///
219/// Decodes node ID, device type, subtype, and the extended fields (manufacturer, backbone,
220/// Multi Information Byte) via decode_discovery_response(), then emits pairing's diagnostic log
221/// lines for whichever extended fields the payload actually included.
222/// The inversion flag is derived from the type via `default_inverted_for_type()`.
223void PairingEngine::parse_device_from_discovery(const IoFrame &frame, IoDevice &device, std::string &device_id) {
224 const DiscoveryResponseInfo info = decode_discovery_response(frame, device, device_id);
225
227 const char *mfr_name = manufacturer_name(info.manufacturer);
228 ESP_LOGI(TAG, "Discovery: device %s manufacturer=%u (%s)", device_id.c_str(), info.manufacturer, mfr_name);
229 if (info.manufacturer == 0 || info.manufacturer > MANUFACTURER_ID_MAX) {
230 ESP_LOGW(TAG,
231 "Unknown manufacturer ID %u reported by device %s. "
232 "Please file a GitHub issue with this ID and your device model so support can be added.",
233 info.manufacturer, device_id.c_str());
234 }
235 }
237 ESP_LOGD(TAG, "Discovery: backbone=%02X%02X%02X", info.backbone[0], info.backbone[1], info.backbone[2]);
238 }
240 uint8_t const att = discovery_att_class(info.flags);
241 uint8_t const power_save = discovery_power_save_mode(info.flags);
242 ESP_LOGI(TAG, "Discovery: device %s turnaround=%s power_save=%s flags=0x%02X", device_id.c_str(),
243 att_class_name(att), power_save_mode_name(power_save), info.flags);
244 if (power_save == POWER_SAVE_LOW_POWER) {
245 ESP_LOGI(TAG,
246 "Device %s reports low-power mode. "
247 "Consider adding 'low_power: true' to YAML if commands are unreliable.",
248 device_id.c_str());
249 }
250 }
251}
252
253// --- Phase helpers ---
254
255/// Phase 1: broadcast discovery command(s) and wait for a device response (0x29).
256///
257/// Sends each configured discovery command in order, waiting up to
258/// `pairing_discovery_wait_ms` for a valid response after each TX.
259/// Retries up to PAIRING_DISCOVERY_MAX_ATTEMPTS times per command.
261 if (tuning_->pairing_discovery_initial_dwell_ms > 0) {
262 ESP_LOGD(TAG, "Discovery: initial dwell %u ms", tuning_->pairing_discovery_initial_dwell_ms);
263 delay(tuning_->pairing_discovery_initial_dwell_ms);
264 }
265
266 // Tracks whether any single attempt saw traffic that failed to classify as a valid discovery
267 // response, so the final "gave up after retries" return can distinguish INVALID (something was
268 // heard, just not a valid response) from NO_RESPONSE (nothing heard at all) instead of always
269 // collapsing to NO_RESPONSE.
270 bool saw_invalid = false;
271
272 for (size_t command_index = 0; command_index < tuning_->pairing_discovery_commands.size(); ++command_index) {
273 auto command = static_cast<uint8_t>(tuning_->pairing_discovery_commands[command_index]);
274 const uint8_t *destination = resolve_discovery_destination(command, tuning_->pairing_discovery_destination_auto,
275 tuning_->pairing_discovery_destination.data());
276 ESP_LOGD(TAG, "Discovery command %zu/%zu: cmd=0x%02X dst=%02X%02X%02X", command_index + 1,
277 tuning_->pairing_discovery_commands.size(), command, destination[0], destination[1], destination[2]);
278
279 for (uint8_t attempt = 1; attempt <= PAIRING_DISCOVERY_MAX_ATTEMPTS; ++attempt) {
280 this->telemetry_.increment_discovery_attempt();
282 engine_.record_debug(pairing_stage_name(context.state), attempt, false);
283 this->telemetry_.set_phase(context.state);
284 if (!create_discovery_request(context.req, node_id_, command, destination, tuning_->pairing_discovery_low_power,
285 tuning_->pairing_discovery_payload_enabled, tuning_->pairing_discovery_payload,
286 system_key_) ||
287 !engine_.transmit_frame(context.req, FREQ_CH2, LONG_PREAMBLE)) {
289 }
290
292 engine_.record_debug(pairing_stage_name(context.state), attempt, false);
293 this->telemetry_.set_phase(context.state);
294 auto result = wait_for_discovery_response_(tuning_->pairing_discovery_wait_ms, context.packet, context.rx);
296 parse_device_from_discovery(context.rx, context.device, context.device_id);
298 return result;
299 }
301 saw_invalid = true;
302 }
303
304 if (attempt < PAIRING_DISCOVERY_MAX_ATTEMPTS) {
305 ESP_LOGI(TAG, "Discovery attempt %u/%u for cmd=0x%02X: no response, retrying...", attempt,
307 }
308 }
309 }
312}
313
314/// Phase 2: authenticated key exchange (0x31 → 0x3C → 0x32 → 0x33).
315///
316/// Steps:
317/// 1. Transmit CMD_KEY_INIT (0x31)
318/// 2. Wait for device challenge (0x3C)
319/// 3. Transmit CMD_KEY_TRANSFER (0x32) with encrypted system key
320/// 4. Wait for CMD_KEY_CONFIRM (0x33)
321///
322/// Step 4 depends on the driver's TX→RX turnaround: fast-turnaround radios await
323/// the 0x33 through the standard send_and_receive() exchange; slow-turnaround
324/// radios use the dedicated wait_for_key_confirm_() path with a key-init
325/// re-trigger, because the 0x33 would otherwise arrive while the receiver is
326/// still settling (see RadioDriver::has_fast_tx_rx_turnaround()).
329 engine_.record_debug(pairing_stage_name(context.state), 1, false);
330 this->telemetry_.set_phase(context.state);
331 if (!create_key_init(context.key_init, node_id_, context.device.node_id) ||
332 !engine_.transmit_frame(context.key_init, FREQ_CH2, LONG_PREAMBLE)) {
333 return false;
334 }
335
337 engine_.record_debug(pairing_stage_name(context.state), 1, true);
338 this->telemetry_.set_phase(context.state);
340 return false;
341 }
342
343 // Some devices send 0x33 directly after 0x31 without requiring 0x32.
344 if (context.rx.cmd == CMD_KEY_CONFIRM) {
345 ESP_LOGI(TAG, "Device accepted key immediately (0x33 without 0x32 exchange)");
346 context.resp = context.rx;
347 return true;
348 }
349
350 // No challenge bytes here: the raw 0x3C payload plus the 0x3D response it provokes is a
351 // known-plaintext/known-ciphertext pair under the system key (see redaction.h). The generic
352 // frame-log helpers (log_frame()/log_component_capture()) already mask both commands.
353 ESP_LOGI(TAG, "Challenge (0x3C) received: data_len=%u freq=%" PRIu32 " rssi=%d", context.rx.data_len,
354 context.packet.freq_hz, radio_()->get_last_capture().rssi_dbm);
355
357 engine_.record_debug(pairing_stage_name(context.state), 1, true);
358 this->telemetry_.set_phase(context.state);
359 if (!create_key_transfer(context.req, context.key_init, context.device.node_id, node_id_, system_key_,
360 context.rx.data)) {
361 return false;
362 }
363
365 engine_.record_debug(pairing_stage_name(context.state), 1, true);
366 this->telemetry_.set_phase(context.state);
367 // Fast-turnaround radios catch the 0x33 through the standard exchange wait. Slow-turnaround
368 // radios miss it while re-entering RX, so they use the dedicated wait loop and, on a miss,
369 // re-send the key-init to trigger the device's auto-confirm.
370 bool key_ok = false;
371 if (radio_()->has_fast_tx_rx_turnaround()) {
372 key_ok = engine_.send_and_receive(context.req, context.resp, FREQ_CH2) && frame_is_key_confirm(context.resp);
373 } else {
374 key_ok = wait_for_key_confirm_(context);
375 for (int re = 0; !key_ok && re < 2; re++) {
376 ESP_LOGI(TAG, "Key confirm missed, re-sending key-init to trigger auto-confirm (attempt %d/2)", re + 1);
377 App.feed_wdt();
379 if (!engine_.transmit_frame(context.key_init, FREQ_CH2, LONG_PREAMBLE))
380 continue;
382 context.device.node_id) &&
383 context.rx.cmd == CMD_KEY_CONFIRM) {
384 key_ok = true;
385 }
386 }
387 }
388 if (!key_ok) {
389 ESP_LOGW(TAG, "Key exchange failed");
390 return false;
391 }
392 return true;
393}
394
395/// Phase 3: send SetConfig1 (0x6F) to enable automatic status updates. Best-effort.
397 if (!create_set_config1(context.req, node_id_, context.device.node_id))
398 return false;
399 return engine_.send_and_receive(context.req, context.resp, FREQ_CH2);
400}
401
402// --- Orchestrator ---
403
404/// Pairing orchestrator — high-level three-phase flow.
405///
406/// Phase 1: run_discovery_phase_() finds a device in pairing mode.
407/// Phase 2: run_key_exchange_phase_() performs authenticated key establishment.
408/// Phase 3: finalize_pairing_configuration_() sends SetConfig1 (best-effort).
409///
410/// On success the device is added to the registry and a YAML snippet is printed to the log.
411/// The hub's thin wrapper manages the busy_ flag before and after this call.
413 this->telemetry_.begin();
414 this->engine_.set_pairing_telemetry(&this->telemetry_);
415
416 ESP_LOGI(TAG, "Starting device discovery...");
417 ESP_LOGI(TAG, "%s", tuning_config_full_snapshot(*tuning_).c_str());
418
420
421 // Phase 1: Discovery
422 auto disc_disp = run_discovery_phase_(context);
424 log_discovery_diagnostic(disc_disp);
425 this->finish_pairing_attempt_(disc_disp == decisions::PairingDiscoveryDisposition::INVALID
428 return false;
429 }
430
431 // Phase 2: Key exchange — retry up to the configured number of times.
432 bool key_exchanged = false;
433 for (uint8_t ke_attempt = 0; ke_attempt < tuning_->pairing_key_exchange_retries; ke_attempt++) {
434 if (ke_attempt > 0) {
435 ESP_LOGI(TAG, "Retrying key exchange (attempt %d/%u)...", ke_attempt + 1, tuning_->pairing_key_exchange_retries);
436 App.feed_wdt();
438 }
439 if (run_key_exchange_phase_(context)) {
440 key_exchanged = true;
441 break;
442 }
443 }
444 if (!key_exchanged) {
445 this->finish_pairing_attempt_(PairingOutcome::KEY_EXCHANGE_FAILED);
446 return false;
447 }
448
449 // Phase 3: Final configuration — best-effort SetConfig1. Pairing proceeds either way; the
450 // result only affects which outcome telemetry reports (PAIRED vs. CONFIG_FAILED).
451 const bool config_ok = finalize_pairing_configuration_(context);
453
455 engine_.record_debug(pairing_stage_name(context.state), 1, true);
456 this->telemetry_.set_phase(context.state);
457
458 registry_.put(context.device_id, context.device);
459 this->telemetry_.set_paired_device(context.device.node_id, context.device.type);
460
461 const std::string type_diag = format_device_type_diagnostic(context.device.type);
462 const std::string type_yaml = format_device_type_for_yaml(context.device.type);
463 const std::string snippet = build_device_yaml_snippet(context.device.type, context.device.subtype, context.device_id,
465
466 if (snippet.empty()) {
467 // metadata_complete is true here (build_device_yaml_snippet() never returns empty for
468 // metadata_complete == false), so this is specifically "we know the type, but there's no
469 // ESPHome platform for it yet".
470 ESP_LOGW(TAG,
471 "Device %s paired successfully, but this repo does not yet expose an ESPHome platform for type=%s "
472 "class=%s subtype=%u.",
473 context.device_id.c_str(), type_diag.c_str(), device_capability_class_name(context.device.type),
474 context.device.subtype);
475 ESP_LOGW(TAG,
476 "No ready-to-paste YAML was generated. If you want to experiment manually, choose the most likely "
477 "platform and set io_device_type: %s.",
478 type_yaml.c_str());
479 ESP_LOGW(TAG, "Please file a GitHub issue with this device type, subtype, model, and the pairing log so support "
480 "can be added.");
481
483 engine_.record_debug(pairing_stage_name(context.state), 1, true);
484 this->telemetry_.set_phase(context.state);
485 this->finish_pairing_attempt_(outcome);
486 return true;
487 }
488
489 if (!context.discovery_metadata_complete) {
490 ESP_LOGW(TAG,
491 "Device %s paired successfully, but the discovery response did not include type/subtype "
492 "metadata, so the platform (cover/light/switch/lock) can't be determined automatically.",
493 context.device_id.c_str());
494 ESP_LOGI(TAG, "Add this to your YAML once you know what kind of device it is:\n%s", snippet.c_str());
495 ESP_LOGW(TAG, "Please file a GitHub issue with the pairing log and device model so this discovery edge case can "
496 "be investigated.");
497
499 engine_.record_debug(pairing_stage_name(context.state), 1, true);
500 this->telemetry_.set_phase(context.state);
501 this->finish_pairing_attempt_(outcome);
502 return true;
503 }
504
505 ESP_LOGI(TAG, "Device %s paired successfully! Add this to your YAML:\n%s", context.device_id.c_str(),
506 snippet.c_str());
507
508 if (yaml_device_type_name(context.device.type) == nullptr) {
509 ESP_LOGW(TAG,
510 "This snippet uses the raw device type %s because the project does not yet expose a named YAML alias "
511 "for %s.",
512 type_yaml.c_str(), type_diag.c_str());
513 ESP_LOGW(TAG, "Please file a GitHub issue with this type, subtype, device model, and the pairing log so support "
514 "can be added.");
515 }
516
518 engine_.record_debug(pairing_stage_name(context.state), 1, true);
519 this->telemetry_.set_phase(context.state);
520 this->finish_pairing_attempt_(outcome);
521 return true;
522}
523
524void PairingEngine::finish_pairing_attempt_(PairingOutcome outcome) {
525 this->telemetry_.set_outcome(outcome);
526 this->engine_.set_pairing_telemetry(nullptr);
527 this->telemetry_.log_summary();
528
530 const uint8_t advice_count = advisor::analyze_pairing_telemetry(this->telemetry_, this->node_id_, advice);
531 std::string advice_codes;
532 for (uint8_t i = 0; i < advice_count; i++) {
533 ESP_LOGW(TAG, "Pairing advisor: %s", advisor::pairing_advice_message(advice[i]).c_str());
534 if (!advice_codes.empty())
535 advice_codes += ',';
536 advice_codes += advisor::pairing_advice_code_name(advice[i].code);
537 }
538 this->telemetry_.set_advice_codes(advice_codes);
539}
540
541void PairingEngine::record_discovery_rx_telemetry_(const IoFrame &frame, bool accepted, int16_t rssi) {
542 if (accepted) {
543 this->telemetry_.record_rx(frame, rssi);
544 } else {
545 this->telemetry_.record_rx_reject(frame, rssi);
546 }
547}
548
549} // namespace home_io_control
550} // namespace esphome
Owns the per-hub device table, update callbacks, and linked-remote associations.
Authenticated exchange engine — outbound and inbound protocol flows.
void set_pairing_telemetry(PairingTelemetry *telemetry)
Attach a telemetry recorder so transmit_frame()'s LBT loop records defer events.
decisions::PairingDiscoveryDisposition run_discovery_phase_(pairing::PairingContext &context)
Phase 1: broadcast discovery command(s) and wait for a device response (0x29).
PairingEngine(RadioDriver **radio_ptr, const uint8_t *node_id, const uint8_t *system_key, const TuningConfig *tuning, ExchangeEngine &engine, DeviceRegistry &registry, PairingTelemetry &telemetry)
Construct the engine with all required collaborators.
static void parse_device_from_discovery(const IoFrame &frame, IoDevice &device, std::string &device_id)
Extract node ID, device type, and subtype from a CMD_DISCOVER_RESP frame.
bool run_key_exchange_phase_(pairing::PairingContext &context)
Phase 2: authenticated key exchange (0x31 → 0x3C → 0x32 → 0x33).
bool wait_for_key_confirm_(pairing::PairingContext &context)
Transmit the 0x32 key transfer and wait for the 0x33 key confirm with retry.
bool wait_for_key_challenge_(uint32_t timeout_ms, RadioRxPacket &packet, IoFrame &challenge_frame, const uint8_t device_node_id[NODE_ID_SIZE])
Wait for a key-challenge (0x3C) or direct key-confirm (0x33) from the target device.
bool discover_and_pair()
Discover and pair a device currently in pairing mode (three-phase orchestrator).
decisions::PairingDiscoveryDisposition wait_for_discovery_response_(uint32_t timeout_ms, RadioRxPacket &packet, IoFrame &response_frame)
Wait for a discovery response (0x29) within timeout_ms with per-chip frequency hopping.
bool finalize_pairing_configuration_(pairing::PairingContext &context)
Phase 3: send SetConfig1 (0x6F) to enable automatic status updates; best-effort.
Fixed-size per-attempt telemetry recorder for the pairing flow.
void set_advice_codes(const std::string &codes)
Record the advisor's short advice codes for the ;advice= result-sensor field.
void log_summary() const
Emit a multi-line human-readable summary via ESP_LOGI. Call once, at the end of the attempt.
void set_outcome(PairingOutcome outcome)
Record the final outcome of the attempt.
void record_rx(const IoFrame &frame, int16_t rssi)
Record that we received and accepted a frame.
Abstract radio driver for IO-Homecontrol.
Pure transition helpers for hub-owned exchange and pairing frame decisions.
std::string pairing_advice_message(const PairingAdvice &advice)
static constexpr uint8_t PAIRING_ADVICE_MAX
Maximum number of advice entries a single attempt can produce (one slot per non-NONE code).
const char * pairing_advice_code_name(PairingAdviceCode code)
uint8_t analyze_pairing_telemetry(const PairingTelemetry &telemetry, const uint8_t own_node_id[NODE_ID_SIZE], PairingAdvice out[PAIRING_ADVICE_MAX])
Inspect a completed pairing attempt's telemetry and produce actionable advice.
PairingDiscoveryDisposition
Disposition during pairing discovery phase.
@ NO_RESPONSE
No packets received on the channel within timeout.
@ INVALID
Packets seen but none were valid discovery (0x29) frames.
PairingKeyChallengeDisposition classify_pairing_key_challenge(const IoFrame &candidate, const uint8_t device_id[NODE_ID_SIZE], const uint8_t controller_id[NODE_ID_SIZE])
Decide if a frame is a valid key-challenge (0x3C) during pairing key exchange.
PairingDiscoveryDisposition classify_pairing_discovery_response(const IoFrame &candidate, const uint8_t controller_id[NODE_ID_SIZE])
Decide if a frame is a valid discovery response (0x29) during pairing.
uint32_t response_wait_slice_ms(uint32_t remaining_ms)
Slice remaining wait time into bounded intervals to allow frequency hopping.
bool frame_matches_exchange_endpoints(const IoFrame &request, const IoFrame &candidate)
Check if candidate frame endpoints are the reverse of the request (dst==request.src,...
@ REGISTER_DEVICE
Registering device in the runtime registry for the current boot.
Definition hub_pairing.h:53
@ TX_DISCOVER
Discovery broadcast (0x28) sent; awaiting device response.
Definition hub_pairing.h:47
@ WAIT_DISCOVER_RESPONSE
Listening for discovery response (0x29) from a device in pairing mode.
Definition hub_pairing.h:48
@ COMPLETE
Pairing completed successfully; device ready for use.
Definition hub_pairing.h:54
@ WAIT_KEY_CHALLENGE
Waiting for challenge (0x3C) from device as part of key transfer.
Definition hub_pairing.h:50
@ WAIT_KEY_CONFIRM
Waiting for key‑confirm (0x33) from device (key receipt acknowledgement).
Definition hub_pairing.h:52
@ TX_KEY_INIT
Key‑init (0x31) sent to the discovered device.
Definition hub_pairing.h:49
@ TX_KEY_TRANSFER
Key‑transfer (0x32) sent with encrypted system key.
Definition hub_pairing.h:51
const char * manufacturer_name(uint8_t id)
Get a human-readable manufacturer name from the protocol manufacturer byte.
static constexpr uint8_t DEVICE_METADATA_SIZE
Packed device metadata uses two bytes where the high 8 bits carry the upper type bits and the low byt...
uint8_t discovery_power_save_mode(uint8_t flags)
Extract the power save mode field from a discovery response's Multi Information Byte.
static constexpr uint8_t NODE_ID_SIZE
Device/node addresses are 3 bytes (e.g., "123ABC").
Definition proto_sizes.h:20
std::string format_device_type_for_yaml(DeviceType type)
Build the YAML value for a device's io_device_type key.
static constexpr uint8_t CMD_ERROR_RESP
Error response to any command.
const char * att_class_name(uint8_t att_class)
Get a human-readable turnaround time string for an ATT class value.
static constexpr uint8_t DISCOVERY_RESP_MANUFACTURER_OFFSET
Manufacturer ID at data[5].
constexpr uint8_t PAIRING_DISCOVERY_MAX_ATTEMPTS
Retry discovery TX up to this many times.
std::string build_device_yaml_snippet(DeviceType type, uint8_t subtype, const std::string &device_id, bool metadata_complete, bool inverted)
Build the ready-to-paste YAML block describing a device, for both a fully-decoded device and one whos...
static constexpr const char * TAG
const char * power_save_mode_name(uint8_t mode)
Get a human-readable power save mode name.
constexpr uint32_t PAIRING_KEY_CONFIRM_TIMEOUT_MS
Wait for 0x33 key confirm after sending 0x32.
bool create_discovery_request(IoFrame &f, const uint8_t *own, uint8_t command, const uint8_t *dst, bool low_power, bool payload_enabled, uint8_t payload, const uint8_t *system_key)
Build a configurable discovery request command (0x28, 0x2A, or 0x2E).
const char * command_name(uint8_t cmd)
Get a human-readable name for any IO-Homecontrol command ID.
PairingOutcome
Final disposition of a pairing attempt, used by the result sensor string.
@ INVALID_RESPONSE
Discovery saw traffic but nothing valid.
@ KEY_EXCHANGE_FAILED
Discovery succeeded but the key exchange did not complete.
@ PAIRED
All three phases completed successfully.
@ CONFIG_FAILED
Key exchange succeeded but SetConfig1 failed (still counted as paired).
@ NO_RESPONSE
No device responded to discovery.
static constexpr uint8_t DISCOVERY_RESP_BACKBONE_OFFSET
Byte offsets within CMD_DISCOVER_RESP (0x29) payload data.
static constexpr uint8_t CMD_KEY_CONFIRM
Device confirms key was received.
static constexpr int32_t EXCHANGE_RETRY_DELAY_MS
Gap between retries within one HA command.
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...
constexpr uint32_t PAIRING_KEY_CONFIRM_SLICE_MS
RX slice during key confirm wait (hop each slice).
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 * pairing_stage_name(pairing::PairingState state)
Get a short, log/telemetry-friendly name for a pairing state.
Definition hub_pairing.h:76
static constexpr uint8_t DISCOVERY_RESP_FLAGS_OFFSET
Flags byte at data[6].
std::string tuning_config_full_snapshot(const TuningConfig &cfg)
Format the current tuning configuration as a full one-line snapshot.
bool parse(const uint8_t *buf, uint8_t buf_len, IoFrame &f)
Parse a wire buffer into a parsed IoFrame (validates length and CTRL0).
static constexpr uint8_t MANUFACTURER_ID_MAX
Maximum manufacturer ID with a known name in the lookup table.
static constexpr uint8_t EXCHANGE_RETRY_COUNT
Attempts per command before reporting failure.
const char * device_capability_class_name(DeviceType type)
Get a human‑readable name for a capability class.
static constexpr uint32_t FREQ_CH2
Channel 2: 868.95 MHz (1W and 2W, TX channel).
uint8_t discovery_att_class(uint8_t flags)
Extract the ATT class field from a discovery response's Multi Information Byte.
static constexpr uint8_t POWER_SAVE_LOW_POWER
Device sleeps — needs long preamble to wake.
constexpr uint32_t PAIRING_KEY_CHALLENGE_TIMEOUT_MS
Wait window for the device's 0x3C challenge.
const char * yaml_device_type_name(DeviceType type)
Return the YAML-friendly device-type name for types exposed in the Python schema.
static constexpr uint16_t LONG_PREAMBLE
Preamble is a sequence of 0xAA bytes that precedes every frame.
std::string format_device_type_diagnostic(DeviceType type)
Human-readable device type string for diagnostics, including the raw numeric value.
const uint8_t * resolve_discovery_destination(uint8_t command, bool destination_auto, const uint8_t destination[NODE_ID_SIZE])
Resolve the destination address for a discovery command.
DiscoveryResponseInfo decode_discovery_response(const IoFrame &frame, IoDevice &device, std::string &device_id)
Decode a discovery-response payload (CMD_DISCOVER_RESP 0x29 or CMD_DISCOVER_SPE_RESP 0x2B — both carr...
bool create_key_transfer(IoFrame &f, IoFrame &old_frame, const uint8_t *dst, const uint8_t *src, const uint8_t key[AES_KEY_SIZE], const uint8_t challenge[HMAC_SIZE])
Build a key-transfer frame (0x32) containing the system key encrypted with the transfer key.
Device discovery and key-exchange engine for IO-Homecontrol pairing.
Command builders for the IO‑Homecontrol protocol.
Extended discovery-response fields (manufacturer, Multi Information Byte, backbone address,...
uint8_t backbone[NODE_ID_SIZE]
Backbone address as reported by the device.
uint8_t manufacturer
Raw manufacturer ID; name via manufacturer_name().
uint8_t flags
Multi Information Byte; decode with DISCOVERY_FLAGS_* masks.
Runtime state of a paired IO‑Homecontrol device.
bool inverted
True if open/close positions are swapped (e.g., horizontal awning).
uint8_t subtype
Device subtype (manufacturer‑specific).
uint8_t node_id[NODE_ID_SIZE]
Device's 3‑byte radio address.
DeviceType type
Device type (shutter, awning, etc.).
Parsed IO‑Homecontrol frame (CTRL0/1 + addresses + command + data).
Definition proto_frame.h:71
uint8_t data[FRAME_MAX_DATA_SIZE]
Command parameters (0–23 bytes).
Definition proto_frame.h:77
uint8_t src[NODE_ID_SIZE]
Source node ID (3 bytes).
Definition proto_frame.h:75
uint8_t dst[NODE_ID_SIZE]
Destination node ID (3 bytes).
Definition proto_frame.h:74
uint8_t data_len
Actual length of data.
Definition proto_frame.h:78
Raw packet received from the radio.
uint8_t len
Length of packet in bytes.
uint32_t freq_hz
Frequency the packet was received on (Hz).
uint8_t data[RADIO_PACKET_BUFFER_SIZE]
Raw packet data buffer.
All runtime tunable parameters for pairing and radio diagnostics.
One piece of advice, with the node/RSSI it pertains to (if any).
Context object that lives for the duration of a single pairing attempt.
Definition hub_pairing.h:59
IoFrame req
Outbound frame buffer (reused across all phases).
Definition hub_pairing.h:62
PairingState state
Current state machine state.
Definition hub_pairing.h:60
IoFrame resp
Inbound frame buffer (holds key‑confirm response).
Definition hub_pairing.h:63
RadioRxPacket packet
Raw radio capture for the current phase.
Definition hub_pairing.h:66
IoDevice device
Resolved device metadata after discovery (node_id, type, subtype, etc.).
Definition hub_pairing.h:61
IoFrame key_init
Key‑init frame retained for key‑transfer IV derivation.
Definition hub_pairing.h:65
std::string device_id
Hex string representation of the paired node ID.
Definition hub_pairing.h:67
bool discovery_metadata_complete
True when discovery carried type/subtype bytes.
Definition hub_pairing.h:68
IoFrame rx
Raw RX frame during waiting phases (discovery, challenge, confirm).
Definition hub_pairing.h:64
Runtime tuning configuration for pairing and radio diagnostics.