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 "proto_commands.h"
13#include "proto_constants.h"
14#include "proto_crypto.h"
15#include "esphome/core/application.h"
16#include "esphome/core/hal.h"
17#include "esphome/core/log.h"
18
19#include <algorithm>
20#include <cinttypes>
21#include <cstring>
22
23namespace esphome {
24namespace home_io_control {
25
26static const char *const TAG = "home_io_control.exchange";
27
28// ============================================================================
29// Construction
30// ============================================================================
31
32ExchangeEngine::ExchangeEngine(RadioDriver **radio_ptr, const uint8_t *node_id, const uint8_t *system_key,
33 const TuningConfig *tuning)
34 : radio_ptr_(radio_ptr), node_id_(node_id), system_key_(system_key), tuning_(tuning) {}
35
36// ============================================================================
37// Debug snapshot helpers
38// ============================================================================
39
40void ExchangeEngine::reset_debug(uint8_t request_cmd) {
41 this->debug_ = DebugInfo{};
42 this->debug_.request_cmd = request_cmd;
43}
44
45void ExchangeEngine::record_debug(const char *stage, uint8_t tries, bool saw_challenge) {
46 this->debug_.stage = stage;
47 this->debug_.tries = tries;
48 this->debug_.saw_challenge = this->debug_.saw_challenge || saw_challenge;
49
50 const RadioCaptureInfo &capture = (*this->radio_ptr_)->get_last_capture();
51 this->debug_.capture_valid = capture.valid;
52 this->debug_.capture_rx_done = capture.rx_done;
53 this->debug_.capture_crc_error = capture.crc_error;
54 this->debug_.capture_freq_hz = capture.freq_hz;
55 this->debug_.capture_irq_status = capture.irq_status;
56 this->debug_.capture_packet_status = capture.packet_status;
57 this->debug_.capture_reported_len = capture.reported_len;
58 this->debug_.capture_frame_len = capture.frame_len;
59 this->debug_.capture_rssi_dbm = capture.rssi_dbm;
60}
61
62void ExchangeEngine::log_debug(const char *device_id) const {
63 const auto &d = this->debug_;
64 ESP_LOGW(TAG,
65 "Exchange failed: device=%s cmd=%s(0x%02X) stage=%s tries=%u saw_challenge=%u cap_valid=%u cap_rx_done=%u "
66 "cap_crc_err=%u cap_freq=%" PRIu32
67 " cap_irq=0x%04X cap_pkt=0x%02X cap_reported_len=%u cap_frame_len=%u cap_rssi=%d",
68 device_id, command_name(d.request_cmd), d.request_cmd, d.stage, d.tries, d.saw_challenge, d.capture_valid,
69 d.capture_rx_done, d.capture_crc_error, d.capture_freq_hz, d.capture_irq_status, d.capture_packet_status,
70 d.capture_reported_len, d.capture_frame_len, d.capture_rssi_dbm);
71}
72
73// ============================================================================
74// Frequency hopping
75// ============================================================================
76
77void ExchangeEngine::reset_hop_timestamp() { this->last_hop_us_ = micros(); }
78
80 RadioDriver *radio = *this->radio_ptr_;
81 uint32_t const cur = radio->get_current_freq();
82 uint32_t next;
83 switch (cur) {
84 case FREQ_CH1:
85 next = FREQ_CH2;
86 break;
87 case FREQ_CH3:
88 next = FREQ_CH1;
89 break;
90 default:
91 next = FREQ_CH3;
92 break;
93 }
94 radio->change_frequency(next);
95 this->last_hop_us_ = micros();
96}
97
99 if ((micros() - this->last_hop_us_) > HOP_TIME_US)
100 this->hop_frequency();
101}
102
103// ============================================================================
104// Transmit with LBT
105// ============================================================================
106
107bool ExchangeEngine::transmit_frame(const IoFrame &frame, uint32_t freq, uint16_t preamble) {
108 RadioDriver *radio = *this->radio_ptr_;
109 uint8_t buf[FRAME_MAX_SIZE];
110 uint8_t const len = serialize(frame, buf, sizeof(buf));
111 if (len == 0) {
112 ESP_LOGW(TAG, "tx: serialize_failed cmd=0x%02X", frame.cmd);
113 return false;
114 }
115 for (uint8_t lbt = 0; lbt < this->tuning_->lbt_max_retries; lbt++) {
116 int16_t const rssi = radio->read_rssi();
117 if (rssi < this->tuning_->lbt_rssi_threshold_dbm)
118 break;
119 ESP_LOGD(TAG, "LBT: channel busy (RSSI %d dBm), retry %u/%u", rssi, lbt + 1, this->tuning_->lbt_max_retries);
120 if (this->pairing_telemetry_ != nullptr)
121 this->pairing_telemetry_->record_lbt_defer(rssi);
122 delay(LBT_RETRY_DELAY_MS);
123 }
124 RadioTxConfig tx_config{};
125 tx_config.freq_hz = freq;
126 tx_config.preamble_len = preamble;
127 if (!radio->send_packet(buf, len, tx_config)) {
128 ESP_LOGW(TAG, "tx: send_failed cmd=0x%02X", frame.cmd);
129 return false;
130 }
131 if (this->pairing_telemetry_ != nullptr)
132 this->pairing_telemetry_->record_tx(frame.cmd);
133 return true;
134}
135
136// ============================================================================
137// Outbound exchange — main entry point
138// ============================================================================
139
140namespace {
141
142/// @brief Map OutboundExchangeState to a short string for debug logging.
143///
144/// OutboundExchangeState is written at each step for debug capture but is
145/// never read back for control-flow decisions — all branching is driven by
146/// return values and disposition enums.
147const char *outbound_stage_name(exchange::OutboundExchangeState state) {
148 switch (state) {
150 return "idle";
152 return "tx_request";
154 return "wait_first_response";
156 return "build_auth_response";
158 return "tx_auth_response";
160 return "wait_final_response";
162 return "success";
164 default:
165 return "failed";
166 }
167}
168
169/// @brief Map InboundAuthState to a short string for debug logging.
170const char *inbound_stage_name(exchange::InboundAuthState state) {
171 switch (state) {
173 return "idle";
175 return "tx_challenge";
177 return "wait_challenge_response";
179 return "verified";
181 default:
182 return "failed";
183 }
184}
185
186/// Check if frame is a 0x3D challenge response.
187bool frame_is_challenge_response(const IoFrame &frame) { return frame.cmd == CMD_CHALLENGE_RESP; }
188
189/// Log an exchanged frame with context (stage, try index, length).
190void log_exchange_frame(const char *stage, int tries, const IoFrame &frame, uint8_t len) {
191 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],
192 frame.src[1], frame.src[2], frame.dst[0], frame.dst[1], frame.dst[2], len);
193}
194
195/// Determine if a candidate frame is a valid final response for the request.
196bool is_valid_final_response(const IoFrame &candidate, const IoFrame &request) {
197 return decisions::classify_exchange_final_response(request, candidate) ==
199}
200
201} // namespace
202
203bool ExchangeEngine::send_and_receive(const IoFrame &request, IoFrame &response, uint32_t freq) {
204 this->reset_debug(request.cmd);
205 const uint16_t request_preamble = is_start(request) ? LONG_PREAMBLE : (*this->radio_ptr_)->response_preamble();
206
207 for (uint8_t tries = 0; tries < EXCHANGE_RETRY_COUNT; tries++) {
209 context.try_index = tries + 1;
210 context.exchange_start_ms = millis();
211 context.wait_ms =
212 is_start(request) ? this->tuning_->exchange_start_response_wait_ms : this->tuning_->exchange_response_wait_ms;
214
215 if (tries > 0) {
216 App.feed_wdt();
218 }
219
220 if (!this->transmit_request_(request, freq, request_preamble, context))
221 continue;
222
224 this->record_debug(outbound_stage_name(context.state), context.try_index, false);
225 auto first_disp = this->wait_for_first_response_(request, context);
227 continue;
230 this->record_debug("success_direct", context.try_index, false);
231 response = context.rx;
232 return true;
233 }
234
235 if (!this->handle_authentication_(request, freq, context))
236 continue;
237
239 this->record_debug(outbound_stage_name(context.state), context.try_index, true);
240 auto final_disp = this->wait_for_final_response_(request, context);
242 continue;
243
245 this->record_debug("success_auth", context.try_index, true);
246 response = context.rx;
247 return true;
248 }
249
250 return false;
251}
252
253// ============================================================================
254// Outbound exchange step helpers
255// ============================================================================
256
257bool ExchangeEngine::transmit_request_(const IoFrame &request, uint32_t freq, uint16_t preamble,
259 if (!this->transmit_frame(request, freq, preamble)) {
261 this->record_debug("tx_request_failed", ctx.try_index, false);
262 return false;
263 }
264 return true;
265}
266
267decisions::ExchangeFirstResponseDisposition ExchangeEngine::wait_for_first_response_(
268 const IoFrame &request, exchange::OutboundExchangeContext &ctx) {
269 RadioDriver *radio = *this->radio_ptr_;
270 RadioRxPacket packet{};
271 const uint32_t deadline = millis() + ctx.wait_ms;
272 while ((int32_t) (deadline - millis()) > 0) {
273 const uint32_t remaining = deadline - millis();
274 const uint32_t slice = std::min<uint32_t>(remaining, radio->exchange_wait_slice_ms());
275 if (!radio->wait_for_packet(packet, slice)) {
276 if ((int32_t) (deadline - millis()) > 0)
277 this->hop_frequency();
278 continue;
279 }
280 if (!parse(packet.data, packet.len, ctx.rx)) {
281 this->record_debug("first_parse_fail", ctx.try_index, false);
282 continue;
283 }
284 auto disp = decisions::classify_exchange_first_response(request, ctx.rx);
286 this->record_debug("first_wrong_exchange", ctx.try_index, false);
287 log_exchange_frame("Ignored first response", ctx.try_index, ctx.rx, packet.len);
288 continue;
289 }
290 ctx.first_response_ms = millis();
291 return disp;
292 }
294 this->record_debug("wait_first_timeout", ctx.try_index, false);
295 ESP_LOGI(TAG, "Try %d ended: no first response for cmd=%s(0x%02X) within %" PRIu32 " ms", ctx.try_index,
296 command_name(request.cmd), request.cmd, ctx.wait_ms);
298}
299
300bool ExchangeEngine::handle_authentication_(const IoFrame &request, uint32_t freq,
302 ctx.saw_challenge = true;
304 this->record_debug(outbound_stage_name(ctx.state), ctx.try_index, true);
305
306 IoFrame auth_resp;
307 if (!create_challenge_resp(auth_resp, request.dst, this->node_id_, ctx.rx.data, request, this->system_key_)) {
309 this->record_debug("auth_build_failed", ctx.try_index, true);
310 return false;
311 }
312
313 // No challenge bytes here: the raw 0x3C payload plus the 0x3D response it provokes is a
314 // known-plaintext/known-ciphertext pair under the system key (see redaction.h). The generic
315 // frame-log helpers (log_frame()/log_component_capture()) already mask both commands.
316 ESP_LOGI(TAG, "Auth challenge try=%d wait_ms=%" PRIu32 " req_cmd=0x%02X req_len=%u", ctx.try_index,
317 ctx.first_response_ms - ctx.exchange_start_ms, request.cmd, request.data_len);
318
320 this->record_debug(outbound_stage_name(ctx.state), ctx.try_index, true);
321 if (!this->transmit_frame(auth_resp, freq, (*this->radio_ptr_)->response_preamble())) {
323 this->record_debug("tx_auth_failed", ctx.try_index, true);
324 return false;
325 }
326 return true;
327}
328
329decisions::ExchangeFinalResponseDisposition ExchangeEngine::wait_for_final_response_(
330 const IoFrame &request, exchange::OutboundExchangeContext &ctx) {
331 RadioDriver *radio = *this->radio_ptr_;
332 RadioRxPacket packet{};
333 // Same budget as any other continuation frame — RESPONSE_AUTH_WAIT_MS was always an alias for
334 // RESPONSE_WAIT_MS, so the two share one knob rather than inventing a third.
335 const uint32_t auth_wait_ms = this->tuning_->exchange_response_wait_ms;
336 const uint32_t deadline = millis() + auth_wait_ms;
337 while ((int32_t) (deadline - millis()) > 0) {
338 const uint32_t remaining = deadline - millis();
339 const uint32_t slice = std::min<uint32_t>(remaining, radio->exchange_wait_slice_ms());
340 if (!radio->wait_for_packet(packet, slice)) {
341 if ((int32_t) (deadline - millis()) > 0)
342 this->hop_frequency();
343 continue;
344 }
345 if (!parse(packet.data, packet.len, ctx.rx)) {
346 this->record_debug("final_parse_fail", ctx.try_index, true);
347 continue;
348 }
349 if (is_valid_final_response(ctx.rx, request))
351 this->record_debug("final_wrong_exchange", ctx.try_index, true);
352 log_exchange_frame("Ignored final response", ctx.try_index, ctx.rx, packet.len);
353 }
355 this->record_debug("wait_final_timeout", ctx.try_index, true);
356 ESP_LOGI(TAG, "Try %d ended: no matching final response for cmd=%s(0x%02X) within %" PRIu32 " ms", ctx.try_index,
357 command_name(request.cmd), request.cmd, auth_wait_ms);
359}
360
361// ============================================================================
362// Broadcast roll-call
363// ============================================================================
364
365uint8_t ExchangeEngine::collect_broadcast_responses(const IoFrame &request, uint32_t freq, uint8_t expected_cmd,
366 uint32_t window_ms, const BroadcastReplyHandler &on_reply) {
367 this->reset_debug(request.cmd);
368
369 if (!this->transmit_frame(request, freq, LONG_PREAMBLE)) {
370 this->record_debug("broadcast_tx_failed", 1, false);
371 return 0;
372 }
373
374 RadioDriver *radio = *this->radio_ptr_;
375 RadioRxPacket packet{};
376 IoFrame rx{};
377 uint8_t count = 0;
378 const uint32_t deadline = millis() + window_ms;
379
380 while ((int32_t) (deadline - millis()) > 0) {
381 const uint32_t remaining = deadline - millis();
382 const uint32_t slice = std::min<uint32_t>(remaining, radio->exchange_wait_slice_ms());
383 if (!radio->wait_for_packet(packet, slice)) {
384 if ((int32_t) (deadline - millis()) > 0)
385 this->hop_frequency();
386 continue;
387 }
388 if (!parse(packet.data, packet.len, rx))
389 continue;
390 if (rx.cmd != expected_cmd)
391 continue;
392 if (memcmp(rx.dst, this->node_id_, NODE_ID_SIZE) != 0)
393 continue;
394
395 on_reply(rx, radio->get_last_capture().rssi_dbm);
396 if (count < UINT8_MAX)
397 ++count;
398 }
399
400 this->record_debug("broadcast_collect_done", 1, false);
401 return count;
402}
403
404// ============================================================================
405// Inbound authentication
406// ============================================================================
407
408bool ExchangeEngine::authenticate_request(const IoFrame &request, uint32_t freq) {
409 RadioDriver *radio = *this->radio_ptr_;
412 this->record_debug(inbound_stage_name(context.state), 1, true);
413
414 if (!create_challenge_req(context.challenge, request.src, this->node_id_)) {
416 this->record_debug(inbound_stage_name(context.state), 1, true);
417 return false;
418 }
419 if (!this->transmit_frame(context.challenge, freq, SHORT_PREAMBLE)) {
421 this->record_debug(inbound_stage_name(context.state), 1, true);
422 return false;
423 }
424
426 this->record_debug(inbound_stage_name(context.state), 1, true);
427
428 RadioRxPacket packet{};
429 if (!radio->wait_for_packet(packet, this->tuning_->exchange_response_wait_ms)) {
431 this->record_debug(inbound_stage_name(context.state), 1, true);
432 return false;
433 }
434
435 IoFrame rx;
436 if (!parse(packet.data, packet.len, rx) || !frame_is_challenge_response(rx)) {
438 this->record_debug(inbound_stage_name(context.state), 1, true);
439 return false;
440 }
441
442 // Transcript is the *device's* own frame (cmd + data), not our challenge — the challenged
443 // party authenticates what it said. Long assumed by symmetry with our outbound direction;
444 // confirmed against real hardware bytes by the device-side 0x3D in
445 // tests/corpus/captures/velux_kux100/pairing_full.yaml.
446 uint8_t frame_data[FRAME_MAX_SIZE];
447 frame_data[0] = request.cmd;
448 memcpy(frame_data + 1, request.data, request.data_len);
449 if (!crypto::verify_hmac(frame_data, request.data_len + 1, rx.data, context.challenge.data, this->system_key_)) {
451 this->record_debug(inbound_stage_name(context.state), 1, true);
452 return false;
453 }
454
456 this->record_debug(inbound_stage_name(context.state), 1, true);
457 return true;
458}
459
460} // namespace home_io_control
461} // namespace esphome
std::function< void(const IoFrame &frame, int16_t rssi_dbm)> BroadcastReplyHandler
Invoked for each matching broadcast reply, as it arrives.
void hop_frequency()
Advance to the next IO-Homecontrol channel (CH1→CH2→CH3→CH1).
void maybe_hop()
Unconditionally hop only if the minimum dwell has elapsed.
void reset_debug(uint8_t request_cmd)
Clear the debug snapshot and record the upcoming request command.
uint8_t collect_broadcast_responses(const IoFrame &request, uint32_t freq, uint8_t expected_cmd, uint32_t window_ms, const BroadcastReplyHandler &on_reply)
Transmit request once and hand every matching broadcast reply to on_reply within window_ms.
void log_debug(const char *device_id) const
Log the debug snapshot as a WARN-level structured line.
ExchangeEngine(RadioDriver **radio_ptr, const uint8_t *node_id, const uint8_t *system_key, const TuningConfig *tuning)
Construct the engine with double-pointer indirection into the hub's RadioDriver pointer and direct po...
bool transmit_frame(const IoFrame &frame, uint32_t freq, uint16_t preamble)
Transmit a raw IoFrame with LBT and the given preamble length.
bool authenticate_request(const IoFrame &request, uint32_t freq)
Authenticate an inbound device command via 0x3C challenge / 0x3D HMAC.
bool send_and_receive(const IoFrame &request, IoFrame &response, uint32_t freq)
Execute an outbound authenticated exchange with retry.
void reset_hop_timestamp()
Reset the hop-timer (called after radio init in hub setup()).
void record_debug(const char *stage, uint8_t tries, bool saw_challenge)
Update the debug snapshot with the current stage and radio capture.
Abstract radio driver for IO-Homecontrol.
uint32_t get_current_freq() const
Get the current RF frequency.
virtual void change_frequency(uint32_t freq_hz)=0
Change the carrier frequency using fast hop (no standby transition needed).
const RadioCaptureInfo & get_last_capture() const
Get the most recent radio capture info.
virtual bool send_packet(const uint8_t *data, uint8_t len, const RadioTxConfig &tx_config)=0
Send a packet using the specified carrier frequency and preamble settings.
virtual uint32_t exchange_wait_slice_ms() const
Per-channel dwell while waiting for an authenticated exchange response.
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.
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.
ExchangeFinalResponseDisposition
Disposition for the final response after authentication.
@ ACCEPT
Frame matches expected response — exchange succeeds.
@ IGNORE_UNRELATED
Frame doesn't match endpoints — ignore.
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.
ExchangeFinalResponseDisposition classify_exchange_final_response(const IoFrame &request, const IoFrame &candidate)
Decide if a candidate frame is an acceptable final response after authentication.
InboundAuthState
Progress stages of inbound authentication (device‑initiated commands).
@ WAIT_CHALLENGE_RESPONSE
Timer running; waiting for device's HMAC proof (0x3D).
@ VERIFIED
Device successfully authenticated; command is trusted.
@ TX_CHALLENGE
Challenge (0x3C) sent to device; awaiting 0x3D response.
@ IDLE
No inbound authentication in progress.
@ FAILED
Authentication failed (timeout or HMAC mismatch).
OutboundExchangeState
Progress stages of an outbound authenticated exchange (non‑pairing).
@ TX_REQUEST
Request frame transmitted; awaiting first response from device.
@ TX_AUTH_RESPONSE
Auth response (0x3D) transmitted; awaiting device's final reply.
@ BUILD_AUTH_RESPONSE
Building the 0x3D challenge response after receiving 0x3C.
@ FAILED
Exchange failed (timeout, retries exhausted, or radio error).
@ SUCCESS
Exchange completed successfully; device acknowledged.
@ WAIT_FIRST_RESPONSE
Listening for first response. This may be a challenge (0x3C) or the final response.
@ WAIT_FINAL_RESPONSE
Listening for the authenticated final response (e.g., status frame).
static constexpr uint8_t NODE_ID_SIZE
Device/node addresses are 3 bytes (e.g., "123ABC").
Definition proto_sizes.h:20
static constexpr const char * TAG
bool is_start(const IoFrame &f)
Check START flag.
static constexpr uint32_t FREQ_CH1
The protocol uses 3 frequency channels in the 868 MHz ISM band.
static constexpr uint32_t FREQ_CH3
Channel 3: 869.85 MHz (2W only).
static constexpr int32_t HOP_TIME_US
Timing constants for frequency hopping and response waiting.
const char * command_name(uint8_t cmd)
Get a human-readable name for any IO-Homecontrol command ID.
static constexpr uint8_t FRAME_MAX_SIZE
Maximum frame size (9 header + 23 data).
Definition proto_sizes.h:30
static constexpr int32_t EXCHANGE_RETRY_DELAY_MS
Gap between retries within one HA command.
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.
bool parse(const uint8_t *buf, uint8_t buf_len, IoFrame &f)
Parse a wire buffer into a parsed IoFrame (validates length and CTRL0).
static constexpr uint8_t EXCHANGE_RETRY_COUNT
Attempts per command before reporting failure.
static constexpr uint32_t FREQ_CH2
Channel 2: 868.95 MHz (1W and 2W, TX channel).
static constexpr uint8_t LBT_RETRY_DELAY_MS
Backoff between LBT checks (≥ 5ms per ETSI).
static constexpr uint16_t SHORT_PREAMBLE
8 bytes for response/continuation frames
static constexpr uint8_t CMD_CHALLENGE_RESP
HMAC proof answering a 0x3C.
static constexpr uint16_t LONG_PREAMBLE
Preamble is a sequence of 0xAA bytes that precedes every frame.
bool create_challenge_req(IoFrame &f, const uint8_t *dst, const uint8_t *src, const uint8_t challenge[HMAC_SIZE])
Build a challenge request (0x3C) using a caller-supplied challenge.
uint8_t serialize(const IoFrame &f, uint8_t *buf, uint8_t buf_size)
Serialize a parsed frame into a wire buffer (without CRC).
Command builders for the IO‑Homecontrol protocol.
IO-Homecontrol command IDs, result codes and protocol enumerations.
Cryptographic helpers for the IO‑Homecontrol protocol.
Snapshot of the last exchange attempt for diagnostics.
uint8_t request_cmd
Command ID of the original request.
Parsed IO‑Homecontrol frame (CTRL0/1 + addresses + command + data).
Definition proto_frame.h:71
uint8_t data[FRAME_MAX_DATA_SIZE]
Command parameters (0–23 bytes).
Definition proto_frame.h:77
uint8_t src[NODE_ID_SIZE]
Source node ID (3 bytes).
Definition proto_frame.h:75
uint8_t dst[NODE_ID_SIZE]
Destination node ID (3 bytes).
Definition proto_frame.h:74
uint8_t data_len
Actual length of data.
Definition proto_frame.h:78
Diagnostic capture from a radio operation.
uint8_t frame_len
Number of valid bytes in frame[].
uint16_t irq_status
Raw IRQ status register value.
uint8_t packet_status
Packet status byte (chip-specific).
bool crc_error
True if a CRC error was detected.
bool valid
True if capture is valid.
uint8_t reported_len
Length reported by the radio chip.
bool rx_done
True if RxDone IRQ fired.
uint32_t freq_hz
RF frequency of capture (Hz).
int16_t rssi_dbm
Received signal strength (dBm).
Raw packet received from the radio.
uint8_t len
Length of packet in bytes.
uint8_t data[RADIO_PACKET_BUFFER_SIZE]
Raw packet data buffer.
Configuration for transmitting a packet: carrier frequency and preamble length.
uint16_t preamble_len
Preamble length in symbol periods (bytes).
uint32_t freq_hz
Carrier frequency in Hz.
All runtime tunable parameters for pairing and radio diagnostics.
Context for a single inbound authentication (device‑initiated command).
IoFrame challenge
The 0x3C challenge frame we sent (needed to verify 0x3D response).
InboundAuthState state
Current authentication state.
Context carried across one outbound authenticated exchange.
uint8_t try_index
Current retry attempt (1‑based within EXCHANGE_RETRY_COUNT).
IoFrame rx
Most recent candidate frame received during the exchange.
uint32_t exchange_start_ms
Timestamp when the exchange attempt began (millis).
uint32_t wait_ms
Current timeout window for the active wait (ms).
OutboundExchangeState state
Current state machine state.