16#include "esphome/core/application.h"
17#include "esphome/core/hal.h"
18#include "esphome/core/log.h"
28static const char *
const TAG =
"home_io_control.exchange";
39 : radio_ptr_(radio_ptr), node_id_(node_id), system_key_(system_key), tuning_(tuning) {}
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));
55 add(d.final_waits, 1);
63 this->debug_.stage = stage;
64 this->debug_.tries = tries;
65 this->debug_.saw_challenge = this->debug_.saw_challenge || saw_challenge;
77 if (!capture.
valid && this->debug_.capture_valid)
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;
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;
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",
109 char fields[EXCHANGE_DEBUG_LINE_SIZE];
111 ESP_LOGW(
TAG,
"Exchange failed: %s", fields);
115 char fields[EXCHANGE_DEBUG_LINE_SIZE];
122 ESP_LOGI(
TAG,
"Exchange accepted without a closing reply: %s", fields);
148 }
while (next == skip_freq);
150 this->last_hop_us_ = micros();
154 if ((micros() - this->last_hop_us_) <= HOP_TIME_US)
161 if ((*this->radio_ptr_)->reception_in_progress())
190 uint8_t buf[FRAME_MAX_WIRE_SIZE];
191 uint8_t
const len =
serialize(frame, buf,
sizeof(buf));
193 ESP_LOGW(
TAG,
"tx: serialize_failed cmd=0x%02X", frame.
cmd);
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);
206 for (uint8_t lbt = 0;; lbt++) {
207 if (lbt < lbt_checks) {
215 ESP_LOGD(
TAG,
"LBT: channel busy (RSSI %d dBm on %" PRIu32
" Hz), retry %u/%u", result.
cca_level_dbm, freq, lbt + 1,
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);
222 if (!result.
sent()) {
223 ESP_LOGW(
TAG,
"tx: send_failed cmd=0x%02X", frame.
cmd);
226 if (this->transmit_observer_ !=
nullptr)
227 this->transmit_observer_->on_transmit(frame, tx_config, len);
231uint32_t ExchangeEngine::tx_time_ms_(
const IoFrame &frame, uint32_t freq, uint16_t preamble)
const {
232 uint8_t buf[FRAME_MAX_WIRE_SIZE];
233 uint8_t
const len =
serialize(frame, buf,
sizeof(buf));
236 uint32_t
const tx_us = (*this->radio_ptr_)->tx_air_time_us(len, tx_config_for(frame, freq, preamble));
240bool ExchangeEngine::try_fits_budget_(uint32_t elapsed_ms, uint32_t gap_ms, uint32_t tx_ms)
const {
262 return "wait_first_response";
264 return "build_auth_response";
266 return "tx_auth_response";
268 return "wait_final_response";
283 return "tx_challenge";
285 return "wait_challenge_response";
295bool frame_is_challenge_response(
const IoFrame &frame) {
return frame.cmd == CMD_CHALLENGE_RESP; }
303void log_unparsable_frame(
const char *stage,
int tries,
const RadioRxPacket &packet) {
308 ESP_LOGW(
TAG,
"%s try=%d: %u bytes did not parse as a frame on %" PRIu32
" Hz", stage, tries, packet.len,
310#ifdef IOHOME_FRAME_LOG
313 ESP_LOGD(
TAG,
" raw: %s", hex);
318void log_exchange_frame(
const char *stage,
int tries,
const IoFrame &frame, uint8_t len) {
319 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],
320 frame.src[1], frame.src[2], frame.dst[0], frame.dst[1], frame.dst[2], len);
324constexpr size_t TRY_AGE_BUFFER_SIZE = 12;
333 if (ctx.target_last_seen_ms == 0) {
334 snprintf(buf,
sizeof(buf),
"n/a");
337 snprintf(buf,
sizeof(buf),
"%" PRIu32, ctx.try_start_ms - ctx.target_last_seen_ms);
342bool is_low_power_start(
const IoFrame &request) {
return is_start(request) && (request.ctrl1 & CTRL1_LOW_POWER) != 0; }
345bool is_valid_final_response(
const IoFrame &candidate,
const IoFrame &request) {
353 uint8_t max_tries, uint16_t request_preamble_override) {
361 (*this->radio_ptr_)->clear_last_capture();
364 uint8_t tries_allowed = std::max<uint8_t>(1, std::min<uint8_t>(max_tries, EXCHANGE_RETRY_COUNT));
365 this->debug_.max_tries = tries_allowed;
366 const PreamblePlan preamble_plan = this->plan_request_preamble_(request, request_preamble_override);
367 this->debug_.wake_belief_use = preamble_plan.use;
369 this->debug_.wake_belief = preamble_plan.belief;
375 const uint32_t exchange_begin_ms = millis();
376 uint8_t unconfirmed_tries = 0;
378 for (uint8_t tries = 0; tries < tries_allowed; tries++) {
382 is_start(request) ? this->tuning_->exchange_start_response_wait_ms : this->tuning_->exchange_response_wait_ms;
390 if (!this->try_fits_budget_(millis() - exchange_begin_ms, EXCHANGE_RETRY_DELAY_MS,
391 this->tx_time_ms_(request, freq, preamble_plan.for_try(context.
try_index)))) {
392 this->
record_debug(
"retry_budget_exhausted", tries,
false);
393 ESP_LOGI(
TAG,
"Exchange budget exhausted after %u tries for cmd=%s(0x%02X) (%" PRIu32
" of %u ms)", tries,
395 this->tuning_->exchange_total_budget_ms);
399 delay(EXCHANGE_RETRY_DELAY_MS);
400 this->counters_.retransmits++;
409 if (!this->transmit_request_(request, freq, context.
request_preamble, context))
414 auto first_disp = this->wait_for_first_response_(request, context);
422 char age[TRY_AGE_BUFFER_SIZE];
423 format_try_age(context, age);
424 ESP_LOGI(
TAG,
"Try %d answered: cmd=%s(0x%02X) wait_ms=%" PRIu32
" preamble=%u age_ms=%s", context.
try_index,
427 response = context.
rx;
431 if (!this->handle_authentication_(request, freq, context))
436 auto final_disp = this->wait_for_final_response_(request, context);
452 const uint8_t further_tries = this->tries_after_unconfirmed_(
453 request, unconfirmed_tries, context.
try_index, millis() - exchange_begin_ms,
454 this->tx_time_ms_(request, freq, preamble_plan.for_try(context.
try_index + 1)));
455 if (further_tries == 0)
457 tries_allowed = std::min<uint8_t>(tries_allowed, tries + 1 + further_tries);
463 response = context.
rx;
470 if (unconfirmed_tries > 0)
472 this->spend_wake_evidence_if_silent_(request, preamble_plan);
476void ExchangeEngine::spend_wake_evidence_if_silent_(
const IoFrame &request,
const PreamblePlan &plan) {
484 ESP_LOGD(
TAG,
"Low-power target %s silent while believed awake: next exchange leads with the wake-up preamble",
486 this->wake_evidence_spent_handler_(request.
dst);
499 return (*this->radio_ptr_)->response_preamble();
500 return is_low_power_start(request) ? this->tuning_->low_power_wake_preamble : this->tuning_->normal_start_preamble;
510 return "no_provider";
515 return "not_low_power";
519ExchangeEngine::PreamblePlan ExchangeEngine::plan_request_preamble_(
const IoFrame &request,
520 uint16_t override_preamble)
const {
531 if (override_preamble != 0) {
533 }
else if (!is_low_power_start(request)) {
537 }
else if (!this->target_evidence_provider_) {
546 decisions::TargetEvidence evidence{};
547 const bool known = this->target_evidence_provider_(request.
dst, evidence);
548 plan.short_preamble = this->tuning_->normal_start_preamble;
549 plan.wake_preamble = this->tuning_->low_power_wake_preamble;
550 plan.last_seen_ms = known ? evidence.last_seen_ms : 0;
556bool ExchangeEngine::transmit_request_(
const IoFrame &request, uint32_t freq, uint16_t preamble,
560 this->
record_debug(
"tx_request_failed", ctx.try_index,
false);
569 spec.window_ms = ctx.wait_ms;
577 RadioRxPacket packet{};
578 auto outcome = this->
listen(spec, packet, ctx.rx, [&](
const IoFrame *parsed,
const RadioRxPacket &pkt) {
579 if (parsed == nullptr) {
580 this->record_debug(
"first_parse_fail", ctx.try_index, false);
581 this->counters_.parse_failures++;
582 log_unparsable_frame(
"Unparsable first response", ctx.try_index, pkt);
583 return ReplyDisposition::IGNORE;
587 this->
record_debug(
"first_wrong_exchange", ctx.try_index,
false);
588 log_exchange_frame(
"Ignored first response", ctx.try_index, *parsed, pkt.len);
591 ctx.first_response_ms = millis();
599 this->record_debug(
"wait_first_timeout", ctx.try_index,
false);
600 char age[TRY_AGE_BUFFER_SIZE];
601 format_try_age(ctx, age);
602 ESP_LOGI(
TAG,
"Try %d ended: no first response for cmd=%s(0x%02X) within %" PRIu32
" ms preamble=%u age_ms=%s",
603 ctx.try_index,
command_name(request.cmd), request.cmd, ctx.wait_ms, ctx.request_preamble, age);
607bool ExchangeEngine::handle_authentication_(
const IoFrame &request, uint32_t freq,
608 exchange::OutboundExchangeContext &ctx) {
609 ctx.saw_challenge =
true;
610 ctx.state = exchange::OutboundExchangeState::BUILD_AUTH_RESPONSE;
611 this->record_debug(outbound_stage_name(ctx.state), ctx.try_index,
true);
616 char age[TRY_AGE_BUFFER_SIZE];
617 format_try_age(ctx, age);
618 ESP_LOGI(TAG,
"Auth challenge try=%d wait_ms=%" PRIu32
" req_cmd=0x%02X req_len=%u preamble=%u age_ms=%s",
619 ctx.try_index, ctx.first_response_ms - ctx.try_start_ms, request.cmd, request.data_len, ctx.request_preamble,
622 ctx.state = exchange::OutboundExchangeState::TX_AUTH_RESPONSE;
623 this->record_debug(outbound_stage_name(ctx.state), ctx.try_index,
true);
626 if (!this->answer_challenge(request, ctx.rx, freq)) {
627 ctx.state = exchange::OutboundExchangeState::FAILED;
628 this->record_debug(
"auth_response_failed", ctx.try_index,
true);
631 this->counters_.challenge_round_trips++;
638 this->key_for(request.
dst)))
640 return this->
transmit_frame(auth_resp, freq, (*this->radio_ptr_)->response_preamble());
643uint8_t ExchangeEngine::tries_after_unconfirmed_(
const IoFrame &request, uint8_t unconfirmed_tries, uint8_t try_index,
644 uint32_t elapsed_ms, uint32_t next_tx_ms) {
648 if (request.
cmd == CMD_EXECUTE && this->target_evidence_provider_)
649 this->target_evidence_provider_(request.
dst, evidence);
650 if (!decisions::retry_after_unconfirmed_accept_is_safe(request, evidence.
confirms_execute, unconfirmed_tries))
652 if (request.
cmd != CMD_EXECUTE)
653 return EXCHANGE_RETRY_COUNT;
655 if (!this->try_fits_budget_(elapsed_ms, UNCONFIRMED_EXECUTE_RESEND_DELAY_MS, next_tx_ms))
660 "Try %u accepted without a closing reply for cmd=%s(0x%02X): re-sending, the device normally "
661 "confirms (final_rx_ignored=%u final_rx_failed=%u)",
662 try_index, command_name(request.
cmd), request.
cmd, this->debug_.final_rx_ignored,
663 this->debug_.final_rx_failed);
667 delay(UNCONFIRMED_EXECUTE_RESEND_DELAY_MS - EXCHANGE_RETRY_DELAY_MS);
670 return UNCONFIRMED_EXECUTE_MAX_RESENDS;
673decisions::ExchangeFinalResponseDisposition ExchangeEngine::wait_for_final_response_(
674 const IoFrame &request, exchange::OutboundExchangeContext &ctx) {
677 const uint32_t auth_wait_ms = this->tuning_->exchange_response_wait_ms;
680 spec.window_ms = auth_wait_ms;
684 spec.policy = ListenPolicy::HOLD_REQUEST_CHANNEL;
689 RadioRxPacket packet{};
690 auto outcome = this->listen(spec, packet, ctx.rx, [&](
const IoFrame *parsed,
const RadioRxPacket &pkt) {
691 if (parsed == nullptr) {
692 this->record_debug(
"final_parse_fail", ctx.try_index, true);
693 this->counters_.parse_failures++;
694 log_unparsable_frame(
"Unparsable final response", ctx.try_index, pkt);
695 return ReplyDisposition::IGNORE;
697 if (is_valid_final_response(*parsed, request))
698 return ReplyDisposition::ACCEPT;
699 this->record_debug(
"final_wrong_exchange", ctx.try_index,
true);
700 log_exchange_frame(
"Ignored final response", ctx.try_index, *parsed, pkt.len);
701 return ReplyDisposition::IGNORE;
704 this->note_final_wait_(stats);
705 if (outcome == ListenOutcome::ACCEPTED)
706 return decisions::ExchangeFinalResponseDisposition::ACCEPT;
708 ctx.state = exchange::OutboundExchangeState::FAILED;
709 this->record_debug(
"wait_final_timeout", ctx.try_index,
true);
710 ESP_LOGI(TAG,
"Try %d ended: no matching final response for cmd=%s(0x%02X) within %" PRIu32
" ms", ctx.try_index,
712 return decisions::ExchangeFinalResponseDisposition::IGNORE_UNRELATED;
728 const uint32_t tx_done_ms = millis();
753 if (parsed ==
nullptr || parsed->
cmd != expected_cmd || memcmp(parsed->
dst, this->node_id_, NODE_ID_SIZE) != 0)
760 on_reply(*parsed, info);
761 if (count < UINT8_MAX)
787void count_saturating(uint8_t &counter) {
788 if (counter < UINT8_MAX)
797bool dispatch_received_packet(
const ReplyHandler &on_frame,
const RadioRxPacket &packet, IoFrame &frame,
798 ListenOutcome &outcome, ListenStats *stats) {
799 const bool parsed = parse(packet.data, packet.len, frame);
800 switch (on_frame(parsed ? &frame :
nullptr, packet)) {
801 case ReplyDisposition::ACCEPT:
802 outcome = ListenOutcome::ACCEPTED;
804 case ReplyDisposition::ABORT:
805 outcome = ListenOutcome::ABORTED;
807 case ReplyDisposition::IGNORE:
810 if (stats !=
nullptr)
811 count_saturating(stats->frames_ignored);
817void count_failed_reception(ListenStats *stats,
const RadioDriver *radio) {
818 if (stats ==
nullptr)
820 count_saturating(stats->failed_receptions);
821 stats->last_failed_irq = radio->get_last_capture().irq_status;
826bool preamble_or_sync_incoming(RadioDriver *radio,
const ListenSpec &spec) {
827 return spec.linger_on_preamble && (radio->is_preamble_detected() || radio->is_sync_detected());
836uint32_t resolve_dwell_ms(
const ListenSpec &spec,
bool rotating, RadioDriver *radio,
const TuningConfig &tuning) {
839 if (spec.dwell_ms != 0)
840 return spec.dwell_ms;
841 return radio->hop_dwell_ms(tuning);
846void ExchangeEngine::listen_hop_(uint32_t skip,
const ListenSpec &spec) {
847 this->hop_frequency(skip);
855 const uint32_t deadline = millis() + spec.
window_ms;
862 const uint32_t dwell = resolve_dwell_ms(spec, rotating, radio, *this->tuning_);
868 this->listen_hop_(skip, spec);
870 while ((int32_t) (deadline - millis()) > 0) {
871 const uint32_t remaining = deadline - millis();
875 const uint32_t slice = rotating ? std::min(remaining, dwell) : remaining;
879 if (dispatch_received_packet(on_frame, packet, frame, outcome, spec.
stats))
885 !preamble_or_sync_incoming(radio, spec))
886 this->listen_hop_(skip, spec);
890 if ((int32_t) (deadline - millis()) <= 0)
893 count_failed_reception(spec.
stats, radio);
896 if (!preamble_or_sync_incoming(radio, spec)) {
897 this->listen_hop_(skip, spec);
902 const uint32_t ext = std::min((uint32_t) (deadline - millis()), spec.
linger_dwell_ms);
904 if (radio->
wait_for_packet(packet, ext) && dispatch_received_packet(on_frame, packet, frame, outcome, spec.
stats))
919 if (this->device_key_provider_) {
920 if (
const uint8_t *device_key = this->device_key_provider_(node); device_key !=
nullptr)
923 return this->system_key_;
931 const uint8_t *key) {
952 if (!radio->
wait_for_packet(packet, this->tuning_->exchange_response_wait_ms)) {
963 if (!
parse(packet.
data, packet.
len, rx) || !frame_is_challenge_response(rx) || rx.
data_len < HMAC_SIZE) {
976 this->counters_.challenge_round_trips++;
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.
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()).
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).
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.
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
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.
@ 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)
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.
@ IDLE
No active exchange; idle state.
@ 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 ...
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.
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 tries
Retry count (1-based).
uint16_t capture_irq_status
Raw IRQ register value.
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.
bool capture_rx_done
True if RxDone IRQ fired.
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).
uint8_t data[FRAME_MAX_DATA_SIZE]
Command parameters (0–23 bytes). Never includes mac.
uint8_t src[NODE_ID_SIZE]
Source node ID (3 bytes).
uint8_t dst[NODE_ID_SIZE]
Destination node ID (3 bytes).
uint8_t data_len
Actual length of data.
How one listen window is to be spent — everything ExchangeEngine::listen() needs; everything else is ...
uint32_t request_freq
Channel the request went out on (Hz).
uint32_t window_ms
Total time budget for this listen, in milliseconds.
uint32_t linger_dwell_ms
Length of the preamble/sync extension, in milliseconds.
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.
bool valid
True if capture is valid.
uint8_t reported_len
Length reported by the radio chip.
bool rx_done
True if RxDone IRQ fired.
uint32_t freq_hz
RF frequency of capture (Hz).
int16_t rssi_dbm
Received signal strength (dBm).
Raw packet received from the radio.
uint8_t len
Length of packet in bytes.
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 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.
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...