Home IO Control
ESPHome add-on for IO-Homecontrol devices
Loading...
Searching...
No Matches
exchange_engine.h
Go to the documentation of this file.
1#pragma once
2
3/// @file exchange_engine.h
4/// @brief Self-contained authenticated exchange engine for IO-Homecontrol 2W.
5/// @ingroup hioc_hub
6///
7/// ExchangeEngine encapsulates the outbound authenticated exchange state
8/// machine and the inbound challenge-response authentication path. It owns:
9/// - `send_and_receive()` — retry loop with challenge/response support.
10/// - `authenticate_request()` — verify a device-initiated command via HMAC.
11/// - `collect_broadcast_responses()` — send once, report every matching reply in a window.
12/// - Transmit with LBT (listen-before-talk) and frequency hopping.
13/// - Exchange debug snapshot captured on every attempt.
14///
15/// The engine holds double-pointer and raw-pointer references to its owner's
16/// state (radio driver, node/system key, tuning config) so that changes made
17/// by the hub after construction (e.g., radio driver allocation in setup(),
18/// direct member writes in unit tests) are automatically visible.
19///
20/// All transmit and channel-hop traffic funnels through this engine: the hub's
21/// thin `transmit_frame_()` / `hop_frequency_()` wrappers delegate here, and
22/// `PairingEngine` holds a direct reference for its own exchanges.
23
24#include "hub_exchange.h"
25#include "hub_decisions.h"
26#include "pairing_telemetry.h"
27#include "proto_frame.h"
28#include "proto_timing.h"
29#include "radio_interface.h"
30#include "tuning_config.h"
31
32#include <cstdint>
33#include <functional>
34
35namespace esphome {
36namespace home_io_control {
37
38/// @brief Authenticated exchange engine — outbound and inbound protocol flows.
39///
40/// All timing constants (retry count/delay, response windows) come from
41/// proto_timing.h; per-chip dwell overrides are queried from the RadioDriver.
42/// @brief What an outbound exchange actually achieved.
43///
44/// Deliberately not a bool: "the device accepted the command" and "the device told us what
45/// happened" are different facts, and some devices only ever deliver the first.
46///
47/// Some devices challenge a command, authenticate it, execute it, and then transmit nothing for
48/// several seconds — up to a dozen — reporting via an asynchronous status update later instead of
49/// closing the exchange with a synchronous reply, all well outside the exchange's own response
50/// window. Other devices on the same protocol close the exchange properly with a synchronous 0x04
51/// (see tests/corpus/captures/exchange/somfy_awning_exchange_open_sx1276.yaml), so the four-frame exchange
52/// is real — just not universal, and a caller cannot assume either shape from the command alone.
53///
54/// SUCCESS_UNCONFIRMED exists so that silence after a real authentication is not treated the same
55/// as a request the device may never have heard at all: the two need different retry rules (see
56/// decisions::retry_after_unconfirmed_accept_is_safe()) and different reporting to the caller.
57enum class ExchangeOutcome : uint8_t {
58 FAILED, ///< No usable reply; the device may never have heard the request.
59 SUCCESS_WITH_RESPONSE, ///< Device replied; the caller's `response` frame is populated.
60 SUCCESS_UNCONFIRMED, ///< Device authenticated the request — so it received and accepted it —
61 ///< but sent no final response. `response` is NOT populated. Callers that
62 ///< need payload (key exchange) must treat this as failure; callers that
63 ///< only need "the command landed" should treat it as success. For every
64 ///< command but CMD_EXECUTE, this outcome is only returned after the full
65 ///< retry budget is spent — see retry_after_unconfirmed_accept_is_safe().
66};
67
69 public:
70 /// Construct the engine with double-pointer indirection into the hub's
71 /// RadioDriver pointer and direct pointers to the node/key byte arrays and
72 /// the tuning config. Pointers must remain valid for the lifetime of the
73 /// engine (guaranteed because hub owns all referenced members).
74 /// @param radio_ptr Address of the hub's `RadioDriver *radio_` member.
75 /// @param node_id Pointer to the hub's `node_id_[NODE_ID_SIZE]` array.
76 /// @param system_key Pointer to the hub's `system_key_[AES_KEY_SIZE]` array.
77 /// @param tuning Pointer to the hub's `TuningConfig tuning_` member.
78 ExchangeEngine(RadioDriver **radio_ptr, const uint8_t *node_id, const uint8_t *system_key,
79 const TuningConfig *tuning);
80
81 // Non-copyable; the hub owns exactly one engine tied to its member addresses.
82 ExchangeEngine(const ExchangeEngine &) = delete;
84
85 // -------------------------------------------------------------------------
86 // Core exchange API
87 // -------------------------------------------------------------------------
88
89 /// Execute an outbound authenticated exchange with retry.
90 /// @param request Frame to transmit (cmd + endpoints already filled).
91 /// @param response Populated only for @ref ExchangeOutcome::SUCCESS_WITH_RESPONSE.
92 /// @param freq RF channel frequency (Hz).
93 /// @param max_tries Cap on transmit attempts for this exchange, clamped to
94 /// [1, EXCHANGE_RETRY_COUNT]. Callers whose failure is already re-armed elsewhere (a
95 /// scheduler-owned status poll) pass SCHEDULED_POLL_MAX_TRIES so a dead device does not
96 /// block loop() for the full retry product.
97 /// @return What the device actually told us — see @ref ExchangeOutcome.
98 ExchangeOutcome send_and_receive(const IoFrame &request, IoFrame &response, uint32_t freq,
99 uint8_t max_tries = EXCHANGE_RETRY_COUNT);
100
101 /// Authenticate an inbound device command via 0x3C challenge / 0x3D HMAC.
102 /// @param request The received inbound frame (e.g., CMD_STATUS_UPDATE).
103 /// @param freq RF channel the frame arrived on.
104 /// @return true if HMAC verified; false on timeout or mismatch.
105 bool authenticate_request(const IoFrame &request, uint32_t freq);
106
107 /// @brief Invoked for each matching broadcast reply, as it arrives.
108 /// @param frame Parsed reply frame (responder's address is `frame.src`).
109 /// @param rssi_dbm RSSI of this reply.
110 using BroadcastReplyHandler = std::function<void(const IoFrame &frame, int16_t rssi_dbm)>;
111
112 /// Transmit `request` once and hand every matching broadcast reply to `on_reply` within
113 /// `window_ms`.
114 ///
115 /// Unlike send_and_receive(), this neither retries the transmit nor performs any
116 /// authentication — a broadcast roll-call has no per-transaction proof, so replies are
117 /// informational only (see the caller's protocol notes). Every candidate packet is parsed
118 /// and checked against `expected_cmd` and `node_id_` (as the frame's destination); anything
119 /// that fails `parse()`, carries a different `cmd`, or is not addressed to us is ignored
120 /// without ending collection. The receiver leaves the request channel before the first
121 /// listen and alternates between the other two for the rest of the window — unlike
122 /// wait_for_first_response_(), which holds the request channel for the whole wait — because a
123 /// broadcast reply does not come back on the channel that asked for it (1 of 149 measured),
124 /// while a unicast reply does. Replies on the request channel are therefore not caught by this
125 /// loop.
126 ///
127 /// This method stores nothing and imposes no capacity: it neither buffers replies nor
128 /// deduplicates them, so the same responder answering twice within one window invokes
129 /// `on_reply` twice. Storage, deduplication, and any capacity limit belong to the caller,
130 /// which knows how little of each reply it actually needs to keep — see
131 /// `ManagementActions::scan_paired_devices()`, which decodes each frame on arrival into a
132 /// compact record rather than retaining whole frames. Collection always runs to the deadline.
133 /// @param request Frame to transmit once (cmd + endpoints already filled).
134 /// @param freq RF channel frequency (Hz) for the initial transmit.
135 /// @param expected_cmd Command byte a reply must carry to be considered.
136 /// @param window_ms How long to listen after the transmit, in milliseconds. The caller
137 /// owns this budget explicitly (rather than this method reading
138 /// `tuning_->pairing_discovery_wait_ms` itself) because a caller that
139 /// transmits more than once needs to divide one total time budget across
140 /// several calls — see `ManagementActions::scan_paired_devices()`.
141 /// @param on_reply Invoked once per matching reply, before the next packet is awaited, so
142 /// the handler must be cheap and must not block. Keep captures to a few
143 /// pointers: small callables avoid std::function's heap fallback on the
144 /// implementations this project builds against.
145 /// @return Number of matching replies handed to `on_reply` (duplicates included).
146 uint8_t collect_broadcast_responses(const IoFrame &request, uint32_t freq, uint8_t expected_cmd, uint32_t window_ms,
147 const BroadcastReplyHandler &on_reply);
148
149 /// @brief The one listen primitive every radio wait loop in this project is built on.
150 ///
151 /// Listens for up to `spec.window_ms`, applying `spec.policy` (hold the current channel, rotate
152 /// all three, or rotate skipping the request channel), and hands every packet the radio
153 /// delivers to `on_frame` before deciding whether to keep waiting. See @ref ListenPolicy for the
154 /// measurements behind each policy and @ref ListenSpec for what each field controls.
155 ///
156 /// Parses each received packet into `frame`, so on ListenOutcome::ACCEPTED the caller's `frame`
157 /// already holds the accepted frame and `packet` already holds its raw bytes — no copy is
158 /// needed. A packet that fails to parse is still handed to `on_frame` (with a null `parsed`
159 /// pointer), so a caller that wants to log or count unparsable frames still can.
160 ///
161 /// Any richer result than accept/refuse/timeout — a disposition with more than three values, a
162 /// captured "did we see any traffic at all" flag — is the caller's business: capture it in
163 /// `on_frame`'s closure and return ACCEPT/IGNORE. `ListenOutcome` itself never grows a fourth
164 /// value; that is how a shared primitive would turn back into one loop per caller.
165 ///
166 /// @param spec How this listen window is to be spent.
167 /// @param packet Scratch space for the whole listen: holds the last received packet on
168 /// return. On ACCEPTED that is the accepted packet; on ABORTED, the one `on_frame` refused;
169 /// on TIMED_OUT, whatever arrived last (or the caller's initial value, if nothing did).
170 /// @param frame Same lifetime as `packet`, parsed from it: holds the last received frame on
171 /// return, with the same ACCEPTED/ABORTED/TIMED_OUT correspondence as `packet` above.
172 /// @param on_frame Invoked for every packet the radio delivers; decides whether to accept,
173 /// abort, or keep listening. See @ref ReplyHandler.
174 /// @return ACCEPTED or ABORTED as `on_frame` decided, or TIMED_OUT if `spec.window_ms` elapsed
175 /// first.
176 ListenOutcome listen(const ListenSpec &spec, RadioRxPacket &packet, IoFrame &frame, const ReplyHandler &on_frame);
177
178 // -------------------------------------------------------------------------
179 // Infrastructure delegated from the hub
180 // -------------------------------------------------------------------------
181
182 /// Transmit a raw IoFrame with LBT and the given preamble length.
183 /// @param frame Frame to transmit.
184 /// @param freq RF frequency in Hz.
185 /// @param preamble Preamble length in bytes (e.g. `LONG_PREAMBLE`, `SHORT_PREAMBLE`, or a
186 /// tuning-configured value such as `normal_start_preamble`).
187 /// @return true if the radio accepted the packet; false otherwise.
188 bool transmit_frame(const IoFrame &frame, uint32_t freq, uint16_t preamble);
189
190 /// @brief Advance the receiver one step along the protocol's channel rotation
191 /// (CH1→CH2→CH3→CH1).
192 /// @param skip_freq Channel to pass over, or 0 to rotate through all three. Used by the
193 /// broadcast roll-call, whose replies never come back on the channel that asked.
194 void hop_frequency(uint32_t skip_freq = 0);
195
196 /// Hop only if the minimum dwell has elapsed and no frame is currently arriving on this
197 /// channel — @ref RadioDriver::reception_in_progress() gates the hop so a reception in
198 /// progress is never destroyed mid-arrival (issue #81). A deferred hop does not reset the
199 /// dwell timer: it fires on the first call after the reception clears, not a further
200 /// HOP_TIME_US later.
201 /// Called from the hub's `loop()` to honour passive channel scanning.
202 void maybe_hop();
203
204 /// Reset the hop-timer (called after radio init in hub setup()).
205 void reset_hop_timestamp();
206
207 // -------------------------------------------------------------------------
208 // Pairing telemetry hook
209 // -------------------------------------------------------------------------
210
211 /// Attach a telemetry recorder so transmit_frame()'s LBT loop records defer events.
212 /// Set by PairingEngine for the duration of a `discover_and_pair()` attempt only — nullptr
213 /// (the default) for every non-pairing exchange, which is the common case and stays a no-op.
214 /// @param telemetry Non-owning pointer, or nullptr to detach.
215 void set_pairing_telemetry(PairingTelemetry *telemetry) { this->pairing_telemetry_ = telemetry; }
216
217 // -------------------------------------------------------------------------
218 // Exchange debug snapshot
219 // -------------------------------------------------------------------------
220
221 /// @brief Snapshot of the last exchange attempt for diagnostics.
222 struct DebugInfo {
223 const char *stage{"idle"}; ///< Last recorded stage label.
224 uint8_t tries{0}; ///< Retry count (1-based).
225 uint8_t max_tries{EXCHANGE_RETRY_COUNT}; ///< Attempt cap this exchange was budgeted for.
226 uint8_t request_cmd{0}; ///< Command ID of the original request.
227 bool saw_challenge{false}; ///< True if a 0x3C was seen during this exchange.
228 bool capture_valid{false}; ///< True if radio capture is meaningful.
229 bool capture_rx_done{false}; ///< True if RxDone IRQ fired.
230 bool capture_crc_error{false}; ///< True if CRC error flagged; see RadioCaptureInfo::crc_error.
231 uint32_t capture_freq_hz{0}; ///< RF frequency of the captured packet.
232 uint16_t capture_irq_status{0}; ///< Raw IRQ register value.
233 uint8_t capture_packet_status{0}; ///< Chip packet-status byte.
234 uint8_t capture_reported_len{0}; ///< Length reported by radio packet engine.
235 uint8_t capture_frame_len{0}; ///< Parsed protocol frame length.
236 int16_t capture_rssi_dbm{0}; ///< RSSI of the captured packet (dBm).
237 };
238
239 /// Clear the debug snapshot and record the upcoming request command.
240 void reset_debug(uint8_t request_cmd);
241
242 /// Update the debug snapshot with the current stage and radio capture.
243 void record_debug(const char *stage, uint8_t tries, bool saw_challenge);
244
245 /// Log the debug snapshot as a WARN-level structured line.
246 /// @param device_id Human-readable device identifier for the log line.
247 void log_debug(const char *device_id) const;
248
249 /// Read-only access to the current debug snapshot.
250 [[nodiscard]] const DebugInfo &get_debug() const { return debug_; }
251
252 // -------------------------------------------------------------------------
253 // Exchange-engine counters
254 // -------------------------------------------------------------------------
255
256 /// @brief Free-running counters for the engine's own retry/parse behavior — not per-device (see
257 /// the RSSI/Exchange-Failures per-device sensors for that) and not per-attempt (see DebugInfo for
258 /// that). Persist until explicitly reset, so a caller can diff two snapshots across an arbitrary
259 /// window. Coverage differs per field, see each field's own comment below: `lbt_retries` is the
260 /// only one that covers pairing traffic too (pairing calls transmit_frame() the same way normal
261 /// exchanges do); the other three only observe ExchangeEngine's own send_and_receive() path.
262 ///
263 /// Internal-only, deliberately: nothing outside the unit tests reads counters() today — no HA
264 /// sensor, no log line, no periodic dump. Built to back a scripted continuous-operation
265 /// reliability test against dedicated bench hardware; that hardware was retired before the test
266 /// could run, so no consumer exists yet. Kept anyway (zero runtime cost, no YAML/schema surface,
267 /// already tested) as a cheap, ready primitive for whenever someone next needs to debug
268 /// exchange-level health — wiring it to a sensor or log line at that point is a small,
269 /// self-contained addition, not a redesign.
270 struct Counters {
271 uint32_t lbt_retries{0}; ///< transmit_frame() LBT backoff iterations (channel busy);
272 ///< covers pairing traffic too, via pairing_engine.cpp's
273 ///< own transmit_frame() calls.
274 uint32_t retransmits{0}; ///< send_and_receive() TX attempts beyond the first; no
275 ///< pairing path.
276 uint32_t challenge_round_trips{0}; ///< Completed 0x3C/0x3D challenge-response cycles, either
277 ///< direction: a device challenging our outbound command
278 ///< (handle_authentication_()) or us authenticating an
279 ///< inbound one (authenticate_request()); no pairing path.
280 uint32_t parse_failures{0}; ///< Frames wait_for_first_response_()/wait_for_final_response_()
281 ///< could not parse; does not count pairing_engine.cpp's own
282 ///< parse-null sites or collect_broadcast_responses()'s.
283 };
284
285 /// Read-only access to the running counters. No production caller yet — see the Counters
286 /// doc comment above.
287 [[nodiscard]] const Counters &counters() const { return this->counters_; }
288
289 /// Zero every counter (e.g. to start a fresh measurement window). No production caller yet —
290 /// see the Counters doc comment above.
291 void reset_counters() { this->counters_ = Counters{}; }
292
293 private:
294 // --- listen() helper -------------------------------------------------------
295
296 /// Retune per `skip` (see hop_frequency()) and fire `spec.on_hop` if set. Factored out of
297 /// listen() purely to keep that function's cognitive complexity under the clang-tidy
298 /// threshold — a member function call doesn't add to the caller's complexity the way an
299 /// inline lambda definition does.
300 /// @param skip Channel to pass over, or 0 to rotate through all three — see hop_frequency().
301 /// @param spec The listen this hop belongs to; only `on_hop` is read.
302 void listen_hop_(uint32_t skip, const ListenSpec &spec);
303
304 // --- Outbound exchange step helpers --------------------------------------
305
306 /// Transmit one request attempt and update context state on failure.
307 bool transmit_request_(const IoFrame &request, uint32_t freq, uint16_t preamble,
309
310 /// Preamble length for an outbound request frame. A non-start frame keeps the chip's short
311 /// response preamble. A start frame gets `LONG_PREAMBLE` only when it carries `CTRL1_LOW_POWER`
312 /// (its target is a duty-cycled receiver that must be woken); every other start frame gets the
313 /// runtime-tunable `normal_start_preamble`. The bit and the preamble therefore always agree,
314 /// because both derive from the target's per-device `low_power` property.
315 [[nodiscard]] uint16_t request_preamble_for_(const IoFrame &request) const;
316
317 /// Block until the first response arrives or the wait window expires.
318 decisions::ExchangeFirstResponseDisposition wait_for_first_response_(const IoFrame &request,
320
321 /// Send the 0x3D challenge response after receiving a 0x3C from the device.
322 bool handle_authentication_(const IoFrame &request, uint32_t freq, exchange::OutboundExchangeContext &ctx);
323
324 /// Block until the final authenticated response arrives or the window expires.
325 decisions::ExchangeFinalResponseDisposition wait_for_final_response_(const IoFrame &request,
327
328 // --- Dependencies (back-references into the hub) -------------------------
329
330 RadioDriver **radio_ptr_; ///< Double-pointer: *radio_ptr_ is always the hub's active driver.
331 const uint8_t *node_id_; ///< Hub's node_id_[NODE_ID_SIZE] array.
332 const uint8_t *system_key_; ///< Hub's system_key_[AES_KEY_SIZE] array.
333 const TuningConfig *tuning_; ///< Hub's live TuningConfig (read on every LBT check).
334 PairingTelemetry *pairing_telemetry_{nullptr}; ///< Set only during a pairing attempt; see set_pairing_telemetry().
335
336 // --- Engine state --------------------------------------------------------
337
338 uint32_t last_hop_us_{0}; ///< Timestamp of the last channel hop (µs, from micros()).
339 DebugInfo debug_{}; ///< Snapshot updated throughout each exchange attempt.
340 Counters counters_{}; ///< Free-running counters; see counters()/reset_counters().
341};
342
343} // namespace home_io_control
344} // namespace esphome
std::function< void(const IoFrame &frame, int16_t rssi_dbm)> BroadcastReplyHandler
Invoked for each matching broadcast reply, as it arrives.
void reset_counters()
Zero every counter (e.g.
ListenOutcome listen(const ListenSpec &spec, RadioRxPacket &packet, IoFrame &frame, const ReplyHandler &on_frame)
The one listen primitive every radio wait loop in this project is built on.
ExchangeOutcome send_and_receive(const IoFrame &request, IoFrame &response, uint32_t freq, uint8_t max_tries=EXCHANGE_RETRY_COUNT)
Execute an outbound authenticated exchange with retry.
void maybe_hop()
Hop only if the minimum dwell has elapsed and no frame is currently arriving on this channel — RadioD...
void reset_debug(uint8_t request_cmd)
Clear the debug snapshot and record the upcoming request command.
uint8_t collect_broadcast_responses(const IoFrame &request, uint32_t freq, uint8_t expected_cmd, uint32_t window_ms, const BroadcastReplyHandler &on_reply)
Transmit request once and hand every matching broadcast reply to on_reply within window_ms.
void log_debug(const char *device_id) const
Log the debug snapshot as a WARN-level structured line.
const DebugInfo & get_debug() const
Read-only access to the current debug snapshot.
void set_pairing_telemetry(PairingTelemetry *telemetry)
Attach a telemetry recorder so transmit_frame()'s LBT loop records defer events.
ExchangeEngine(RadioDriver **radio_ptr, const uint8_t *node_id, const uint8_t *system_key, const TuningConfig *tuning)
Construct the engine with double-pointer indirection into the hub's RadioDriver pointer and direct po...
bool transmit_frame(const IoFrame &frame, uint32_t freq, uint16_t preamble)
Transmit a raw IoFrame with LBT and the given preamble length.
bool authenticate_request(const IoFrame &request, uint32_t freq)
Authenticate an inbound device command via 0x3C challenge / 0x3D HMAC.
void reset_hop_timestamp()
Reset the hop-timer (called after radio init in hub setup()).
ExchangeEngine & operator=(const ExchangeEngine &)=delete
const Counters & counters() const
Read-only access to the running counters.
ExchangeEngine(const ExchangeEngine &)=delete
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.
Fixed-size per-attempt telemetry recorder for the pairing flow.
Abstract radio driver for IO-Homecontrol.
Pure transition helpers for hub-owned exchange and pairing frame decisions.
Internal exchange-state model for hub-owned authenticated non‑pairing flows.
ExchangeFinalResponseDisposition
Disposition for the final response after authentication.
ExchangeFirstResponseDisposition
Disposition for the first response in an authenticated exchange.
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...
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.
static constexpr uint8_t EXCHANGE_RETRY_COUNT
Attempts per command before reporting failure.
ListenOutcome
How one call to ExchangeEngine::listen() ended.
Structured per-attempt telemetry recorder for the pairing flow.
IO-Homecontrol 2W frame container: control bytes, IoFrame and (de)serialization.
Physical-layer radio and timing parameters for the IO-Homecontrol protocol.
Radio abstraction layer for IO-Homecontrol.
Free-running counters for the engine's own retry/parse behavior — not per-device (see the RSSI/Exchan...
uint32_t parse_failures
Frames wait_for_first_response_()/wait_for_final_response_() could not parse; does not count pairing_...
uint32_t challenge_round_trips
Completed 0x3C/0x3D challenge-response cycles, either direction: a device challenging our outbound co...
uint32_t lbt_retries
transmit_frame() LBT backoff iterations (channel busy); covers pairing traffic too,...
uint32_t retransmits
send_and_receive() TX attempts beyond the first; no pairing path.
Snapshot of the last exchange attempt for diagnostics.
bool saw_challenge
True if a 0x3C was seen during this exchange.
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.
uint8_t capture_packet_status
Chip packet-status byte.
bool capture_valid
True if radio capture is meaningful.
uint8_t request_cmd
Command ID of the original request.
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.
Parsed IO‑Homecontrol frame (CTRL0/1 + addresses + command + data).
Definition proto_frame.h:88
How one listen window is to be spent — everything ExchangeEngine::listen() needs; everything else is ...
Raw packet received from the radio.
All runtime tunable parameters for pairing and radio diagnostics.
Context carried across one outbound authenticated exchange.
Runtime tuning configuration for pairing and radio diagnostics.