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 <cstdio>
23#include <cstring>
24
25namespace esphome {
26namespace home_io_control {
27
28static const char *const TAG = "home_io_control.exchange";
29
30/// Microseconds per millisecond, for rounding a driver's transmit-time estimate up to whole ms.
31static constexpr uint32_t US_PER_MS = 1000;
32
33// ============================================================================
34// Construction
35// ============================================================================
36
37ExchangeEngine::ExchangeEngine(RadioDriver **radio_ptr, const uint8_t *node_id, const uint8_t *system_key,
38 const TuningConfig *tuning)
39 : radio_ptr_(radio_ptr), node_id_(node_id), system_key_(system_key), tuning_(tuning) {}
40
41// ============================================================================
42// Debug snapshot helpers
43// ============================================================================
44
45void ExchangeEngine::reset_debug(uint8_t request_cmd) {
46 this->debug_ = DebugInfo{};
47 this->debug_.request_cmd = request_cmd;
48}
49
50void ExchangeEngine::note_final_wait_(const ListenStats &stats) {
51 DebugInfo &d = this->debug_;
52 const auto add = [](uint8_t &total, uint8_t n) {
53 total = static_cast<uint8_t>(std::min<unsigned>(total + n, UINT8_MAX));
54 };
55 add(d.final_waits, 1);
56 add(d.final_rx_ignored, stats.frames_ignored);
57 add(d.final_rx_failed, stats.failed_receptions);
58 if (stats.failed_receptions != 0)
59 d.final_rx_irq = stats.last_failed_irq;
60}
61
62void ExchangeEngine::record_debug(const char *stage, uint8_t tries, bool saw_challenge) {
63 this->debug_.stage = stage;
64 this->debug_.tries = tries;
65 this->debug_.saw_challenge = this->debug_.saw_challenge || saw_challenge;
66
67 const RadioCaptureInfo &capture = (*this->radio_ptr_)->get_last_capture();
68 // send_and_receive() blanks the radio capture at the start of each exchange (the path this
69 // snapshot mainly serves), so a valid capture here belongs to the current exchange rather than a
70 // previous one. wait_for_packet() re-clears the capture before each listen, so the *last*
71 // record_debug() call is always the final timed-out wait, which saw nothing. Keep the first
72 // informative capture instead: it distinguishes "the radio never detected a frame" from "it
73 // received one and this layer discarded it" — the question a failure report has to answer.
74 // Known gap: record_debug() calls outside that blanking — the bare ones in pairing_engine.cpp,
75 // authenticate_request(), and collect_broadcast_responses()'s "broadcast_tx_failed" branch — can
76 // still carry a capture from before their flow began. None of those snapshots reaches log_debug().
77 if (!capture.valid && this->debug_.capture_valid)
78 return;
79 this->debug_.capture_valid = capture.valid;
80 this->debug_.capture_rx_done = capture.rx_done;
81 this->debug_.capture_crc_error = capture.crc_error;
82 this->debug_.capture_freq_hz = capture.freq_hz;
83 this->debug_.capture_irq_status = capture.irq_status;
84 this->debug_.capture_packet_status = capture.packet_status;
85 this->debug_.capture_reported_len = capture.reported_len;
86 this->debug_.capture_frame_len = capture.frame_len;
87 this->debug_.capture_rssi_dbm = capture.rssi_dbm;
88}
89
90int render_exchange_debug(char *buf, size_t buf_size, const char *device_id, const ExchangeEngine::DebugInfo &d) {
91 return snprintf(buf, buf_size,
92 "device=%s cmd=%s(0x%02X) stage=%s tries=%u max_tries=%u saw_challenge=%u cap_valid=%u "
93 "cap_rx_done=%u cap_crc_err=%u cap_freq=%" PRIu32
94 " cap_irq=0x%04X cap_pkt=0x%02X cap_reported_len=%u cap_frame_len=%u cap_rssi=%d belief=%s "
95 "last_preamble=%u final_waits=%u final_rx_ignored=%u final_rx_failed=%u final_rx_irq=0x%04X",
96 device_id, command_name(d.request_cmd), d.request_cmd, d.stage, d.tries, d.max_tries,
97 // Rendered as 0/1: these are flags in a field list, not prose, and a caller greps them.
98 static_cast<unsigned>(d.saw_challenge), static_cast<unsigned>(d.capture_valid),
99 static_cast<unsigned>(d.capture_rx_done), static_cast<unsigned>(d.capture_crc_error),
106}
107
108void ExchangeEngine::log_debug(const char *device_id) const {
109 char fields[EXCHANGE_DEBUG_LINE_SIZE];
110 render_exchange_debug(fields, sizeof(fields), device_id, this->debug_);
111 ESP_LOGW(TAG, "Exchange failed: %s", fields);
112}
113
114void ExchangeEngine::log_debug_unconfirmed(const char *device_id) const {
115 char fields[EXCHANGE_DEBUG_LINE_SIZE];
116 render_exchange_debug(fields, sizeof(fields), device_id, this->debug_);
117 // "accepted" describes what the device did with the request, not what it did with our challenge
118 // answer — see the WAIT_FINAL_RESPONSE branch in send_and_receive() for why silence here has two
119 // possible causes. The final_rx_* fields are what tells them apart: a reception during the final
120 // wait means something came back and was lost here. The cap_* fields cannot, because they keep
121 // the first informative reception, which is the device's challenge.
122 ESP_LOGI(TAG, "Exchange accepted without a closing reply: %s", fields);
123}
124
125// ============================================================================
126// Frequency hopping
127// ============================================================================
128
129void ExchangeEngine::reset_hop_timestamp() { this->last_hop_us_ = micros(); }
130
131void ExchangeEngine::hop_frequency(uint32_t skip_freq) {
132 RadioDriver *radio = *this->radio_ptr_;
133 uint32_t next = radio->get_current_freq();
134 // At most two iterations: the rotation cycles through all three channels and only one of them
135 // can be skipped, so a channel that is not skip_freq is always one or two steps away.
136 do {
137 switch (next) {
138 case FREQ_CH1:
139 next = FREQ_CH2;
140 break;
141 case FREQ_CH3:
142 next = FREQ_CH1;
143 break;
144 default:
145 next = FREQ_CH3;
146 break;
147 }
148 } while (next == skip_freq);
149 radio->change_frequency(next);
150 this->last_hop_us_ = micros();
151}
152
154 if ((micros() - this->last_hop_us_) <= HOP_TIME_US)
155 return;
156 // A frame arriving on this channel outranks the dwell timer: change_frequency() retunes under a
157 // running demodulator and, on the software-PHY chips, also clears the IRQ word and the DIO
158 // latch, so hopping here destroys the frame rather than deferring it (issue #81).
159 // last_hop_us_ is deliberately left alone: the dwell has already been served, so the hop should
160 // happen on the very next pass once the reception clears, not a further HOP_TIME_US later.
161 if ((*this->radio_ptr_)->reception_in_progress())
162 return;
163 this->hop_frequency();
164}
165
166// ============================================================================
167// Transmit with LBT
168// ============================================================================
169
170namespace {
171
172/// The RadioTxConfig for `frame` on `freq` with `preamble`: the one place a transmission's shape
173/// (channel, preamble, wake-up level) is decided, for transmit_frame() and for the budget estimate.
174RadioTxConfig tx_config_for(const IoFrame &frame, uint32_t freq, uint16_t preamble) {
175 RadioTxConfig tx_config{};
176 tx_config.freq_hz = freq;
177 tx_config.preamble_len = preamble;
178 tx_config.wake = tx_wake_for(is_start(frame), preamble);
179 return tx_config;
180}
181
182} // namespace
183
184bool ExchangeEngine::transmit_frame(const IoFrame &frame, uint32_t freq, uint16_t preamble) {
185 RadioDriver *radio = *this->radio_ptr_;
186 // FRAME_MAX_WIRE_SIZE, not FRAME_MAX_SIZE: a frame with an out-of-length MAC trailer
187 // (IoFrame::has_mac, e.g. CMD_ONEWAY_ADD_CONTROLLER) serializes to more than
188 // FRAME_MAX_SIZE/FRAME_MAX_DECLARED_SIZE bytes, and serialize() rejects a buffer too small to
189 // hold its actual output rather than truncating into it.
190 uint8_t buf[FRAME_MAX_WIRE_SIZE];
191 uint8_t const len = serialize(frame, buf, sizeof(buf));
192 if (len == 0) {
193 ESP_LOGW(TAG, "tx: serialize_failed cmd=0x%02X", frame.cmd);
194 return false;
195 }
196 // Listen before talk: up to lbt_max_retries sends that the radio makes only if the TX channel is
197 // clear, measured by the driver on that channel right before transmitting; after the last busy
198 // result, one send without the check, so a noisy channel delays a frame but never drops it.
199 // Only a frame that opens a session (CTRL0 START) is checked. A frame without START answers one
200 // the peer has just sent and is listening for on this channel for only a few tens of ms, so it
201 // goes out at once: a 5 ms back-off already loses the exchange on some receivers (issue #178),
202 // and the frame that opened the session was checked by whoever sent it (ADR 0045).
203 const uint8_t lbt_checks = is_start(frame) ? this->tuning_->lbt_max_retries : 0;
204 RadioTxConfig tx_config = tx_config_for(frame, freq, preamble);
205 TxResult result;
206 for (uint8_t lbt = 0;; lbt++) {
207 if (lbt < lbt_checks) {
208 tx_config.cca_threshold_dbm = this->tuning_->lbt_rssi_threshold_dbm;
209 } else {
210 tx_config.cca_threshold_dbm.reset();
211 }
212 result = radio->send_packet(buf, len, tx_config);
214 break;
215 ESP_LOGD(TAG, "LBT: channel busy (RSSI %d dBm on %" PRIu32 " Hz), retry %u/%u", result.cca_level_dbm, freq, lbt + 1,
216 lbt_checks);
217 this->counters_.lbt_retries++;
218 if (this->transmit_observer_ != nullptr)
219 this->transmit_observer_->on_lbt_defer(result.cca_level_dbm);
220 delay(LBT_RETRY_DELAY_MS);
221 }
222 if (!result.sent()) {
223 ESP_LOGW(TAG, "tx: send_failed cmd=0x%02X", frame.cmd);
224 return false;
225 }
226 if (this->transmit_observer_ != nullptr)
227 this->transmit_observer_->on_transmit(frame, tx_config, len);
228 return true;
229}
230
231uint32_t ExchangeEngine::final_reply_tail_ms_(const IoFrame &request, uint32_t freq) const {
232 // The 0x3D answer is not built before the guard runs, so the request stands in for its size (a
233 // 0x3D is no longer than a request); the overhead covers the answer's channel check and SPI.
234 return this->tuning_->exchange_response_wait_ms +
235 this->tx_time_ms(request, freq, (*this->radio_ptr_)->response_preamble()) + EXCHANGE_ATTEMPT_OVERHEAD_MS;
236}
237
238uint32_t ExchangeEngine::tx_time_ms(const IoFrame &frame, uint32_t freq, uint16_t preamble) const {
239 uint8_t buf[FRAME_MAX_WIRE_SIZE];
240 uint8_t const len = serialize(frame, buf, sizeof(buf));
241 if (len == 0)
242 return 0;
243 uint32_t const tx_us = (*this->radio_ptr_)->tx_air_time_us(len, tx_config_for(frame, freq, preamble));
244 return (tx_us + US_PER_MS - 1) / US_PER_MS;
245}
246
248 uint16_t request_preamble_override, bool state_unknown) const {
249 const bool low_power = (request.ctrl1 & CTRL1_LOW_POWER) != 0;
250 // A settling read of a low-power receiver the hub has no news of leads with one short attempt
251 // (WakeBelief::UNKNOWN). Every other read, and any read whose preamble the caller fixed, has none.
252 std::optional<uint32_t> short_lead_tx_ms;
253 if (kind == decisions::ReadKind::SETTLING && low_power) {
254 const PreamblePlan preamble_plan = this->plan_request_preamble_(request, request_preamble_override, state_unknown);
255 if (preamble_plan.use == WakeBeliefUse::APPLIED && preamble_plan.belief == decisions::WakeBelief::UNKNOWN)
256 short_lead_tx_ms = this->tx_time_ms(request, freq, preamble_plan.short_preamble);
257 }
259 this->plan_attempts(kind, low_power, request, freq, this->request_preamble_for(request), 0, short_lead_tx_ms);
260 plan.state_unknown = state_unknown;
261 return plan;
262}
263
265 uint32_t freq, uint16_t preamble, uint16_t last_window_ms,
266 std::optional<uint32_t> short_lead_tx_ms) const {
267 return decisions::attempt_plan(kind, low_power, *this->tuning_, this->tx_time_ms(request, freq, preamble),
268 last_window_ms, short_lead_tx_ms);
269}
270
271bool ExchangeEngine::try_fits_budget_(uint32_t elapsed_ms, uint32_t gap_ms, uint32_t tx_ms, uint32_t window_ms,
272 uint32_t auth_tail_ms, uint32_t allowance_ms) const {
273 return decisions::attempt_fits(elapsed_ms, gap_ms, tx_ms, window_ms, auth_tail_ms,
274 this->tuning_->exchange_total_budget_ms + allowance_ms);
275}
276
277// ============================================================================
278// Outbound exchange — main entry point
279// ============================================================================
280
281namespace {
282
283/// @brief Map OutboundExchangeState to a short string for debug logging.
284///
285/// OutboundExchangeState is written at each step for debug capture but is
286/// never read back for control-flow decisions — all branching is driven by
287/// return values and disposition enums.
288const char *outbound_stage_name(exchange::OutboundExchangeState state) {
289 switch (state) {
291 return "idle";
293 return "tx_request";
295 return "wait_first_response";
297 return "build_auth_response";
299 return "tx_auth_response";
301 return "wait_final_response";
303 return "success";
305 default:
306 return "failed";
307 }
308}
309
310/// @brief Map InboundAuthState to a short string for debug logging.
311const char *inbound_stage_name(exchange::InboundAuthState state) {
312 switch (state) {
314 return "idle";
316 return "tx_challenge";
318 return "wait_challenge_response";
320 return "verified";
322 default:
323 return "failed";
324 }
325}
326
327/// Check if frame is a 0x3D challenge response.
328bool frame_is_challenge_response(const IoFrame &frame) { return frame.cmd == CMD_CHALLENGE_RESP; }
329
330/// Log a frame that arrived but could not be parsed. Printing it distinguishes "the radio heard
331/// nothing" from "we heard something and rejected it" in a failure report — the two need opposite
332/// fixes: a device that never transmitted needs a longer wait or a link check, while one that
333/// transmits noise this layer can't decode needs the RX bandwidth or framing looked at. Redacted
334/// through the same helper as every other frame log, so an unparsable frame can't leak key material
335/// by being unrecognisable (see ADR 0011).
336void log_unparsable_frame(const char *stage, int tries, const RadioRxPacket &packet) {
337 // The *fact* that a frame failed to parse stays unconditional — it is a real fault worth
338 // surfacing to someone who never enables a debug flag. The raw bytes only help someone already
339 // debugging the PHY, and a noisy channel can produce several of these a minute, so they sit
340 // behind the frame-log flag with the rest of that detail.
341 ESP_LOGW(TAG, "%s try=%d: %u bytes did not parse as a frame on %" PRIu32 " Hz", stage, tries, packet.len,
342 packet.freq_hz);
343#ifdef IOHOME_FRAME_LOG
345 render_frame_hex_redacted(packet.data, packet.len, hex, sizeof(hex));
346 ESP_LOGD(TAG, " raw: %s", hex);
347#endif
348}
349
350/// Log an exchanged frame with context (stage, try index, length).
351void log_exchange_frame(const char *stage, int tries, const IoFrame &frame, uint8_t len) {
352 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],
353 frame.src[1], frame.src[2], frame.dst[0], frame.dst[1], frame.dst[2], len);
354}
355
356/// Buffer for format_try_age(): "n/a", or a uint32_t's ten decimal digits, plus the terminator.
357constexpr size_t TRY_AGE_BUFFER_SIZE = 12;
358
359/// Render a try's `age_ms=` field: how long before this try the target was last heard, or "n/a"
360/// when there is no stamp (never heard, or the target evidence was not looked up). Every try line
361/// carries it next to the try's preamble, so field logs show which preamble reaches a low-power
362/// receiver how long after it last spoke — the data the wake-belief windows are sized from. The
363/// stamp is read once, before the exchange, so a challenge heard on an earlier try of the same
364/// exchange does not reset it.
365void format_try_age(const exchange::OutboundExchangeContext &ctx, char (&buf)[TRY_AGE_BUFFER_SIZE]) {
366 if (ctx.target_last_seen_ms == 0) {
367 snprintf(buf, sizeof(buf), "n/a");
368 return;
369 }
370 snprintf(buf, sizeof(buf), "%" PRIu32, ctx.try_start_ms - ctx.target_last_seen_ms);
371}
372
373/// True for a start frame addressed to a duty-cycled receiver — the only frame that has a wake-up
374/// preamble to choose. One definition, shared by the fixed rule and the per-try wake belief.
375bool is_low_power_start(const IoFrame &request) { return is_start(request) && (request.ctrl1 & CTRL1_LOW_POWER) != 0; }
376
377/// Determine if a candidate frame is a valid final response for the request.
378bool is_valid_final_response(const IoFrame &candidate, const IoFrame &request) {
379 return decisions::classify_exchange_final_response(request, candidate) ==
381}
382
383/// The final-reply window the budget guard counts ahead of the try that follows an unconfirmed one.
384/// None before a CMD_EXECUTE re-send: it is the last copy, and tries_after_unconfirmed_() has
385/// already checked it against the budget without that window, so counting it here would wait the
386/// long re-send gap and then not send. The full window before any other request's next try.
387uint32_t auth_tail_after_unconfirmed(const IoFrame &request, uint32_t final_window_ms) {
388 return request.cmd == CMD_EXECUTE ? 0 : final_window_ms;
389}
390
391} // namespace
392
393ExchangeOutcome ExchangeEngine::send_and_receive(const IoFrame &request, IoFrame &response, uint32_t freq,
394 uint8_t max_tries, uint16_t request_preamble_override) {
395 // Never below 1 (a caller passing 0 must not silently transmit nothing) and never above
396 // EXCHANGE_RETRY_COUNT: a caller that wants more attempts passes a plan.
397 const uint16_t window_ms =
398 is_start(request) ? this->tuning_->exchange_start_response_wait_ms : this->tuning_->exchange_response_wait_ms;
399 return this->send_and_receive(request, response, freq, decisions::attempt_plan_for_tries(max_tries, window_ms),
400 request_preamble_override);
401}
402
403ExchangeOutcome ExchangeEngine::send_and_receive(const IoFrame &request, IoFrame &response, uint32_t freq,
404 const decisions::AttemptPlan &plan,
405 uint16_t request_preamble_override) {
406 this->reset_debug(request.cmd);
407 // Blank the radio's diagnostic capture at the start of the exchange. The radio only clears it
408 // when it actually begins a listen, so an exchange that transmits and then hears nothing at all
409 // would otherwise inherit the *previous* exchange's capture and claim "we heard a frame" when
410 // nothing was on air. Done here rather than in reset_debug() because collect_broadcast_responses()
411 // shares that helper but does not need this: its only capture reader runs per delivered reply
412 // (with the listen repopulating the capture first), and its DebugInfo is never passed to log_debug().
413 (*this->radio_ptr_)->clear_last_capture();
414 // Clamp once: never below 1 and never above EXCHANGE_MAX_ATTEMPTS, whatever the plan says.
415 uint8_t tries_allowed = std::max<uint8_t>(1, std::min<uint8_t>(plan.max_attempts, EXCHANGE_MAX_ATTEMPTS));
416 this->debug_.max_tries = tries_allowed;
417 const PreamblePlan preamble_plan =
418 this->plan_request_preamble_(request, request_preamble_override, plan.state_unknown);
419 this->debug_.wake_belief_use = preamble_plan.use;
420 if (preamble_plan.use == WakeBeliefUse::APPLIED) {
421 this->debug_.wake_belief = preamble_plan.belief;
422 if (preamble_plan.belief == decisions::WakeBelief::AWAKE) {
423 ESP_LOGD(TAG, "Low-power target %s believed %s: short preamble first", node_id_to_string(request.dst).c_str(),
424 decisions::wake_belief_name(preamble_plan.belief));
425 }
426 }
427 const uint32_t exchange_begin_ms = millis();
428 uint8_t unconfirmed_tries = 0; // tries that ended challenged but never closed
429 uint32_t auth_tail_ms = 0; // the final-reply window a challenged try adds; 0 until the target challenges
430
431 for (uint8_t tries = 0; tries < tries_allowed; tries++) {
433 context.try_index = tries + 1;
434 // Every attempt but the last listens the early window; the last (and a re-send, which is always
435 // the last) the whole one.
436 const uint16_t window_ms = plan.window_for_attempt(context.try_index, tries_allowed);
437 context.wait_ms = window_ms;
439
440 if (tries > 0) {
441 // The attempt count is a maximum, not a promise: don't start a try that could not finish
442 // inside the budget, counting the window it listens and, once the target has challenged, the
443 // final-reply window. See EXCHANGE_TOTAL_BUDGET_MS -- this is what keeps a failing command
444 // from blocking the ESPHome loop for the full attempts x (transmission + window) product.
445 if (!this->try_fits_budget_(
446 millis() - exchange_begin_ms, plan.gap_ms,
447 this->tx_time_ms(request, freq, this->try_preamble_(plan, preamble_plan, context.try_index)), window_ms,
448 auth_tail_ms, plan.budget_allowance_ms)) {
449 this->record_debug("retry_budget_exhausted", tries, false);
450 ESP_LOGI(TAG, "Exchange budget exhausted after %u tries for cmd=%s(0x%02X) (%" PRIu32 " of %u ms)", tries,
451 command_name(request.cmd), request.cmd, millis() - exchange_begin_ms,
452 this->tuning_->exchange_total_budget_ms);
453 break;
454 }
455 App.feed_wdt();
456 delay(plan.gap_ms); // 0 for a plan without a gap: a no-op that still yields
457 this->counters_.retransmits++;
458 }
459
460 // Stamped after the retry gap, so a try's wait_ms and age_ms measure from its own transmit and
461 // not from the end of the previous try.
462 context.try_start_ms = millis();
463 context.request_preamble = this->try_preamble_(plan, preamble_plan, context.try_index);
464 context.target_last_seen_ms = preamble_plan.last_seen_ms;
465 this->debug_.last_try_preamble = context.request_preamble;
466 if (!this->transmit_request_(request, freq, context.request_preamble, context))
467 continue;
468
470 this->record_debug(outbound_stage_name(context.state), context.try_index, false);
471 auto first_disp = this->wait_for_first_response_(request, context);
473 continue;
476 this->record_debug("success_direct", context.try_index, false);
477 // The hit counterpart of "Try N ended": an unauthenticated reply (a status poll's answer)
478 // prints no challenge line, so without this a log would only ever show which preambles missed.
479 char age[TRY_AGE_BUFFER_SIZE];
480 format_try_age(context, age);
481 ESP_LOGI(TAG, "Try %d answered: cmd=%s(0x%02X) wait_ms=%" PRIu32 " preamble=%u age_ms=%s", context.try_index,
482 command_name(request.cmd), request.cmd, context.first_response_ms - context.try_start_ms,
483 context.request_preamble, age);
484 response = context.rx;
486 }
487
488 auth_tail_ms = this->final_reply_tail_ms_(request, freq);
489 if (!this->handle_authentication_(request, freq, context))
490 continue;
491
493 this->record_debug(outbound_stage_name(context.state), context.try_index, true);
494 auto final_disp = this->wait_for_final_response_(request, context);
496 // The device challenged us, so it demonstrably received the request. It does not follow that
497 // it acted on it: a challenge says nothing about whether our 0x3D answer arrived and
498 // verified, and a device that never got that answer never executes. Silence here therefore
499 // has two causes that look identical from this side — our answer was lost, or the device
500 // replied and this side lost the reply — on top of the devices that simply never close an
501 // exchange with a synchronous reply (see ExchangeOutcome). log_debug_unconfirmed()'s final_rx_*
502 // fields are what separates them after the fact: a reception during the final wait means a
503 // reply came back and was lost on this side.
504 // How many more tries to spend is tries_after_unconfirmed_(): a CMD_EXECUTE may already be
505 // acting on this copy, so it is repeated only where that is harmless and the silence is an
506 // anomaly, and then only once; a CMD_SUBSCRIBE_EVENT is sent once more, since its answer is the
507 // reading; everything else keeps its full retry budget.
509 this->record_debug("success_auth_unconfirmed", context.try_index, true);
510 unconfirmed_tries++;
511 const uint8_t further_tries = this->tries_after_unconfirmed_(
512 request, unconfirmed_tries, context.try_index, static_cast<uint8_t>(tries_allowed - context.try_index),
513 millis() - exchange_begin_ms,
514 this->tx_time_ms(request, freq, this->try_preamble_(plan, preamble_plan, context.try_index + 1)), plan);
515 if (further_tries == 0)
517 auth_tail_ms = auth_tail_after_unconfirmed(request, auth_tail_ms);
518 tries_allowed = std::min<uint8_t>(tries_allowed, tries + 1 + further_tries);
519 continue;
520 }
521
523 this->record_debug("success_auth", context.try_index, true);
524 response = context.rx;
526 }
527
528 // An exchange that authenticated on some try but never got a reply is not the same as one the
529 // device never answered at all: callers that only need "the request landed" can act on it, and
530 // callers that need the payload still cannot.
531 if (unconfirmed_tries > 0)
533 this->spend_wake_evidence_if_silent_(request, preamble_plan);
535}
536
537uint16_t ExchangeEngine::try_preamble_(const decisions::AttemptPlan &plan, const PreamblePlan &preamble_plan,
538 uint8_t try_index) const {
539 // The plan's short lead is try 1 (a receiver in an unknown state, WakeBelief::UNKNOWN); every later
540 // try follows the belief.
541 if (plan.short_lead && try_index == 1)
542 return this->tuning_->normal_start_preamble;
543 return preamble_plan.for_try(try_index);
544}
545
546void ExchangeEngine::spend_wake_evidence_if_silent_(const IoFrame &request, const PreamblePlan &plan) {
547 // Believed moving, yet silent to the short preamble a moving receiver hears and to the wake-up
548 // preamble alike: it has most likely come to rest. Spend the moving evidence so the next exchange
549 // leads with the wake-up preamble instead of losing its first try (or, for a single-try poll, its
550 // only one) to the short preamble again. A challenge means it heard us, so that keeps the evidence.
551 // So does a silent STOP: nothing was stopped, and the receiver may well still be travelling.
552 // Nor does silence while the remaining time it reported has not run out: a moving receiver that
553 // missed one frame is still moving, and spending the evidence would send the next try with the
554 // wake-up preamble it ignores (ADR 0077).
555 if (plan.use == WakeBeliefUse::APPLIED && plan.belief == decisions::WakeBelief::AWAKE &&
556 !this->debug_.saw_challenge && !decisions::is_stop_request(request) && this->wake_evidence_spent_handler_ &&
557 !this->still_travelling_by_its_own_count_(request)) {
558 ESP_LOGD(TAG, "Low-power target %s silent while believed awake: next exchange leads with the wake-up preamble",
559 node_id_to_string(request.dst).c_str());
560 this->wake_evidence_spent_handler_(request.dst);
561 }
562}
563
564bool ExchangeEngine::still_travelling_by_its_own_count_(const IoFrame &request) const {
565 decisions::TargetEvidence evidence{};
566 return this->target_evidence_provider_ && this->target_evidence_provider_(request.dst, evidence) &&
567 decisions::moving_until_holds(evidence, millis());
568}
569
570// ============================================================================
571// Outbound exchange step helpers
572// ============================================================================
573
574uint16_t ExchangeEngine::request_preamble_for(const IoFrame &request) const {
575 // Gate on the start flag first: several device-role / continuation builders (key transfer,
576 // status-update response) set CTRL1_LOW_POWER on a non-start frame, and those must keep the
577 // short response preamble, not be lengthened.
578 if (!is_start(request))
579 return (*this->radio_ptr_)->response_preamble();
580 return is_low_power_start(request) ? this->tuning_->low_power_wake_preamble : this->tuning_->normal_start_preamble;
581}
582
584 switch (use) {
586 return "override";
588 return "off";
590 return "no_provider";
592 return "applied";
594 default:
595 return "not_low_power";
596 }
597}
598
599ExchangeEngine::PreamblePlan ExchangeEngine::plan_request_preamble_(const IoFrame &request, uint16_t override_preamble,
600 bool state_unknown) const {
601 PreamblePlan plan;
602 plan.fixed = override_preamble != 0 ? override_preamble : this->request_preamble_for(request);
603 // Every reason not to apply the belief keeps the fixed preamble and is recorded, so the
604 // exchange-failure log names it. The override is the caller's explicit choice (pairing), so it
605 // is never second-guessed. Only a low-power *start* frame has a wake-up preamble to reorder.
606 // The try count is deliberately not a reason: a single-try exchange (most scheduler-owned status
607 // polls) follows the belief's first try like any other. Its likeliest moment is the settle poll
608 // seconds after a command or STOP, when the receiver is travelling or has just answered — the
609 // state in which a moving VELUX solar receiver ignores the wake-up preamble — so a fixed wake-up
610 // preamble there loses the poll and a backoff slot instead of guarding against anything.
611 if (override_preamble != 0) {
612 plan.use = WakeBeliefUse::OVERRIDE;
613 } else if (!is_low_power_start(request)) {
615 } else if (!this->tuning_->low_power_wake_belief) {
617 } else if (!this->target_evidence_provider_) {
619 } else {
620 plan.use = WakeBeliefUse::APPLIED;
621 }
622 if (plan.use != WakeBeliefUse::APPLIED)
623 return plan;
624
625 // A destination the hub has no record of has no evidence: treat it as asleep, the safe default.
626 decisions::TargetEvidence evidence{};
627 const bool known = this->target_evidence_provider_(request.dst, evidence);
628 plan.short_preamble = this->tuning_->normal_start_preamble;
629 plan.wake_preamble = this->tuning_->low_power_wake_preamble;
630 plan.last_seen_ms = known ? evidence.last_seen_ms : 0;
631 plan.belief = known ? decisions::wake_belief(evidence, millis(), decisions::is_stop_request(request),
632 request.cmd == CMD_PRIVATE, state_unknown)
633 : decisions::WakeBelief::ASLEEP;
634 return plan;
635}
636
637bool ExchangeEngine::transmit_request_(const IoFrame &request, uint32_t freq, uint16_t preamble,
639 if (!this->transmit_frame(request, freq, preamble)) {
641 this->record_debug("tx_request_failed", ctx.try_index, false);
642 return false;
643 }
644 return true;
645}
646
647decisions::ExchangeFirstResponseDisposition ExchangeEngine::wait_for_first_response_(
648 const IoFrame &request, exchange::OutboundExchangeContext &ctx) {
649 ListenSpec spec;
650 spec.window_ms = ctx.wait_ms;
651 // A unicast reply comes back on the channel the request went out on: 0 of 300 unicast RX
652 // events (CHALLENGE_REQ + PRIVATE_RESP, 50 cycles each on SX1276/SX1262/LR1121) arrived off
653 // the request channel. Holding the channel needs no dwell and no hop-after-timeout guard —
654 // there is nowhere else a reply could come from.
656
658 RadioRxPacket packet{};
659 // The frame log holds its lines from here until the 0x3D has gone out (handle_authentication_()),
660 // so a challenge's RX line and the 0x3D's TX line print after the answer instead of delaying it:
661 // some receivers drop the command when the answer is late (issue #163). A rejected frame's lines
662 // are printed as soon as it is rejected, and any other outcome releases the hold below. No-ops
663 // without IOHOME_FRAME_LOG.
665 auto outcome = this->listen(spec, packet, ctx.rx, [&](const IoFrame *parsed, const RadioRxPacket &pkt) {
666 if (parsed == nullptr) {
667 frame_log_flush();
668 this->record_debug("first_parse_fail", ctx.try_index, false);
669 this->counters_.parse_failures++;
670 log_unparsable_frame("Unparsable first response", ctx.try_index, pkt);
671 return ReplyDisposition::IGNORE;
672 }
673 disp = decisions::classify_exchange_first_response(request, *parsed);
676 this->record_debug("first_wrong_exchange", ctx.try_index, false);
677 log_exchange_frame("Ignored first response", ctx.try_index, *parsed, pkt.len);
679 }
680 ctx.first_response_ms = millis();
681 ctx.first_response_us = micros();
683 });
684
685 // Only a challenge keeps the hold: send_and_receive_() hands it straight to handle_authentication_(),
686 // which releases it after the 0x3D.
689 if (outcome == ListenOutcome::ACCEPTED)
690 return disp;
691
693 this->record_debug("wait_first_timeout", ctx.try_index, false);
694 char age[TRY_AGE_BUFFER_SIZE];
695 format_try_age(ctx, age);
696 ESP_LOGI(TAG, "Try %d ended: no first response for cmd=%s(0x%02X) within %" PRIu32 " ms preamble=%u age_ms=%s",
697 ctx.try_index, command_name(request.cmd), request.cmd, ctx.wait_ms, ctx.request_preamble, age);
699}
700
701bool ExchangeEngine::handle_authentication_(const IoFrame &request, uint32_t freq,
702 exchange::OutboundExchangeContext &ctx) {
703 ctx.saw_challenge = true;
704 ctx.state = exchange::OutboundExchangeState::BUILD_AUTH_RESPONSE;
705 this->record_debug(outbound_stage_name(ctx.state), ctx.try_index, true);
706
707 // No challenge bytes here: the raw 0x3C payload plus the 0x3D response it provokes is a
708 // known-plaintext/known-ciphertext pair under the system key (see redaction.h). The generic
709 // frame-log helpers (log_frame()/log_component_capture()) already mask both commands.
710 ctx.state = exchange::OutboundExchangeState::TX_AUTH_RESPONSE;
711 this->record_debug(outbound_stage_name(ctx.state), ctx.try_index, true);
712 // Nothing is logged before the 0x3D: some receivers drop the command when the answer comes late,
713 // and a log line here sits between challenge and answer (issue #163); the frame log is held for
714 // the same reason (wait_for_first_response_()). The line below is printed after the send; every driver re-arms RX
715 // inside send_packet(), so the final reply is still received while it prints. answer_challenge() covers both the 0x3D
716 // build and its transmit as one step, so a failure here does not distinguish which of the two failed.
717 const bool answered = this->answer_challenge(request, ctx.rx, freq);
718 const uint32_t answer_us = micros() - ctx.first_response_us;
719 // The challenge's and the 0x3D's frame-log lines, held since wait_for_first_response_().
721
722 char age[TRY_AGE_BUFFER_SIZE];
723 format_try_age(ctx, age);
724 // answer_us: from the challenge being accepted until answer_challenge() returned: the 0x3D's
725 // build, its airtime up to TxDone, and the driver's RX re-arm and post-TX settle.
726 ESP_LOGI(TAG,
727 "Auth challenge try=%d wait_ms=%" PRIu32
728 " req_cmd=0x%02X req_len=%u preamble=%u age_ms=%s answer_us=%" PRIu32,
729 ctx.try_index, ctx.first_response_ms - ctx.try_start_ms, request.cmd, request.data_len, ctx.request_preamble,
730 age, answer_us);
731
732 if (!answered) {
733 ctx.state = exchange::OutboundExchangeState::FAILED;
734 this->record_debug("auth_response_failed", ctx.try_index, true);
735 return false;
736 }
737 this->counters_.challenge_round_trips++;
738 return true;
739}
740
741bool ExchangeEngine::answer_challenge(const IoFrame &request, const IoFrame &challenge, uint32_t freq) {
742 IoFrame auth_resp;
743 if (!create_challenge_resp(auth_resp, request.dst, this->node_id_, challenge.data, request,
744 this->key_for(request.dst)))
745 return false;
746 return this->transmit_frame(auth_resp, freq, (*this->radio_ptr_)->response_preamble());
747}
748
749uint8_t ExchangeEngine::tries_after_unconfirmed_(const IoFrame &request, uint8_t unconfirmed_tries, uint8_t try_index,
750 uint8_t tries_left, uint32_t elapsed_ms, uint32_t next_tx_ms,
751 const decisions::AttemptPlan &plan) {
752 // The last try the exchange allows: no further copy can follow, so none is planned, announced or
753 // waited for (an EXECUTE re-send would otherwise sleep its long gap and then not be sent).
754 if (tries_left == 0)
755 return 0;
756 // Only an EXECUTE's answer depends on the target, so only an EXECUTE pays for the lookup. A
757 // destination the hub has no record of has never confirmed anything.
758 decisions::TargetEvidence evidence{};
759 if (request.cmd == CMD_EXECUTE && this->target_evidence_provider_)
760 this->target_evidence_provider_(request.dst, evidence);
761 if (!decisions::retry_after_unconfirmed_accept_is_safe(request, evidence.confirms_execute, unconfirmed_tries))
762 return 0;
763 // A subscription's answer carries the reading, so its one re-send goes out at the ordinary retry
764 // gap (nothing moves), and it is also the last copy: no ordinary failure retry after it.
765 if (request.cmd == CMD_SUBSCRIBE_EVENT)
766 return UNCONFIRMED_SUBSCRIBE_MAX_RESENDS;
767 // Any other request is repeated, but never into more copies than EXCHANGE_RETRY_COUNT in all, a
768 // plan's larger attempt count notwithstanding: the target has heard this request, so more copies
769 // than that would only repeat a write it already holds.
770 if (request.cmd != CMD_EXECUTE)
771 return static_cast<uint8_t>(EXCHANGE_RETRY_COUNT - std::min<uint8_t>(try_index, EXCHANGE_RETRY_COUNT));
772 // A re-send that could not finish its transmission and first-reply window inside the exchange
773 // budget is not worth waiting for. Its final-reply window is left out: the re-send is the last
774 // copy, and what a silent final wait costs is already the unconfirmed outcome this path ends in.
775 if (!this->try_fits_budget_(elapsed_ms, UNCONFIRMED_EXECUTE_RESEND_DELAY_MS, next_tx_ms, plan.window_ms, 0,
777 return 0;
778 // The re-send may well succeed, and then no exchange-failure or unconfirmed line is printed at
779 // all, so this is the one record that the first copy's reply went missing.
780 ESP_LOGI(TAG,
781 "Try %u accepted without a closing reply for cmd=%s(0x%02X): re-sending, the device normally "
782 "confirms (final_rx_ignored=%u final_rx_failed=%u)",
783 try_index, command_name(request.cmd), request.cmd, this->debug_.final_rx_ignored,
784 this->debug_.final_rx_failed);
785 // Stretch the plan's retry gap the loop is about to wait to the longer re-send gap, so a device
786 // still busy acting on the first copy has finished before the re-send reaches it.
787 App.feed_wdt();
788 delay(UNCONFIRMED_EXECUTE_RESEND_DELAY_MS - std::min<uint32_t>(plan.gap_ms, UNCONFIRMED_EXECUTE_RESEND_DELAY_MS));
789 // The re-send is the only further copy of a CMD_EXECUTE: if it goes unanswered altogether, the
790 // ordinary failure retry must not add a third one to a device that already has the command.
791 return UNCONFIRMED_EXECUTE_MAX_RESENDS;
792}
793
794decisions::ExchangeFinalResponseDisposition ExchangeEngine::wait_for_final_response_(
795 const IoFrame &request, exchange::OutboundExchangeContext &ctx) {
796 // Same budget as any other continuation frame — RESPONSE_AUTH_WAIT_MS was always an alias for
797 // RESPONSE_WAIT_MS, so the two share one knob rather than inventing a third.
798 const uint32_t auth_wait_ms = this->tuning_->exchange_response_wait_ms;
799
800 ListenSpec spec;
801 spec.window_ms = auth_wait_ms;
802 // Same reasoning as wait_for_first_response_(): a unicast reply comes back on the request
803 // channel (0 of 300 unicast RX events measured off-channel across all three chips), so holding
804 // the channel for the whole wait is strictly correct and needs no dwell.
805 spec.policy = ListenPolicy::HOLD_REQUEST_CHANNEL;
806
807 ListenStats stats;
808 spec.stats = &stats;
809
810 RadioRxPacket packet{};
811 auto outcome = this->listen(spec, packet, ctx.rx, [&](const IoFrame *parsed, const RadioRxPacket &pkt) {
812 if (parsed == nullptr) {
813 this->record_debug("final_parse_fail", ctx.try_index, true);
814 this->counters_.parse_failures++;
815 log_unparsable_frame("Unparsable final response", ctx.try_index, pkt);
816 return ReplyDisposition::IGNORE;
817 }
818 if (is_valid_final_response(*parsed, request))
819 return ReplyDisposition::ACCEPT;
820 this->record_debug("final_wrong_exchange", ctx.try_index, true);
821 log_exchange_frame("Ignored final response", ctx.try_index, *parsed, pkt.len);
822 return ReplyDisposition::IGNORE;
823 });
824
825 this->note_final_wait_(stats);
826 if (outcome == ListenOutcome::ACCEPTED)
827 return decisions::ExchangeFinalResponseDisposition::ACCEPT;
828
829 ctx.state = exchange::OutboundExchangeState::FAILED;
830 this->record_debug("wait_final_timeout", ctx.try_index, true);
831 ESP_LOGI(TAG, "Try %d ended: no matching final response for cmd=%s(0x%02X) within %" PRIu32 " ms", ctx.try_index,
832 command_name(request.cmd), request.cmd, auth_wait_ms);
833 return decisions::ExchangeFinalResponseDisposition::IGNORE_UNRELATED;
834}
835
836// ============================================================================
837// Broadcast roll-call
838// ============================================================================
839
840uint8_t ExchangeEngine::collect_broadcast_responses(const IoFrame &request, uint32_t freq, uint8_t expected_cmd,
841 uint32_t window_ms, const BroadcastReplyHandler &on_reply,
842 ListenPolicy policy) {
843 this->reset_debug(request.cmd);
844
845 if (!this->transmit_frame(request, freq, this->request_preamble_for(request))) {
846 this->record_debug("broadcast_tx_failed", 1, false);
847 return 0;
848 }
849 const uint32_t tx_done_ms = millis();
850
851 RadioDriver *radio = *this->radio_ptr_;
852 uint8_t count = 0;
853
854 ListenSpec spec;
855 spec.window_ms = window_ms;
856 // policy is the caller's choice: ROTATE_SKIPPING_REQUEST (the default) leaves the request
857 // channel unattended because so few replies land there for that caller's population; a caller
858 // whose replies do land there passes ROTATE_ALL_CHANNELS instead. request_freq is set
859 // unconditionally — harmless for ROTATE_ALL_CHANNELS, which never reads it.
860 spec.policy = policy;
861 spec.request_freq = freq;
862 // dwell_ms is left at 0: no measured reason to dwell differently from discovery, so listen()
863 // asks the driver via hop_dwell_ms() instead of hardcoding a value here.
864 // A reception proves this channel carries responders and replies arrive spread across the whole
865 // window, so staying on it costs nothing; hopping away would only shrink the time spent where
866 // responders already are.
867 spec.hop_after_ignored_frame = false;
868 spec.linger_on_preamble = true;
869 spec.linger_dwell_ms = PREAMBLE_LINGER_DWELL_MS;
870
871 RadioRxPacket packet{};
872 IoFrame frame{};
873 this->listen(spec, packet, frame, [&](const IoFrame *parsed, const RadioRxPacket &pkt) {
874 if (parsed == nullptr || parsed->cmd != expected_cmd || memcmp(parsed->dst, this->node_id_, NODE_ID_SIZE) != 0)
876
877 BroadcastReplyInfo info{};
878 info.rssi_dbm = radio->get_last_capture().rssi_dbm;
879 info.rx_freq_hz = pkt.freq_hz;
880 info.after_tx_ms = millis() - tx_done_ms;
881 on_reply(*parsed, info);
882 if (count < UINT8_MAX)
883 ++count;
884 // Collection always runs to the deadline: a roll-call has no single "the" reply, so nothing
885 // this loop can see is ever a reason to stop early.
887 });
888
889 this->record_debug("broadcast_collect_done", 1, false);
890 return count;
891}
892
893// ============================================================================
894// Shared listen primitive
895//
896// The one listen loop every radio wait in this project runs through: send_and_receive()'s
897// first/final-response waits, collect_broadcast_responses(), and PairingEngine's discovery/
898// key-transfer/key-confirm waits all call listen() below with a ListenSpec that picks one of
899// the three ListenPolicy values (hold the request channel, rotate all three, or rotate skipping
900// the request channel). Per-loop behavior — what counts as a match, what aborts, what gets
901// logged — lives entirely in the caller's ReplyHandler; this function owns only the
902// slice/hop/deadline mechanics common to all of them.
903// ============================================================================
904
905namespace {
906
907/// Count one event in a ListenStats field without wrapping past 255.
908void count_saturating(uint8_t &counter) {
909 if (counter < UINT8_MAX)
910 counter++;
911}
912
913/// Parse one received packet, hand it to `on_frame`, and translate an ACCEPT/ABORT disposition
914/// into `outcome`; a frame the handler ignores is counted in `stats` when the caller asked for
915/// counts. Factored out of listen()'s two reception sites to keep that function's cognitive
916/// complexity under the clang-tidy threshold.
917/// @return true if the listen should stop (ACCEPT or ABORT was returned); false to keep waiting.
918bool dispatch_received_packet(const ReplyHandler &on_frame, const RadioRxPacket &packet, IoFrame &frame,
919 ListenOutcome &outcome, ListenStats *stats) {
920 const bool parsed = parse(packet.data, packet.len, frame);
921 switch (on_frame(parsed ? &frame : nullptr, packet)) {
922 case ReplyDisposition::ACCEPT:
923 outcome = ListenOutcome::ACCEPTED;
924 return true;
925 case ReplyDisposition::ABORT:
926 outcome = ListenOutcome::ABORTED;
927 return true;
928 case ReplyDisposition::IGNORE:
929 break;
930 }
931 if (stats != nullptr)
932 count_saturating(stats->frames_ignored);
933 return false;
934}
935
936/// Record a holding listen's failed reception. Called at the moment it happens, because the re-arm
937/// that follows clears the radio capture that describes it.
938void count_failed_reception(ListenStats *stats, const RadioDriver *radio) {
939 if (stats == nullptr)
940 return;
941 count_saturating(stats->failed_receptions);
942 stats->last_failed_irq = radio->get_last_capture().irq_status;
943}
944
945/// A frame is arriving: hopping now would cut it off mid-reception. Both halves are live on every
946/// current chip.
947bool preamble_or_sync_incoming(RadioDriver *radio, const ListenSpec &spec) {
948 return spec.linger_on_preamble && (radio->is_preamble_detected() || radio->is_sync_detected());
949}
950
951/// Per-channel dwell for a rotating listen: `spec.dwell_ms` if the caller set one, otherwise the
952/// driver's own answer to "how long must this radio sit on a channel after retuning before it can
953/// hear anything at all" (see RadioDriver::hop_dwell_ms()). Unused (returns 0) for a holding
954/// listen — HOLD never slices, so it never asks. Factored out of listen() purely to avoid a
955/// nested conditional operator and keep that function's cognitive complexity under the clang-tidy
956/// threshold — no behavior beyond the two-way fallback.
957uint32_t resolve_dwell_ms(const ListenSpec &spec, bool rotating, RadioDriver *radio, const TuningConfig &tuning) {
958 if (!rotating)
959 return 0;
960 if (spec.dwell_ms != 0)
961 return spec.dwell_ms;
962 return radio->hop_dwell_ms(tuning);
963}
964
965} // namespace
966
967void ExchangeEngine::listen_hop_(uint32_t skip, const ListenSpec &spec) {
968 this->hop_frequency(skip);
969 if (spec.on_hop)
970 spec.on_hop();
971}
972
974 const ReplyHandler &on_frame) {
975 RadioDriver *radio = *this->radio_ptr_;
976 const uint32_t deadline = millis() + spec.window_ms;
977 const bool rotating = spec.policy != ListenPolicy::HOLD_REQUEST_CHANNEL;
978 const uint32_t skip = spec.policy == ListenPolicy::ROTATE_SKIPPING_REQUEST ? spec.request_freq : 0;
979 // 0 means "ask the driver": no rotating call site in this project has a measured reason to
980 // dwell differently from the chip's own retune-cost answer (RadioDriver::hop_dwell_ms()), so
981 // all of them leave spec.dwell_ms at 0 and share one chip-specific knob instead of inventing a
982 // second.
983 const uint32_t dwell = resolve_dwell_ms(spec, rotating, radio, *this->tuning_);
984
985 // A skipping listen's replies almost never return on the requesting channel (see ListenPolicy's
986 // own doc comment for why), so it leaves that channel before its first dwell rather than
987 // spending one there.
989 this->listen_hop_(skip, spec);
990
991 while ((int32_t) (deadline - millis()) > 0) {
992 const uint32_t remaining = deadline - millis();
993 // A holding listen waits the whole remaining window in one call: it has nothing to do between
994 // slices, wait_for_packet() feeds the watchdog while it blocks, and every expired slice costs
995 // an RX re-arm. Only a rotating listen needs a per-channel dwell.
996 const uint32_t slice = rotating ? std::min(remaining, dwell) : remaining;
997
998 if (radio->wait_for_packet(packet, slice)) {
1000 if (dispatch_received_packet(on_frame, packet, frame, outcome, spec.stats))
1001 return outcome;
1002 // The roll-call leaves this false because a reception proves responders are on this
1003 // channel (see the field doc in hub_exchange.h); discovery is the only listen that hops
1004 // after an ignored frame.
1005 if (rotating && spec.hop_after_ignored_frame && (int32_t) (deadline - millis()) > 0 &&
1006 !preamble_or_sync_incoming(radio, spec))
1007 this->listen_hop_(skip, spec);
1008 continue;
1009 }
1010
1011 if ((int32_t) (deadline - millis()) <= 0)
1012 break;
1013 if (!rotating) {
1014 count_failed_reception(spec.stats, radio); // HOLD: an early false is a failed reception, not a timeout.
1015 continue;
1016 }
1017 if (!preamble_or_sync_incoming(radio, spec)) {
1018 this->listen_hop_(skip, spec);
1019 continue;
1020 }
1021 // Preamble/sync seen at the dwell boundary: extend by linger_dwell_ms and give the frame its
1022 // air time instead of hopping — a short extension wait, not another full per-channel dwell.
1023 const uint32_t ext = std::min((uint32_t) (deadline - millis()), spec.linger_dwell_ms);
1025 if (radio->wait_for_packet(packet, ext) && dispatch_received_packet(on_frame, packet, frame, outcome, spec.stats))
1026 return outcome;
1027 }
1029}
1030
1031// ============================================================================
1032// Inbound authentication
1033// ============================================================================
1034
1035bool ExchangeEngine::authenticate_request(const IoFrame &request, uint32_t freq) {
1036 return this->authenticate_request(request, freq, this->key_for(request.src));
1037}
1038
1039const uint8_t *ExchangeEngine::key_for(const uint8_t *node) const {
1040 if (this->device_key_provider_) {
1041 if (const uint8_t *device_key = this->device_key_provider_(node); device_key != nullptr)
1042 return device_key;
1043 }
1044 return this->system_key_;
1045}
1046
1047bool ExchangeEngine::authenticate_request(const IoFrame &request, uint32_t freq, const uint8_t *key) {
1048 return this->challenge_sender(request, freq, key) == ChallengeResult::VERIFIED;
1049}
1050
1052 const uint8_t *key) {
1053 uint8_t challenge[HMAC_SIZE];
1054 crypto::generate_challenge(challenge);
1055 return this->challenge_sender(request, freq, key, challenge, (*this->radio_ptr_)->response_preamble());
1056}
1057
1059 const uint8_t *key, const uint8_t challenge[HMAC_SIZE],
1060 uint16_t preamble) {
1061 RadioDriver *radio = *this->radio_ptr_;
1064 this->record_debug(inbound_stage_name(context.state), 1, true);
1065
1066 if (!create_challenge_req(context.challenge, request.src, this->node_id_, challenge)) {
1068 this->record_debug(inbound_stage_name(context.state), 1, true);
1070 }
1071 if (!this->transmit_frame(context.challenge, freq, preamble)) {
1073 this->record_debug(inbound_stage_name(context.state), 1, true);
1075 }
1076
1078 this->record_debug(inbound_stage_name(context.state), 1, true);
1079
1080 RadioRxPacket packet{};
1081 if (!radio->wait_for_packet(packet, this->tuning_->exchange_response_wait_ms)) {
1083 this->record_debug(inbound_stage_name(context.state), 1, true);
1085 }
1086
1087 // Transcript is the *device's* own frame (cmd + data), not our challenge — the challenged
1088 // party authenticates what it said. Confirmed against real hardware bytes by the device-side
1089 // 0x3D in tests/corpus/captures/pairing/velux_kux100_pairing_full.yaml.
1090 // Anything but a 0x3D counts as no answer; a 0x3D that does not verify is a wrong one.
1091 IoFrame rx;
1092 if (!parse(packet.data, packet.len, rx) || !frame_is_challenge_response(rx) || rx.data_len < HMAC_SIZE) {
1094 this->record_debug(inbound_stage_name(context.state), 1, true);
1096 }
1097 if (!verify_challenge_resp(request, context.challenge.data, rx.data, key)) {
1099 this->record_debug(inbound_stage_name(context.state), 1, true);
1101 }
1102
1104 this->record_debug(inbound_stage_name(context.state), 1, true);
1105 this->counters_.challenge_round_trips++;
1107}
1108
1109} // namespace home_io_control
1110} // namespace esphome
decisions::AttemptPlan plan_attempts(decisions::ReadKind kind, bool low_power, const IoFrame &request, uint32_t freq, uint16_t preamble, uint16_t last_window_ms=0, std::optional< uint32_t > short_lead_tx_ms=std::nullopt) const
The attempt plan for a request sent with an explicit preamble, for a loop that runs its own attempts ...
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.
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.
WakeBeliefUse
Whether an exchange's start preamble followed the low-power wake belief, and if not,...
@ OVERRIDE
The caller forced a preamble (pairing's directed frames).
@ SWITCHED_OFF
The low_power_wake_belief tuning switch is off.
@ NO_PROVIDER
No evidence source installed (set_target_evidence_provider()).
@ NOT_LOW_POWER
Not a low-power start frame: there is no wake-up preamble to reorder.
@ APPLIED
The tries followed the belief in DebugInfo::wake_belief.
uint8_t collect_broadcast_responses(const IoFrame &request, uint32_t freq, uint8_t expected_cmd, uint32_t window_ms, const BroadcastReplyHandler &on_reply, ListenPolicy policy=ListenPolicy::ROTATE_SKIPPING_REQUEST)
Transmit request once and hand every matching broadcast reply to on_reply within window_ms.
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.
void log_debug_unconfirmed(const char *device_id) const
Log the debug snapshot for an exchange that ended accepted-but-unconfirmed, at INFO.
void log_debug(const char *device_id) const
Log the debug snapshot as a WARN-level structured line.
const uint8_t * key_for(const uint8_t *node) const
The key an exchange with node uses: the device's own key when the provider has one,...
ChallengeResult challenge_sender(const IoFrame &request, uint32_t freq, const uint8_t *key)
One challenge of request's sender under key, telling a lost answer from a wrong one.
std::function< void(const IoFrame &frame, const BroadcastReplyInfo &info)> BroadcastReplyHandler
Invoked for each matching broadcast reply, as it arrives.
uint32_t tx_time_ms(const IoFrame &frame, uint32_t freq, uint16_t preamble) const
The radio's estimate of how long transmitting frame on freq with preamble takes, in whole millisecond...
uint16_t request_preamble_for(const IoFrame &request) const
Preamble length for an outbound request frame.
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...
static const char * wake_belief_use_name(WakeBeliefUse use)
Log label for a WakeBeliefUse that is not APPLIED (an applied one logs the belief itself).
bool answer_challenge(const IoFrame &request, const IoFrame &challenge, uint32_t freq)
Send a 0x3D challenge response over request's transcript, proving knowledge of the system key to whoe...
bool transmit_frame(const IoFrame &frame, uint32_t freq, uint16_t preamble)
Transmit a raw IoFrame with the given preamble length.
bool authenticate_request(const IoFrame &request, uint32_t freq)
Authenticate an inbound device command via 0x3C challenge / 0x3D HMAC.
ExchangeOutcome send_and_receive(const IoFrame &request, IoFrame &response, uint32_t freq, uint8_t max_tries=EXCHANGE_RETRY_COUNT, uint16_t request_preamble_override=0)
Execute an outbound authenticated exchange with retry.
void reset_hop_timestamp()
Reset the hop-timer (called after radio init in hub setup()).
decisions::AttemptPlan plan_read(decisions::ReadKind kind, const IoFrame &request, uint32_t freq, uint16_t request_preamble_override=0, bool state_unknown=false) const
The attempt plan for a read of request's target, with the transmit time this engine would plan for th...
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).
virtual TxResult 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.
const RadioCaptureInfo & get_last_capture() const
Get the most recent radio capture info.
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.
void generate_challenge(uint8_t out[HMAC_SIZE])
Generate 6 random bytes for a challenge using the ESP32 hardware RNG.
bool moving_until_holds(const TargetEvidence &evidence, uint32_t now)
True while the remaining time the receiver last reported has not run out: by its own account it is st...
AttemptPlan attempt_plan(ReadKind kind, bool low_power, const TuningConfig &tuning, uint32_t attempt_tx_ms, uint16_t last_window_ms=0, std::optional< uint32_t > short_lead_tx_ms=std::nullopt)
The attempt plan for a request, from what it is for and who it asks.
bool attempt_fits(uint32_t elapsed_ms, uint32_t gap_ms, uint32_t tx_ms, uint32_t window_ms, uint32_t auth_tail_ms, uint32_t budget_ms)
The attempt budget rule: an attempt starts only if the whole attempt, up to the end of the last wait ...
ExchangeFirstResponseDisposition classify_exchange_first_response(const IoFrame &request, const IoFrame &candidate)
Decide how to handle the first response packet in an authenticated exchange.
AttemptPlan attempt_plan_for_tries(uint8_t max_tries, uint16_t window_ms)
The plan for a caller that names only a number of attempts: that many (at least one,...
@ ACCEPT
Frame matches expected response — exchange succeeds.
bool is_stop_request(const IoFrame &request)
True for a CMD_EXECUTE whose main byte is POS_STOP.
ExchangeFirstResponseDisposition
Disposition for the first response in an authenticated exchange.
@ REQUIRE_AUTH
Matching 0x3C challenge — device demands authentication.
@ IGNORE_UNRELATED
Frame doesn't match endpoints or failed parse — keep waiting.
@ COMPLETE_DIRECT
Matching non-challenge frame — operation complete, no auth needed.
ReadKind
What a request is for, which decides how its attempts are spent.
@ SETTLING
A read that decides where a device ended up: as many attempts as fit the budget.
const char * wake_belief_name(WakeBelief belief)
Lowercase name of a belief for log lines.
WakeBelief
Whether a low-power receiver is believed to be travelling right now, judged from what this hub has se...
@ ASLEEP
Not known to be moving — lead with the wake-up preamble.
@ UNKNOWN
A status read to a receiver the hub has no recent news of (no moving evidence, and either no status r...
@ AWAKE
Moving, or about to be stopped — the short preamble is the one it hears.
WakeBelief wake_belief(const TargetEvidence &evidence, uint32_t now, bool is_stop, bool is_status_read=false, bool state_unknown=false)
Judge whether a low-power receiver is travelling.
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 const char * TAG
bool verify_challenge_resp(const IoFrame &origin, const uint8_t challenge[HMAC_SIZE], const uint8_t mac[HMAC_SIZE], const uint8_t *key)
Check a device's 0x3D for origin, in constant time (crypto::verify_hmac()).
bool is_start(const IoFrame &f)
Check START flag.
int render_exchange_debug(char *buf, size_t buf_size, const char *device_id, const ExchangeEngine::DebugInfo &d)
Render the structured field list shared by both exchange-debug log lines.
const char * command_name(uint8_t cmd)
Get a human-readable name for any IO-Homecontrol command ID.
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.
@ 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.
constexpr TxWake tx_wake_for(bool start, uint16_t preamble)
The wake-up level of a frame, from the two facts that decide it: whether it starts an exchange (a con...
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:72
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:20
ListenPolicy
Which channels a listen covers.
@ ROTATE_SKIPPING_REQUEST
The two channels that are not the request channel.
@ 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).
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.
ListenOutcome
How one call to ExchangeEngine::listen() ended.
@ ACCEPTED
The handler returned ReplyDisposition::ACCEPT for some received frame.
@ TIMED_OUT
spec.window_ms elapsed with no ACCEPT/ABORT.
static constexpr uint32_t US_PER_MS
Microseconds per millisecond, for rounding a driver's transmit-time estimate up to whole ms.
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.
uint8_t serialize(const IoFrame &f, uint8_t *buf, uint8_t buf_size)
Serialize a parsed frame into a wire buffer (without CRC).
static const char *const TAG
Command builders for the IO‑Homecontrol protocol.
IO-Homecontrol command IDs, result codes and protocol enumerations.
Cryptographic helpers for the IO‑Homecontrol protocol.
Per-reply facts collect_broadcast_responses() hands its caller alongside the frame.
uint32_t rx_freq_hz
Channel the reply was received on (RadioRxPacket::freq_hz).
int16_t rssi_dbm
RSSI of this reply (from the radio's last capture).
uint32_t after_tx_ms
Milliseconds from the request's transmit completing to this reply's delivery.
Snapshot of the last exchange attempt for diagnostics.
bool saw_challenge
True if a 0x3C was seen during this exchange.
uint16_t final_rx_irq
Radio IRQ status at the most recent of those failed receptions.
uint8_t capture_reported_len
Length reported by radio packet engine.
int16_t capture_rssi_dbm
RSSI of the captured packet (dBm).
bool capture_crc_error
True if CRC error flagged; see RadioCaptureInfo::crc_error.
uint8_t capture_frame_len
Parsed protocol frame length.
decisions::WakeBelief wake_belief
Belief the tries followed; only meaningful when wake_belief_use is WakeBeliefUse::APPLIED.
uint8_t capture_packet_status
Chip packet-status byte.
uint8_t final_rx_ignored
Frames received during those waits and not accepted as the reply.
bool capture_valid
True if radio capture is meaningful.
uint8_t request_cmd
Command ID of the original request.
uint16_t last_try_preamble
Preamble (bytes) of the most recent request transmit attempt, 0 = none.
WakeBeliefUse wake_belief_use
Whether the tries followed wake_belief, and why not if they did not.
uint8_t final_rx_failed
Receptions started during those waits that could not be decoded.
const char * stage
Last recorded stage label.
uint32_t capture_freq_hz
RF frequency of the captured packet.
uint8_t max_tries
Attempt cap this exchange was budgeted for.
uint8_t final_waits
Final-reply waits run (one per try that got as far as our 0x3D).
Parsed IO‑Homecontrol frame (CTRL0/1 + addresses + command + data).
Definition proto_frame.h:93
uint8_t data[FRAME_MAX_DATA_SIZE]
Command parameters (0–23 bytes). Never includes mac.
Definition proto_frame.h:99
uint8_t src[NODE_ID_SIZE]
Source node ID (3 bytes).
Definition proto_frame.h:97
uint8_t dst[NODE_ID_SIZE]
Destination node ID (3 bytes).
Definition proto_frame.h:96
uint8_t data_len
Actual length of data.
uint8_t ctrl1
Control byte 1: low power, beacon, etc.
Definition proto_frame.h:95
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.
ListenStats * stats
Where to count what this listen heard without accepting it; null (the default) counts nothing.
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.
What a listen heard without accepting it — filled by ExchangeEngine::listen() when a caller passes on...
uint16_t last_failed_irq
Radio IRQ status captured at the most recent failed reception.
uint8_t frames_ignored
Frames the radio delivered that the handler did not accept (wrong exchange, unrelated traffic,...
uint8_t failed_receptions
Receptions the radio started but could not deliver: a holding listen's early return before its deadli...
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.
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.
uint32_t freq_hz
Frequency the packet was received on (Hz).
uint8_t data[RADIO_PACKET_BUFFER_SIZE]
Raw packet data buffer.
Configuration for transmitting a packet: carrier frequency, preamble length, wake-up level and clear-...
uint16_t preamble_len
Preamble length in symbol periods (bytes).
uint32_t freq_hz
Carrier frequency in Hz.
std::optional< int16_t > cca_threshold_dbm
If set, send only if the energy on freq_hz, measured right before the first transmitted symbol,...
TxWake wake
Wake-up level the receiver needs (tx_wake_for()).
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.
uint16_t exchange_response_wait_ms
Reply window for continuation frames and the post-auth final response.
uint16_t exchange_total_budget_ms
Ceiling on one whole exchange, retries included.
bool low_power_wake_belief
Order a low_power device's start-frame tries by its wake belief (short preamble first when it is beli...
What send_packet() did with a frame.
@ CHANNEL_BUSY
The clear-channel check found the channel busy; nothing was sent.
int16_t cca_level_dbm
The level the clear-channel check measured; set with CHANNEL_BUSY.
How one exchange spends its attempts. Handed to ExchangeEngine::send_and_receive().
uint16_t window_ms
How long the last attempt listens for the first reply.
bool short_lead
True when the first attempt is a short lead to a low-power receiver in an unknown state (WakeBelief::...
uint16_t gap_ms
Wait before each attempt after the first; 0 for none.
uint32_t budget_allowance_ms
Time the exchange may run past exchange_total_budget_ms.
bool state_unknown
True when the caller asked for the read to treat the receiver's state as unknown (AttemptRequest::wit...
uint8_t max_attempts
Most transmissions of the request, [1, EXCHANGE_MAX_ATTEMPTS].
uint16_t window_for_attempt(uint8_t attempt, uint8_t attempts) const
What the hub knows about the device an exchange is addressed to, as the exchange engine sees it.
bool confirms_execute
The device has closed a CMD_EXECUTE exchange with a reply before.
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.
uint32_t first_response_ms
Timestamp when the first valid response arrived (for RTT/timing).
uint16_t request_preamble
Start preamble (bytes) this try's request went out with.
uint8_t try_index
Current retry attempt (1‑based within EXCHANGE_RETRY_COUNT).
uint32_t try_start_ms
millis() when this try's request transmit began, after any retry gap.
IoFrame rx
Most recent candidate frame received during the exchange.
uint32_t wait_ms
Current timeout window for the active wait (ms).
OutboundExchangeState state
Current state machine state.
uint32_t target_last_seen_ms
When the target was last heard (millis), 0 = unknown; logged as the try's age_ms= so field logs can r...