Home IO Control
ESPHome add-on for IO-Homecontrol devices
Loading...
Searching...
No Matches
hub_exchange.h
Go to the documentation of this file.
1#pragma once
2
3/// @file hub_exchange.h
4/// @brief Internal exchange-state model for hub-owned authenticated non‑pairing flows.
5/// @ingroup hioc_hub
6///
7/// This module defines the progress-stage enums and context structures used for
8/// outbound authenticated exchanges (controller → device) and inbound authentication
9/// (device → controller). These are the building blocks that power commands like
10/// set_position, request_status, and handling unsolicited status‑update frames.
11///
12/// Note on the enums: ExchangeEngine's blocking helpers drive control flow through
13/// the decisions:: classifiers; the state enums below are written at each step but
14/// only read back by the exchange debug snapshot, so log lines can name the stage
15/// an exchange reached before failing.
16///
17/// Exchange lifecycle (outbound):
18/// 1. Controller sends a command with START flag (e.g., CMD_EXECUTE, CMD_PRIVATE).
19/// 2. Device may challenge with CMD_CHALLENGE_REQ (0x3C) if it requires auth.
20/// 3. Controller computes HMAC and responds with CMD_CHALLENGE_RESP (0x3D).
21/// 4. Device finally sends the response frame (e.g., CMD_PRIVATE_RESP with position).
22///
23/// Inbound authentication (device-initiated):
24/// Device sends a command that requires verification (e.g., CMD_STATUS_UPDATE).
25/// Controller challenges with 0x3C, device proves knowledge of system key with 0x3D,
26/// controller acknowledges with CMD_STATUS_UPDATE_RESP (0x72).
27///
28/// Both paths rely on the HMAC construction defined in proto_crypto.h which uses
29/// AES-128-ECB to encrypt an IV derived from the original frame bytes and a
30/// 6-byte random challenge.
31///
32/// This header also defines the shared listen primitive's types (ListenPolicy,
33/// ReplyDisposition, ListenOutcome, ReplyHandler, ListenSpec) — the single channel-policy-aware
34/// wait loop both ExchangeEngine's outbound waits and PairingEngine's waits are built on. See
35/// ExchangeEngine::listen() (exchange_engine.h) for the loop itself.
36
37#include "proto_frame.h"
38#include "radio_interface.h"
39#include <cstdint>
40#include <functional>
41#include <string>
42
43namespace esphome {
44namespace home_io_control {
45
46namespace exchange {
47
48/// @brief Progress stages of an outbound authenticated exchange (non‑pairing).
49///
50/// Recorded for the exchange debug snapshot; never branched on. See the file
51/// header note for why this is a stage marker rather than a driving state machine.
52enum class OutboundExchangeState : uint8_t {
53 IDLE, ///< No active exchange; idle state.
54 TX_REQUEST, ///< Request frame transmitted; awaiting first response from device.
55 WAIT_FIRST_RESPONSE, ///< Listening for first response. This may be a challenge (0x3C) or the final response.
56 BUILD_AUTH_RESPONSE, ///< Building the 0x3D challenge response after receiving 0x3C.
57 TX_AUTH_RESPONSE, ///< Auth response (0x3D) transmitted; awaiting device's final reply.
58 WAIT_FINAL_RESPONSE, ///< Listening for the authenticated final response (e.g., status frame).
59 SUCCESS, ///< Exchange completed successfully; device acknowledged.
60 FAILED, ///< Exchange failed (timeout, retries exhausted, or radio error).
61};
62
63/// @brief Progress stages of inbound authentication (device‑initiated commands).
64///
65/// Recorded for the exchange debug snapshot; never branched on. See the file
66/// header note for why this is a stage marker rather than a driving state machine.
67enum class InboundAuthState : uint8_t {
68 IDLE, ///< No inbound authentication in progress.
69 TX_CHALLENGE, ///< Challenge (0x3C) sent to device; awaiting 0x3D response.
70 WAIT_CHALLENGE_RESPONSE, ///< Timer running; waiting for device's HMAC proof (0x3D).
71 VERIFIED, ///< Device successfully authenticated; command is trusted.
72 FAILED, ///< Authentication failed (timeout or HMAC mismatch).
73};
74
75/// @brief Context carried across one outbound authenticated exchange.
77 OutboundExchangeState state{OutboundExchangeState::IDLE}; ///< Current state machine state.
78 uint8_t try_index{0}; ///< Current retry attempt (1‑based within EXCHANGE_RETRY_COUNT).
79 bool saw_challenge{false}; ///< True if a 0x3C challenge was received during this exchange.
80 uint32_t try_start_ms{0}; ///< millis() when this try's request transmit began, after any retry gap.
81 uint32_t wait_ms{0}; ///< Current timeout window for the active wait (ms).
82 uint32_t first_response_ms{0}; ///< Timestamp when the first valid response arrived (for RTT/timing).
83 uint16_t request_preamble{0}; ///< Start preamble (bytes) this try's request went out with.
84 uint32_t target_last_seen_ms{0}; ///< When the target was last heard (millis), 0 = unknown; logged as
85 ///< the try's `age_ms=` so field logs can relate hits to wake state.
86 IoFrame rx{}; ///< Most recent candidate frame received during the exchange.
87};
88
89/// @brief Context for a single inbound authentication (device‑initiated command).
91 InboundAuthState state{InboundAuthState::IDLE}; ///< Current authentication state.
92 IoFrame challenge{}; ///< The 0x3C challenge frame we sent (needed to verify 0x3D response).
93};
94
95} // namespace exchange
96
97/// @brief Which channels a listen covers.
98///
99/// A property of what is being waited for, not of the radio: a unicast exchange is a conversation
100/// pinned to the channel the request went out on, so its reply always lands there too and there is
101/// nothing to hop for. A broadcast has no single recipient, though — every device that answers is
102/// continuing its own independent channel-hopping rather than joining a pinned conversation, so the
103/// channel it happens to be on when it replies is largely decoupled from whichever channel carried
104/// the request. Whether "largely" still means "skip the request channel" depends on the replying
105/// population: Somfy always-alive roll-call replies land on the request channel about 1 in 149
106/// times (skipping it costs almost nothing), while real VELUX low-power roll-call replies have
107/// been observed landing there. Shared by @ref ExchangeEngine::listen() and, prospectively, any
108/// other caller that waits for a radio reply — hence living beside the primitive rather than
109/// folded into one loop's local logic.
110enum class ListenPolicy : uint8_t {
111 HOLD_REQUEST_CHANNEL, ///< Never retunes, never slices. Unicast replies.
112 ROTATE_ALL_CHANNELS, ///< CH1->CH2->CH3->CH1. Broadcast whose replies may use any channel, including the
113 ///< request channel (the low-power roll-call pass).
114 ROTATE_SKIPPING_REQUEST, ///< The two channels that are not the request channel. Discovery and the
115 ///< always-alive roll-call pass.
116};
117
118/// @brief What the caller wants done with the frame a listen just received.
119enum class ReplyDisposition : uint8_t {
120 ACCEPT, ///< This is the frame the caller was waiting for — stop listening, return ACCEPTED.
121 IGNORE, ///< Unparsable / not ours / wrong exchange — keep listening.
122 ABORT, ///< An explicit refusal (e.g. CMD_ERROR_RESP) — stop listening, return ABORTED.
123};
124
125/// @brief How one call to @ref ExchangeEngine::listen() ended.
126enum class ListenOutcome : uint8_t {
127 ACCEPTED, ///< The handler returned ReplyDisposition::ACCEPT for some received frame.
128 ABORTED, ///< The handler returned ReplyDisposition::ABORT for some received frame.
129 TIMED_OUT, ///< `spec.window_ms` elapsed with no ACCEPT/ABORT.
130};
131
132/// @brief Invoked for every packet the radio delivers during a listen, before the listen decides
133/// whether to keep waiting.
134///
135/// @param parsed Points at the caller's own output frame, already filled by `parse()`; nullptr
136/// when `parse()` rejected the packet (the exchange waits log that case, the pairing waits do
137/// not).
138/// @param packet The raw packet, for length/frequency logging.
139/// @return What the listen should do next — see @ref ReplyDisposition.
140///
141/// Keep captures to a few pointers: small callables avoid `std::function`'s heap fallback on the
142/// implementations this project builds against.
143using ReplyHandler = std::function<ReplyDisposition(const IoFrame *parsed, const RadioRxPacket &packet)>;
144
145/// @brief What a listen heard without accepting it — filled by @ref ExchangeEngine::listen() when
146/// a caller passes one in ListenSpec::stats.
147///
148/// A listen that ends empty-handed has two very different stories behind it: nothing reached the
149/// radio at all, or something did and was lost or rejected on this side. The radio capture cannot
150/// tell them apart after the fact, because every re-arm inside a holding listen clears it; these
151/// counters are taken at the moment each event happens instead. Counts saturate at 255.
153 uint8_t frames_ignored{0}; ///< Frames the radio delivered that the handler did not accept (wrong
154 ///< exchange, unrelated traffic, unparsable).
155 uint8_t failed_receptions{0}; ///< Receptions the radio started but could not deliver: a holding
156 ///< listen's early return before its deadline (CRC or length failure).
157 uint16_t last_failed_irq{0}; ///< Radio IRQ status captured at the most recent failed reception.
158};
159
160/// @brief How one listen window is to be spent — everything @ref ExchangeEngine::listen() needs;
161/// everything else is the handler's business.
163 uint32_t window_ms{0}; ///< Total time budget for this listen, in milliseconds.
164 ListenPolicy policy{ListenPolicy::HOLD_REQUEST_CHANNEL}; ///< Which channels this listen covers.
165 /// Channel the request went out on (Hz). Required by ListenPolicy::ROTATE_SKIPPING_REQUEST,
166 /// where it names the channel to leave before the first listen; ignored by the other policies.
167 uint32_t request_freq{0};
168 /// Per-channel dwell for the ROTATE_* policies, in milliseconds; ignored by
169 /// ListenPolicy::HOLD_REQUEST_CHANNEL. **0 (the default) means "ask the driver"** —
170 /// `listen()` falls back to `radio->hop_dwell_ms(tuning)`, the chip's own answer to "how long
171 /// must this radio sit on a channel before it can hear anything at all". Every ROTATE_* call
172 /// site today leaves this at 0: discovery and the broadcast roll-call's two passes have no
173 /// measured reason to dwell differently from each other. A non-zero value is reserved for the
174 /// rare case where one listen *does* have a measured reason to dwell differently from the
175 /// others on every chip — a difference that is per-chip and per-loop, not just per-chip, is the
176 /// only thing that justifies it; that measurement doesn't exist for any call site today. Not a
177 /// tuning knob either way: a constant at the call site, never user-configurable.
178 uint32_t dwell_ms{0};
179 /// Hop after a frame the handler ignored. Only discovery sets this true (the default): a
180 /// broadcast discovery window is full of unrelated traffic and the reply channel is unknown, so
181 /// an ignored frame there is no reason to keep spending the window on this channel. The
182 /// roll-call is the one rotating listen that sets this false — a reception is positive evidence
183 /// the other way: it proves responders are on this channel, and replies arrive spread across the
184 /// whole window, so staying put costs nothing.
186 /// Extend the listen instead of hopping while the chip reports a preamble or sync word, so an
187 /// arriving frame is not cut off mid-reception. Set by both rotating listens (pairing discovery
188 /// and the broadcast roll-call): every rotating dwell is short relative to one frame's air time,
189 /// so without this guard a reply would routinely get cut off mid-retune.
191 /// Length of the preamble/sync extension, in milliseconds. Sized to one frame's air time rather
192 /// than to a hop slice, so it is the same on every chip regardless of how long that chip dwells
193 /// per channel.
194 uint32_t linger_dwell_ms{0};
195 /// Called on every hop, for callers that count hops in their own telemetry (pairing does; the
196 /// exchange loops do not). Empty by default, in which case no callback fires.
197 std::function<void()> on_hop;
198 /// Where to count what this listen heard without accepting it; null (the default) counts
199 /// nothing. The final-reply wait of an authenticated exchange is the one caller today: its
200 /// counts are what separates a device that never replied from a reply this side lost.
202};
203
204} // namespace home_io_control
205} // namespace esphome
InboundAuthState
Progress stages of inbound authentication (device‑initiated commands).
@ WAIT_CHALLENGE_RESPONSE
Timer running; waiting for device's HMAC proof (0x3D).
@ VERIFIED
Device successfully authenticated; command is trusted.
@ TX_CHALLENGE
Challenge (0x3C) sent to device; awaiting 0x3D response.
@ IDLE
No inbound authentication in progress.
OutboundExchangeState
Progress stages of an outbound authenticated exchange (non‑pairing).
@ TX_REQUEST
Request frame transmitted; awaiting first response from device.
@ TX_AUTH_RESPONSE
Auth response (0x3D) transmitted; awaiting device's final reply.
@ BUILD_AUTH_RESPONSE
Building the 0x3D challenge response after receiving 0x3C.
@ SUCCESS
Exchange completed successfully; device acknowledged.
@ WAIT_FIRST_RESPONSE
Listening for first response. This may be a challenge (0x3C) or the final response.
@ WAIT_FINAL_RESPONSE
Listening for the authenticated final response (e.g., status frame).
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...
ReplyDisposition
What the caller wants done with the frame a listen just received.
@ ABORT
An explicit refusal (e.g. CMD_ERROR_RESP) — stop listening, return ABORTED.
@ 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.
@ HOLD_REQUEST_CHANNEL
Never retunes, never slices. Unicast replies.
ListenOutcome
How one call to ExchangeEngine::listen() ended.
@ ABORTED
The handler returned ReplyDisposition::ABORT for some received frame.
@ ACCEPTED
The handler returned ReplyDisposition::ACCEPT for some received frame.
@ TIMED_OUT
spec.window_ms elapsed with no ACCEPT/ABORT.
IO-Homecontrol 2W frame container: control bytes, IoFrame and (de)serialization.
Radio abstraction layer for IO-Homecontrol.
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 ...
uint32_t request_freq
Channel the request went out on (Hz).
uint32_t window_ms
Total time budget for this listen, in milliseconds.
uint32_t linger_dwell_ms
Length of the preamble/sync extension, in milliseconds.
ListenStats * stats
Where to count what this listen heard without accepting it; null (the default) counts nothing.
ListenPolicy policy
Which channels this listen covers.
bool linger_on_preamble
Extend the listen instead of hopping while the chip reports a preamble or sync word,...
uint32_t dwell_ms
Per-channel dwell for the ROTATE_* policies, in milliseconds; ignored by ListenPolicy::HOLD_REQUEST_C...
bool hop_after_ignored_frame
Hop after a frame the handler ignored.
std::function< void()> on_hop
Called on every hop, for callers that count hops in their own telemetry (pairing does; the exchange l...
What a listen heard without accepting it — filled by ExchangeEngine::listen() when a caller passes on...
uint16_t last_failed_irq
Radio IRQ status captured at the most recent failed reception.
uint8_t frames_ignored
Frames the radio delivered that the handler did not accept (wrong exchange, unrelated traffic,...
uint8_t failed_receptions
Receptions the radio started but could not deliver: a holding listen's early return before its deadli...
Raw packet received from the radio.
Context for a single inbound authentication (device‑initiated command).
IoFrame challenge
The 0x3C challenge frame we sent (needed to verify 0x3D response).
InboundAuthState state
Current authentication state.
Context carried across one outbound authenticated exchange.
uint32_t first_response_ms
Timestamp when the first valid response arrived (for RTT/timing).
uint16_t request_preamble
Start preamble (bytes) this try's request went out with.
uint8_t try_index
Current retry attempt (1‑based within EXCHANGE_RETRY_COUNT).
uint32_t try_start_ms
millis() when this try's request transmit began, after any retry gap.
IoFrame rx
Most recent candidate frame received during the exchange.
uint32_t wait_ms
Current timeout window for the active wait (ms).
OutboundExchangeState state
Current state machine state.
uint32_t target_last_seen_ms
When the target was last heard (millis), 0 = unknown; logged as the try's age_ms= so field logs can r...
bool saw_challenge
True if a 0x3C challenge was received during this exchange.