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