Home IO Control
ESPHome add-on for IO-Homecontrol devices
Loading...
Searching...
No Matches
hub_exchange.cpp
Go to the documentation of this file.
1#include "hub_exchange.h"
2
3#include "hub_decisions.h"
4#include "hub_core.h"
5#include "proto_commands.h"
6#include "proto_crypto.h"
7#include "esphome/core/application.h"
8#include "esphome/core/log.h"
9
10#include <algorithm>
11#include <cstring>
12
13namespace esphome {
14namespace home_io_control {
15
16static const char *const TAG = "home_io_control.exchange";
17
18/// @file hub_exchange.cpp
19/// @brief Outbound authenticated exchange state machine (non-pairing flows).
20/// @ingroup hioc_hub
21///
22/// Implements IOHomeControlComponent::send_and_receive_() and its stepwise
23/// helpers: transmit_request_(), wait_for_first_response_(),
24/// handle_authentication_(), wait_for_final_response_(). These functions
25/// encapsulate the retry loop, challenge-response authentication, and final
26/// response handling for commands sent to paired devices.
27///
28/// The exchange logic is separated from hub_core.cpp to keep the main loop
29/// and device-management concerns distinct from the protocol state machine.
30/// Pairing flows (discovery/key exchange) live in hub_pairing.cpp.
31
32namespace {
33
34/// @brief Map OutboundExchangeState enum to a short string for debug logging.
35/// @param state State value.
36/// @return Null‑terminated string label.
37const char *outbound_stage_name(exchange::OutboundExchangeState state) {
38 switch (state) {
40 return "idle";
42 return "tx_request";
44 return "wait_first_response";
46 return "build_auth_response";
48 return "tx_auth_response";
50 return "wait_final_response";
52 return "success";
54 default:
55 return "failed";
56 }
57}
58
59/// @brief Map InboundAuthState enum to a short string for debug logging.
60/// @param state State value.
61/// @return Null‑terminated string label.
62const char *inbound_stage_name(exchange::InboundAuthState state) {
63 switch (state) {
65 return "idle";
67 return "tx_challenge";
69 return "wait_challenge_response";
71 return "verified";
73 default:
74 return "failed";
75 }
76}
77
78// response_wait_slice_ms provided by decisions namespace (hub_decisions.h)
79
80/// Return preamble length for authenticated challenge response (0x3D).
81///
82/// SX1262 requires a longer preamble for the challenge response to improve
83/// lock-on reliability in the RX->TX turn-around after receiving 0x3C. For
84/// SX1276 we keep the short preamble because its IoHomeOn hardware path already
85/// matches the baseline waveform.
86///
87/// @param radio Radio driver instance (used to query chip name).
88/// @return Preamble length in symbol periods.
89uint16_t auth_response_preamble(const RadioDriver *radio) {
90 // SX1276 is the baseline waveform. The longer 0x3D preamble stays scoped to SX1262 so the radio-
91 // specific lock-on workaround does not silently perturb the SX1276 behavior.
92 return strcmp(radio->chip_name(), "sx1262") == 0 ? SX1262_AUTH_RESPONSE_PREAMBLE : SHORT_PREAMBLE;
93}
94
95/// Return the per-channel response dwell to use while waiting for exchange packets.
96///
97/// The generic 50 ms slice remains correct for the baseline protocol flow and for pairing.
98/// SX1262 authenticated exchanges are the special case: after we send 0x3D, some devices reply
99/// slightly later than 50 ms on the same channel. Keeping the receiver parked for 90 ms avoids
100/// hopping away just before that final response arrives.
101///
102/// @param radio Radio driver instance (used to scope the workaround to SX1262 only).
103/// @param remaining_ms Total time left in the current wait window.
104/// @return Slice length in milliseconds, capped by the remaining wait budget.
105uint32_t exchange_response_wait_slice_ms(const RadioDriver *radio, uint32_t remaining_ms) {
106 const uint32_t max_slice =
108 return std::min<uint32_t>(remaining_ms, max_slice);
109}
110
111/// Check if frame is a 0x3D challenge response.
112bool frame_is_challenge_response(const IoFrame &frame) { return frame.cmd == CMD_CHALLENGE_RESP; }
113
114/// Log an exchanged frame with context (stage, try index, length).
115///
116/// Used to trace both first responses and final responses. Intended for
117/// debugging packet flows where unrelated frames are ignored.
118///
119/// @param stage String label for the current stage (e.g., "first_response").
120/// @param tries Attempt number (1‑based).
121/// @param frame Parsed IoFrame to log.
122/// @param len Length of raw packet (for correlation with capture info).
123void log_exchange_frame(const char *stage, int tries, const IoFrame &frame, uint8_t len) {
124 ESP_LOGD(TAG, "%s try=%d cmd=0x%02X src=%02X%02X%02X dst=%02X%02X%02X len=%u", stage, tries, frame.cmd, frame.src[0],
125 frame.src[1], frame.src[2], frame.dst[0], frame.dst[1], frame.dst[2], len);
126}
127
128/// Determine if a candidate frame is a valid final response for the request.
129///
130/// Used by `wait_for_final_response_()` to accept a frame. The check validates
131/// endpoint matching (dst == request.src, src == request.dst) via the decisions
132/// namespace.
133///
134/// @param candidate Parsed IoFrame from the device.
135/// @param request Original outbound request.
136/// @return true if candidate is acceptable as final response; false otherwise.
137bool is_valid_final_response(const IoFrame &candidate, const IoFrame &request) {
138 return decisions::classify_exchange_final_response(request, candidate) ==
140}
141
142} // namespace
143
144bool IOHomeControlComponent::send_and_receive_(const IoFrame &request, IoFrame &response, uint32_t freq) {
145 this->busy_ = true;
146 this->reset_exchange_debug_(request.cmd);
147 const uint16_t request_preamble = is_start(request) ? LONG_PREAMBLE : SHORT_PREAMBLE;
148
149 for (int tries = 0; tries < EXCHANGE_RETRY_COUNT; tries++) {
151 context.try_index = tries + 1;
152 context.exchange_start_ms = millis();
155
156 if (tries > 0) {
157 App.feed_wdt();
159 }
160
161 // Step 1: Transmit request
162 if (!this->transmit_request_(request, freq, request_preamble, context)) {
163 continue; // state already set to FAILED by helper
164 }
165
166 // Step 2: Wait for first response
168 this->record_exchange_debug_(outbound_stage_name(context.state), context.try_index, false);
169 auto first_disp = this->wait_for_first_response_(request, context);
171 continue; // timeout or no valid response
172 }
175 this->record_exchange_debug_("success_direct", context.try_index, false);
176 response = context.rx;
177 this->busy_ = false;
178 return true;
179 }
180
181 // Step 3: Handle authentication challenge
182 if (!this->handle_authentication_(request, freq, context)) {
183 continue;
184 }
185
186 // Step 4: Wait for final authenticated response
188 this->record_exchange_debug_(outbound_stage_name(context.state), context.try_index, true);
189 auto final_disp = this->wait_for_final_response_(request, context);
191 continue;
192 }
193
194 // Success
196 this->record_exchange_debug_("success_auth", context.try_index, true);
197 response = context.rx;
198 this->busy_ = false;
199 return true;
200 }
201
202 this->busy_ = false;
203 return false;
204}
205
206// --- Outbound exchange helper implementations ---
207
208/// Transmit the initial request frame and update exchange context on failure.
209///
210/// This helper isolates the one-shot TX operation from the retry loop. It does
211/// NOT implement retries itself — the orchestrator (`send_and_receive_`) calls
212/// this repeatedly until success or retry exhaustion. On failure we mark the
213/// context state as FAILED and record debug info; success returns true.
214bool IOHomeControlComponent::transmit_request_(const IoFrame &request, uint32_t freq, uint16_t preamble,
216 if (!this->transmit_frame_(request, freq, preamble)) {
218 this->record_exchange_debug_("tx_request_failed", ctx.try_index, false);
219 return false;
220 }
221 return true;
222}
223
224/// Wait for the first response packet within the configured timeout window.
225///
226/// Listens on the current RF channel, hopping to the next channel after each
227/// slice if no packet arrives. Parses incoming frames and classifies them via
228/// `decisions::classify_exchange_first_response()`:
229/// - COMPLETE_DIRECT → matching non‑challenge frame (operation complete, no auth)
230/// - REQUIRE_AUTH → matching 0x3C challenge (device demands authentication)
231/// - IGNORE_UNRELATED → all others (timeout, wrong endpoints, unparsable)
233 const IoFrame &request, exchange::OutboundExchangeContext &ctx) {
234 RadioRxPacket packet{};
235 const uint32_t deadline = millis() + ctx.wait_ms;
236 while ((int32_t) (deadline - millis()) > 0) {
237 const uint32_t remaining = deadline - millis();
238 const uint32_t slice = exchange_response_wait_slice_ms(this->radio_, remaining);
239 if (!this->radio_->wait_for_packet(packet, slice)) {
240 if ((int32_t) (deadline - millis()) > 0)
241 this->hop_frequency_();
242 continue;
243 }
244 if (!parse(packet.data, packet.len, ctx.rx)) {
245 this->record_exchange_debug_("first_parse_fail", ctx.try_index, false);
246 continue;
247 }
248 auto disp = decisions::classify_exchange_first_response(request, ctx.rx);
250 this->record_exchange_debug_("first_wrong_exchange", ctx.try_index, false);
251 log_exchange_frame("Ignored first response", ctx.try_index, ctx.rx, packet.len);
252 continue;
253 }
254 ctx.first_response_ms = millis();
255 return disp;
256 }
258 this->record_exchange_debug_("wait_first_timeout", ctx.try_index, false);
259 ESP_LOGI(TAG, "Try %d ended: no first response for cmd=0x%02X within %u ms", ctx.try_index, request.cmd, ctx.wait_ms);
261}
262
263/// Handle device authentication challenge (0x3C → 0x3D exchange).
264///
265/// When the first response is a challenge request (0x3C), this helper builds
266/// the HMAC challenge response using `create_challenge_resp()` and transmits
267/// it with the SX1262‑specific longer preamble. The exchange context is
268/// updated with the BUILD_AUTH_RESPONSE and TX_AUTH_RESPONSE states.
271 ctx.saw_challenge = true;
273 this->record_exchange_debug_(outbound_stage_name(ctx.state), ctx.try_index, true);
274
275 IoFrame auth_resp;
276 if (!create_challenge_resp(auth_resp, request.dst, this->node_id_, ctx.rx.data, request, this->system_key_)) {
278 this->record_exchange_debug_("auth_build_failed", ctx.try_index, true);
279 return false;
280 }
281
282 ESP_LOGI(TAG, "Auth challenge try=%d wait_ms=%u challenge=%02X%02X%02X%02X%02X%02X req_cmd=0x%02X req_len=%u",
283 ctx.try_index, ctx.first_response_ms - ctx.exchange_start_ms, ctx.rx.data[0], ctx.rx.data[1], ctx.rx.data[2],
284 ctx.rx.data[3], ctx.rx.data[4], ctx.rx.data[5], request.cmd, request.data_len);
285
287 this->record_exchange_debug_(outbound_stage_name(ctx.state), ctx.try_index, true);
288 if (!this->transmit_frame_(auth_resp, freq, auth_response_preamble(this->radio_))) {
290 this->record_exchange_debug_("tx_auth_failed", ctx.try_index, true);
291 return false;
292 }
293 return true;
294}
295
296/// Wait for the final (authenticated) response after challenge has been answered.
297///
298/// After sending the 0x3D challenge response, the device will reply with the
299/// actual command response (e.g. 0x04 status) signed using the shared system
300/// key. This helper loops within `RESPONSE_AUTH_WAIT_MS`, hopping channels on
301/// each slice, parsing and validating that the frame matches the original
302/// request endpoints. Non‑matching frames are logged and ignored.
304 const IoFrame &request, exchange::OutboundExchangeContext &ctx) {
305 RadioRxPacket packet{};
306 const uint32_t deadline = millis() + RESPONSE_AUTH_WAIT_MS;
307 while ((int32_t) (deadline - millis()) > 0) {
308 const uint32_t remaining = deadline - millis();
309 const uint32_t slice = exchange_response_wait_slice_ms(this->radio_, remaining);
310 if (!this->radio_->wait_for_packet(packet, slice)) {
311 if ((int32_t) (deadline - millis()) > 0)
312 this->hop_frequency_();
313 continue;
314 }
315 if (!parse(packet.data, packet.len, ctx.rx)) {
316 this->record_exchange_debug_("final_parse_fail", ctx.try_index, true);
317 continue;
318 }
319 if (is_valid_final_response(ctx.rx, request)) {
321 }
322 this->record_exchange_debug_("final_wrong_exchange", ctx.try_index, true);
323 log_exchange_frame("Ignored final response", ctx.try_index, ctx.rx, packet.len);
324 }
326 this->record_exchange_debug_("wait_final_timeout", ctx.try_index, true);
327 ESP_LOGI(TAG, "Try %d ended: no matching final response for cmd=0x%02X within %u ms", ctx.try_index, request.cmd,
330}
331
332bool IOHomeControlComponent::authenticate_request_(const IoFrame &request, uint32_t freq) {
335 this->record_exchange_debug_(inbound_stage_name(context.state), 1, true);
336
337 // Inbound authentication: send a 0x3C challenge and verify the device's 0x3D HMAC response
338 // against the original command byte plus payload.
339 if (!create_challenge_req(context.challenge, request.src, this->node_id_)) {
341 this->record_exchange_debug_(inbound_stage_name(context.state), 1, true);
342 return false;
343 }
344 if (!this->transmit_frame_(context.challenge, freq, SHORT_PREAMBLE)) {
346 this->record_exchange_debug_(inbound_stage_name(context.state), 1, true);
347 return false;
348 }
349
351 this->record_exchange_debug_(inbound_stage_name(context.state), 1, true);
352
353 RadioRxPacket packet{};
354 if (!this->radio_->wait_for_packet(packet, RESPONSE_WAIT_MS)) {
356 this->record_exchange_debug_(inbound_stage_name(context.state), 1, true);
357 return false;
358 }
359
360 IoFrame rx;
361 if (!parse(packet.data, packet.len, rx) || !frame_is_challenge_response(rx)) {
363 this->record_exchange_debug_(inbound_stage_name(context.state), 1, true);
364 return false;
365 }
366
367 uint8_t frame_data[FRAME_MAX_SIZE];
368 frame_data[0] = request.cmd;
369 memcpy(frame_data + 1, request.data, request.data_len);
370 if (!crypto::verify_hmac(frame_data, request.data_len + 1, rx.data, context.challenge.data, this->system_key_)) {
372 this->record_exchange_debug_(inbound_stage_name(context.state), 1, true);
373 return false;
374 }
375
377 this->record_exchange_debug_(inbound_stage_name(context.state), 1, true);
378 return true;
379}
380
381} // namespace home_io_control
382} // namespace esphome
void reset_exchange_debug_(uint8_t request_cmd)
Definition hub_core.cpp:37
bool send_and_receive_(const IoFrame &request, IoFrame &response, uint32_t freq)
Main request/response exchange with retry and automatic authentication.
bool transmit_request_(const IoFrame &request, uint32_t freq, uint16_t preamble, exchange::OutboundExchangeContext &ctx)
Wrap transmit_frame_ and mark context failed on error.
void record_exchange_debug_(const char *stage, uint8_t tries, bool saw_challenge)
Definition hub_core.cpp:42
void hop_frequency_()
Hop to the next channel in the 3‑channel sequence: CH1 → CH2 → CH3 → CH1.
Definition hub_core.cpp:171
decisions::ExchangeFirstResponseDisposition wait_for_first_response_(const IoFrame &request, exchange::OutboundExchangeContext &ctx)
Wait loop for the first response packet; classifies via decisions::classify_exchange_first_response.
decisions::ExchangeFinalResponseDisposition wait_for_final_response_(const IoFrame &request, exchange::OutboundExchangeContext &ctx)
Wait loop for the final authenticated response; uses is_valid_final_response().
bool transmit_frame_(const IoFrame &frame, uint32_t freq, uint16_t preamble)
Transmit a raw IoFrame on the current frequency with given preamble length.
Definition hub_core.cpp:196
bool handle_authentication_(const IoFrame &request, uint32_t freq, exchange::OutboundExchangeContext &ctx)
Perform challenge-response (TX auth response) after a 0x3C is received.
bool authenticate_request_(const IoFrame &request, uint32_t freq)
Handle an inbound authenticated command from a device (status updates, etc.).
Abstract radio driver for IO-Homecontrol.
virtual const char * chip_name() const =0
Get a human‑readable chip name.
IO-Homecontrol ESPHome component — protocol controller.
Pure transition helpers for hub-owned exchange and pairing frame decisions.
Internal exchange-state model for hub-owned authenticated non‑pairing flows.
bool verify_hmac(const uint8_t *data, uint8_t len, const uint8_t hmac[HMAC_SIZE], const uint8_t challenge[HMAC_SIZE], const uint8_t key[AES_KEY_SIZE])
Verify a received HMAC using constant-time comparison.
ExchangeFirstResponseDisposition classify_exchange_first_response(const IoFrame &request, const IoFrame &candidate)
Decide how to handle the first response packet in an authenticated exchange.
ExchangeFinalResponseDisposition
Disposition for the final response after authentication.
@ ACCEPT
Frame matches expected response — exchange succeeds.
@ IGNORE_UNRELATED
Frame doesn't match endpoints — ignore.
ExchangeFirstResponseDisposition
Disposition for the first response in an authenticated exchange.
@ IGNORE_UNRELATED
Frame doesn't match endpoints or failed parse — keep waiting.
@ COMPLETE_DIRECT
Matching non-challenge frame — operation complete, no auth needed.
ExchangeFinalResponseDisposition classify_exchange_final_response(const IoFrame &request, const IoFrame &candidate)
Decide if a candidate frame is an acceptable final response after authentication.
InboundAuthState
State machine for 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.
@ FAILED
Authentication failed (timeout or HMAC mismatch).
OutboundExchangeState
State machine for 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).
bool is_start(const IoFrame &f)
Check START flag.
static constexpr uint8_t FRAME_MAX_SIZE
Maximum frame size (9 header + 23 data).
Definition proto_frame.h:91
static constexpr int32_t EXCHANGE_RETRY_DELAY_MS
Gap between retries within one HA command.
Definition proto_frame.h:66
bool create_challenge_resp(IoFrame &f, const uint8_t *dst, const uint8_t *src, const uint8_t challenge[HMAC_SIZE], const IoFrame &origin, const uint8_t *key)
Build a challenge response (0x3D) proving we know the system key.
static constexpr int32_t SX1262_EXCHANGE_RESPONSE_WAIT_SLICE_MS
SX1262-specific per-channel dwell while waiting for authenticated exchange responses.
Definition proto_frame.h:57
bool parse(const uint8_t *buf, uint8_t buf_len, IoFrame &f)
Parse a wire buffer into a parsed IoFrame (validates length and CTRL0).
static constexpr uint8_t EXCHANGE_RETRY_COUNT
Attempts per command before reporting failure.
Definition proto_frame.h:67
bool create_challenge_req(IoFrame &f, const uint8_t *dst, const uint8_t *src)
Build a challenge request (0x3C) containing 6 random bytes.
static constexpr uint16_t SHORT_PREAMBLE
8 bytes for response/continuation frames
Definition proto_frame.h:41
static constexpr int32_t RESPONSE_WAIT_MS
Wait for response to non-start frame.
Definition proto_frame.h:62
static constexpr uint8_t CMD_CHALLENGE_RESP
Controller responds with HMAC proof.
static constexpr uint16_t LONG_PREAMBLE
Preamble is a sequence of 0xAA bytes that precedes every frame.
Definition proto_frame.h:40
static constexpr int32_t RESPONSE_AUTH_WAIT_MS
Wait for final response after challenge response.
Definition proto_frame.h:64
static constexpr int32_t RESPONSE_CHANNEL_WAIT_MS
Per-channel dwell while waiting for an exchange response.
Definition proto_frame.h:61
static const char *const TAG
Definition hub_core.cpp:35
static constexpr uint16_t SX1262_AUTH_RESPONSE_PREAMBLE
SX1262-specific preamble for the outbound 0x3D challenge response.
Definition proto_frame.h:49
static constexpr int32_t RESPONSE_START_WAIT_MS
Wait for response to start frame (longer).
Definition proto_frame.h:63
Command builders for the IO‑Homecontrol protocol.
Cryptographic helpers for the IO‑Homecontrol protocol.
Parsed IO‑Homecontrol frame (CTRL0/1 + addresses + command + data).
uint8_t data[FRAME_MAX_DATA_SIZE]
Command parameters (0–23 bytes).
uint8_t src[NODE_ID_SIZE]
Source node ID (3 bytes).
uint8_t dst[NODE_ID_SIZE]
Destination node ID (3 bytes).
uint8_t data_len
Actual length of data.
Raw packet received from the radio.
uint8_t len
Length of packet in bytes.
uint8_t data[RADIO_PACKET_BUFFER_SIZE]
Raw packet data buffer.
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.