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// ============================================================================
31// Construction
32// ============================================================================
33
34ExchangeEngine::ExchangeEngine(RadioDriver **radio_ptr, const uint8_t *node_id, const uint8_t *system_key,
35 const TuningConfig *tuning)
36 : radio_ptr_(radio_ptr), node_id_(node_id), system_key_(system_key), tuning_(tuning) {}
37
38// ============================================================================
39// Debug snapshot helpers
40// ============================================================================
41
42void ExchangeEngine::reset_debug(uint8_t request_cmd) {
43 this->debug_ = DebugInfo{};
44 this->debug_.request_cmd = request_cmd;
45}
46
47void ExchangeEngine::note_final_wait_(const ListenStats &stats) {
48 DebugInfo &d = this->debug_;
49 const auto add = [](uint8_t &total, uint8_t n) {
50 total = static_cast<uint8_t>(std::min<unsigned>(total + n, UINT8_MAX));
51 };
52 add(d.final_waits, 1);
53 add(d.final_rx_ignored, stats.frames_ignored);
54 add(d.final_rx_failed, stats.failed_receptions);
55 if (stats.failed_receptions != 0)
56 d.final_rx_irq = stats.last_failed_irq;
57}
58
59void ExchangeEngine::record_debug(const char *stage, uint8_t tries, bool saw_challenge) {
60 this->debug_.stage = stage;
61 this->debug_.tries = tries;
62 this->debug_.saw_challenge = this->debug_.saw_challenge || saw_challenge;
63
64 const RadioCaptureInfo &capture = (*this->radio_ptr_)->get_last_capture();
65 // send_and_receive() blanks the radio capture at the start of each exchange (the path this
66 // snapshot mainly serves), so a valid capture here belongs to the current exchange rather than a
67 // previous one. wait_for_packet() re-clears the capture before each listen, so the *last*
68 // record_debug() call is always the final timed-out wait, which saw nothing. Keep the first
69 // informative capture instead: it distinguishes "the radio never detected a frame" from "it
70 // received one and this layer discarded it" — the question a failure report has to answer.
71 // Known gap: record_debug() calls outside that blanking — the bare ones in pairing_engine.cpp,
72 // authenticate_request(), and collect_broadcast_responses()'s "broadcast_tx_failed" branch — can
73 // still carry a capture from before their flow began. None of those snapshots reaches log_debug().
74 if (!capture.valid && this->debug_.capture_valid)
75 return;
76 this->debug_.capture_valid = capture.valid;
77 this->debug_.capture_rx_done = capture.rx_done;
78 this->debug_.capture_crc_error = capture.crc_error;
79 this->debug_.capture_freq_hz = capture.freq_hz;
80 this->debug_.capture_irq_status = capture.irq_status;
81 this->debug_.capture_packet_status = capture.packet_status;
82 this->debug_.capture_reported_len = capture.reported_len;
83 this->debug_.capture_frame_len = capture.frame_len;
84 this->debug_.capture_rssi_dbm = capture.rssi_dbm;
85}
86
87int render_exchange_debug(char *buf, size_t buf_size, const char *device_id, const ExchangeEngine::DebugInfo &d) {
88 return snprintf(buf, buf_size,
89 "device=%s cmd=%s(0x%02X) stage=%s tries=%u max_tries=%u saw_challenge=%u cap_valid=%u "
90 "cap_rx_done=%u cap_crc_err=%u cap_freq=%" PRIu32
91 " cap_irq=0x%04X cap_pkt=0x%02X cap_reported_len=%u cap_frame_len=%u cap_rssi=%d belief=%s "
92 "last_preamble=%u final_waits=%u final_rx_ignored=%u final_rx_failed=%u final_rx_irq=0x%04X",
93 device_id, command_name(d.request_cmd), d.request_cmd, d.stage, d.tries, d.max_tries,
94 // Rendered as 0/1: these are flags in a field list, not prose, and a caller greps them.
95 static_cast<unsigned>(d.saw_challenge), static_cast<unsigned>(d.capture_valid),
96 static_cast<unsigned>(d.capture_rx_done), static_cast<unsigned>(d.capture_crc_error),
103}
104
105void ExchangeEngine::log_debug(const char *device_id) const {
106 char fields[EXCHANGE_DEBUG_LINE_SIZE];
107 render_exchange_debug(fields, sizeof(fields), device_id, this->debug_);
108 ESP_LOGW(TAG, "Exchange failed: %s", fields);
109}
110
111void ExchangeEngine::log_debug_unconfirmed(const char *device_id) const {
112 char fields[EXCHANGE_DEBUG_LINE_SIZE];
113 render_exchange_debug(fields, sizeof(fields), device_id, this->debug_);
114 // "accepted" describes what the device did with the request, not what it did with our challenge
115 // answer — see the WAIT_FINAL_RESPONSE branch in send_and_receive() for why silence here has two
116 // possible causes. The final_rx_* fields are what tells them apart: a reception during the final
117 // wait means something came back and was lost here. The cap_* fields cannot, because they keep
118 // the first informative reception, which is the device's challenge.
119 ESP_LOGI(TAG, "Exchange accepted without a closing reply: %s", fields);
120}
121
122// ============================================================================
123// Frequency hopping
124// ============================================================================
125
126void ExchangeEngine::reset_hop_timestamp() { this->last_hop_us_ = micros(); }
127
128void ExchangeEngine::hop_frequency(uint32_t skip_freq) {
129 RadioDriver *radio = *this->radio_ptr_;
130 uint32_t next = radio->get_current_freq();
131 // At most two iterations: the rotation cycles through all three channels and only one of them
132 // can be skipped, so a channel that is not skip_freq is always one or two steps away.
133 do {
134 switch (next) {
135 case FREQ_CH1:
136 next = FREQ_CH2;
137 break;
138 case FREQ_CH3:
139 next = FREQ_CH1;
140 break;
141 default:
142 next = FREQ_CH3;
143 break;
144 }
145 } while (next == skip_freq);
146 radio->change_frequency(next);
147 this->last_hop_us_ = micros();
148}
149
151 if ((micros() - this->last_hop_us_) <= HOP_TIME_US)
152 return;
153 // A frame arriving on this channel outranks the dwell timer: change_frequency() retunes under a
154 // running demodulator and, on the software-PHY chips, also clears the IRQ word and the DIO
155 // latch, so hopping here destroys the frame rather than deferring it (issue #81).
156 // last_hop_us_ is deliberately left alone: the dwell has already been served, so the hop should
157 // happen on the very next pass once the reception clears, not a further HOP_TIME_US later.
158 if ((*this->radio_ptr_)->reception_in_progress())
159 return;
160 this->hop_frequency();
161}
162
163// ============================================================================
164// Transmit with LBT
165// ============================================================================
166
167bool ExchangeEngine::transmit_frame(const IoFrame &frame, uint32_t freq, uint16_t preamble) {
168 RadioDriver *radio = *this->radio_ptr_;
169 // FRAME_MAX_WIRE_SIZE, not FRAME_MAX_SIZE: a frame with an out-of-length MAC trailer
170 // (IoFrame::has_mac, e.g. CMD_ONEWAY_ADD_CONTROLLER) serializes to more than
171 // FRAME_MAX_SIZE/FRAME_MAX_DECLARED_SIZE bytes, and serialize() rejects a buffer too small to
172 // hold its actual output rather than truncating into it.
173 uint8_t buf[FRAME_MAX_WIRE_SIZE];
174 uint8_t const len = serialize(frame, buf, sizeof(buf));
175 if (len == 0) {
176 ESP_LOGW(TAG, "tx: serialize_failed cmd=0x%02X", frame.cmd);
177 return false;
178 }
179 for (uint8_t lbt = 0; lbt < this->tuning_->lbt_max_retries; lbt++) {
180 int16_t const rssi = radio->read_rssi();
181 if (rssi < this->tuning_->lbt_rssi_threshold_dbm)
182 break;
183 ESP_LOGD(TAG, "LBT: channel busy (RSSI %d dBm), retry %u/%u", rssi, lbt + 1, this->tuning_->lbt_max_retries);
184 this->counters_.lbt_retries++;
185 if (this->transmit_observer_ != nullptr)
186 this->transmit_observer_->on_lbt_defer(rssi);
187 delay(LBT_RETRY_DELAY_MS);
188 }
189 RadioTxConfig tx_config{};
190 tx_config.freq_hz = freq;
191 tx_config.preamble_len = preamble;
192 if (!radio->send_packet(buf, len, tx_config)) {
193 ESP_LOGW(TAG, "tx: send_failed cmd=0x%02X", frame.cmd);
194 return false;
195 }
196 if (this->transmit_observer_ != nullptr)
197 this->transmit_observer_->on_transmit(frame, tx_config, len);
198 return true;
199}
200
201// ============================================================================
202// Outbound exchange — main entry point
203// ============================================================================
204
205namespace {
206
207/// @brief Map OutboundExchangeState to a short string for debug logging.
208///
209/// OutboundExchangeState is written at each step for debug capture but is
210/// never read back for control-flow decisions — all branching is driven by
211/// return values and disposition enums.
212const char *outbound_stage_name(exchange::OutboundExchangeState state) {
213 switch (state) {
215 return "idle";
217 return "tx_request";
219 return "wait_first_response";
221 return "build_auth_response";
223 return "tx_auth_response";
225 return "wait_final_response";
227 return "success";
229 default:
230 return "failed";
231 }
232}
233
234/// @brief Map InboundAuthState to a short string for debug logging.
235const char *inbound_stage_name(exchange::InboundAuthState state) {
236 switch (state) {
238 return "idle";
240 return "tx_challenge";
242 return "wait_challenge_response";
244 return "verified";
246 default:
247 return "failed";
248 }
249}
250
251/// Check if frame is a 0x3D challenge response.
252bool frame_is_challenge_response(const IoFrame &frame) { return frame.cmd == CMD_CHALLENGE_RESP; }
253
254/// Log a frame that arrived but could not be parsed. Printing it distinguishes "the radio heard
255/// nothing" from "we heard something and rejected it" in a failure report — the two need opposite
256/// fixes: a device that never transmitted needs a longer wait or a link check, while one that
257/// transmits noise this layer can't decode needs the RX bandwidth or framing looked at. Redacted
258/// through the same helper as every other frame log, so an unparsable frame can't leak key material
259/// by being unrecognisable (see ADR 0011).
260void log_unparsable_frame(const char *stage, int tries, const RadioRxPacket &packet) {
261 // The *fact* that a frame failed to parse stays unconditional — it is a real fault worth
262 // surfacing to someone who never enables a debug flag. The raw bytes only help someone already
263 // debugging the PHY, and a noisy channel can produce several of these a minute, so they sit
264 // behind the frame-log flag with the rest of that detail.
265 ESP_LOGW(TAG, "%s try=%d: %u bytes did not parse as a frame on %" PRIu32 " Hz", stage, tries, packet.len,
266 packet.freq_hz);
267#ifdef IOHOME_FRAME_LOG
269 render_frame_hex_redacted(packet.data, packet.len, hex, sizeof(hex));
270 ESP_LOGD(TAG, " raw: %s", hex);
271#endif
272}
273
274/// Log an exchanged frame with context (stage, try index, length).
275void log_exchange_frame(const char *stage, int tries, const IoFrame &frame, uint8_t len) {
276 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],
277 frame.src[1], frame.src[2], frame.dst[0], frame.dst[1], frame.dst[2], len);
278}
279
280/// Buffer for format_try_age(): "n/a", or a uint32_t's ten decimal digits, plus the terminator.
281constexpr size_t TRY_AGE_BUFFER_SIZE = 12;
282
283/// Render a try's `age_ms=` field: how long before this try the target was last heard, or "n/a"
284/// when there is no stamp (never heard, or the target evidence was not looked up). Every try line
285/// carries it next to the try's preamble, so field logs show which preamble reaches a low-power
286/// receiver how long after it last spoke — the data the wake-belief windows are sized from. The
287/// stamp is read once, before the exchange, so a challenge heard on an earlier try of the same
288/// exchange does not reset it.
289void format_try_age(const exchange::OutboundExchangeContext &ctx, char (&buf)[TRY_AGE_BUFFER_SIZE]) {
290 if (ctx.target_last_seen_ms == 0) {
291 snprintf(buf, sizeof(buf), "n/a");
292 return;
293 }
294 snprintf(buf, sizeof(buf), "%" PRIu32, ctx.try_start_ms - ctx.target_last_seen_ms);
295}
296
297/// True for a start frame addressed to a duty-cycled receiver — the only frame that has a wake-up
298/// preamble to choose. One definition, shared by the fixed rule and the per-try wake belief.
299bool is_low_power_start(const IoFrame &request) { return is_start(request) && (request.ctrl1 & CTRL1_LOW_POWER) != 0; }
300
301/// Determine if a candidate frame is a valid final response for the request.
302bool is_valid_final_response(const IoFrame &candidate, const IoFrame &request) {
303 return decisions::classify_exchange_final_response(request, candidate) ==
305}
306
307} // namespace
308
309ExchangeOutcome ExchangeEngine::send_and_receive(const IoFrame &request, IoFrame &response, uint32_t freq,
310 uint8_t max_tries, uint16_t request_preamble_override) {
311 this->reset_debug(request.cmd);
312 // Blank the radio's diagnostic capture at the start of the exchange. The radio only clears it
313 // when it actually begins a listen, so an exchange that transmits and then hears nothing at all
314 // would otherwise inherit the *previous* exchange's capture and claim "we heard a frame" when
315 // nothing was on air. Done here rather than in reset_debug() because collect_broadcast_responses()
316 // shares that helper but does not need this: its only capture reader runs per delivered reply
317 // (with the listen repopulating the capture first), and its DebugInfo is never passed to log_debug().
318 (*this->radio_ptr_)->clear_last_capture();
319 // Clamp once: never below 1 (a caller passing 0 must not silently transmit nothing) and never
320 // above EXCHANGE_RETRY_COUNT (the budget check downstream assumes that ceiling).
321 uint8_t tries_allowed = std::max<uint8_t>(1, std::min<uint8_t>(max_tries, EXCHANGE_RETRY_COUNT));
322 this->debug_.max_tries = tries_allowed;
323 const PreamblePlan preamble_plan = this->plan_request_preamble_(request, request_preamble_override);
324 this->debug_.wake_belief_use = preamble_plan.use;
325 if (preamble_plan.use == WakeBeliefUse::APPLIED) {
326 this->debug_.wake_belief = preamble_plan.belief;
327 if (preamble_plan.belief != decisions::WakeBelief::ASLEEP) {
328 ESP_LOGD(TAG, "Low-power target %s believed %s: short preamble first", node_id_to_string(request.dst).c_str(),
329 decisions::wake_belief_name(preamble_plan.belief));
330 }
331 }
332 const uint32_t exchange_begin_ms = millis();
333 uint8_t unconfirmed_tries = 0; // tries that ended challenged but never closed
334
335 for (uint8_t tries = 0; tries < tries_allowed; tries++) {
337 context.try_index = tries + 1;
338 context.wait_ms =
339 is_start(request) ? this->tuning_->exchange_start_response_wait_ms : this->tuning_->exchange_response_wait_ms;
341
342 if (tries > 0) {
343 // The retry count is a maximum, not a promise: don't start a try the exchange has no budget
344 // left for. See EXCHANGE_TOTAL_BUDGET_MS -- this is what keeps a failing command from
345 // blocking the ESPHome loop for the full retries x response-window product.
346 if (millis() - exchange_begin_ms >= this->tuning_->exchange_total_budget_ms) {
347 this->record_debug("retry_budget_exhausted", tries, false);
348 ESP_LOGI(TAG, "Exchange budget exhausted after %u tries for cmd=%s(0x%02X) (%" PRIu32 " of %u ms)", tries,
349 command_name(request.cmd), request.cmd, millis() - exchange_begin_ms,
350 this->tuning_->exchange_total_budget_ms);
351 break;
352 }
353 App.feed_wdt();
354 delay(EXCHANGE_RETRY_DELAY_MS);
355 this->counters_.retransmits++;
356 }
357
358 // Stamped after the retry gap, so a try's wait_ms and age_ms measure from its own transmit and
359 // not from the end of the previous try.
360 context.try_start_ms = millis();
361 context.request_preamble = preamble_plan.for_try(context.try_index);
362 context.target_last_seen_ms = preamble_plan.last_seen_ms;
363 this->debug_.last_try_preamble = context.request_preamble;
364 if (!this->transmit_request_(request, freq, context.request_preamble, context))
365 continue;
366
368 this->record_debug(outbound_stage_name(context.state), context.try_index, false);
369 auto first_disp = this->wait_for_first_response_(request, context);
371 continue;
374 this->record_debug("success_direct", context.try_index, false);
375 // The hit counterpart of "Try N ended": an unauthenticated reply (a status poll's answer)
376 // prints no challenge line, so without this a log would only ever show which preambles missed.
377 char age[TRY_AGE_BUFFER_SIZE];
378 format_try_age(context, age);
379 ESP_LOGI(TAG, "Try %d answered: cmd=%s(0x%02X) wait_ms=%" PRIu32 " preamble=%u age_ms=%s", context.try_index,
380 command_name(request.cmd), request.cmd, context.first_response_ms - context.try_start_ms,
381 context.request_preamble, age);
382 response = context.rx;
384 }
385
386 if (!this->handle_authentication_(request, freq, context))
387 continue;
388
390 this->record_debug(outbound_stage_name(context.state), context.try_index, true);
391 auto final_disp = this->wait_for_final_response_(request, context);
393 // The device challenged us, so it demonstrably received the request. It does not follow that
394 // it acted on it: a challenge says nothing about whether our 0x3D answer arrived and
395 // verified, and a device that never got that answer never executes. Silence here therefore
396 // has two causes that look identical from this side — our answer was lost, or the device
397 // replied and this side lost the reply — on top of the devices that simply never close an
398 // exchange with a synchronous reply (see ExchangeOutcome). log_debug_unconfirmed()'s final_rx_*
399 // fields are what separates them after the fact: a reception during the final wait means a
400 // reply came back and was lost on this side.
401 // How many more tries to spend is tries_after_unconfirmed_(): a CMD_EXECUTE may already be
402 // acting on this copy, so it is repeated only where that is harmless and the silence is an
403 // anomaly, and then only once; everything else keeps its full retry budget.
405 this->record_debug("success_auth_unconfirmed", context.try_index, true);
406 unconfirmed_tries++;
407 const uint8_t further_tries =
408 this->tries_after_unconfirmed_(request, unconfirmed_tries, context.try_index, millis() - exchange_begin_ms);
409 if (further_tries == 0)
411 tries_allowed = std::min<uint8_t>(tries_allowed, tries + 1 + further_tries);
412 continue;
413 }
414
416 this->record_debug("success_auth", context.try_index, true);
417 response = context.rx;
419 }
420
421 // An exchange that authenticated on some try but never got a reply is not the same as one the
422 // device never answered at all: callers that only need "the request landed" can act on it, and
423 // callers that need the payload still cannot.
424 return unconfirmed_tries > 0 ? ExchangeOutcome::SUCCESS_UNCONFIRMED : ExchangeOutcome::FAILED;
425}
426
427// ============================================================================
428// Outbound exchange step helpers
429// ============================================================================
430
431uint16_t ExchangeEngine::request_preamble_for(const IoFrame &request) const {
432 // Gate on the start flag first: several device-role / continuation builders (key transfer,
433 // status-update response) set CTRL1_LOW_POWER on a non-start frame, and those must keep the
434 // short response preamble, not be lengthened.
435 if (!is_start(request))
436 return (*this->radio_ptr_)->response_preamble();
437 return is_low_power_start(request) ? LONG_PREAMBLE : this->tuning_->normal_start_preamble;
438}
439
441 switch (use) {
443 return "override";
445 return "off";
447 return "no_provider";
449 return "applied";
451 default:
452 return "not_low_power";
453 }
454}
455
456ExchangeEngine::PreamblePlan ExchangeEngine::plan_request_preamble_(const IoFrame &request,
457 uint16_t override_preamble) const {
458 PreamblePlan plan;
459 plan.fixed = override_preamble != 0 ? override_preamble : this->request_preamble_for(request);
460 // Every reason not to apply the belief keeps the fixed preamble and is recorded, so the
461 // exchange-failure log names it. The override is the caller's explicit choice (pairing), so it
462 // is never second-guessed. Only a low-power *start* frame has a wake-up preamble to reorder.
463 // The try count is deliberately not a reason: a single-try exchange (most scheduler-owned status
464 // polls) follows the belief's first try like any other. Its likeliest moment is the settle poll
465 // seconds after a command or STOP, when the receiver is travelling or has just answered — the
466 // state in which a moving VELUX solar receiver ignores the wake-up preamble — so a fixed wake-up
467 // preamble there loses the poll and a backoff slot instead of guarding against anything.
468 if (override_preamble != 0) {
469 plan.use = WakeBeliefUse::OVERRIDE;
470 } else if (!is_low_power_start(request)) {
472 } else if (!this->tuning_->low_power_wake_belief) {
474 } else if (!this->target_evidence_provider_) {
476 } else {
477 plan.use = WakeBeliefUse::APPLIED;
478 }
479 if (plan.use != WakeBeliefUse::APPLIED)
480 return plan;
481
482 // A destination the hub has no record of has no evidence: treat it as asleep, the safe default.
483 decisions::TargetEvidence evidence{};
484 const bool known = this->target_evidence_provider_(request.dst, evidence);
485 plan.short_preamble = this->tuning_->normal_start_preamble;
486 plan.last_seen_ms = known ? evidence.last_seen_ms : 0;
487 plan.belief = known ? decisions::wake_belief(evidence, millis(), decisions::is_stop_request(request))
488 : decisions::WakeBelief::ASLEEP;
489 return plan;
490}
491
492bool ExchangeEngine::transmit_request_(const IoFrame &request, uint32_t freq, uint16_t preamble,
494 if (!this->transmit_frame(request, freq, preamble)) {
496 this->record_debug("tx_request_failed", ctx.try_index, false);
497 return false;
498 }
499 return true;
500}
501
502decisions::ExchangeFirstResponseDisposition ExchangeEngine::wait_for_first_response_(
503 const IoFrame &request, exchange::OutboundExchangeContext &ctx) {
504 ListenSpec spec;
505 spec.window_ms = ctx.wait_ms;
506 // A unicast reply comes back on the channel the request went out on: 0 of 300 unicast RX
507 // events (CHALLENGE_REQ + PRIVATE_RESP, 50 cycles each on SX1276/SX1262/LR1121) arrived off
508 // the request channel. Holding the channel needs no dwell and no hop-after-timeout guard —
509 // there is nowhere else a reply could come from.
511
513 RadioRxPacket packet{};
514 auto outcome = this->listen(spec, packet, ctx.rx, [&](const IoFrame *parsed, const RadioRxPacket &pkt) {
515 if (parsed == nullptr) {
516 this->record_debug("first_parse_fail", ctx.try_index, false);
517 this->counters_.parse_failures++;
518 log_unparsable_frame("Unparsable first response", ctx.try_index, pkt);
519 return ReplyDisposition::IGNORE;
520 }
521 disp = decisions::classify_exchange_first_response(request, *parsed);
523 this->record_debug("first_wrong_exchange", ctx.try_index, false);
524 log_exchange_frame("Ignored first response", ctx.try_index, *parsed, pkt.len);
526 }
527 ctx.first_response_ms = millis();
529 });
530
531 if (outcome == ListenOutcome::ACCEPTED)
532 return disp;
533
535 this->record_debug("wait_first_timeout", ctx.try_index, false);
536 char age[TRY_AGE_BUFFER_SIZE];
537 format_try_age(ctx, age);
538 ESP_LOGI(TAG, "Try %d ended: no first response for cmd=%s(0x%02X) within %" PRIu32 " ms preamble=%u age_ms=%s",
539 ctx.try_index, command_name(request.cmd), request.cmd, ctx.wait_ms, ctx.request_preamble, age);
541}
542
543bool ExchangeEngine::handle_authentication_(const IoFrame &request, uint32_t freq,
544 exchange::OutboundExchangeContext &ctx) {
545 ctx.saw_challenge = true;
546 ctx.state = exchange::OutboundExchangeState::BUILD_AUTH_RESPONSE;
547 this->record_debug(outbound_stage_name(ctx.state), ctx.try_index, true);
548
549 // No challenge bytes here: the raw 0x3C payload plus the 0x3D response it provokes is a
550 // known-plaintext/known-ciphertext pair under the system key (see redaction.h). The generic
551 // frame-log helpers (log_frame()/log_component_capture()) already mask both commands.
552 char age[TRY_AGE_BUFFER_SIZE];
553 format_try_age(ctx, age);
554 ESP_LOGI(TAG, "Auth challenge try=%d wait_ms=%" PRIu32 " req_cmd=0x%02X req_len=%u preamble=%u age_ms=%s",
555 ctx.try_index, ctx.first_response_ms - ctx.try_start_ms, request.cmd, request.data_len, ctx.request_preamble,
556 age);
557
558 ctx.state = exchange::OutboundExchangeState::TX_AUTH_RESPONSE;
559 this->record_debug(outbound_stage_name(ctx.state), ctx.try_index, true);
560 // answer_challenge() covers both the 0x3D build and its transmit as one step, so a failure here
561 // does not distinguish which of the two failed.
562 if (!this->answer_challenge(request, ctx.rx, freq)) {
563 ctx.state = exchange::OutboundExchangeState::FAILED;
564 this->record_debug("auth_response_failed", ctx.try_index, true);
565 return false;
566 }
567 this->counters_.challenge_round_trips++;
568 return true;
569}
570
571bool ExchangeEngine::answer_challenge(const IoFrame &request, const IoFrame &challenge, uint32_t freq) {
572 IoFrame auth_resp;
573 if (!create_challenge_resp(auth_resp, request.dst, this->node_id_, challenge.data, request, this->system_key_))
574 return false;
575 return this->transmit_frame(auth_resp, freq, (*this->radio_ptr_)->response_preamble());
576}
577
578uint8_t ExchangeEngine::tries_after_unconfirmed_(const IoFrame &request, uint8_t unconfirmed_tries, uint8_t try_index,
579 uint32_t elapsed_ms) {
580 // Only an EXECUTE's answer depends on the target, so only an EXECUTE pays for the lookup. A
581 // destination the hub has no record of has never confirmed anything.
582 decisions::TargetEvidence evidence{};
583 if (request.cmd == CMD_EXECUTE && this->target_evidence_provider_)
584 this->target_evidence_provider_(request.dst, evidence);
585 if (!decisions::retry_after_unconfirmed_accept_is_safe(request, evidence.confirms_execute, unconfirmed_tries))
586 return 0;
587 if (request.cmd != CMD_EXECUTE)
588 return EXCHANGE_RETRY_COUNT; // no cap of its own: the exchange's retry count and budget bound it
589 // A re-send that could not start inside the exchange budget is not worth waiting for.
590 if (elapsed_ms + UNCONFIRMED_EXECUTE_RESEND_DELAY_MS >= this->tuning_->exchange_total_budget_ms)
591 return 0;
592 // The re-send may well succeed, and then no exchange-failure or unconfirmed line is printed at
593 // all, so this is the one record that the first copy's reply went missing.
594 ESP_LOGI(TAG,
595 "Try %u accepted without a closing reply for cmd=%s(0x%02X): re-sending, the device normally "
596 "confirms (final_rx_ignored=%u final_rx_failed=%u)",
597 try_index, command_name(request.cmd), request.cmd, this->debug_.final_rx_ignored,
598 this->debug_.final_rx_failed);
599 // Stretch the ordinary retry gap the loop is about to wait to the longer re-send gap, so a device
600 // still busy acting on the first copy has finished before the re-send reaches it.
601 App.feed_wdt();
602 delay(UNCONFIRMED_EXECUTE_RESEND_DELAY_MS - EXCHANGE_RETRY_DELAY_MS);
603 // The re-send is the only further copy of a CMD_EXECUTE: if it goes unanswered altogether, the
604 // ordinary failure retry must not add a third one to a device that already has the command.
605 return UNCONFIRMED_EXECUTE_MAX_RESENDS;
606}
607
608decisions::ExchangeFinalResponseDisposition ExchangeEngine::wait_for_final_response_(
609 const IoFrame &request, exchange::OutboundExchangeContext &ctx) {
610 // Same budget as any other continuation frame — RESPONSE_AUTH_WAIT_MS was always an alias for
611 // RESPONSE_WAIT_MS, so the two share one knob rather than inventing a third.
612 const uint32_t auth_wait_ms = this->tuning_->exchange_response_wait_ms;
613
614 ListenSpec spec;
615 spec.window_ms = auth_wait_ms;
616 // Same reasoning as wait_for_first_response_(): a unicast reply comes back on the request
617 // channel (0 of 300 unicast RX events measured off-channel across all three chips), so holding
618 // the channel for the whole wait is strictly correct and needs no dwell.
619 spec.policy = ListenPolicy::HOLD_REQUEST_CHANNEL;
620
621 ListenStats stats;
622 spec.stats = &stats;
623
624 RadioRxPacket packet{};
625 auto outcome = this->listen(spec, packet, ctx.rx, [&](const IoFrame *parsed, const RadioRxPacket &pkt) {
626 if (parsed == nullptr) {
627 this->record_debug("final_parse_fail", ctx.try_index, true);
628 this->counters_.parse_failures++;
629 log_unparsable_frame("Unparsable final response", ctx.try_index, pkt);
630 return ReplyDisposition::IGNORE;
631 }
632 if (is_valid_final_response(*parsed, request))
633 return ReplyDisposition::ACCEPT;
634 this->record_debug("final_wrong_exchange", ctx.try_index, true);
635 log_exchange_frame("Ignored final response", ctx.try_index, *parsed, pkt.len);
636 return ReplyDisposition::IGNORE;
637 });
638
639 this->note_final_wait_(stats);
640 if (outcome == ListenOutcome::ACCEPTED)
641 return decisions::ExchangeFinalResponseDisposition::ACCEPT;
642
643 ctx.state = exchange::OutboundExchangeState::FAILED;
644 this->record_debug("wait_final_timeout", ctx.try_index, true);
645 ESP_LOGI(TAG, "Try %d ended: no matching final response for cmd=%s(0x%02X) within %" PRIu32 " ms", ctx.try_index,
646 command_name(request.cmd), request.cmd, auth_wait_ms);
647 return decisions::ExchangeFinalResponseDisposition::IGNORE_UNRELATED;
648}
649
650// ============================================================================
651// Broadcast roll-call
652// ============================================================================
653
654uint8_t ExchangeEngine::collect_broadcast_responses(const IoFrame &request, uint32_t freq, uint8_t expected_cmd,
655 uint32_t window_ms, const BroadcastReplyHandler &on_reply,
656 ListenPolicy policy) {
657 this->reset_debug(request.cmd);
658
659 if (!this->transmit_frame(request, freq, this->request_preamble_for(request))) {
660 this->record_debug("broadcast_tx_failed", 1, false);
661 return 0;
662 }
663 const uint32_t tx_done_ms = millis();
664
665 RadioDriver *radio = *this->radio_ptr_;
666 uint8_t count = 0;
667
668 ListenSpec spec;
669 spec.window_ms = window_ms;
670 // policy is the caller's choice: ROTATE_SKIPPING_REQUEST (the default) leaves the request
671 // channel unattended because so few replies land there for that caller's population; a caller
672 // whose replies do land there passes ROTATE_ALL_CHANNELS instead. request_freq is set
673 // unconditionally — harmless for ROTATE_ALL_CHANNELS, which never reads it.
674 spec.policy = policy;
675 spec.request_freq = freq;
676 // dwell_ms is left at 0: no measured reason to dwell differently from discovery, so listen()
677 // asks the driver via hop_dwell_ms() instead of hardcoding a value here.
678 // A reception proves this channel carries responders and replies arrive spread across the whole
679 // window, so staying on it costs nothing; hopping away would only shrink the time spent where
680 // responders already are.
681 spec.hop_after_ignored_frame = false;
682 spec.linger_on_preamble = true;
683 spec.linger_dwell_ms = PREAMBLE_LINGER_DWELL_MS;
684
685 RadioRxPacket packet{};
686 IoFrame frame{};
687 this->listen(spec, packet, frame, [&](const IoFrame *parsed, const RadioRxPacket &pkt) {
688 if (parsed == nullptr || parsed->cmd != expected_cmd || memcmp(parsed->dst, this->node_id_, NODE_ID_SIZE) != 0)
690
691 BroadcastReplyInfo info{};
692 info.rssi_dbm = radio->get_last_capture().rssi_dbm;
693 info.rx_freq_hz = pkt.freq_hz;
694 info.after_tx_ms = millis() - tx_done_ms;
695 on_reply(*parsed, info);
696 if (count < UINT8_MAX)
697 ++count;
698 // Collection always runs to the deadline: a roll-call has no single "the" reply, so nothing
699 // this loop can see is ever a reason to stop early.
701 });
702
703 this->record_debug("broadcast_collect_done", 1, false);
704 return count;
705}
706
707// ============================================================================
708// Shared listen primitive
709//
710// The one listen loop every radio wait in this project runs through: send_and_receive()'s
711// first/final-response waits, collect_broadcast_responses(), and PairingEngine's discovery/
712// key-transfer/key-confirm waits all call listen() below with a ListenSpec that picks one of
713// the three ListenPolicy values (hold the request channel, rotate all three, or rotate skipping
714// the request channel). Per-loop behavior — what counts as a match, what aborts, what gets
715// logged — lives entirely in the caller's ReplyHandler; this function owns only the
716// slice/hop/deadline mechanics common to all of them.
717// ============================================================================
718
719namespace {
720
721/// Count one event in a ListenStats field without wrapping past 255.
722void count_saturating(uint8_t &counter) {
723 if (counter < UINT8_MAX)
724 counter++;
725}
726
727/// Parse one received packet, hand it to `on_frame`, and translate an ACCEPT/ABORT disposition
728/// into `outcome`; a frame the handler ignores is counted in `stats` when the caller asked for
729/// counts. Factored out of listen()'s two reception sites to keep that function's cognitive
730/// complexity under the clang-tidy threshold.
731/// @return true if the listen should stop (ACCEPT or ABORT was returned); false to keep waiting.
732bool dispatch_received_packet(const ReplyHandler &on_frame, const RadioRxPacket &packet, IoFrame &frame,
733 ListenOutcome &outcome, ListenStats *stats) {
734 const bool parsed = parse(packet.data, packet.len, frame);
735 switch (on_frame(parsed ? &frame : nullptr, packet)) {
736 case ReplyDisposition::ACCEPT:
737 outcome = ListenOutcome::ACCEPTED;
738 return true;
739 case ReplyDisposition::ABORT:
740 outcome = ListenOutcome::ABORTED;
741 return true;
742 case ReplyDisposition::IGNORE:
743 break;
744 }
745 if (stats != nullptr)
746 count_saturating(stats->frames_ignored);
747 return false;
748}
749
750/// Record a holding listen's failed reception. Called at the moment it happens, because the re-arm
751/// that follows clears the radio capture that describes it.
752void count_failed_reception(ListenStats *stats, const RadioDriver *radio) {
753 if (stats == nullptr)
754 return;
755 count_saturating(stats->failed_receptions);
756 stats->last_failed_irq = radio->get_last_capture().irq_status;
757}
758
759/// A frame is arriving: hopping now would cut it off mid-reception. Both halves are live on every
760/// current chip.
761bool preamble_or_sync_incoming(RadioDriver *radio, const ListenSpec &spec) {
762 return spec.linger_on_preamble && (radio->is_preamble_detected() || radio->is_sync_detected());
763}
764
765/// Per-channel dwell for a rotating listen: `spec.dwell_ms` if the caller set one, otherwise the
766/// driver's own answer to "how long must this radio sit on a channel after retuning before it can
767/// hear anything at all" (see RadioDriver::hop_dwell_ms()). Unused (returns 0) for a holding
768/// listen — HOLD never slices, so it never asks. Factored out of listen() purely to avoid a
769/// nested conditional operator and keep that function's cognitive complexity under the clang-tidy
770/// threshold — no behavior beyond the two-way fallback.
771uint32_t resolve_dwell_ms(const ListenSpec &spec, bool rotating, RadioDriver *radio, const TuningConfig &tuning) {
772 if (!rotating)
773 return 0;
774 if (spec.dwell_ms != 0)
775 return spec.dwell_ms;
776 return radio->hop_dwell_ms(tuning);
777}
778
779} // namespace
780
781void ExchangeEngine::listen_hop_(uint32_t skip, const ListenSpec &spec) {
782 this->hop_frequency(skip);
783 if (spec.on_hop)
784 spec.on_hop();
785}
786
788 const ReplyHandler &on_frame) {
789 RadioDriver *radio = *this->radio_ptr_;
790 const uint32_t deadline = millis() + spec.window_ms;
791 const bool rotating = spec.policy != ListenPolicy::HOLD_REQUEST_CHANNEL;
792 const uint32_t skip = spec.policy == ListenPolicy::ROTATE_SKIPPING_REQUEST ? spec.request_freq : 0;
793 // 0 means "ask the driver": no rotating call site in this project has a measured reason to
794 // dwell differently from the chip's own retune-cost answer (RadioDriver::hop_dwell_ms()), so
795 // all of them leave spec.dwell_ms at 0 and share one chip-specific knob instead of inventing a
796 // second.
797 const uint32_t dwell = resolve_dwell_ms(spec, rotating, radio, *this->tuning_);
798
799 // A skipping listen's replies almost never return on the requesting channel (see ListenPolicy's
800 // own doc comment for why), so it leaves that channel before its first dwell rather than
801 // spending one there.
803 this->listen_hop_(skip, spec);
804
805 while ((int32_t) (deadline - millis()) > 0) {
806 const uint32_t remaining = deadline - millis();
807 // A holding listen waits the whole remaining window in one call: it has nothing to do between
808 // slices, wait_for_packet() feeds the watchdog while it blocks, and every expired slice costs
809 // an RX re-arm. Only a rotating listen needs a per-channel dwell.
810 const uint32_t slice = rotating ? std::min(remaining, dwell) : remaining;
811
812 if (radio->wait_for_packet(packet, slice)) {
814 if (dispatch_received_packet(on_frame, packet, frame, outcome, spec.stats))
815 return outcome;
816 // The roll-call leaves this false because a reception proves responders are on this
817 // channel (see the field doc in hub_exchange.h); discovery is the only listen that hops
818 // after an ignored frame.
819 if (rotating && spec.hop_after_ignored_frame && (int32_t) (deadline - millis()) > 0 &&
820 !preamble_or_sync_incoming(radio, spec))
821 this->listen_hop_(skip, spec);
822 continue;
823 }
824
825 if ((int32_t) (deadline - millis()) <= 0)
826 break;
827 if (!rotating) {
828 count_failed_reception(spec.stats, radio); // HOLD: an early false is a failed reception, not a timeout.
829 continue;
830 }
831 if (!preamble_or_sync_incoming(radio, spec)) {
832 this->listen_hop_(skip, spec);
833 continue;
834 }
835 // Preamble/sync seen at the dwell boundary: extend by linger_dwell_ms and give the frame its
836 // air time instead of hopping — a short extension wait, not another full per-channel dwell.
837 const uint32_t ext = std::min((uint32_t) (deadline - millis()), spec.linger_dwell_ms);
839 if (radio->wait_for_packet(packet, ext) && dispatch_received_packet(on_frame, packet, frame, outcome, spec.stats))
840 return outcome;
841 }
843}
844
845// ============================================================================
846// Inbound authentication
847// ============================================================================
848
849bool ExchangeEngine::authenticate_request(const IoFrame &request, uint32_t freq) {
850 RadioDriver *radio = *this->radio_ptr_;
853 this->record_debug(inbound_stage_name(context.state), 1, true);
854
855 if (!create_challenge_req(context.challenge, request.src, this->node_id_)) {
857 this->record_debug(inbound_stage_name(context.state), 1, true);
858 return false;
859 }
860 if (!this->transmit_frame(context.challenge, freq, radio->response_preamble())) {
862 this->record_debug(inbound_stage_name(context.state), 1, true);
863 return false;
864 }
865
867 this->record_debug(inbound_stage_name(context.state), 1, true);
868
869 RadioRxPacket packet{};
870 if (!radio->wait_for_packet(packet, this->tuning_->exchange_response_wait_ms)) {
872 this->record_debug(inbound_stage_name(context.state), 1, true);
873 return false;
874 }
875
876 IoFrame rx;
877 if (!parse(packet.data, packet.len, rx) || !frame_is_challenge_response(rx)) {
879 this->record_debug(inbound_stage_name(context.state), 1, true);
880 return false;
881 }
882
883 // Transcript is the *device's* own frame (cmd + data), not our challenge — the challenged
884 // party authenticates what it said. Long assumed by symmetry with our outbound direction;
885 // confirmed against real hardware bytes by the device-side 0x3D in
886 // tests/corpus/captures/pairing/velux_kux100_pairing_full.yaml.
887 uint8_t frame_data[FRAME_MAX_SIZE];
888 frame_data[0] = request.cmd;
889 memcpy(frame_data + 1, request.data, request.data_len);
890 if (!crypto::verify_hmac(frame_data, request.data_len + 1, rx.data, context.challenge.data, this->system_key_)) {
892 this->record_debug(inbound_stage_name(context.state), 1, true);
893 return false;
894 }
895
897 this->record_debug(inbound_stage_name(context.state), 1, true);
898 this->counters_.challenge_round_trips++;
899 return true;
900}
901
902} // namespace home_io_control
903} // namespace esphome
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.
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.
std::function< void(const IoFrame &frame, const BroadcastReplyInfo &info)> BroadcastReplyHandler
Invoked for each matching broadcast reply, as it arrives.
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 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.
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()).
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 uint16_t response_preamble() const
Return the preamble length for response/continuation frames.
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.
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.
@ IGNORE_UNRELATED
Frame doesn't match endpoints or failed parse — keep waiting.
@ COMPLETE_DIRECT
Matching non-challenge frame — operation complete, no auth needed.
const char * wake_belief_name(WakeBelief belief)
Lowercase name of a belief for log lines.
WakeBelief
How likely a low-power receiver is to be awake right now, judged from what this hub has seen of it.
@ ASLEEP
No recent sign of life — lead with the wake-up preamble.
WakeBelief wake_belief(const TargetEvidence &evidence, uint32_t now, bool is_stop)
Judge how awake a low-power receiver is.
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 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.
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
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.
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.
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 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.
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 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...