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 "proto_frame.h"
27#include "proto_timing.h"
28#include "radio_interface.h"
29#include "transmit_observer.h"
30#include "tuning_config.h"
31
32#include <cstdint>
33#include <functional>
34#include <optional>
35#include <utility>
36
37namespace esphome {
38namespace home_io_control {
39
40/// @brief Authenticated exchange engine — outbound and inbound protocol flows.
41///
42/// All timing constants (retry count/delay, response windows) come from
43/// proto_timing.h; per-chip dwell overrides are queried from the RadioDriver.
44/// @brief What an outbound exchange actually achieved.
45///
46/// Deliberately not a bool: "the device accepted the command" and "the device told us what
47/// happened" are different facts, and some devices only ever deliver the first.
48///
49/// Some devices challenge a command, authenticate it, execute it, and then transmit nothing for
50/// several seconds — up to a dozen — reporting via an asynchronous status update later instead of
51/// closing the exchange with a synchronous reply, all well outside the exchange's own response
52/// window. Other devices on the same protocol close the exchange properly with a synchronous 0x04
53/// (see tests/corpus/captures/exchange/somfy_awning_exchange_open_sx1276.yaml), so the four-frame exchange
54/// is real — just not universal, and a caller cannot assume either shape from the command alone.
55///
56/// SUCCESS_UNCONFIRMED exists so that silence after a real authentication is not treated the same
57/// as a request the device may never have heard at all: the two need different retry rules (see
58/// decisions::retry_after_unconfirmed_accept_is_safe()) and different reporting to the caller.
59/// Silence is not always harmless, though: a device that got the request may still have missed our
60/// challenge answer and not acted, which is why a CMD_EXECUTE to a device that normally confirms is
61/// sent once more before this outcome is returned.
62enum class ExchangeOutcome : uint8_t {
63 FAILED, ///< No usable reply; the device may never have heard the request.
64 SUCCESS_WITH_RESPONSE, ///< Device replied; the caller's `response` frame is populated.
65 SUCCESS_UNCONFIRMED, ///< Device authenticated the request — so it received and accepted it —
66 ///< but sent no final response. `response` is NOT populated. Callers that
67 ///< need payload (key exchange) must treat this as failure; callers that
68 ///< only need "the command landed" should treat it as success. For every
69 ///< command but CMD_EXECUTE, this outcome is only returned after the full
70 ///< retry budget is spent; a CMD_EXECUTE gets at most one re-send, and only
71 ///< to a device that normally confirms — see
72 ///< retry_after_unconfirmed_accept_is_safe().
73};
74
76 public:
77 /// Construct the engine with double-pointer indirection into the hub's
78 /// RadioDriver pointer and direct pointers to the node/key byte arrays and
79 /// the tuning config. Pointers must remain valid for the lifetime of the
80 /// engine (guaranteed because hub owns all referenced members).
81 /// @param radio_ptr Address of the hub's `RadioDriver *radio_` member.
82 /// @param node_id Pointer to the hub's `node_id_[NODE_ID_SIZE]` array.
83 /// @param system_key Pointer to the hub's `system_key_[AES_KEY_SIZE]` array.
84 /// @param tuning Pointer to the hub's `TuningConfig tuning_` member.
85 ExchangeEngine(RadioDriver **radio_ptr, const uint8_t *node_id, const uint8_t *system_key,
86 const TuningConfig *tuning);
87
88 // Non-copyable; the hub owns exactly one engine tied to its member addresses.
89 ExchangeEngine(const ExchangeEngine &) = delete;
91
92 // -------------------------------------------------------------------------
93 // Core exchange API
94 // -------------------------------------------------------------------------
95
96 /// Execute an outbound authenticated exchange with retry.
97 /// @param request Frame to transmit (cmd + endpoints already filled).
98 /// @param response Populated only for @ref ExchangeOutcome::SUCCESS_WITH_RESPONSE.
99 /// @param freq RF channel frequency (Hz).
100 /// @param max_tries Cap on transmit attempts for this exchange, clamped to
101 /// [1, EXCHANGE_RETRY_COUNT]. Callers whose failure is already re-armed elsewhere (a
102 /// scheduler-owned status poll) pass SCHEDULED_POLL_MAX_TRIES so a dead device does not
103 /// block loop() for the full retry product. The attempts keep the default gap and the
104 /// tuned reply window; a caller that wants more attempts or other timing passes an
105 /// decisions::AttemptPlan (the overload below).
106 /// @param request_preamble_override Preamble for the request frame in bytes, or 0 (the default) for
107 /// request_preamble_for()'s rule. Only pairing passes a value: it sends its directed start
108 /// frames with a preamble the device has just proven it hears (see
109 /// PairingEngine::pairing_start_preamble_()). The 0x3D challenge response keeps the
110 /// driver's response_preamble() either way. An override also switches the low-power wake
111 /// belief off for that exchange: the caller has already chosen the preamble.
112 /// @return What the device actually told us — see @ref ExchangeOutcome.
113 ExchangeOutcome send_and_receive(const IoFrame &request, IoFrame &response, uint32_t freq,
114 uint8_t max_tries = EXCHANGE_RETRY_COUNT, uint16_t request_preamble_override = 0);
115
116 /// The same exchange with an explicit attempt plan (decisions::attempt_plan()).
117 /// @param request As for the `max_tries` overload.
118 /// @param response As for the `max_tries` overload.
119 /// @param freq As for the `max_tries` overload.
120 /// @param plan Attempt count (clamped to [1, EXCHANGE_MAX_ATTEMPTS]), gap before each further
121 /// attempt, and first-reply window (the early attempts' and the last one's). An attempt that cannot finish
122 /// inside `exchange_total_budget_ms` is not started whatever the plan says (try_fits_budget_()).
123 /// @param request_preamble_override As for the `max_tries` overload.
124 /// @return As for the `max_tries` overload.
125 ExchangeOutcome send_and_receive(const IoFrame &request, IoFrame &response, uint32_t freq,
126 const decisions::AttemptPlan &plan, uint16_t request_preamble_override = 0);
127
128 /// The radio's estimate of how long transmitting `frame` on `freq` with `preamble` takes, in
129 /// whole milliseconds rounded up; 0 for a frame that does not serialize (it is never sent).
130 [[nodiscard]] uint32_t tx_time_ms(const IoFrame &frame, uint32_t freq, uint16_t preamble) const;
131
132 /// The attempt plan for a request sent with an explicit preamble, for a loop that runs its own
133 /// attempts (the key pull) under the same budget rule as send_and_receive().
134 /// @param kind What the request is for.
135 /// @param low_power The target is a low-power receiver.
136 /// @param request The request that will be sent (its size sets the transmit estimate).
137 /// @param freq RF channel the request goes out on (Hz).
138 /// @param preamble Preamble the request is sent with, in bytes.
139 /// @param last_window_ms Listen time of the last attempt, or 0 for `exchange_start_response_wait_ms`.
140 /// @param short_lead_tx_ms Transmit time of a short lead attempt when the plan leads with one
141 /// (decisions::attempt_plan()); empty for none.
142 [[nodiscard]] decisions::AttemptPlan plan_attempts(decisions::ReadKind kind, bool low_power, const IoFrame &request,
143 uint32_t freq, uint16_t preamble, uint16_t last_window_ms = 0,
144 std::optional<uint32_t> short_lead_tx_ms = std::nullopt) const;
145
146 /// The attempt plan for a read of @p request's target, with the transmit time this engine would
147 /// plan for the request: the radio's air-time estimate for the request's start preamble
148 /// (request_preamble_for(), the long wake-up preamble for a low-power target) on @p freq. The
149 /// public way to ask decisions::attempt_plan() without knowing the radio's timing; the plan
150 /// then agrees with the guard that runs each attempt (try_fits_budget_()).
151 /// @param kind What the read is for.
152 /// @param request The request that will be sent (its CTRL1 low-power bit selects the power class).
153 ///
154 /// A settling read of a low-power receiver whose state the hub has no news of
155 /// (decisions::WakeBelief::UNKNOWN, a status read only) leads with one short attempt before the
156 /// wake-up attempts, and when all of them do not fit the exchange budget with it, may run past
157 /// the budget by that attempt's cost (decisions::AttemptPlan::short_lead, budget_allowance_ms); the
158 /// engine's guard honours the same allowance. A read with a fixed preamble, a single-try read and a command have no
159 /// lead.
160 /// @param freq RF channel the request goes out on (Hz).
161 /// @param request_preamble_override The preamble the caller will fix for the exchange (as for
162 /// send_and_receive()), or 0; a fixed preamble leaves no lead.
163 /// @param state_unknown The caller has no trustworthy news of the target
164 /// (decisions::AttemptRequest::with_state_unknown()); carried in the plan so the exchange
165 /// resolves the same belief when it sends.
166 /// @return The plan to hand to the plan overload of send_and_receive().
167 [[nodiscard]] decisions::AttemptPlan plan_read(decisions::ReadKind kind, const IoFrame &request, uint32_t freq,
168 uint16_t request_preamble_override = 0,
169 bool state_unknown = false) const;
170
171 /// Authenticate an inbound device command via 0x3C challenge / 0x3D HMAC.
172 /// @param request The received inbound frame (e.g., CMD_STATUS_UPDATE).
173 /// @param freq RF channel the frame arrived on.
174 /// @return true if HMAC verified under the sender's key (see set_device_key_provider()); false on
175 /// timeout or mismatch.
176 bool authenticate_request(const IoFrame &request, uint32_t freq);
177
178 /// The key an exchange with `node` uses: the device's own key when the provider has one, else the hub key.
179 /// @param node Node ID (NODE_ID_SIZE bytes) of the device on the other end.
180 [[nodiscard]] const uint8_t *key_for(const uint8_t *node) const;
181
182 /// Same as authenticate_request(), but checks the device's 0x3D against `key` instead of the
183 /// hub's system key. Used by the key pull to make a device prove it holds the key it just sent.
184 /// @param request The device's frame the challenge is about (its cmd + data are the transcript).
185 /// @param freq RF channel to challenge on.
186 /// @param key 16-byte key the device's 0x3D must verify against.
187 /// @return true if the device answered and its HMAC verified with `key`.
188 bool authenticate_request(const IoFrame &request, uint32_t freq, const uint8_t *key);
189
190 /// @brief What one challenge (our 0x3C, the device's 0x3D) came to.
191 enum class ChallengeResult : uint8_t {
192 VERIFIED, ///< The device's 0x3D verified under the key.
193 NO_ANSWER, ///< Our 0x3C could not be sent, or no 0x3D came back within the response window.
194 MISMATCH, ///< A 0x3D came back, but it does not verify under the key.
195 };
196
197 /// One challenge of `request`'s sender under `key`, telling a lost answer from a wrong one. A caller
198 /// may retry a NO_ANSWER; a MISMATCH means the device does not hold `key`. Sends a fresh random
199 /// challenge with the radio's response preamble.
200 /// @param request The device's frame the challenge is about (its cmd + data are the transcript).
201 /// @param freq RF channel to challenge on.
202 /// @param key 16-byte key the device's 0x3D must verify against.
203 /// @return See ChallengeResult.
204 ChallengeResult challenge_sender(const IoFrame &request, uint32_t freq, const uint8_t *key);
205
206 /// The same, with the caller's challenge bytes and preamble. A caller that challenges again after a
207 /// NO_ANSWER passes the same bytes each time, so a 0x3D that arrives late, in the next try's window,
208 /// still verifies; a fresh challenge would turn that late answer into a MISMATCH.
209 /// @param request The device's frame the challenge is about (its cmd + data are the transcript).
210 /// @param freq RF channel to challenge on.
211 /// @param key 16-byte key the device's 0x3D must verify against.
212 /// @param challenge The 6 challenge bytes to send.
213 /// @param preamble Preamble of our 0x3C, in bytes.
214 /// @return See ChallengeResult.
215 ChallengeResult challenge_sender(const IoFrame &request, uint32_t freq, const uint8_t *key,
216 const uint8_t challenge[HMAC_SIZE], uint16_t preamble);
217
218 /// @brief Per-reply facts collect_broadcast_responses() hands its caller alongside the frame.
219 ///
220 /// The engine owns the TX-complete instant and the received packet; the caller cannot
221 /// reconstruct either on its own (its own `millis()` taken before the call would include the
222 /// transmit itself — up to ~213 ms for a LONG_PREAMBLE start frame — and skew latency figures).
224 int16_t rssi_dbm{0}; ///< RSSI of this reply (from the radio's last capture).
225 uint32_t rx_freq_hz{0}; ///< Channel the reply was received on (RadioRxPacket::freq_hz).
226 uint32_t after_tx_ms{0}; ///< Milliseconds from the request's transmit completing to this reply's delivery.
227 };
228
229 /// @brief Invoked for each matching broadcast reply, as it arrives.
230 /// @param frame Parsed reply frame (responder's address is `frame.src`).
231 /// @param info RSSI, receive channel, and post-transmit latency for this reply.
232 using BroadcastReplyHandler = std::function<void(const IoFrame &frame, const BroadcastReplyInfo &info)>;
233
234 /// Transmit `request` once and hand every matching broadcast reply to `on_reply` within
235 /// `window_ms`.
236 ///
237 /// Unlike send_and_receive(), this neither retries the transmit nor performs any
238 /// authentication — a broadcast roll-call has no per-transaction proof, so replies are
239 /// informational only (see the caller's protocol notes). Every candidate packet is parsed
240 /// and checked against `expected_cmd` and `node_id_` (as the frame's destination); anything
241 /// that fails `parse()`, carries a different `cmd`, or is not addressed to us is ignored
242 /// without ending collection.
243 ///
244 /// `policy` selects which channels the listen covers, defaulting to
245 /// `ListenPolicy::ROTATE_SKIPPING_REQUEST` (measured: 1 of 149 Somfy always-alive roll-call
246 /// replies landed on the request channel — leaving it before the first listen costs almost
247 /// nothing). Pass `ListenPolicy::ROTATE_ALL_CHANNELS` for a roll-call whose responders can
248 /// reply on the request channel (real VELUX low-power `0x2B`s were observed there).
249 /// `ListenPolicy::HOLD_REQUEST_CHANNEL` is not a valid broadcast policy — a broadcast has no
250 /// pinned conversation to hold — and callers must not pass it; there is no runtime check for
251 /// this because the only non-default caller fixes its policy at compile time.
252 ///
253 /// This method stores nothing and imposes no capacity: it neither buffers replies nor
254 /// deduplicates them, so the same responder answering twice within one window invokes
255 /// `on_reply` twice. Storage, deduplication, and any capacity limit belong to the caller,
256 /// which knows how little of each reply it actually needs to keep — see
257 /// `ManagementActions::scan_paired_devices()`, which decodes each frame on arrival into a
258 /// compact record rather than retaining whole frames. Collection always runs to the deadline.
259 /// @param request Frame to transmit once (cmd + endpoints already filled).
260 /// @param freq RF channel frequency (Hz) for the initial transmit.
261 /// @param expected_cmd Command byte a reply must carry to be considered.
262 /// @param window_ms How long to listen after the transmit, in milliseconds. The caller
263 /// owns this budget explicitly (rather than this method reading
264 /// `tuning_->pairing_discovery_wait_ms` itself) because a caller that
265 /// transmits more than once needs to divide one total time budget across
266 /// several calls — see `ManagementActions::scan_paired_devices()`.
267 /// @param on_reply Invoked once per matching reply, before the next packet is awaited, so
268 /// the handler must be cheap and must not block. Keep captures to a few
269 /// pointers: small callables avoid std::function's heap fallback on the
270 /// implementations this project builds against.
271 /// @param policy Which channels to listen on; see above. Defaults to
272 /// `ListenPolicy::ROTATE_SKIPPING_REQUEST`.
273 /// @return Number of matching replies handed to `on_reply` (duplicates included).
274 uint8_t collect_broadcast_responses(const IoFrame &request, uint32_t freq, uint8_t expected_cmd, uint32_t window_ms,
275 const BroadcastReplyHandler &on_reply,
277
278 /// @brief The one listen primitive every radio wait loop in this project is built on.
279 ///
280 /// Listens for up to `spec.window_ms`, applying `spec.policy` (hold the current channel, rotate
281 /// all three, or rotate skipping the request channel), and hands every packet the radio
282 /// delivers to `on_frame` before deciding whether to keep waiting. See @ref ListenPolicy for the
283 /// measurements behind each policy and @ref ListenSpec for what each field controls.
284 ///
285 /// Parses each received packet into `frame`, so on ListenOutcome::ACCEPTED the caller's `frame`
286 /// already holds the accepted frame and `packet` already holds its raw bytes — no copy is
287 /// needed. A packet that fails to parse is still handed to `on_frame` (with a null `parsed`
288 /// pointer), so a caller that wants to log or count unparsable frames still can.
289 ///
290 /// Any richer result than accept/refuse/timeout — a disposition with more than three values, a
291 /// captured "did we see any traffic at all" flag — is the caller's business: capture it in
292 /// `on_frame`'s closure and return ACCEPT/IGNORE. `ListenOutcome` itself never grows a fourth
293 /// value; that is how a shared primitive would turn back into one loop per caller.
294 ///
295 /// @param spec How this listen window is to be spent.
296 /// @param packet Scratch space for the whole listen: holds the last received packet on
297 /// return. On ACCEPTED that is the accepted packet; on ABORTED, the one `on_frame` refused;
298 /// on TIMED_OUT, whatever arrived last (or the caller's initial value, if nothing did).
299 /// @param frame Same lifetime as `packet`, parsed from it: holds the last received frame on
300 /// return, with the same ACCEPTED/ABORTED/TIMED_OUT correspondence as `packet` above.
301 /// @param on_frame Invoked for every packet the radio delivers; decides whether to accept,
302 /// abort, or keep listening. See @ref ReplyHandler.
303 /// @return ACCEPTED or ABORTED as `on_frame` decided, or TIMED_OUT if `spec.window_ms` elapsed
304 /// first.
305 ListenOutcome listen(const ListenSpec &spec, RadioRxPacket &packet, IoFrame &frame, const ReplyHandler &on_frame);
306
307 // -------------------------------------------------------------------------
308 // Infrastructure delegated from the hub
309 // -------------------------------------------------------------------------
310
311 /// Transmit a raw IoFrame with the given preamble length. A frame that opens a session (CTRL0
312 /// START) listens before talk on the TX channel itself (the radio checks it inside send_packet();
313 /// see RadioTxConfig::cca_threshold_dbm); a frame without START answers within a session and goes
314 /// out at once, unchecked (ADR 0045).
315 /// @param frame Frame to transmit.
316 /// @param freq RF frequency in Hz.
317 /// @param preamble Preamble length in bytes (e.g. `LONG_PREAMBLE`, `SHORT_PREAMBLE`, or a
318 /// tuning-configured value such as `normal_start_preamble`).
319 /// @return true if the radio accepted the packet; false otherwise.
320 bool transmit_frame(const IoFrame &frame, uint32_t freq, uint16_t preamble);
321
322 /// Preamble length for an outbound request frame. A non-start frame keeps the chip's short
323 /// response preamble. A start frame gets `LONG_PREAMBLE` only when it carries `CTRL1_LOW_POWER`
324 /// (its target is a duty-cycled receiver that must be woken); every other start frame gets the
325 /// runtime-tunable `normal_start_preamble`. For a directed frame, the target's per-device
326 /// `low_power` property sets the bit; for the roll-call broadcast, the pass being sent sets it (see
327 /// `ManagementActions::scan_paired_devices()`).
328 ///
329 /// This is the asleep / always-alive rule. `send_and_receive()` may pick a shorter preamble per
330 /// try for a low-power target it believes awake (see set_target_evidence_provider()), so the bit and
331 /// the preamble agree here but not necessarily on every try there; callers that send once, like
332 /// the discover-confirm step, always get the rule above.
333 ///
334 /// Public so any caller building its own start frame outside `send_and_receive()` — pairing's
335 /// discover-confirm step (0x2C) is one such caller — follows the same ADR 0029 rule instead of
336 /// re-implementing it next to a second copy.
337 /// @param request Frame the preamble is being chosen for.
338 /// @return Preamble length in bytes.
339 [[nodiscard]] uint16_t request_preamble_for(const IoFrame &request) const;
340
341 /// Send a 0x3D challenge response over `request`'s transcript, proving knowledge of the system
342 /// key to whoever sent `challenge`.
343 ///
344 /// The one place that builds and sends a 0x3D, so the 0x3D transcript rule (HMAC over the
345 /// challenged frame's cmd + data, create_challenge_resp()) and the response_preamble() choice
346 /// have exactly one owner, shared by the normal inbound-challenge path
347 /// (`handle_authentication_()`) and pairing's own post-0x32 challenge wait
348 /// (`PairingEngine::wait_for_key_confirm_()`). Does not touch `challenge_round_trips` —
349 /// callers that want it counted (handle_authentication_()) increment it themselves; the pairing
350 /// path deliberately does not, per that counter's own "no pairing path" doc.
351 /// @param request The frame whose cmd+data the challenger wants proof of (the transcript).
352 /// @param challenge The inbound 0x3C carrying the challenge bytes in `challenge.data`.
353 /// @param freq RF channel frequency (Hz) to transmit the 0x3D on.
354 /// @return true if the 0x3D was built and transmitted; false on a build or transmit failure.
355 bool answer_challenge(const IoFrame &request, const IoFrame &challenge, uint32_t freq);
356
357 /// @brief Advance the receiver one step along the protocol's channel rotation
358 /// (CH1→CH2→CH3→CH1).
359 /// @param skip_freq Channel to pass over, or 0 to rotate through all three. Used by the
360 /// always-alive roll-call pass (and discovery), whose replies almost never come back on the
361 /// channel that asked; the low-power roll-call pass rotates through all three instead.
362 void hop_frequency(uint32_t skip_freq = 0);
363
364 /// Hop only if the minimum dwell has elapsed and no frame is currently arriving on this
365 /// channel — @ref RadioDriver::reception_in_progress() gates the hop so a reception in
366 /// progress is never destroyed mid-arrival (issue #81). A deferred hop does not reset the
367 /// dwell timer: it fires on the first call after the reception clears, not a further
368 /// HOP_TIME_US later.
369 /// Called from the hub's `loop()` to honour passive channel scanning.
370 void maybe_hop();
371
372 /// Reset the hop-timer (called after radio init in hub setup()).
373 void reset_hop_timestamp();
374
375 // -------------------------------------------------------------------------
376 // Transmit observer
377 // -------------------------------------------------------------------------
378
379 /// Attach the observer transmit_frame() reports LBT deferrals and sent frames to. One slot:
380 /// attaching replaces the previous observer. PairingEngine attaches its PairingTelemetry for
381 /// the duration of a `discover_and_pair()` attempt and detaches it afterwards; outside that,
382 /// the slot is empty and reporting is a no-op.
383 /// @param observer Non-owning pointer, or nullptr to detach.
384 void set_transmit_observer(TransmitObserver *observer) { this->transmit_observer_ = observer; }
385
386 // -------------------------------------------------------------------------
387 // Per-target evidence
388 // -------------------------------------------------------------------------
389
390 /// @brief Looks up what the hub knows about a destination node (decisions::TargetEvidence).
391 /// @param dst Destination node ID (NODE_ID_SIZE bytes) of the request being sent.
392 /// @param out Filled with that device's evidence when it is known.
393 /// @return false when the destination is not a registered device (no evidence to give).
394 using TargetEvidenceProvider = std::function<bool(const uint8_t *dst, decisions::TargetEvidence &out)>;
395
396 /// Install the source of per-target evidence. Installed once, when the hub is constructed. The
397 /// provider only looks evidence up; every decision drawn from it stays in the engine — the wake
398 /// belief (decisions::wake_belief(), gated by the `low_power_wake_belief` tuning switch) and the
399 /// re-send of an unconfirmed CMD_EXECUTE (decisions::retry_after_unconfirmed_accept_is_safe()), so
400 /// each decision lives in one place. With no provider installed every low-power exchange keeps
401 /// `LONG_PREAMBLE` on every try.
402 /// @param provider Evidence lookup, or an empty function to detach.
404 this->target_evidence_provider_ = std::move(provider);
405 }
406
407 /// @brief Looks up the system key a device keeps of its own.
408 /// @param node Node ID (NODE_ID_SIZE bytes) of the device on the other end of the exchange.
409 /// @return The device's AES_KEY_SIZE-byte key, or nullptr when it has none (the hub key applies).
410 /// The pointer must stay valid until the exchange returns.
411 using DeviceKeyProvider = std::function<const uint8_t *(const uint8_t *node)>;
412
413 /// Install the source of per-device system keys. Installed once, when the hub is constructed. The
414 /// engine picks the key per frame: our `0x3D` is signed with the key of the request's destination,
415 /// and an inbound `0x3D` is verified against the key of the request's sender. A node the provider
416 /// does not know, or one without a key of its own, uses the hub key, so every call site of
417 /// send_and_receive() stays unchanged. With no provider installed every exchange uses the hub key.
418 /// @param provider Key lookup, or an empty function to detach.
419 void set_device_key_provider(DeviceKeyProvider provider) { this->device_key_provider_ = std::move(provider); }
420
421 /// @brief Forgets a destination's moving evidence.
422 /// @param dst Destination node ID (NODE_ID_SIZE bytes) of the request that went unanswered.
423 using WakeEvidenceSpentHandler = std::function<void(const uint8_t *dst)>;
424
425 /// Install what to do when an exchange planned on an `AWAKE` belief drew no frame at all: the
426 /// receiver did not answer the short preamble a moving one hears, nor the wake-up preamble, so the
427 /// belief that it is travelling is spent and the next exchange must lead with the wake-up preamble.
428 /// The engine decides when; the hub owns the device record and clears it. Installed once, next to
429 /// set_wake_evidence_provider().
430 /// @param handler Evidence reset, or an empty function to detach.
432 this->wake_evidence_spent_handler_ = std::move(handler);
433 }
434
435 // -------------------------------------------------------------------------
436 // Exchange debug snapshot
437 // -------------------------------------------------------------------------
438
439 /// @brief Whether an exchange's start preamble followed the low-power wake belief, and if not,
440 /// why. Reported as the `belief=` field of the exchange-failure log line, so a posted log says
441 /// which of these applied instead of one ambiguous "not applied".
442 enum class WakeBeliefUse : uint8_t {
443 NOT_LOW_POWER, ///< Not a low-power start frame: there is no wake-up preamble to reorder.
444 OVERRIDE, ///< The caller forced a preamble (pairing's directed frames).
445 SWITCHED_OFF, ///< The `low_power_wake_belief` tuning switch is off.
446 NO_PROVIDER, ///< No evidence source installed (set_target_evidence_provider()).
447 APPLIED, ///< The tries followed the belief in DebugInfo::wake_belief.
448 };
449
450 /// Log label for a WakeBeliefUse that is not APPLIED (an applied one logs the belief itself).
451 /// @param use Value to name.
452 /// @return "not_low_power", "override", "off", "no_provider" or "applied".
453 [[nodiscard]] static const char *wake_belief_use_name(WakeBeliefUse use);
454
455 /// @brief Snapshot of the last exchange attempt for diagnostics.
456 struct DebugInfo {
457 const char *stage{"idle"}; ///< Last recorded stage label.
458 uint8_t tries{0}; ///< Retry count (1-based).
459 uint8_t max_tries{EXCHANGE_RETRY_COUNT}; ///< Attempt cap this exchange was budgeted for.
460 WakeBeliefUse wake_belief_use{WakeBeliefUse::NOT_LOW_POWER}; ///< Whether the tries followed `wake_belief`,
461 ///< and why not if they did not.
463 ///< meaningful when `wake_belief_use` is
464 ///< WakeBeliefUse::APPLIED.
465 uint16_t last_try_preamble{0}; ///< Preamble (bytes) of the most recent request transmit attempt, 0 = none.
466 uint8_t request_cmd{0}; ///< Command ID of the original request.
467 bool saw_challenge{false}; ///< True if a 0x3C was seen during this exchange.
468 bool capture_valid{false}; ///< True if radio capture is meaningful.
469 bool capture_rx_done{false}; ///< True if RxDone IRQ fired.
470 bool capture_crc_error{false}; ///< True if CRC error flagged; see RadioCaptureInfo::crc_error.
471 uint32_t capture_freq_hz{0}; ///< RF frequency of the captured packet.
472 uint16_t capture_irq_status{0}; ///< Raw IRQ register value.
473 uint8_t capture_packet_status{0}; ///< Chip packet-status byte.
474 uint8_t capture_reported_len{0}; ///< Length reported by radio packet engine.
475 uint8_t capture_frame_len{0}; ///< Parsed protocol frame length.
476 int16_t capture_rssi_dbm{0}; ///< RSSI of the captured packet (dBm).
477 // What the final-reply waits of this exchange heard, counted per event (see ListenStats). The
478 // capture fields above keep the *first* informative reception, which in an authenticated
479 // exchange is the device's challenge, so they cannot describe the wait for the final reply.
480 uint8_t final_waits{0}; ///< Final-reply waits run (one per try that got as far as our 0x3D).
481 uint8_t final_rx_ignored{0}; ///< Frames received during those waits and not accepted as the reply.
482 uint8_t final_rx_failed{0}; ///< Receptions started during those waits that could not be decoded.
483 uint16_t final_rx_irq{0}; ///< Radio IRQ status at the most recent of those failed receptions.
484 };
485
486 /// Clear the debug snapshot and record the upcoming request command.
487 void reset_debug(uint8_t request_cmd);
488
489 /// Update the debug snapshot with the current stage and radio capture.
490 void record_debug(const char *stage, uint8_t tries, bool saw_challenge);
491
492 /// Log the debug snapshot as a WARN-level structured line.
493 /// @param device_id Human-readable device identifier for the log line.
494 void log_debug(const char *device_id) const;
495
496 /// Log the debug snapshot for an exchange that ended accepted-but-unconfirmed, at INFO.
497 ///
498 /// Same fields as log_debug(), different prefix and level: the device challenged us, so this is
499 /// not a failure and must not read like one. It is logged at all because the snapshot is the
500 /// only evidence of what happened after our challenge answer went out — whether the radio saw
501 /// nothing (our answer likely never arrived, and the device never acted) or received something
502 /// it could not use (the device answered and this side lost the reply). Without it the whole
503 /// path is silent, and a field report has no way to tell those apart.
504 /// @param device_id Human-readable device identifier for the log line.
505 void log_debug_unconfirmed(const char *device_id) const;
506
507 /// Read-only access to the current debug snapshot.
508 [[nodiscard]] const DebugInfo &get_debug() const { return debug_; }
509
510 // -------------------------------------------------------------------------
511 // Exchange-engine counters
512 // -------------------------------------------------------------------------
513
514 /// @brief Free-running counters for the engine's own retry/parse behavior — not per-device (see
515 /// the RSSI/Exchange-Failures per-device sensors for that) and not per-attempt (see DebugInfo for
516 /// that). Persist until explicitly reset, so a caller can diff two snapshots across an arbitrary
517 /// window. Coverage differs per field, see each field's own comment below: `lbt_retries` is the
518 /// only one that covers pairing traffic too (pairing calls transmit_frame() the same way normal
519 /// exchanges do); the other three only observe ExchangeEngine's own send_and_receive() path.
520 ///
521 /// Internal-only, deliberately: nothing outside the unit tests reads counters() today — no HA
522 /// sensor, no log line, no periodic dump. Built to back a scripted continuous-operation
523 /// reliability test against dedicated bench hardware; that hardware was retired before the test
524 /// could run, so no consumer exists yet. Kept anyway (zero runtime cost, no YAML/schema surface,
525 /// already tested) as a cheap, ready primitive for whenever someone next needs to debug
526 /// exchange-level health — wiring it to a sensor or log line at that point is a small,
527 /// self-contained addition, not a redesign.
528 struct Counters {
529 uint32_t lbt_retries{0}; ///< transmit_frame() LBT backoff iterations (channel busy);
530 ///< covers pairing traffic too, via pairing_engine.cpp's
531 ///< own transmit_frame() calls.
532 uint32_t retransmits{0}; ///< send_and_receive() TX attempts beyond the first; no
533 ///< pairing path.
534 uint32_t challenge_round_trips{0}; ///< Completed 0x3C/0x3D challenge-response cycles, either
535 ///< direction: a device challenging our outbound command
536 ///< (handle_authentication_()) or us authenticating an
537 ///< inbound one (authenticate_request()); no pairing path.
538 uint32_t parse_failures{0}; ///< Frames wait_for_first_response_()/wait_for_final_response_()
539 ///< could not parse; does not count pairing_engine.cpp's own
540 ///< parse-null sites or collect_broadcast_responses()'s.
541 };
542
543 /// Read-only access to the running counters. No production caller yet — see the Counters
544 /// doc comment above.
545 [[nodiscard]] const Counters &counters() const { return this->counters_; }
546
547 /// Zero every counter (e.g. to start a fresh measurement window). No production caller yet —
548 /// see the Counters doc comment above.
549 void reset_counters() { this->counters_ = Counters{}; }
550
551 private:
552 // --- listen() helper -------------------------------------------------------
553
554 /// Retune per `skip` (see hop_frequency()) and fire `spec.on_hop` if set. Factored out of
555 /// listen() purely to keep that function's cognitive complexity under the clang-tidy
556 /// threshold — a member function call doesn't add to the caller's complexity the way an
557 /// inline lambda definition does.
558 /// @param skip Channel to pass over, or 0 to rotate through all three — see hop_frequency().
559 /// @param spec The listen this hop belongs to; only `on_hop` is read.
560 void listen_hop_(uint32_t skip, const ListenSpec &spec);
561
562 // --- Transmission shape and the exchange budget ---------------------------
563
564 /// What a challenged try adds to the budget guard once the target has challenged: the final-reply
565 /// window, the 0x3D answer's own air time and its overhead. Counted for every later try of the
566 /// exchange (a silent target never adds it).
567 /// @param request The request being exchanged (stands in for the 0x3D's size).
568 /// @param freq RF channel (Hz).
569 [[nodiscard]] uint32_t final_reply_tail_ms_(const IoFrame &request, uint32_t freq) const;
570
571 /// The exchange budget rule: a try starts only if the whole try, up to the end of the last wait it
572 /// can make, ends inside the budget.
573 /// @param elapsed_ms Time since the exchange began.
574 /// @param gap_ms Wait before the try is sent.
575 /// @param tx_ms The try's transmit time (tx_time_ms()).
576 /// @param window_ms How long the try then listens for the first reply.
577 /// @param auth_tail_ms What a challenged try adds (final_reply_tail_ms_()) once the target has
578 /// challenged in this exchange, otherwise 0.
579 /// @param allowance_ms What the plan lets the exchange run past the budget
580 /// (decisions::AttemptPlan::budget_allowance_ms), 0 for none.
581 [[nodiscard]] bool try_fits_budget_(uint32_t elapsed_ms, uint32_t gap_ms, uint32_t tx_ms, uint32_t window_ms,
582 uint32_t auth_tail_ms, uint32_t allowance_ms = 0) const;
583
584 // --- Outbound exchange step helpers --------------------------------------
585
586 /// Transmit one request attempt and update context state on failure.
587 bool transmit_request_(const IoFrame &request, uint32_t freq, uint16_t preamble,
589
590 /// Block until the first response arrives or the wait window expires.
591 decisions::ExchangeFirstResponseDisposition wait_for_first_response_(const IoFrame &request,
593
594 /// Send the 0x3D challenge response after receiving a 0x3C from the device.
595 bool handle_authentication_(const IoFrame &request, uint32_t freq, exchange::OutboundExchangeContext &ctx);
596
597 /// Block until the final authenticated response arrives or the window expires.
598 decisions::ExchangeFinalResponseDisposition wait_for_final_response_(const IoFrame &request,
600
601 /// Decide how many more tries follow a try that ended accepted without a closing reply. When
602 /// that repeats a CMD_EXECUTE, log it and wait out the part of UNCONFIRMED_EXECUTE_RESEND_DELAY_MS
603 /// that the loop's own retry gap does not cover. For a CMD_EXECUTE, looks the target up through the
604 /// evidence provider; then applies decisions::retry_after_unconfirmed_accept_is_safe().
605 /// @param request Outbound request frame.
606 /// @param unconfirmed_tries Tries of this exchange so far that ended that way (1-based).
607 /// @param try_index The try that just ended, for the log line.
608 /// @param tries_left Tries the exchange still allows after this one; 0 ends it here,
609 /// before any re-send gap is waited.
610 /// @param elapsed_ms Time since the exchange began.
611 /// @param next_tx_ms The re-send's transmit time; a re-send whose transmission and first
612 /// reply window would not end inside the exchange budget is not attempted
613 /// (try_fits_budget_()). The re-send is the last copy, so its final-reply
614 /// window is not counted.
615 /// @param plan The exchange's attempt plan: its gap is part of the re-send wait and
616 /// its (full) window is the re-send's.
617 /// @return 0 to end the exchange now; UNCONFIRMED_EXECUTE_MAX_RESENDS for a CMD_EXECUTE that is
618 /// re-sent and UNCONFIRMED_SUBSCRIBE_MAX_RESENDS for a CMD_SUBSCRIBE_EVENT, either of which
619 /// also caps the ordinary failure retries after it; for any other request the tries that
620 /// bring the exchange to EXCHANGE_RETRY_COUNT in all, whatever the plan's attempt count.
621 uint8_t tries_after_unconfirmed_(const IoFrame &request, uint8_t unconfirmed_tries, uint8_t try_index,
622 uint8_t tries_left, uint32_t elapsed_ms, uint32_t next_tx_ms,
623 const decisions::AttemptPlan &plan);
624
625 /// Add one final-reply wait's ListenStats to the debug snapshot's `final_*` fields.
626 /// @param stats What that wait heard without accepting it.
627 void note_final_wait_(const ListenStats &stats);
628
629 /// @brief How the request's start preamble is chosen across one exchange's tries. Resolved once
630 /// per exchange by plan_request_preamble_(), then asked for each try.
631 struct PreamblePlan {
632 uint16_t fixed{0}; ///< Every try, unless `use` is WakeBeliefUse::APPLIED.
633 WakeBeliefUse use{WakeBeliefUse::NOT_LOW_POWER}; ///< APPLIED: order the tries by `belief`.
634 decisions::WakeBelief belief{decisions::WakeBelief::ASLEEP}; ///< Only read when APPLIED.
635 uint16_t short_preamble{0}; ///< The moving receiver's preamble; only read when APPLIED.
636 uint16_t wake_preamble{0}; ///< The resting receiver's wake-up preamble; only read when APPLIED.
637 uint32_t last_seen_ms{0}; ///< When the target was last heard (`millis()`), for the per-try `age=` log
638 ///< field; 0 = never, or the evidence was not looked up (belief not APPLIED).
639
640 /// @param try_index 1-based try number.
641 /// @return Preamble in bytes for that try.
642 [[nodiscard]] uint16_t for_try(uint8_t try_index) const {
643 return use == WakeBeliefUse::APPLIED
644 ? decisions::low_power_try_preamble(belief, try_index, short_preamble, wake_preamble)
645 : fixed;
646 }
647 };
648
649 /// Decide how `request`'s start preamble is chosen across this exchange's tries. An explicit
650 /// override wins; a frame that is not a low-power start frame keeps request_preamble_for()'s rule;
651 /// a low-power start frame is ordered by the target's wake belief when the switch is on and a
652 /// provider is installed, whatever the exchange's try count. Consults the provider at most once,
653 /// so a belief is stable within an exchange.
654 /// @param request Frame being sent.
655 /// @param override_preamble Caller-forced preamble in bytes, or 0 for none.
656 /// @param state_unknown The caller has no trustworthy news of the target (decisions::wake_belief()).
657 [[nodiscard]] PreamblePlan plan_request_preamble_(const IoFrame &request, uint16_t override_preamble,
658 bool state_unknown = false) const;
659
660 /// The start preamble of one try of an exchange run on @p plan: the short start preamble for the
661 /// plan's short lead (try 1), otherwise the preamble plan's.
662 /// @param plan The exchange's attempt plan.
663 /// @param preamble_plan How the request's preamble is chosen across tries.
664 /// @param try_index 1-based try number.
665 [[nodiscard]] uint16_t try_preamble_(const decisions::AttemptPlan &plan, const PreamblePlan &preamble_plan,
666 uint8_t try_index) const;
667
668 /// Spends a device's moving evidence when an exchange planned on an `AWAKE` belief drew no frame at
669 /// all (see set_wake_evidence_spent_handler()). Called once, when the exchange has failed.
670 /// @param request The exchange's request frame (a STOP never spends the evidence).
671 /// @param plan The preamble plan the exchange was sent with.
672 void spend_wake_evidence_if_silent_(const IoFrame &request, const PreamblePlan &plan);
673 /// True while the remaining time the target last reported has not run out (decisions::moving_until_holds()).
674 [[nodiscard]] bool still_travelling_by_its_own_count_(const IoFrame &request) const;
675
676 // --- Dependencies (back-references into the hub) -------------------------
677
678 RadioDriver **radio_ptr_; ///< Double-pointer: *radio_ptr_ is always the hub's active driver.
679 const uint8_t *node_id_; ///< Hub's node_id_[NODE_ID_SIZE] array.
680 const uint8_t *system_key_; ///< Hub's system_key_[AES_KEY_SIZE] array.
681 const TuningConfig *tuning_; ///< Hub's live TuningConfig (read on every LBT check).
682 TransmitObserver *transmit_observer_{nullptr}; ///< Non-owning; see set_transmit_observer().
683 TargetEvidenceProvider target_evidence_provider_; ///< See set_target_evidence_provider().
684 DeviceKeyProvider device_key_provider_; ///< See set_device_key_provider().
685 WakeEvidenceSpentHandler wake_evidence_spent_handler_; ///< See set_wake_evidence_spent_handler().
686
687 // --- Engine state --------------------------------------------------------
688
689 uint32_t last_hop_us_{0}; ///< Timestamp of the last channel hop (µs, from micros()).
690 DebugInfo debug_{}; ///< Snapshot updated throughout each exchange attempt.
691 Counters counters_{}; ///< Free-running counters; see counters()/reset_counters().
692};
693
694/// Longest rendered exchange-debug field list, plus headroom for a long command name. Stays well
695/// under ESP-IDF's 512-byte log line together with the longest message prefix, so the trailing
696/// fields are never cut off on hardware.
697static constexpr size_t EXCHANGE_DEBUG_LINE_SIZE = 384;
698
699/// @brief Render the structured field list shared by both exchange-debug log lines.
700///
701/// Split out of the log calls so the rendering is testable on host: the test log macros discard
702/// their arguments, so a formatter that writes into a caller's buffer is the only shape whose
703/// output a test can assert on.
704///
705/// @param buf Destination buffer, EXCHANGE_DEBUG_LINE_SIZE is always enough.
706/// @param buf_size Size of `buf`.
707/// @param device_id Human-readable device identifier.
708/// @param d Snapshot to render.
709/// @return snprintf()'s return value: the length the full line would have, so a caller (or a
710/// test) can detect truncation by comparing it against `buf_size`.
711int render_exchange_debug(char *buf, size_t buf_size, const char *device_id, const ExchangeEngine::DebugInfo &d);
712
713} // namespace home_io_control
714} // namespace esphome
void set_wake_evidence_spent_handler(WakeEvidenceSpentHandler handler)
Install what to do when an exchange planned on an AWAKE belief drew no frame at all: the receiver did...
std::function< void(const uint8_t *dst)> WakeEvidenceSpentHandler
Forgets a destination's moving evidence.
decisions::AttemptPlan plan_attempts(decisions::ReadKind kind, bool low_power, const IoFrame &request, uint32_t freq, uint16_t preamble, uint16_t last_window_ms=0, std::optional< uint32_t > short_lead_tx_ms=std::nullopt) const
The attempt plan for a request sent with an explicit preamble, for a loop that runs its own attempts ...
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.
ChallengeResult
What one challenge (our 0x3C, the device's 0x3D) came to.
WakeBeliefUse
Whether an exchange's start preamble followed the low-power wake belief, and if not,...
@ 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.
void set_transmit_observer(TransmitObserver *observer)
Attach the observer transmit_frame() reports LBT deferrals and sent frames to.
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.
std::function< bool(const uint8_t *dst, decisions::TargetEvidence &out)> TargetEvidenceProvider
Looks up what the hub knows about a destination node (decisions::TargetEvidence).
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.
const DebugInfo & get_debug() const
Read-only access to the current debug snapshot.
std::function< void(const IoFrame &frame, const BroadcastReplyInfo &info)> BroadcastReplyHandler
Invoked for each matching broadcast reply, as it arrives.
uint32_t tx_time_ms(const IoFrame &frame, uint32_t freq, uint16_t preamble) const
The radio's estimate of how long transmitting frame on freq with preamble takes, in whole millisecond...
void set_device_key_provider(DeviceKeyProvider provider)
Install the source of per-device system keys.
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.
std::function< const uint8_t *(const uint8_t *node)> DeviceKeyProvider
Looks up the system key a device keeps of its own.
bool authenticate_request(const IoFrame &request, uint32_t freq)
Authenticate an inbound device command via 0x3C challenge / 0x3D HMAC.
ExchangeOutcome send_and_receive(const IoFrame &request, IoFrame &response, uint32_t freq, uint8_t max_tries=EXCHANGE_RETRY_COUNT, uint16_t request_preamble_override=0)
Execute an outbound authenticated exchange with retry.
void reset_hop_timestamp()
Reset the hop-timer (called after radio init in hub setup()).
decisions::AttemptPlan plan_read(decisions::ReadKind kind, const IoFrame &request, uint32_t freq, uint16_t request_preamble_override=0, bool state_unknown=false) const
The attempt plan for a read of request's target, with the transmit time this engine would plan for th...
ExchangeEngine & operator=(const ExchangeEngine &)=delete
void set_target_evidence_provider(TargetEvidenceProvider provider)
Install the source of per-target evidence.
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.
Abstract radio driver for IO-Homecontrol.
Receives transmit events from ExchangeEngine::transmit_frame().
Pure transition helpers for hub-owned exchange and pairing frame decisions.
Internal exchange-state model for hub-owned authenticated non‑pairing flows.
uint16_t low_power_try_preamble(WakeBelief belief, uint8_t try_index, uint16_t short_preamble, uint16_t wake_preamble)
Start-preamble length for one try of a directed exchange to a low-power receiver.
ExchangeFinalResponseDisposition
Disposition for the final response after authentication.
ExchangeFirstResponseDisposition
Disposition for the first response in an authenticated exchange.
ReadKind
What a request is for, which decides how its attempts are spent.
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.
static constexpr size_t EXCHANGE_DEBUG_LINE_SIZE
Longest rendered exchange-debug field list, plus headroom for a long command name.
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.
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.
ListenPolicy
Which channels a listen covers.
@ ROTATE_SKIPPING_REQUEST
The two channels that are not the request channel.
ListenOutcome
How one call to ExchangeEngine::listen() ended.
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.
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.
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.
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
How one listen window is to be spent — everything ExchangeEngine::listen() needs; everything else is ...
What a listen heard without accepting it — filled by ExchangeEngine::listen() when a caller passes on...
Raw packet received from the radio.
All runtime tunable parameters for pairing and radio diagnostics.
How one exchange spends its attempts. Handed to ExchangeEngine::send_and_receive().
What the hub knows about the device an exchange is addressed to, as the exchange engine sees it.
Context carried across one outbound authenticated exchange.
Observer interface for every frame the exchange engine puts on air.
Runtime tuning configuration for pairing and radio diagnostics.