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