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///
21/// Implements IOHomeControlComponent::send_and_receive_() and its stepwise
22/// helpers: transmit_request_(), wait_for_first_response_(),
23/// handle_authentication_(), wait_for_final_response_(). These functions
24/// encapsulate the retry loop, challenge-response authentication, and final
25/// response handling for commands sent to paired devices.
26///
27/// The exchange logic is separated from hub_core.cpp to keep the main loop
28/// and device-management concerns distinct from the protocol state machine.
29/// Pairing flows (discovery/key exchange) live in hub_pairing.cpp.
30
31namespace {
32
33/// @brief Map OutboundExchangeState enum to a short string for debug logging.
34/// @param state State value.
35/// @return Null‑terminated string label.
36const char *outbound_stage_name(exchange::OutboundExchangeState state) {
37 switch (state) {
39 return "idle";
41 return "tx_request";
43 return "wait_first_response";
45 return "build_auth_response";
47 return "tx_auth_response";
49 return "wait_final_response";
51 return "success";
53 default:
54 return "failed";
55 }
56}
57
58/// @brief Map InboundAuthState enum to a short string for debug logging.
59/// @param state State value.
60/// @return Null‑terminated string label.
61const char *inbound_stage_name(exchange::InboundAuthState state) {
62 switch (state) {
64 return "idle";
66 return "tx_challenge";
68 return "wait_challenge_response";
70 return "verified";
72 default:
73 return "failed";
74 }
75}
76
77// response_wait_slice_ms provided by decisions namespace (hub_decisions.h)
78
79/// Return preamble length for authenticated challenge response (0x3D).
80///
81/// SX1262 requires a longer preamble for the challenge response to improve
82/// lock-on reliability in the RX->TX turn-around after receiving 0x3C. For
83/// SX1276 we keep the short preamble because its IoHomeOn hardware path already
84/// matches the baseline waveform.
85///
86/// @param radio Radio driver instance (used to query chip name).
87/// @return Preamble length in symbol periods.
88uint16_t auth_response_preamble(const RadioDriver *radio) {
89 // SX1276 is the baseline waveform. The longer 0x3D preamble stays scoped to SX1262 so the radio-
90 // specific lock-on workaround does not silently perturb the SX1276 behavior.
91 return strcmp(radio->chip_name(), "sx1262") == 0 ? SX1262_AUTH_RESPONSE_PREAMBLE : SHORT_PREAMBLE;
92}
93
94/// Return the per-channel response dwell to use while waiting for exchange packets.
95///
96/// The generic 50 ms slice remains correct for the baseline protocol flow and for pairing.
97/// SX1262 authenticated exchanges are the special case: after we send 0x3D, some devices reply
98/// slightly later than 50 ms on the same channel. Keeping the receiver parked for 90 ms avoids
99/// hopping away just before that final response arrives.
100///
101/// @param radio Radio driver instance (used to scope the workaround to SX1262 only).
102/// @param remaining_ms Total time left in the current wait window.
103/// @return Slice length in milliseconds, capped by the remaining wait budget.
104uint32_t exchange_response_wait_slice_ms(const RadioDriver *radio, uint32_t remaining_ms) {
105 const uint32_t max_slice =
107 return std::min<uint32_t>(remaining_ms, max_slice);
108}
109
110/// Check if frame is a 0x3D challenge response.
111bool frame_is_challenge_response(const IoFrame &frame) { return frame.cmd == CMD_CHALLENGE_RESP; }
112
113/// Log an exchanged frame with context (stage, try index, length).
114///
115/// Used to trace both first responses and final responses. Intended for
116/// debugging packet flows where unrelated frames are ignored.
117///
118/// @param stage String label for the current stage (e.g., "first_response").
119/// @param tries Attempt number (1‑based).
120/// @param frame Parsed IoFrame to log.
121/// @param len Length of raw packet (for correlation with capture info).
122void log_exchange_frame(const char *stage, int tries, const IoFrame &frame, uint8_t len) {
123 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],
124 frame.src[1], frame.src[2], frame.dst[0], frame.dst[1], frame.dst[2], len);
125}
126
127/// Determine if a candidate frame is a valid final response for the request.
128///
129/// Used by `wait_for_final_response_()` to accept a frame. The check validates
130/// endpoint matching (dst == request.src, src == request.dst) via the decisions
131/// namespace.
132///
133/// @param candidate Parsed IoFrame from the device.
134/// @param request Original outbound request.
135/// @return true if candidate is acceptable as final response; false otherwise.
136bool is_valid_final_response(const IoFrame &candidate, const IoFrame &request) {
137 return decisions::classify_exchange_final_response(request, candidate) ==
139}
140
141} // namespace
142
143bool IOHomeControlComponent::send_and_receive_(const IoFrame &request, IoFrame &response, uint32_t freq) {
144 this->busy_ = true;
145 this->reset_exchange_debug_(request.cmd);
146 const uint16_t request_preamble = is_start(request) ? LONG_PREAMBLE : SHORT_PREAMBLE;
147
148 for (int tries = 0; tries < EXCHANGE_RETRY_COUNT; tries++) {
150 context.try_index = tries + 1;
151 context.exchange_start_ms = millis();
154
155 if (tries > 0) {
156 App.feed_wdt();
158 }
159
160 // Step 1: Transmit request
161 if (!this->transmit_request_(request, freq, request_preamble, context)) {
162 continue; // state already set to FAILED by helper
163 }
164
165 // Step 2: Wait for first response
167 this->record_exchange_debug_(outbound_stage_name(context.state), context.try_index, false);
168 auto first_disp = this->wait_for_first_response_(request, context);
170 continue; // timeout or no valid response
171 }
174 this->record_exchange_debug_("success_direct", context.try_index, false);
175 response = context.rx;
176 this->busy_ = false;
177 return true;
178 }
179
180 // Step 3: Handle authentication challenge
181 if (!this->handle_authentication_(request, freq, context)) {
182 continue;
183 }
184
185 // Step 4: Wait for final authenticated response
187 this->record_exchange_debug_(outbound_stage_name(context.state), context.try_index, true);
188 auto final_disp = this->wait_for_final_response_(request, context);
190 continue;
191 }
192
193 // Success
195 this->record_exchange_debug_("success_auth", context.try_index, true);
196 response = context.rx;
197 this->busy_ = false;
198 return true;
199 }
200
201 this->busy_ = false;
202 return false;
203}
204
205// --- Outbound exchange helper implementations ---
206
207/// Transmit the initial request frame and update exchange context on failure.
208///
209/// This helper isolates the one-shot TX operation from the retry loop. It does
210/// NOT implement retries itself — the orchestrator (`send_and_receive_`) calls
211/// this repeatedly until success or retry exhaustion. On failure we mark the
212/// context state as FAILED and record debug info; success returns true.
213bool IOHomeControlComponent::transmit_request_(const IoFrame &request, uint32_t freq, uint16_t preamble,
215 if (!this->transmit_frame_(request, freq, preamble)) {
217 this->record_exchange_debug_("tx_request_failed", ctx.try_index, false);
218 return false;
219 }
220 return true;
221}
222
223/// Wait for the first response packet within the configured timeout window.
224///
225/// Listens on the current RF channel, hopping to the next channel after each
226/// slice if no packet arrives. Parses incoming frames and classifies them via
227/// `decisions::classify_exchange_first_response()`:
228/// - COMPLETE_DIRECT → matching non‑challenge frame (operation complete, no auth)
229/// - REQUIRE_AUTH → matching 0x3C challenge (device demands authentication)
230/// - IGNORE_UNRELATED → all others (timeout, wrong endpoints, unparsable)
232 const IoFrame &request, exchange::OutboundExchangeContext &ctx) {
233 RadioRxPacket packet{};
234 const uint32_t deadline = millis() + ctx.wait_ms;
235 while ((int32_t) (deadline - millis()) > 0) {
236 const uint32_t remaining = deadline - millis();
237 const uint32_t slice = exchange_response_wait_slice_ms(this->radio_, remaining);
238 if (!this->radio_->wait_for_packet(packet, slice)) {
239 if ((int32_t) (deadline - millis()) > 0)
240 this->hop_frequency_();
241 continue;
242 }
243 if (!parse(packet.data, packet.len, ctx.rx)) {
244 this->record_exchange_debug_("first_parse_fail", ctx.try_index, false);
245 continue;
246 }
247 auto disp = decisions::classify_exchange_first_response(request, ctx.rx);
249 this->record_exchange_debug_("first_wrong_exchange", ctx.try_index, false);
250 log_exchange_frame("Ignored first response", ctx.try_index, ctx.rx, packet.len);
251 continue;
252 }
253 ctx.first_response_ms = millis();
254 return disp;
255 }
257 this->record_exchange_debug_("wait_first_timeout", ctx.try_index, false);
258 ESP_LOGI(TAG, "Try %d ended: no first response for cmd=0x%02X within %u ms", ctx.try_index, request.cmd, ctx.wait_ms);
260}
261
262/// Handle device authentication challenge (0x3C → 0x3D exchange).
263///
264/// When the first response is a challenge request (0x3C), this helper builds
265/// the HMAC challenge response using `create_challenge_resp()` and transmits
266/// it with the SX1262‑specific longer preamble. The exchange context is
267/// updated with the BUILD_AUTH_RESPONSE and TX_AUTH_RESPONSE states.
270 ctx.saw_challenge = true;
272 this->record_exchange_debug_(outbound_stage_name(ctx.state), ctx.try_index, true);
273
274 IoFrame auth_resp;
275 if (!create_challenge_resp(auth_resp, request.dst, this->node_id_, ctx.rx.data, request, this->system_key_)) {
277 this->record_exchange_debug_("auth_build_failed", ctx.try_index, true);
278 return false;
279 }
280
281 ESP_LOGI(TAG, "Auth challenge try=%d wait_ms=%u challenge=%02X%02X%02X%02X%02X%02X req_cmd=0x%02X req_len=%u",
282 ctx.try_index, ctx.first_response_ms - ctx.exchange_start_ms, ctx.rx.data[0], ctx.rx.data[1], ctx.rx.data[2],
283 ctx.rx.data[3], ctx.rx.data[4], ctx.rx.data[5], request.cmd, request.data_len);
284
286 this->record_exchange_debug_(outbound_stage_name(ctx.state), ctx.try_index, true);
287 if (!this->transmit_frame_(auth_resp, freq, auth_response_preamble(this->radio_))) {
289 this->record_exchange_debug_("tx_auth_failed", ctx.try_index, true);
290 return false;
291 }
292 return true;
293}
294
295/// Wait for the final (authenticated) response after challenge has been answered.
296///
297/// After sending the 0x3D challenge response, the device will reply with the
298/// actual command response (e.g. 0x04 status) signed using the shared system
299/// key. This helper loops within `RESPONSE_AUTH_WAIT_MS`, hopping channels on
300/// each slice, parsing and validating that the frame matches the original
301/// request endpoints. Non‑matching frames are logged and ignored.
303 const IoFrame &request, exchange::OutboundExchangeContext &ctx) {
304 RadioRxPacket packet{};
305 const uint32_t deadline = millis() + RESPONSE_AUTH_WAIT_MS;
306 while ((int32_t) (deadline - millis()) > 0) {
307 const uint32_t remaining = deadline - millis();
308 const uint32_t slice = exchange_response_wait_slice_ms(this->radio_, remaining);
309 if (!this->radio_->wait_for_packet(packet, slice)) {
310 if ((int32_t) (deadline - millis()) > 0)
311 this->hop_frequency_();
312 continue;
313 }
314 if (!parse(packet.data, packet.len, ctx.rx)) {
315 this->record_exchange_debug_("final_parse_fail", ctx.try_index, true);
316 continue;
317 }
318 if (is_valid_final_response(ctx.rx, request)) {
320 }
321 this->record_exchange_debug_("final_wrong_exchange", ctx.try_index, true);
322 log_exchange_frame("Ignored final response", ctx.try_index, ctx.rx, packet.len);
323 }
325 this->record_exchange_debug_("wait_final_timeout", ctx.try_index, true);
326 ESP_LOGI(TAG, "Try %d ended: no matching final response for cmd=0x%02X within %u ms", ctx.try_index, request.cmd,
329}
330
331bool IOHomeControlComponent::authenticate_request_(const IoFrame &request, uint32_t freq) {
334 this->record_exchange_debug_(inbound_stage_name(context.state), 1, true);
335
336 // Inbound authentication: send a 0x3C challenge and verify the device's 0x3D HMAC response
337 // against the original command byte plus payload.
338 if (!create_challenge_req(context.challenge, request.src, this->node_id_)) {
340 this->record_exchange_debug_(inbound_stage_name(context.state), 1, true);
341 return false;
342 }
343 if (!this->transmit_frame_(context.challenge, freq, SHORT_PREAMBLE)) {
345 this->record_exchange_debug_(inbound_stage_name(context.state), 1, true);
346 return false;
347 }
348
350 this->record_exchange_debug_(inbound_stage_name(context.state), 1, true);
351
352 RadioRxPacket packet{};
353 if (!this->radio_->wait_for_packet(packet, RESPONSE_WAIT_MS)) {
355 this->record_exchange_debug_(inbound_stage_name(context.state), 1, true);
356 return false;
357 }
358
359 IoFrame rx;
360 if (!parse(packet.data, packet.len, rx) || !frame_is_challenge_response(rx)) {
362 this->record_exchange_debug_(inbound_stage_name(context.state), 1, true);
363 return false;
364 }
365
366 uint8_t frame_data[FRAME_MAX_SIZE];
367 frame_data[0] = request.cmd;
368 memcpy(frame_data + 1, request.data, request.data_len);
369 if (!crypto::verify_hmac(frame_data, request.data_len + 1, rx.data, context.challenge.data, this->system_key_)) {
371 this->record_exchange_debug_(inbound_stage_name(context.state), 1, true);
372 return false;
373 }
374
376 this->record_exchange_debug_(inbound_stage_name(context.state), 1, true);
377 return true;
378}
379
380} // namespace home_io_control
381} // namespace esphome
void reset_exchange_debug_(uint8_t request_cmd)
Definition hub_core.cpp:36
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:41
void hop_frequency_()
Hop to the next channel in the 3‑channel sequence: CH1 → CH2 → CH3 → CH1.
Definition hub_core.cpp:169
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:194
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:90
static constexpr int32_t EXCHANGE_RETRY_DELAY_MS
Gap between retries within one HA command.
Definition proto_frame.h:65
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:56
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:66
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:40
static constexpr int32_t RESPONSE_WAIT_MS
Wait for response to non-start frame.
Definition proto_frame.h:61
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:39
static constexpr int32_t RESPONSE_AUTH_WAIT_MS
Wait for final response after challenge response.
Definition proto_frame.h:63
static constexpr int32_t RESPONSE_CHANNEL_WAIT_MS
Per-channel dwell while waiting for an exchange response.
Definition proto_frame.h:60
static const char *const TAG
Definition hub_core.cpp:34
static constexpr uint16_t SX1262_AUTH_RESPONSE_PREAMBLE
SX1262-specific preamble for the outbound 0x3D challenge response.
Definition proto_frame.h:48
static constexpr int32_t RESPONSE_START_WAIT_MS
Wait for response to start frame (longer).
Definition proto_frame.h:62
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.