23#include "esphome/core/application.h"
24#include "esphome/core/log.h"
36const char *
const TAG =
"home_io_control";
39bool frame_is_key_confirm(
const IoFrame &frame) {
return frame.cmd == CMD_KEY_CONFIRM; }
45 ESP_LOGW(
TAG,
"No device responded to discovery");
48 ESP_LOGW(
TAG,
"No valid discovery response received");
59void delay_feeding_wdt(uint32_t ms) {
62 const uint32_t chunk = ms < 1000 ? ms : 1000;
75 : radio_ptr_(radio_ptr),
77 system_key_(system_key),
81 telemetry_(telemetry),
82 recent_oneway_sighting_(recent_oneway_sighting) {}
84uint16_t PairingEngine::pairing_start_preamble_(
const IoFrame &frame)
const {
121 spec.
on_hop = [
this]() { this->telemetry_.record_hop(); };
123 bool saw_traffic =
false;
125 engine_.listen(spec, packet, response_frame, [&](
const IoFrame *parsed,
const RadioRxPacket & ) {
127 if (parsed ==
nullptr)
131 this->record_discovery_rx_telemetry_(*parsed, accepted, radio_()->get_last_capture().rssi_dbm);
155 const uint8_t device_node_id[NODE_ID_SIZE]) {
160 bool saw_traffic =
false;
162 engine_.listen(spec, packet, challenge_frame, [&](
const IoFrame *parsed,
const RadioRxPacket & ) {
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);
174 this->telemetry_.record_rx_reject(*parsed, rssi);
177 this->telemetry_.record_rx(*parsed, rssi);
183 ESP_LOGW(TAG, saw_traffic ?
"Key exchange: no valid challenge received" :
"Key exchange: no challenge received");
204 spec.
on_hop = [
this]() { this->telemetry_.record_hop(); };
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;
220 this->telemetry_.record_rx(*parsed, rssi);
241decisions::PairingKeyConfirmDisposition PairingEngine::listen_for_key_confirm_(pairing::PairingContext &context,
243 bool after_challenge) {
245 spec.window_ms = PAIRING_KEY_CONFIRM_TIMEOUT_MS;
246 spec.policy = ListenPolicy::HOLD_REQUEST_CHANNEL;
248 bool saw_any =
false;
249 auto disposition = decisions::PairingKeyConfirmDisposition::IGNORE;
251 engine_.listen(spec, context.packet, context.resp, [&](
const IoFrame *parsed,
const RadioRxPacket &packet) {
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;
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;
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");
270 ESP_LOGW(TAG,
"Key transfer: device responded with cmd=%s(0x%02X) (expected KEY_CONFIRM 0x33)",
271 command_name(parsed->cmd), parsed->cmd);
273 return ReplyDisposition::ABORT;
278 this->telemetry_.record_rx(*parsed, rssi);
279 return disposition == decisions::PairingKeyConfirmDisposition::CONFIRM ? ReplyDisposition::ACCEPT
280 : ReplyDisposition::ABORT;
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;
300 for (uint8_t tries = 0; tries < EXCHANGE_RETRY_COUNT; tries++) {
303 delay(EXCHANGE_RETRY_DELAY_MS);
305 if (!engine_.transmit_frame(context.
req, FREQ_CH2, radio_()->response_preamble()))
308 const uint8_t try_number = tries + 1;
311 ESP_LOGI(TAG,
"Key transfer: device challenged the key transfer, answering 0x3D");
317 if (engine_.answer_challenge(context.
req, context.
resp, FREQ_CH2))
318 disposition = listen_for_key_confirm_(context, try_number,
true);
339 this->telemetry_.set_phase(context.
state);
347 this->telemetry_.set_phase(context.
state);
351 if (radio_()->has_fast_tx_rx_turnaround()) {
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);
372 std::string &device_id) {
375 if (frame.
data_len > DISCOVERY_RESP_MANUFACTURER_OFFSET) {
377 ESP_LOGI(TAG,
"Discovery: device %s manufacturer=%u (%s)", device_id.c_str(), info.
manufacturer, mfr_name);
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.",
385 if (frame.
data_len > DISCOVERY_RESP_BACKBONE_OFFSET + NODE_ID_SIZE - 1) {
388 if (frame.
data_len > DISCOVERY_RESP_FLAGS_OFFSET) {
391 ESP_LOGI(TAG,
"Discovery: device %s turnaround=%s power_save=%s flags=0x%02X", device_id.c_str(),
393 if (power_save == POWER_SAVE_LOW_POWER) {
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.",
408 ESP_LOGI(
TAG,
"Discovery: device %s: %s (%s)%s", context.
device_id.c_str(),
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.",
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);
436 bool saw_invalid =
false;
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]);
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]);
446 this->telemetry_.increment_discovery_attempt();
449 this->telemetry_.set_phase(context.
state);
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)) {
459 this->telemetry_.set_phase(context.
state);
476 ESP_LOGI(TAG,
"Discovery attempt %u/%u for cmd=0x%02X: no response, retrying...", attempt,
509 this->telemetry_.set_phase(context.
state);
511 bool wait_phase_started =
false;
519 !engine_.transmit_frame(context.
req, FREQ_CH2, pairing_start_preamble_(context.
req))) {
524 if (!wait_phase_started) {
525 this->telemetry_.set_phase(context.
state);
526 wait_phase_started =
true;
530 const uint32_t wait_start_ms = millis();
534 ESP_LOGI(TAG,
"Discover confirm: 0x2D from %s on freq=%" PRIu32
" after %" PRIu32
" ms (try %u/%u)",
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",
551 ESP_LOGI(TAG,
"Discover confirm: no 0x2D from %s after %u tries, continuing with key exchange",
557 if (tuning_->pairing_key_init_delay_ms > 0)
558 delay_feeding_wdt(tuning_->pairing_key_init_delay_ms);
598 this->telemetry_.set_phase(context.
state);
600 !engine_.transmit_frame(context.
key_init, FREQ_CH2, pairing_start_preamble_(context.
key_init))) {
606 this->telemetry_.set_phase(context.
state);
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;
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);
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);
635 delay(EXCHANGE_RETRY_DELAY_MS);
636 if (!engine_.transmit_frame(context.
key_init, FREQ_CH2, pairing_start_preamble_(context.
key_init)))
641 if (context.
rx.
cmd == CMD_KEY_CONFIRM) {
654 ESP_LOGW(TAG,
"Key exchange failed");
666 if (engine_.send_and_receive(context.
req, context.
resp, FREQ_CH2, PAIRING_SET_CONFIG1_MAX_TRIES,
668 ESP_LOGI(TAG,
"Pairing: no reply from %s to the optional SetConfig1 (0x6F); the pairing is complete",
684 this->telemetry_.begin();
685 this->engine_.set_transmit_observer(&this->telemetry_);
692 if (this->recent_oneway_sighting_.seen_ms != 0 &&
694 this->telemetry_.record_recent_one_way_sighting(this->recent_oneway_sighting_);
697 ESP_LOGI(TAG,
"Starting device discovery...");
705 log_discovery_diagnostic(disc_disp);
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);
727 }
else if (!run_push_key_step_(context)) {
738 this->telemetry_.set_phase(context.
state);
754 if (snippet.empty()) {
759 "Device %s paired successfully (%s), but no ESPHome platform drives it yet: type=%s "
760 "class=%s subtype=%u.",
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.",
767 ESP_LOGW(TAG,
"Please file a GitHub issue with this device type, subtype, model, and the pairing log so support "
772 this->telemetry_.set_phase(context.
state);
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.",
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 "
788 this->telemetry_.set_phase(context.
state);
793 ESP_LOGI(TAG,
"Device %s paired successfully! Add this to your YAML:\n%s", context.
device_id.c_str(),
798 "This snippet uses the raw device type %s because the project does not yet expose a named YAML alias "
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 "
807 this->telemetry_.set_phase(context.
state);
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);
817 delay(EXCHANGE_RETRY_DELAY_MS);
819 if (run_key_exchange_phase_(context))
829 return verify_device_holds_key_(context);
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)",
843 context.
resp.
cmd == CMD_GET_NAME_RESP;
846 "Key exchange: %s never confirmed the key, but it answered an authenticated request with it. "
847 "Treating it as paired.",
852 const uint8_t code = context.
resp.
data[0];
854 "Key exchange failed: %s refused the name read with 0x%02X %s (%s), so it does not act as a "
858 ESP_LOGW(TAG,
"Key exchange failed: %s does not answer authenticated requests with our key",
865 const uint8_t code = reply.
data_len > 0 ? reply.
data[0] : 0;
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,
873bool PairingEngine::wait_for_pulled_key_(pairing::PairingContext &context) {
875 spec.window_ms = PAIRING_KEY_PULL_REPLY_TIMEOUT_MS;
876 spec.policy = ListenPolicy::HOLD_REQUEST_CHANNEL;
879 engine_.listen(spec, context.packet, context.rx, [&](
const IoFrame *parsed,
const RadioRxPacket & ) {
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;
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;
893 return ReplyDisposition::IGNORE;
895 return outcome == ListenOutcome::ACCEPTED;
899 switch (tuning_->pairing_key_transfer) {
911 ESP_LOGI(TAG,
"Key transfer: auto -> %s (%s answered discovery as %s)", pull ?
"pull" :
"push",
921 this->telemetry_.set_phase(context.
state);
930 device_key ?
"the key" :
"the hub key, which", context.
device_id.c_str());
931 if (
run_roll_call_sweep(engine_, node_id_, key, device_key ? 1 : 0, *tuning_, state, attempt,
933 ESP_LOGW(TAG,
"Could not build the roll-call under the pulled key");
942 ESP_LOGW(TAG,
"========================================");
945 ESP_LOGW(TAG,
"========================================");
946 }
else if (state.
count != 0) {
955 uint8_t registered = 0;
956 for (uint8_t i = 0; i < state.
count; i++) {
962 memcpy(device.
node_id, responder.
src, NODE_ID_SIZE);
972 registry_.put(responder_id, device);
974 ESP_LOGI(TAG,
"Pairing: registered %s (%s) until reboot; paste its block above to keep it", responder_id.c_str(),
982 uint8_t heater[NODE_ID_SIZE];
988 if (registry_.get_declared(heater_id) !=
nullptr)
994 const IoDevice *earlier = registry_.get(heater_id);
995 const bool had_earlier = earlier !=
nullptr;
998 memcpy(candidate.
node_id, heater, NODE_ID_SIZE);
1000 candidate.
subtype = SUBTYPE_HEATER_WITH_SETPOINT;
1003 memcpy(candidate.
system_key, key, AES_KEY_SIZE);
1005 registry_.put(heater_id, candidate);
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(),
1015 const bool answered =
1016 payload_len != 0 &&
create_write_private(request, node_id_, heater,
false, payload, payload_len) &&
1019 const int16_t rssi = radio_()->get_last_capture().rssi_dbm;
1021 registry_.put(heater_id, previous);
1023 registry_.remove_registered_by_pairing(heater_id);
1026 ESP_LOGI(TAG,
"Heater probe: %s did not answer; looking for devices with a roll-call instead", heater_id.c_str());
1029 ESP_LOGI(TAG,
"Heater probe: %s answered (maximum setpoint %.1f C); no roll-call needed", heater_id.c_str(),
1035 memcpy(responder.
src, heater, NODE_ID_SIZE);
1037 responder.
subtype = SUBTYPE_HEATER_WITH_SETPOINT;
1041 responder.
flags = 0;
1044 responder.
key_slot = device_key ? 1 : 0;
1056 this->telemetry_.set_phase(context.
state);
1061 this->telemetry_.set_key_pull_attempted();
1085 ESP_LOGW(TAG,
"Pairing: %s holds this hub's key, but no device that holds it answered; nothing registered",
1096 if (registered == 0) {
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++) {
1111 delay(EXCHANGE_RETRY_DELAY_MS);
1115 this->telemetry_.set_phase(context.
state);
1119 !engine_.transmit_frame(context.
req, FREQ_CH2, pairing_start_preamble_(context.
req)))
1122 this->telemetry_.set_phase(context.
state);
1123 got_key = wait_for_pulled_key_(context);
1125 ESP_LOGI(TAG,
"Key pull try %u: no key transfer (0x32) from %s within %" PRIu32
" ms", tries + 1,
1130 ESP_LOGW(TAG,
"Key pull failed: %s sent no key", context.
device_id.c_str());
1134 uint8_t key[AES_KEY_SIZE];
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());
1148 this->telemetry_.set_phase(context.
state);
1152 uint8_t challenges = 0;
1154 if (challenges > 0) {
1156 delay(EXCHANGE_RETRY_DELAY_MS);
1159 proof = engine_.challenge_sender(context.
rx, FREQ_CH2, key);
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);
1168 this->telemetry_.set_key_pull_unproven();
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());
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);
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();
1190 std::string advice_codes;
1191 for (uint8_t i = 0; i < advice_count; i++) {
1193 if (!advice_codes.empty())
1194 advice_codes +=
',';
1197 this->telemetry_.set_advice_codes(advice_codes);
1200void PairingEngine::record_discovery_rx_telemetry_(
const IoFrame &frame,
bool accepted, int16_t rssi) {
1202 this->telemetry_.record_rx(frame, rssi);
1204 this->telemetry_.record_rx_reject(frame, rssi);
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 ®istry, 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.
@ ACCEPT
Valid discovery response received.
@ 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.
@ ACCEPT
Valid 0x3C challenge from target device.
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.
DiscoverConfirmResult
What PairingEngine::run_discover_confirm_step_() actually observed.
@ ERROR_REPLY
The device answered with CMD_ERROR_RESP.
@ SKIPPED
pairing_discover_confirm tuning mode is skip — no 0x2C was sent.
@ NO_REPLY
Every try was silent (or every transmit failed).
@ ACKED
The device answered with CMD_DISCOVER_CONFIRM_ACK (0x2D).
@ REGISTER_DEVICE
Registering device in the runtime registry for the current boot.
@ TX_DISCOVER
Discovery broadcast (0x28) sent; awaiting device response.
@ WAIT_KEY_PULL_REPLY
Waiting for the device's key transfer (0x32) in answer to our 0x38.
@ TX_KEY_PULL
Key transfer request (0x38) sent: pairing_key_transfer: pull.
@ WAIT_DISCOVER_RESPONSE
Listening for discovery response (0x29) from a device in pairing mode.
@ COMPLETE
Pairing completed successfully; device ready for use.
@ TX_DISCOVER_CONFIRM
Discovery-confirm (0x2C) sent to the discovered device.
@ WAIT_KEY_CHALLENGE
Waiting for challenge (0x3C) from device as part of key transfer.
@ WAIT_KEY_CONFIRM
Waiting for key‑confirm (0x33) from device (key receipt acknowledgement).
@ VERIFY_KEY_HELD
Key exchange unconfirmed: checking with an authenticated read whether the device took our key anyway.
@ SCAN_PULLED_KEY
Roll-call under the pulled key: finding the devices that hold it.
@ VERIFY_PULLED_KEY
Challenging the device to prove it holds the key it sent.
@ TX_KEY_INIT
Key‑init (0x31) sent to the discovered device.
@ TX_KEY_TRANSFER
Key‑transfer (0x32) sent with encrypted system key.
@ WAIT_DISCOVER_CONFIRM
Listening for the device's discovery-confirm ack (0x2D).
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.
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
@ ELECTRICAL_HEATER
Electrical heater.
@ 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.
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.
@ ROTATE_ALL_CHANNELS
CH1->CH2->CH3->CH1.
@ 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 ®istry)
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 ®istry)
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).
uint8_t data[FRAME_MAX_DATA_SIZE]
Command parameters (0–23 bytes). Never includes mac.
uint8_t src[NODE_ID_SIZE]
Source node ID (3 bytes).
uint8_t dst[NODE_ID_SIZE]
Destination node ID (3 bytes).
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.
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.
uint8_t src[NODE_ID_SIZE]
Responder's node ID; also the dedup key.
uint8_t manufacturer
Raw manufacturer ID; name via manufacturer_name().
bool inverted
Decoded position-inversion flag.
DeviceType type
Decoded device type.
bool has_extended
Whether manufacturer/flags above are present.
int16_t rssi_dbm
RSSI of the reply that produced this entry.
bool metadata_complete
Whether type/subtype were present in the payload.
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).
uint8_t flags
Multi Information Byte; decode with DISCOVERY_FLAGS_* masks.
uint8_t subtype
Decoded device subtype.
Accumulated state of one scan, threaded through every sweep.
uint8_t count
Entries in use.
bool truncated
A responder was dropped because the array was full.
ScanResponder responders[SCAN_MAX_REPLIES]
Distinct responders, in arrival order.
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.
uint8_t discovery_manufacturer
Manufacturer byte of the discovery reply; 0 when it carried none.
bool saw_key_challenge
True once the device challenged our key-init this attempt.
IoFrame req
Outbound frame buffer (reused across all phases).
bool discovery_low_power
True when discovery reported POWER_SAVE_LOW_POWER.
bool has_pulled_key
True once a pull produced a different key the device proved.
PairingState state
Current state machine state.
IoFrame resp
Inbound frame buffer (holds key‑confirm response).
RadioRxPacket packet
Raw radio capture for the current phase.
IoDevice device
Resolved device metadata after discovery (node_id, type, subtype, etc.).
uint8_t key_refusal_code
That CMD_ERROR_RESP byte; only meaningful if key_refused.
uint8_t pulled_key[AES_KEY_SIZE]
That key. Key material: never logged outside the pull report.
IoFrame key_init
Key‑init frame retained for key‑transfer IV derivation.
std::string device_id
Hex string representation of the paired node ID.
bool key_refused
True once the device answered the key step with CMD_ERROR_RESP.
bool discovery_metadata_complete
True when discovery carried type/subtype bytes.
IoFrame rx
Raw RX frame during waiting phases (discovery, challenge, confirm).
Runtime tuning configuration for pairing and radio diagnostics.