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 exchange_start_ms{0}; ///< Timestamp when the exchange attempt began (millis).
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 IoFrame rx{}; ///< Most recent candidate frame received during the exchange.
84};
85
86/// @brief Context for a single inbound authentication (device‑initiated command).
88 InboundAuthState state{InboundAuthState::IDLE}; ///< Current authentication state.
89 IoFrame challenge{}; ///< The 0x3C challenge frame we sent (needed to verify 0x3D response).
90};
91
92} // namespace exchange
93
94/// @brief Which channels a listen covers.
95///
96/// A property of what is being waited for, not of the radio: a unicast exchange is a conversation
97/// pinned to the channel the request went out on, so its reply always lands there too and there is
98/// nothing to hop for. A broadcast has no single recipient, though — every device that answers is
99/// continuing its own independent channel-hopping rather than joining a pinned conversation, so the
100/// channel it happens to be on when it replies is effectively decoupled from whichever channel
101/// carried the request. In practice a broadcast reply essentially never lands back on the
102/// requesting channel, so dwelling there wastes part of the listen window. Shared by
103/// @ref ExchangeEngine::listen() and, prospectively, any other caller that waits for a radio reply
104/// — hence living beside the primitive rather than folded into one loop's local logic.
105enum class ListenPolicy : uint8_t {
106 HOLD_REQUEST_CHANNEL, ///< Never retunes, never slices. Unicast replies.
107 ROTATE_ALL_CHANNELS, ///< CH1->CH2->CH3->CH1. Broadcast whose reply channel is unknown.
108 ROTATE_SKIPPING_REQUEST, ///< The two channels that are not the request channel. Roll-call.
109};
110
111/// @brief What the caller wants done with the frame a listen just received.
112enum class ReplyDisposition : uint8_t {
113 ACCEPT, ///< This is the frame the caller was waiting for — stop listening, return ACCEPTED.
114 IGNORE, ///< Unparsable / not ours / wrong exchange — keep listening.
115 ABORT, ///< An explicit refusal (e.g. CMD_ERROR_RESP) — stop listening, return ABORTED.
116};
117
118/// @brief How one call to @ref ExchangeEngine::listen() ended.
119enum class ListenOutcome : uint8_t {
120 ACCEPTED, ///< The handler returned ReplyDisposition::ACCEPT for some received frame.
121 ABORTED, ///< The handler returned ReplyDisposition::ABORT for some received frame.
122 TIMED_OUT, ///< `spec.window_ms` elapsed with no ACCEPT/ABORT.
123};
124
125/// @brief Invoked for every packet the radio delivers during a listen, before the listen decides
126/// whether to keep waiting.
127///
128/// @param parsed Points at the caller's own output frame, already filled by `parse()`; nullptr
129/// when `parse()` rejected the packet (the exchange waits log that case, the pairing waits do
130/// not).
131/// @param packet The raw packet, for length/frequency logging.
132/// @return What the listen should do next — see @ref ReplyDisposition.
133///
134/// Keep captures to a few pointers: small callables avoid `std::function`'s heap fallback on the
135/// implementations this project builds against.
136using ReplyHandler = std::function<ReplyDisposition(const IoFrame *parsed, const RadioRxPacket &packet)>;
137
138/// @brief How one listen window is to be spent — everything @ref ExchangeEngine::listen() needs;
139/// everything else is the handler's business.
141 uint32_t window_ms{0}; ///< Total time budget for this listen, in milliseconds.
142 ListenPolicy policy{ListenPolicy::HOLD_REQUEST_CHANNEL}; ///< Which channels this listen covers.
143 /// Channel the request went out on (Hz). Required by ListenPolicy::ROTATE_SKIPPING_REQUEST,
144 /// where it names the channel to leave before the first listen; ignored by the other policies.
145 uint32_t request_freq{0};
146 /// Per-channel dwell for the ROTATE_* policies, in milliseconds; ignored by
147 /// ListenPolicy::HOLD_REQUEST_CHANNEL. **0 (the default) means "ask the driver"** —
148 /// `listen()` falls back to `radio->hop_dwell_ms(tuning)`, the chip's own answer to "how long
149 /// must this radio sit on a channel before it can hear anything at all". Every ROTATE_* call
150 /// site today leaves this at 0: discovery and the broadcast roll-call have no measured reason to
151 /// dwell differently from each other. A non-zero value is reserved for the rare case where one
152 /// listen *does* have a measured reason to dwell differently from the others on every chip — a
153 /// difference that is per-chip and per-loop, not just per-chip, is the only thing that justifies
154 /// it; that measurement doesn't exist for any call site today. Not a tuning knob either way: a
155 /// constant at the call site, never user-configurable.
156 uint32_t dwell_ms{0};
157 /// Hop after a frame the handler ignored. Only discovery sets this true (the default): a
158 /// broadcast discovery window is full of unrelated traffic and the reply channel is unknown, so
159 /// an ignored frame there is no reason to keep spending the window on this channel. The
160 /// roll-call is the one rotating listen that sets this false — a reception is positive evidence
161 /// the other way: it proves responders are on this channel, and replies arrive spread across the
162 /// whole window, so staying put costs nothing.
164 /// Extend the listen instead of hopping while the chip reports a preamble or sync word, so an
165 /// arriving frame is not cut off mid-reception. Set by both rotating listens (pairing discovery
166 /// and the broadcast roll-call): every rotating dwell is short relative to one frame's air time,
167 /// so without this guard a reply would routinely get cut off mid-retune.
169 /// Length of the preamble/sync extension, in milliseconds. Sized to one frame's air time rather
170 /// than to a hop slice, so it is the same on every chip regardless of how long that chip dwells
171 /// per channel.
172 uint32_t linger_dwell_ms{0};
173 /// Called on every hop, for callers that count hops in their own telemetry (pairing does; the
174 /// exchange loops do not). Empty by default, in which case no callback fires.
175 std::function<void()> on_hop;
176};
177
178} // namespace home_io_control
179} // 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.
@ FAILED
Exchange failed (timeout, retries exhausted, or radio error).
@ 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.
@ ACCEPT
This is the frame the caller was waiting for — stop listening, return ACCEPTED.
@ ABORT
An explicit refusal (e.g. CMD_ERROR_RESP) — stop listening, return ABORTED.
@ IGNORE
Unparsable / not ours / wrong exchange — keep listening.
ListenPolicy
Which channels a listen covers.
@ ROTATE_SKIPPING_REQUEST
The two channels that are not the request channel. Roll-call.
@ ROTATE_ALL_CHANNELS
CH1->CH2->CH3->CH1. Broadcast whose reply channel is unknown.
@ 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:88
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.
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...
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).
uint8_t try_index
Current retry attempt (1‑based within EXCHANGE_RETRY_COUNT).
IoFrame rx
Most recent candidate frame received during the exchange.
uint32_t exchange_start_ms
Timestamp when the exchange attempt began (millis).
uint32_t wait_ms
Current timeout window for the active wait (ms).
OutboundExchangeState state
Current state machine state.
bool saw_challenge
True if a 0x3C challenge was received during this exchange.