Home IO Control
ESPHome add-on for IO-Homecontrol devices
Loading...
Searching...
No Matches
exchange_engine.cpp
Go to the documentation of this file.
1/// @file exchange_engine.cpp
2/// @brief Authenticated exchange engine — outbound and inbound protocol flows.
3/// @ingroup hioc_hub
4///
5/// Implements ExchangeEngine: the retry loop, challenge-response
6/// authentication, final-response wait, listen-before-talk transmit, and
7/// frequency-hopping. Debug-snapshot helpers are also here.
8
9#include "exchange_engine.h"
10
11#include "hub_decisions.h"
12#include "log_frame.h"
13#include "proto_commands.h"
14#include "proto_constants.h"
15#include "proto_crypto.h"
16#include "esphome/core/application.h"
17#include "esphome/core/hal.h"
18#include "esphome/core/log.h"
19
20#include <algorithm>
21#include <cinttypes>
22#include <cstring>
23
24namespace esphome {
25namespace home_io_control {
26
27static const char *const TAG = "home_io_control.exchange";
28
29// ============================================================================
30// Construction
31// ============================================================================
32
33ExchangeEngine::ExchangeEngine(RadioDriver **radio_ptr, const uint8_t *node_id, const uint8_t *system_key,
34 const TuningConfig *tuning)
35 : radio_ptr_(radio_ptr), node_id_(node_id), system_key_(system_key), tuning_(tuning) {}
36
37// ============================================================================
38// Debug snapshot helpers
39// ============================================================================
40
41void ExchangeEngine::reset_debug(uint8_t request_cmd) {
42 this->debug_ = DebugInfo{};
43 this->debug_.request_cmd = request_cmd;
44}
45
46void ExchangeEngine::record_debug(const char *stage, uint8_t tries, bool saw_challenge) {
47 this->debug_.stage = stage;
48 this->debug_.tries = tries;
49 this->debug_.saw_challenge = this->debug_.saw_challenge || saw_challenge;
50
51 const RadioCaptureInfo &capture = (*this->radio_ptr_)->get_last_capture();
52 // send_and_receive() blanks the radio capture at the start of each exchange (the path this
53 // snapshot mainly serves), so a valid capture here belongs to the current exchange rather than a
54 // previous one. wait_for_packet() re-clears the capture before each listen, so the *last*
55 // record_debug() call is always the final timed-out wait, which saw nothing. Keep the first
56 // informative capture instead: it distinguishes "the radio never detected a frame" from "it
57 // received one and this layer discarded it" — the question a failure report has to answer.
58 // Known gap: record_debug() calls outside that blanking — the bare ones in pairing_engine.cpp,
59 // authenticate_request(), and collect_broadcast_responses()'s "broadcast_tx_failed" branch — can
60 // still carry a capture from before their flow began. None of those snapshots reaches log_debug().
61 if (!capture.valid && this->debug_.capture_valid)
62 return;
63 this->debug_.capture_valid = capture.valid;
64 this->debug_.capture_rx_done = capture.rx_done;
65 this->debug_.capture_crc_error = capture.crc_error;
66 this->debug_.capture_freq_hz = capture.freq_hz;
67 this->debug_.capture_irq_status = capture.irq_status;
68 this->debug_.capture_packet_status = capture.packet_status;
69 this->debug_.capture_reported_len = capture.reported_len;
70 this->debug_.capture_frame_len = capture.frame_len;
71 this->debug_.capture_rssi_dbm = capture.rssi_dbm;
72}
73
74void ExchangeEngine::log_debug(const char *device_id) const {
75 const auto &d = this->debug_;
76 ESP_LOGW(TAG,
77 "Exchange failed: device=%s cmd=%s(0x%02X) stage=%s tries=%u max_tries=%u saw_challenge=%u cap_valid=%u "
78 "cap_rx_done=%u cap_crc_err=%u cap_freq=%" PRIu32
79 " cap_irq=0x%04X cap_pkt=0x%02X cap_reported_len=%u cap_frame_len=%u cap_rssi=%d",
80 device_id, command_name(d.request_cmd), d.request_cmd, d.stage, d.tries, d.max_tries, d.saw_challenge,
81 d.capture_valid, d.capture_rx_done, d.capture_crc_error, d.capture_freq_hz, d.capture_irq_status,
82 d.capture_packet_status, d.capture_reported_len, d.capture_frame_len, d.capture_rssi_dbm);
83}
84
85// ============================================================================
86// Frequency hopping
87// ============================================================================
88
89void ExchangeEngine::reset_hop_timestamp() { this->last_hop_us_ = micros(); }
90
91void ExchangeEngine::hop_frequency(uint32_t skip_freq) {
92 RadioDriver *radio = *this->radio_ptr_;
93 uint32_t next = radio->get_current_freq();
94 // At most two iterations: the rotation cycles through all three channels and only one of them
95 // can be skipped, so a channel that is not skip_freq is always one or two steps away.
96 do {
97 switch (next) {
98 case FREQ_CH1:
99 next = FREQ_CH2;
100 break;
101 case FREQ_CH3:
102 next = FREQ_CH1;
103 break;
104 default:
105 next = FREQ_CH3;
106 break;
107 }
108 } while (next == skip_freq);
109 radio->change_frequency(next);
110 this->last_hop_us_ = micros();
111}
112
114 if ((micros() - this->last_hop_us_) <= HOP_TIME_US)
115 return;
116 // A frame arriving on this channel outranks the dwell timer: change_frequency() retunes under a
117 // running demodulator and, on the software-PHY chips, also clears the IRQ word and the DIO
118 // latch, so hopping here destroys the frame rather than deferring it (issue #81).
119 // last_hop_us_ is deliberately left alone: the dwell has already been served, so the hop should
120 // happen on the very next pass once the reception clears, not a further HOP_TIME_US later.
121 if ((*this->radio_ptr_)->reception_in_progress())
122 return;
123 this->hop_frequency();
124}
125
126// ============================================================================
127// Transmit with LBT
128// ============================================================================
129
130bool ExchangeEngine::transmit_frame(const IoFrame &frame, uint32_t freq, uint16_t preamble) {
131 RadioDriver *radio = *this->radio_ptr_;
132 // FRAME_MAX_WIRE_SIZE, not FRAME_MAX_SIZE: a frame with an out-of-length MAC trailer
133 // (IoFrame::has_mac, e.g. CMD_ONEWAY_ADD_CONTROLLER) serializes to more than
134 // FRAME_MAX_SIZE/FRAME_MAX_DECLARED_SIZE bytes, and serialize() rejects a buffer too small to
135 // hold its actual output rather than truncating into it.
136 uint8_t buf[FRAME_MAX_WIRE_SIZE];
137 uint8_t const len = serialize(frame, buf, sizeof(buf));
138 if (len == 0) {
139 ESP_LOGW(TAG, "tx: serialize_failed cmd=0x%02X", frame.cmd);
140 return false;
141 }
142 for (uint8_t lbt = 0; lbt < this->tuning_->lbt_max_retries; lbt++) {
143 int16_t const rssi = radio->read_rssi();
144 if (rssi < this->tuning_->lbt_rssi_threshold_dbm)
145 break;
146 ESP_LOGD(TAG, "LBT: channel busy (RSSI %d dBm), retry %u/%u", rssi, lbt + 1, this->tuning_->lbt_max_retries);
147 this->counters_.lbt_retries++;
148 if (this->pairing_telemetry_ != nullptr)
149 this->pairing_telemetry_->record_lbt_defer(rssi);
150 delay(LBT_RETRY_DELAY_MS);
151 }
152 RadioTxConfig tx_config{};
153 tx_config.freq_hz = freq;
154 tx_config.preamble_len = preamble;
155 if (!radio->send_packet(buf, len, tx_config)) {
156 ESP_LOGW(TAG, "tx: send_failed cmd=0x%02X", frame.cmd);
157 return false;
158 }
159 if (this->pairing_telemetry_ != nullptr)
160 this->pairing_telemetry_->record_tx(frame.cmd);
161 return true;
162}
163
164// ============================================================================
165// Outbound exchange — main entry point
166// ============================================================================
167
168namespace {
169
170/// @brief Map OutboundExchangeState to a short string for debug logging.
171///
172/// OutboundExchangeState is written at each step for debug capture but is
173/// never read back for control-flow decisions — all branching is driven by
174/// return values and disposition enums.
175const char *outbound_stage_name(exchange::OutboundExchangeState state) {
176 switch (state) {
178 return "idle";
180 return "tx_request";
182 return "wait_first_response";
184 return "build_auth_response";
186 return "tx_auth_response";
188 return "wait_final_response";
190 return "success";
192 default:
193 return "failed";
194 }
195}
196
197/// @brief Map InboundAuthState to a short string for debug logging.
198const char *inbound_stage_name(exchange::InboundAuthState state) {
199 switch (state) {
201 return "idle";
203 return "tx_challenge";
205 return "wait_challenge_response";
207 return "verified";
209 default:
210 return "failed";
211 }
212}
213
214/// Check if frame is a 0x3D challenge response.
215bool frame_is_challenge_response(const IoFrame &frame) { return frame.cmd == CMD_CHALLENGE_RESP; }
216
217/// Log a frame that arrived but could not be parsed. Printing it distinguishes "the radio heard
218/// nothing" from "we heard something and rejected it" in a failure report — the two need opposite
219/// fixes: a device that never transmitted needs a longer wait or a link check, while one that
220/// transmits noise this layer can't decode needs the RX bandwidth or framing looked at. Redacted
221/// through the same helper as every other frame log, so an unparsable frame can't leak key material
222/// by being unrecognisable (see ADR 0011).
223void log_unparsable_frame(const char *stage, int tries, const RadioRxPacket &packet) {
224 // The *fact* that a frame failed to parse stays unconditional — it is a real fault worth
225 // surfacing to someone who never enables a debug flag. The raw bytes only help someone already
226 // debugging the PHY, and a noisy channel can produce several of these a minute, so they sit
227 // behind the frame-log flag with the rest of that detail.
228 ESP_LOGW(TAG, "%s try=%d: %u bytes did not parse as a frame on %" PRIu32 " Hz", stage, tries, packet.len,
229 packet.freq_hz);
230#ifdef IOHOME_FRAME_LOG
232 render_frame_hex_redacted(packet.data, packet.len, hex, sizeof(hex));
233 ESP_LOGD(TAG, " raw: %s", hex);
234#endif
235}
236
237/// Log an exchanged frame with context (stage, try index, length).
238void log_exchange_frame(const char *stage, int tries, const IoFrame &frame, uint8_t len) {
239 ESP_LOGD(TAG, "%s try=%d cmd=0x%02X src=%02X%02X%02X dst=%02X%02X%02X len=%u", stage, tries, frame.cmd, frame.src[0],
240 frame.src[1], frame.src[2], frame.dst[0], frame.dst[1], frame.dst[2], len);
241}
242
243/// Determine if a candidate frame is a valid final response for the request.
244bool is_valid_final_response(const IoFrame &candidate, const IoFrame &request) {
245 return decisions::classify_exchange_final_response(request, candidate) ==
247}
248
249} // namespace
250
251ExchangeOutcome ExchangeEngine::send_and_receive(const IoFrame &request, IoFrame &response, uint32_t freq,
252 uint8_t max_tries) {
253 this->reset_debug(request.cmd);
254 // Blank the radio's diagnostic capture at the start of the exchange. The radio only clears it
255 // when it actually begins a listen, so an exchange that transmits and then hears nothing at all
256 // would otherwise inherit the *previous* exchange's capture and claim "we heard a frame" when
257 // nothing was on air. Done here rather than in reset_debug() because collect_broadcast_responses()
258 // shares that helper but does not need this: its only capture reader runs per delivered reply
259 // (with the listen repopulating the capture first), and its DebugInfo is never passed to log_debug().
260 (*this->radio_ptr_)->clear_last_capture();
261 // Clamp once: never below 1 (a caller passing 0 must not silently transmit nothing) and never
262 // above EXCHANGE_RETRY_COUNT (the budget check downstream assumes that ceiling).
263 const uint8_t tries_allowed = std::max<uint8_t>(1, std::min<uint8_t>(max_tries, EXCHANGE_RETRY_COUNT));
264 this->debug_.max_tries = tries_allowed;
265 const uint16_t request_preamble = this->request_preamble_for_(request);
266 const uint32_t exchange_begin_ms = millis();
267 bool accepted_without_reply = false;
268
269 for (uint8_t tries = 0; tries < tries_allowed; tries++) {
271 context.try_index = tries + 1;
272 context.exchange_start_ms = millis();
273 context.wait_ms =
274 is_start(request) ? this->tuning_->exchange_start_response_wait_ms : this->tuning_->exchange_response_wait_ms;
276
277 if (tries > 0) {
278 // The retry count is a maximum, not a promise: don't start a try the exchange has no budget
279 // left for. See EXCHANGE_TOTAL_BUDGET_MS -- this is what keeps a failing command from
280 // blocking the ESPHome loop for the full retries x response-window product.
281 if (millis() - exchange_begin_ms >= this->tuning_->exchange_total_budget_ms) {
282 this->record_debug("retry_budget_exhausted", tries, false);
283 ESP_LOGI(TAG, "Exchange budget exhausted after %u tries for cmd=%s(0x%02X) (%" PRIu32 " of %u ms)", tries,
284 command_name(request.cmd), request.cmd, millis() - exchange_begin_ms,
285 this->tuning_->exchange_total_budget_ms);
286 break;
287 }
288 App.feed_wdt();
290 this->counters_.retransmits++;
291 }
292
293 if (!this->transmit_request_(request, freq, request_preamble, context))
294 continue;
295
297 this->record_debug(outbound_stage_name(context.state), context.try_index, false);
298 auto first_disp = this->wait_for_first_response_(request, context);
300 continue;
303 this->record_debug("success_direct", context.try_index, false);
304 response = context.rx;
306 }
307
308 if (!this->handle_authentication_(request, freq, context))
309 continue;
310
312 this->record_debug(outbound_stage_name(context.state), context.try_index, true);
313 auto final_disp = this->wait_for_final_response_(request, context);
315 // The device challenged us and accepted our answer, so it demonstrably received the request.
316 // Not every device closes the exchange with a synchronous reply (see ExchangeOutcome). A
317 // retry is safe only for a request with no side effect to repeat — CMD_EXECUTE is already
318 // acting on the first copy, so it stops here; everything else spends its full retry budget.
320 this->record_debug("success_auth_unconfirmed", context.try_index, true);
321 accepted_without_reply = true;
324 continue;
325 }
326
328 this->record_debug("success_auth", context.try_index, true);
329 response = context.rx;
331 }
332
333 // An exchange that authenticated on some try but never got a reply is not the same as one the
334 // device never answered at all: callers that only need "the request landed" can act on it, and
335 // callers that need the payload still cannot.
336 return accepted_without_reply ? ExchangeOutcome::SUCCESS_UNCONFIRMED : ExchangeOutcome::FAILED;
337}
338
339// ============================================================================
340// Outbound exchange step helpers
341// ============================================================================
342
343uint16_t ExchangeEngine::request_preamble_for_(const IoFrame &request) const {
344 // Gate on is_start() first: several device-role / continuation builders (key transfer,
345 // status-update response) set CTRL1_LOW_POWER on a non-start frame, and those must keep the
346 // short response preamble, not be lengthened.
347 if (!is_start(request))
348 return (*this->radio_ptr_)->response_preamble();
349 return (request.ctrl1 & CTRL1_LOW_POWER) != 0 ? LONG_PREAMBLE : this->tuning_->normal_start_preamble;
350}
351
352bool ExchangeEngine::transmit_request_(const IoFrame &request, uint32_t freq, uint16_t preamble,
353 exchange::OutboundExchangeContext &ctx) {
354 if (!this->transmit_frame(request, freq, preamble)) {
356 this->record_debug("tx_request_failed", ctx.try_index, false);
357 return false;
358 }
359 return true;
360}
361
362decisions::ExchangeFirstResponseDisposition ExchangeEngine::wait_for_first_response_(
363 const IoFrame &request, exchange::OutboundExchangeContext &ctx) {
364 ListenSpec spec;
365 spec.window_ms = ctx.wait_ms;
366 // A unicast reply comes back on the channel the request went out on: 0 of 300 unicast RX
367 // events (CHALLENGE_REQ + PRIVATE_RESP, 50 cycles each on SX1276/SX1262/LR1121) arrived off
368 // the request channel. Holding the channel needs no dwell and no hop-after-timeout guard —
369 // there is nowhere else a reply could come from.
371
373 RadioRxPacket packet{};
374 auto outcome = this->listen(spec, packet, ctx.rx, [&](const IoFrame *parsed, const RadioRxPacket &pkt) {
375 if (parsed == nullptr) {
376 this->record_debug("first_parse_fail", ctx.try_index, false);
377 this->counters_.parse_failures++;
378 log_unparsable_frame("Unparsable first response", ctx.try_index, pkt);
379 return ReplyDisposition::IGNORE;
380 }
381 disp = decisions::classify_exchange_first_response(request, *parsed);
383 this->record_debug("first_wrong_exchange", ctx.try_index, false);
384 log_exchange_frame("Ignored first response", ctx.try_index, *parsed, pkt.len);
386 }
387 ctx.first_response_ms = millis();
389 });
390
391 if (outcome == ListenOutcome::ACCEPTED)
392 return disp;
393
395 this->record_debug("wait_first_timeout", ctx.try_index, false);
396 ESP_LOGI(TAG, "Try %d ended: no first response for cmd=%s(0x%02X) within %" PRIu32 " ms", ctx.try_index,
397 command_name(request.cmd), request.cmd, ctx.wait_ms);
399}
400
401bool ExchangeEngine::handle_authentication_(const IoFrame &request, uint32_t freq,
402 exchange::OutboundExchangeContext &ctx) {
403 ctx.saw_challenge = true;
404 ctx.state = exchange::OutboundExchangeState::BUILD_AUTH_RESPONSE;
405 this->record_debug(outbound_stage_name(ctx.state), ctx.try_index, true);
406
407 IoFrame auth_resp;
408 if (!create_challenge_resp(auth_resp, request.dst, this->node_id_, ctx.rx.data, request, this->system_key_)) {
409 ctx.state = exchange::OutboundExchangeState::FAILED;
410 this->record_debug("auth_build_failed", ctx.try_index, true);
411 return false;
412 }
413
414 // No challenge bytes here: the raw 0x3C payload plus the 0x3D response it provokes is a
415 // known-plaintext/known-ciphertext pair under the system key (see redaction.h). The generic
416 // frame-log helpers (log_frame()/log_component_capture()) already mask both commands.
417 ESP_LOGI(TAG, "Auth challenge try=%d wait_ms=%" PRIu32 " req_cmd=0x%02X req_len=%u", ctx.try_index,
418 ctx.first_response_ms - ctx.exchange_start_ms, request.cmd, request.data_len);
419
420 ctx.state = exchange::OutboundExchangeState::TX_AUTH_RESPONSE;
421 this->record_debug(outbound_stage_name(ctx.state), ctx.try_index, true);
422 if (!this->transmit_frame(auth_resp, freq, (*this->radio_ptr_)->response_preamble())) {
423 ctx.state = exchange::OutboundExchangeState::FAILED;
424 this->record_debug("tx_auth_failed", ctx.try_index, true);
425 return false;
426 }
427 this->counters_.challenge_round_trips++;
428 return true;
429}
430
431decisions::ExchangeFinalResponseDisposition ExchangeEngine::wait_for_final_response_(
432 const IoFrame &request, exchange::OutboundExchangeContext &ctx) {
433 // Same budget as any other continuation frame — RESPONSE_AUTH_WAIT_MS was always an alias for
434 // RESPONSE_WAIT_MS, so the two share one knob rather than inventing a third.
435 const uint32_t auth_wait_ms = this->tuning_->exchange_response_wait_ms;
436
437 ListenSpec spec;
438 spec.window_ms = auth_wait_ms;
439 // Same reasoning as wait_for_first_response_(): a unicast reply comes back on the request
440 // channel (0 of 300 unicast RX events measured off-channel across all three chips), so holding
441 // the channel for the whole wait is strictly correct and needs no dwell.
442 spec.policy = ListenPolicy::HOLD_REQUEST_CHANNEL;
443
444 RadioRxPacket packet{};
445 auto outcome = this->listen(spec, packet, ctx.rx, [&](const IoFrame *parsed, const RadioRxPacket &pkt) {
446 if (parsed == nullptr) {
447 this->record_debug("final_parse_fail", ctx.try_index, true);
448 this->counters_.parse_failures++;
449 log_unparsable_frame("Unparsable final response", ctx.try_index, pkt);
450 return ReplyDisposition::IGNORE;
451 }
452 if (is_valid_final_response(*parsed, request))
453 return ReplyDisposition::ACCEPT;
454 this->record_debug("final_wrong_exchange", ctx.try_index, true);
455 log_exchange_frame("Ignored final response", ctx.try_index, *parsed, pkt.len);
456 return ReplyDisposition::IGNORE;
457 });
458
459 if (outcome == ListenOutcome::ACCEPTED)
460 return decisions::ExchangeFinalResponseDisposition::ACCEPT;
461
462 ctx.state = exchange::OutboundExchangeState::FAILED;
463 this->record_debug("wait_final_timeout", ctx.try_index, true);
464 ESP_LOGI(TAG, "Try %d ended: no matching final response for cmd=%s(0x%02X) within %" PRIu32 " ms", ctx.try_index,
465 command_name(request.cmd), request.cmd, auth_wait_ms);
466 return decisions::ExchangeFinalResponseDisposition::IGNORE_UNRELATED;
467}
468
469// ============================================================================
470// Broadcast roll-call
471// ============================================================================
472
473uint8_t ExchangeEngine::collect_broadcast_responses(const IoFrame &request, uint32_t freq, uint8_t expected_cmd,
474 uint32_t window_ms, const BroadcastReplyHandler &on_reply) {
475 this->reset_debug(request.cmd);
476
477 if (!this->transmit_frame(request, freq, this->request_preamble_for_(request))) {
478 this->record_debug("broadcast_tx_failed", 1, false);
479 return 0;
480 }
481
482 RadioDriver *radio = *this->radio_ptr_;
483 uint8_t count = 0;
484
485 ListenSpec spec;
486 spec.window_ms = window_ms;
487 // A roll-call reply almost never returns on the channel that asked for it (see ListenPolicy's
488 // own doc comment for why), so dwelling there is wasted listening time: with three channels
489 // split evenly across the window, one of them going unused for replies costs a third of it.
491 spec.request_freq = freq;
492 // dwell_ms is left at 0: no measured reason to dwell differently from discovery, so listen()
493 // asks the driver via hop_dwell_ms() instead of hardcoding a value here.
494 // A reception proves this channel carries responders and replies arrive spread across the whole
495 // window, so staying on it costs nothing; hopping away would only shrink the time spent where
496 // responders already are.
497 spec.hop_after_ignored_frame = false;
498 spec.linger_on_preamble = true;
500
501 RadioRxPacket packet{};
502 IoFrame frame{};
503 this->listen(spec, packet, frame, [&](const IoFrame *parsed, const RadioRxPacket & /*packet*/) {
504 if (parsed == nullptr || parsed->cmd != expected_cmd || memcmp(parsed->dst, this->node_id_, NODE_ID_SIZE) != 0)
506
507 on_reply(*parsed, radio->get_last_capture().rssi_dbm);
508 if (count < UINT8_MAX)
509 ++count;
510 // Collection always runs to the deadline: a roll-call has no single "the" reply, so nothing
511 // this loop can see is ever a reason to stop early.
513 });
514
515 this->record_debug("broadcast_collect_done", 1, false);
516 return count;
517}
518
519// ============================================================================
520// Shared listen primitive
521//
522// The one listen loop every radio wait in this project runs through: send_and_receive()'s
523// first/final-response waits, collect_broadcast_responses(), and PairingEngine's discovery/
524// key-transfer/key-confirm waits all call listen() below with a ListenSpec that picks one of
525// the three ListenPolicy values (hold the request channel, rotate all three, or rotate skipping
526// the request channel). Per-loop behavior — what counts as a match, what aborts, what gets
527// logged — lives entirely in the caller's ReplyHandler; this function owns only the
528// slice/hop/deadline mechanics common to all of them.
529// ============================================================================
530
531namespace {
532
533/// Parse one received packet, hand it to `on_frame`, and translate an ACCEPT/ABORT disposition
534/// into `outcome`. Factored out of listen()'s two reception sites purely to keep that function's
535/// cognitive complexity under the clang-tidy threshold — no behavior beyond the parse/dispatch.
536/// @return true if the listen should stop (ACCEPT or ABORT was returned); false to keep waiting.
537bool dispatch_received_packet(const ReplyHandler &on_frame, const RadioRxPacket &packet, IoFrame &frame,
538 ListenOutcome &outcome) {
539 const bool parsed = parse(packet.data, packet.len, frame);
540 switch (on_frame(parsed ? &frame : nullptr, packet)) {
542 outcome = ListenOutcome::ACCEPTED;
543 return true;
545 outcome = ListenOutcome::ABORTED;
546 return true;
548 return false;
549 }
550 return false;
551}
552
553/// A frame is arriving: hopping now would cut it off mid-reception. Both halves are live on every
554/// current chip.
555bool preamble_or_sync_incoming(RadioDriver *radio, const ListenSpec &spec) {
556 return spec.linger_on_preamble && (radio->is_preamble_detected() || radio->is_sync_detected());
557}
558
559/// Per-channel dwell for a rotating listen: `spec.dwell_ms` if the caller set one, otherwise the
560/// driver's own answer to "how long must this radio sit on a channel after retuning before it can
561/// hear anything at all" (see RadioDriver::hop_dwell_ms()). Unused (returns 0) for a holding
562/// listen — HOLD never slices, so it never asks. Factored out of listen() purely to avoid a
563/// nested conditional operator and keep that function's cognitive complexity under the clang-tidy
564/// threshold — no behavior beyond the two-way fallback.
565uint32_t resolve_dwell_ms(const ListenSpec &spec, bool rotating, RadioDriver *radio, const TuningConfig &tuning) {
566 if (!rotating)
567 return 0;
568 if (spec.dwell_ms != 0)
569 return spec.dwell_ms;
570 return radio->hop_dwell_ms(tuning);
571}
572
573} // namespace
574
575void ExchangeEngine::listen_hop_(uint32_t skip, const ListenSpec &spec) {
576 this->hop_frequency(skip);
577 if (spec.on_hop)
578 spec.on_hop();
579}
580
582 const ReplyHandler &on_frame) {
583 RadioDriver *radio = *this->radio_ptr_;
584 const uint32_t deadline = millis() + spec.window_ms;
585 const bool rotating = spec.policy != ListenPolicy::HOLD_REQUEST_CHANNEL;
586 const uint32_t skip = spec.policy == ListenPolicy::ROTATE_SKIPPING_REQUEST ? spec.request_freq : 0;
587 // 0 means "ask the driver": neither rotating call site in this project has a measured reason to
588 // dwell differently from the chip's own retune-cost answer (RadioDriver::hop_dwell_ms()), so
589 // both leave spec.dwell_ms at 0 and share one chip-specific knob instead of inventing a second.
590 const uint32_t dwell = resolve_dwell_ms(spec, rotating, radio, *this->tuning_);
591
592 // A broadcast reply almost never returns on the requesting channel (see ListenPolicy's own doc
593 // comment for why), so a skipping listen leaves that channel before its first dwell rather than
594 // spending one there.
596 this->listen_hop_(skip, spec);
597
598 while ((int32_t) (deadline - millis()) > 0) {
599 const uint32_t remaining = deadline - millis();
600 // A holding listen waits the whole remaining window in one call: it has nothing to do between
601 // slices, wait_for_packet() feeds the watchdog while it blocks, and every expired slice costs
602 // an RX re-arm. Only a rotating listen needs a per-channel dwell.
603 const uint32_t slice = rotating ? std::min(remaining, dwell) : remaining;
604
605 if (radio->wait_for_packet(packet, slice)) {
607 if (dispatch_received_packet(on_frame, packet, frame, outcome))
608 return outcome;
609 // The roll-call leaves this false because a reception proves responders are on this
610 // channel (see the field doc in hub_exchange.h); discovery is the only listen that hops
611 // after an ignored frame.
612 if (rotating && spec.hop_after_ignored_frame && (int32_t) (deadline - millis()) > 0 &&
613 !preamble_or_sync_incoming(radio, spec))
614 this->listen_hop_(skip, spec);
615 continue;
616 }
617
618 if ((int32_t) (deadline - millis()) <= 0)
619 break;
620 if (!rotating)
621 continue; // HOLD: an early false is a failed reception, not a timeout.
622 if (!preamble_or_sync_incoming(radio, spec)) {
623 this->listen_hop_(skip, spec);
624 continue;
625 }
626 // Preamble/sync seen at the dwell boundary: extend by linger_dwell_ms and give the frame its
627 // air time instead of hopping — a short extension wait, not another full per-channel dwell.
628 const uint32_t ext = std::min((uint32_t) (deadline - millis()), spec.linger_dwell_ms);
630 if (radio->wait_for_packet(packet, ext) && dispatch_received_packet(on_frame, packet, frame, outcome))
631 return outcome;
632 }
634}
635
636// ============================================================================
637// Inbound authentication
638// ============================================================================
639
640bool ExchangeEngine::authenticate_request(const IoFrame &request, uint32_t freq) {
641 RadioDriver *radio = *this->radio_ptr_;
644 this->record_debug(inbound_stage_name(context.state), 1, true);
645
646 if (!create_challenge_req(context.challenge, request.src, this->node_id_)) {
648 this->record_debug(inbound_stage_name(context.state), 1, true);
649 return false;
650 }
651 if (!this->transmit_frame(context.challenge, freq, SHORT_PREAMBLE)) {
653 this->record_debug(inbound_stage_name(context.state), 1, true);
654 return false;
655 }
656
658 this->record_debug(inbound_stage_name(context.state), 1, true);
659
660 RadioRxPacket packet{};
661 if (!radio->wait_for_packet(packet, this->tuning_->exchange_response_wait_ms)) {
663 this->record_debug(inbound_stage_name(context.state), 1, true);
664 return false;
665 }
666
667 IoFrame rx;
668 if (!parse(packet.data, packet.len, rx) || !frame_is_challenge_response(rx)) {
670 this->record_debug(inbound_stage_name(context.state), 1, true);
671 return false;
672 }
673
674 // Transcript is the *device's* own frame (cmd + data), not our challenge — the challenged
675 // party authenticates what it said. Long assumed by symmetry with our outbound direction;
676 // confirmed against real hardware bytes by the device-side 0x3D in
677 // tests/corpus/captures/pairing/velux_kux100_pairing_full.yaml.
678 uint8_t frame_data[FRAME_MAX_SIZE];
679 frame_data[0] = request.cmd;
680 memcpy(frame_data + 1, request.data, request.data_len);
681 if (!crypto::verify_hmac(frame_data, request.data_len + 1, rx.data, context.challenge.data, this->system_key_)) {
683 this->record_debug(inbound_stage_name(context.state), 1, true);
684 return false;
685 }
686
688 this->record_debug(inbound_stage_name(context.state), 1, true);
689 this->counters_.challenge_round_trips++;
690 return true;
691}
692
693} // namespace home_io_control
694} // namespace esphome
std::function< void(const IoFrame &frame, int16_t rssi_dbm)> BroadcastReplyHandler
Invoked for each matching broadcast reply, as it arrives.
ListenOutcome listen(const ListenSpec &spec, RadioRxPacket &packet, IoFrame &frame, const ReplyHandler &on_frame)
The one listen primitive every radio wait loop in this project is built on.
ExchangeOutcome send_and_receive(const IoFrame &request, IoFrame &response, uint32_t freq, uint8_t max_tries=EXCHANGE_RETRY_COUNT)
Execute an outbound authenticated exchange with retry.
void maybe_hop()
Hop only if the minimum dwell has elapsed and no frame is currently arriving on this channel — RadioD...
void reset_debug(uint8_t request_cmd)
Clear the debug snapshot and record the upcoming request command.
uint8_t collect_broadcast_responses(const IoFrame &request, uint32_t freq, uint8_t expected_cmd, uint32_t window_ms, const BroadcastReplyHandler &on_reply)
Transmit request once and hand every matching broadcast reply to on_reply within window_ms.
void log_debug(const char *device_id) const
Log the debug snapshot as a WARN-level structured line.
ExchangeEngine(RadioDriver **radio_ptr, const uint8_t *node_id, const uint8_t *system_key, const TuningConfig *tuning)
Construct the engine with double-pointer indirection into the hub's RadioDriver pointer and direct po...
bool transmit_frame(const IoFrame &frame, uint32_t freq, uint16_t preamble)
Transmit a raw IoFrame with LBT and the given preamble length.
bool authenticate_request(const IoFrame &request, uint32_t freq)
Authenticate an inbound device command via 0x3C challenge / 0x3D HMAC.
void reset_hop_timestamp()
Reset the hop-timer (called after radio init in hub setup()).
void hop_frequency(uint32_t skip_freq=0)
Advance the receiver one step along the protocol's channel rotation (CH1→CH2→CH3→CH1).
void record_debug(const char *stage, uint8_t tries, bool saw_challenge)
Update the debug snapshot with the current stage and radio capture.
Abstract radio driver for IO-Homecontrol.
uint32_t get_current_freq() const
Get the current RF frequency.
virtual void change_frequency(uint32_t freq_hz)=0
Change the carrier frequency using fast hop (no standby transition needed).
const RadioCaptureInfo & get_last_capture() const
Get the most recent radio capture info.
virtual bool send_packet(const uint8_t *data, uint8_t len, const RadioTxConfig &tx_config)=0
Send a packet using the specified carrier frequency and preamble settings.
virtual int16_t read_rssi()=0
Read instantaneous RSSI (in dBm) while in RX mode.
virtual bool wait_for_packet(RadioRxPacket &packet, uint32_t timeout_ms)=0
Wait (blocking) for a packet with timeout.
Self-contained authenticated exchange engine for IO-Homecontrol 2W.
Pure transition helpers for hub-owned exchange and pairing frame decisions.
Shared frame logging helpers for IO-Homecontrol.
bool verify_hmac(const uint8_t *data, uint8_t len, const uint8_t hmac[HMAC_SIZE], const uint8_t challenge[HMAC_SIZE], const uint8_t key[AES_KEY_SIZE])
Verify a received HMAC using constant-time comparison.
ExchangeFirstResponseDisposition classify_exchange_first_response(const IoFrame &request, const IoFrame &candidate)
Decide how to handle the first response packet in an authenticated exchange.
@ ACCEPT
Frame matches expected response — exchange succeeds.
ExchangeFirstResponseDisposition
Disposition for the first response in an authenticated exchange.
@ IGNORE_UNRELATED
Frame doesn't match endpoints or failed parse — keep waiting.
@ COMPLETE_DIRECT
Matching non-challenge frame — operation complete, no auth needed.
bool retry_after_unconfirmed_accept_is_safe(uint8_t cmd)
Whether an authenticated-but-unanswered request may be sent again.
ExchangeFinalResponseDisposition classify_exchange_final_response(const IoFrame &request, const IoFrame &candidate)
Decide if a candidate frame is an acceptable final response after authentication.
InboundAuthState
Progress stages of inbound authentication (device‑initiated commands).
@ WAIT_CHALLENGE_RESPONSE
Timer running; waiting for device's HMAC proof (0x3D).
@ VERIFIED
Device successfully authenticated; command is trusted.
@ TX_CHALLENGE
Challenge (0x3C) sent to device; awaiting 0x3D response.
@ IDLE
No inbound authentication in progress.
@ FAILED
Authentication failed (timeout or HMAC mismatch).
OutboundExchangeState
Progress stages of an outbound authenticated exchange (non‑pairing).
@ TX_REQUEST
Request frame transmitted; awaiting first response from device.
@ TX_AUTH_RESPONSE
Auth response (0x3D) transmitted; awaiting device's final reply.
@ BUILD_AUTH_RESPONSE
Building the 0x3D challenge response after receiving 0x3C.
@ FAILED
Exchange failed (timeout, retries exhausted, or radio error).
@ SUCCESS
Exchange completed successfully; device acknowledged.
@ WAIT_FIRST_RESPONSE
Listening for first response. This may be a challenge (0x3C) or the final response.
@ WAIT_FINAL_RESPONSE
Listening for the authenticated final response (e.g., status frame).
static constexpr uint8_t NODE_ID_SIZE
Device/node addresses are 3 bytes (e.g., "123ABC").
Definition proto_sizes.h:20
static constexpr const char * TAG
bool is_start(const IoFrame &f)
Check START flag.
static constexpr uint32_t FREQ_CH1
The protocol uses 3 frequency channels in the 868 MHz ISM band.
static constexpr uint32_t FREQ_CH3
Channel 3: 869.85 MHz (2W only).
static constexpr int32_t HOP_TIME_US
Timing constants for frequency hopping and response waiting.
const char * command_name(uint8_t cmd)
Get a human-readable name for any IO-Homecontrol command ID.
static constexpr uint8_t FRAME_MAX_SIZE
Historical name for FRAME_MAX_DECLARED_SIZE, kept as an alias rather than a second literal so the two...
Definition proto_sizes.h:44
static constexpr int32_t EXCHANGE_RETRY_DELAY_MS
Gap between retries within one HA command.
std::function< ReplyDisposition(const IoFrame *parsed, const RadioRxPacket &packet)> ReplyHandler
Invoked for every packet the radio delivers during a listen, before the listen decides whether to kee...
@ ACCEPT
This is the frame the caller was waiting for — stop listening, return ACCEPTED.
@ ABORT
An explicit refusal (e.g. CMD_ERROR_RESP) — stop listening, return ABORTED.
@ IGNORE
Unparsable / not ours / wrong exchange — keep listening.
bool create_challenge_resp(IoFrame &f, const uint8_t *dst, const uint8_t *src, const uint8_t challenge[HMAC_SIZE], const IoFrame &origin, const uint8_t *key)
Build a challenge response (0x3D) proving we know the system key.
void render_frame_hex_redacted(const uint8_t *data, uint8_t len, char *out, size_t out_size)
Render a frame's bytes as spaced hex text, masking the payload when the command carries key material ...
Definition log_frame.h:71
ExchangeOutcome
Authenticated exchange engine — outbound and inbound protocol flows.
@ SUCCESS_WITH_RESPONSE
Device replied; the caller's response frame is populated.
@ SUCCESS_UNCONFIRMED
Device authenticated the request — so it received and accepted it — but sent no final response.
@ FAILED
No usable reply; the device may never have heard the request.
constexpr size_t FRAME_LOG_HEX_BUFFER_SIZE
Fits a full 32-byte frame rendered as spaced hex text.
Definition log_frame.h:19
static constexpr uint8_t FRAME_MAX_WIRE_SIZE
Largest number of bytes a buffer must hold to receive or transmit any frame this project knows about,...
Definition proto_sizes.h:68
@ ROTATE_SKIPPING_REQUEST
The two channels that are not the request channel. Roll-call.
@ HOLD_REQUEST_CHANNEL
Never retunes, never slices. Unicast replies.
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 EXCHANGE_RETRY_COUNT
Attempts per command before reporting failure.
static constexpr uint32_t FREQ_CH2
Channel 2: 868.95 MHz (1W and 2W, TX channel).
ListenOutcome
How one call to ExchangeEngine::listen() ended.
@ ABORTED
The handler returned ReplyDisposition::ABORT for some received frame.
@ ACCEPTED
The handler returned ReplyDisposition::ACCEPT for some received frame.
@ TIMED_OUT
spec.window_ms elapsed with no ACCEPT/ABORT.
static constexpr uint8_t LBT_RETRY_DELAY_MS
Backoff between LBT checks (≥ 5ms per ETSI).
static constexpr uint16_t SHORT_PREAMBLE
8 bytes for response/continuation frames
static constexpr uint8_t CMD_CHALLENGE_RESP
HMAC proof answering a 0x3C.
static constexpr uint32_t PREAMBLE_LINGER_DWELL_MS
Preamble/sync linger extension for a rotating listen (ListenSpec::linger_dwell_ms): how much longer t...
static constexpr uint16_t LONG_PREAMBLE
Preamble is a sequence of 0xAA bytes that precedes every frame.
bool create_challenge_req(IoFrame &f, const uint8_t *dst, const uint8_t *src, const uint8_t challenge[HMAC_SIZE])
Build a challenge request (0x3C) using a caller-supplied challenge.
static constexpr uint8_t CTRL1_LOW_POWER
Bit 5: low-power device (e.g., solar-powered).
Definition proto_frame.h:66
uint8_t serialize(const IoFrame &f, uint8_t *buf, uint8_t buf_size)
Serialize a parsed frame into a wire buffer (without CRC).
Command builders for the IO‑Homecontrol protocol.
IO-Homecontrol command IDs, result codes and protocol enumerations.
Cryptographic helpers for the IO‑Homecontrol protocol.
Snapshot of the last exchange attempt for diagnostics.
uint8_t request_cmd
Command ID of the original request.
Parsed IO‑Homecontrol frame (CTRL0/1 + addresses + command + data).
Definition proto_frame.h:88
uint8_t data[FRAME_MAX_DATA_SIZE]
Command parameters (0–23 bytes). Never includes mac.
Definition proto_frame.h:94
uint8_t src[NODE_ID_SIZE]
Source node ID (3 bytes).
Definition proto_frame.h:92
uint8_t dst[NODE_ID_SIZE]
Destination node ID (3 bytes).
Definition proto_frame.h:91
uint8_t data_len
Actual length of data.
Definition proto_frame.h:95
uint8_t ctrl1
Control byte 1: low power, beacon, etc.
Definition proto_frame.h:90
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,...
bool hop_after_ignored_frame
Hop after a frame the handler ignored.
Diagnostic capture from a radio operation.
uint8_t frame_len
Number of valid bytes in frame[].
uint16_t irq_status
Raw IRQ status register value.
uint8_t packet_status
Packet status byte (chip-specific).
bool crc_error
True if a CRC error was detected.
bool valid
True if capture is valid.
uint8_t reported_len
Length reported by the radio chip.
bool rx_done
True if RxDone IRQ fired.
uint32_t freq_hz
RF frequency of capture (Hz).
int16_t rssi_dbm
Received signal strength (dBm).
Raw packet received from the radio.
uint8_t len
Length of packet in bytes.
uint8_t data[RADIO_PACKET_BUFFER_SIZE]
Raw packet data buffer.
Configuration for transmitting a packet: carrier frequency and preamble length.
uint16_t preamble_len
Preamble length in symbol periods (bytes).
uint32_t freq_hz
Carrier frequency in Hz.
All runtime tunable parameters for pairing and radio diagnostics.
uint16_t normal_start_preamble
Preamble for a directed start frame to a non-low-power target.
Context for a single inbound authentication (device‑initiated command).
IoFrame challenge
The 0x3C challenge frame we sent (needed to verify 0x3D response).
InboundAuthState state
Current authentication state.
Context carried across one outbound authenticated exchange.
uint8_t try_index
Current retry attempt (1‑based within EXCHANGE_RETRY_COUNT).
IoFrame rx
Most recent candidate frame received during the exchange.
uint32_t exchange_start_ms
Timestamp when the exchange attempt began (millis).
uint32_t wait_ms
Current timeout window for the active wait (ms).
OutboundExchangeState state
Current state machine state.