Home IO Control
ESPHome add-on for IO-Homecontrol devices
Loading...
Searching...
No Matches
hub_decisions.h
Go to the documentation of this file.
1#pragma once
2
3/// @file hub_decisions.h
4/// @brief Pure transition helpers for hub-owned exchange and pairing frame decisions.
5/// @ingroup hioc_hub
6///
7/// This header contains inline, testable decision logic: frame classification
8/// for exchange and pairing flows, plus shared timing utilities. No state, no
9/// side effects — suitable for unit testing without radio hardware.
10
11#include "proto_frame.h"
12
13#include <cstdint>
14#include <cstring>
15
16namespace esphome {
17namespace home_io_control {
18namespace decisions {
19
20/// @brief Disposition for the first response in an authenticated exchange.
22 IGNORE_UNRELATED, ///< Frame doesn't match endpoints or failed parse — keep waiting.
23 COMPLETE_DIRECT, ///< Matching non-challenge frame — operation complete, no auth needed.
24 REQUIRE_AUTH, ///< Matching 0x3C challenge — device demands authentication.
25};
26
27/// @brief Disposition for the final response after authentication.
29 IGNORE_UNRELATED, ///< Frame doesn't match endpoints — ignore.
30 ACCEPT, ///< Frame matches expected response — exchange succeeds.
31};
32
33/// @brief Disposition during pairing discovery phase.
34enum class PairingDiscoveryDisposition : uint8_t {
35 NO_RESPONSE, ///< No packets received on the channel within timeout.
36 INVALID, ///< Packets seen but none were valid discovery (0x29) frames.
37 ACCEPT, ///< Valid discovery response received.
38};
39
40/// @brief Disposition during pairing key-challenge phase.
41enum class PairingKeyChallengeDisposition : uint8_t {
42 IGNORE, ///< Not a valid challenge (wrong cmd, length, or sender).
43 ACCEPT, ///< Valid 0x3C challenge from target device.
44};
45
46// == Passive RX filtering ==
47
48/// Returns true for commands that are internal to an exchange handshake and carry
49/// no useful information for a passive observer (challenge request/response).
50/// These frames appear in every authenticated exchange between other controllers and
51/// devices on the network, but contain only ephemeral cryptographic data.
52inline bool is_exchange_internal_command(uint8_t cmd) { return cmd == CMD_CHALLENGE_REQ || cmd == CMD_CHALLENGE_RESP; }
53
54// == Utility: endpoint matching ==
55
56/// Check if two frames have identical src/dst node IDs.
57inline bool frame_matches_nodes(const IoFrame &frame, const uint8_t expected_src[NODE_ID_SIZE],
58 const uint8_t expected_dst[NODE_ID_SIZE]) {
59 return std::memcmp(frame.src, expected_src, NODE_ID_SIZE) == 0 &&
60 std::memcmp(frame.dst, expected_dst, NODE_ID_SIZE) == 0;
61}
62
63/// Check if candidate frame endpoints are the reverse of the request (dst==request.src, src==request.dst).
64inline bool frame_matches_exchange_endpoints(const IoFrame &request, const IoFrame &candidate) {
65 return frame_matches_nodes(candidate, request.dst, request.src);
66}
67
68// == Exchange first-response classification ==
69
70/// Decide how to handle the first response packet in an authenticated exchange.
71///
72/// Used by wait_for_first_response_() to determine whether the exchange:
73/// - completes immediately (direct response),
74/// - requires authentication (challenge received), or
75/// - should ignore the frame and keep waiting.
76///
77/// @param request Original outbound request frame.
78/// @param candidate Parsed IoFrame from the device.
79/// @return Disposition indicating next step.
81 const IoFrame &candidate) {
82 if (!frame_matches_exchange_endpoints(request, candidate))
84 // A matching non-0x3C frame is the entire answer for direct-response exchanges such as plain
85 // status reads, so the caller must not force it through the authenticated path.
86 if (candidate.cmd == CMD_CHALLENGE_REQ)
89}
90
91/// Decide if a candidate frame is an acceptable final response after authentication.
92///
93/// Only endpoint matching is checked here; command validity is encoded in the
94/// disposition mapping by the caller.
95///
96/// @param request Original outbound request frame.
97/// @param candidate Parsed IoFrame from the device.
98/// @return ACCEPT if endpoints match; IGNORE_UNRELATED otherwise.
104
105// == Pairing discovery & key-challenge classification ==
106
107/// Decide if a frame is a valid discovery response (0x29) during pairing.
108///
109/// @param candidate Parsed IoFrame.
110/// @return ACCEPT if command is CMD_DISCOVER_RESP; INVALID otherwise.
115
116/// Decide if a frame is a valid key-challenge (0x3C) during pairing key exchange.
117///
118/// The challenge must:
119/// - be CMD_CHALLENGE_REQ,
120/// - have data_len == HMAC_SIZE (6),
121/// - originate from the discovered device node ID,
122/// - be addressed to this controller's node ID.
123///
124/// @param candidate Parsed IoFrame.
125/// @param device_id Node ID of the device being paired (expected sender).
126/// @param controller_id Node ID of this controller (expected destination).
127/// @return ACCEPT if all criteria met; IGNORE otherwise.
129 const uint8_t device_id[NODE_ID_SIZE],
130 const uint8_t controller_id[NODE_ID_SIZE]) {
131 // Pairing reuses the normal 0x3C primitive, but here the challenge is only valid when it comes
132 // from the device we just discovered and targets this controller. That keeps foreign traffic from
133 // contaminating key exchange on a busy channel.
134 return candidate.cmd == CMD_CHALLENGE_REQ && candidate.data_len == HMAC_SIZE &&
135 frame_matches_nodes(candidate, device_id, controller_id)
138}
139
140// == Timing/slicing helper ==
141
142/// Slice remaining wait time into bounded intervals to allow frequency hopping.
143///
144/// The wait loops (exchange and pairing) use this to avoid blocking the radio
145/// for too long without hopping. Each slice is at most RESPONSE_CHANNEL_WAIT_MS.
146///
147/// @param remaining_ms Total time left in the wait window.
148/// @return Time slice to wait in milliseconds.
149inline uint32_t response_wait_slice_ms(uint32_t remaining_ms) {
150 return std::min<uint32_t>(remaining_ms, RESPONSE_CHANNEL_WAIT_MS);
151}
152
153} // namespace decisions
154} // namespace home_io_control
155} // namespace esphome
PairingDiscoveryDisposition
Disposition during pairing discovery phase.
@ NO_RESPONSE
No packets received on the channel within timeout.
@ INVALID
Packets seen but none were valid discovery (0x29) frames.
bool frame_matches_nodes(const IoFrame &frame, const uint8_t expected_src[NODE_ID_SIZE], const uint8_t expected_dst[NODE_ID_SIZE])
Check if two frames have identical src/dst node IDs.
bool is_exchange_internal_command(uint8_t cmd)
Returns true for commands that are internal to an exchange handshake and carry no useful information ...
PairingKeyChallengeDisposition classify_pairing_key_challenge(const IoFrame &candidate, const uint8_t device_id[NODE_ID_SIZE], const uint8_t controller_id[NODE_ID_SIZE])
Decide if a frame is a valid key-challenge (0x3C) during pairing key exchange.
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.
PairingKeyChallengeDisposition
Disposition during pairing key-challenge phase.
@ IGNORE
Not a valid challenge (wrong cmd, length, or sender).
ExchangeFirstResponseDisposition
Disposition for the first response in an authenticated exchange.
@ REQUIRE_AUTH
Matching 0x3C challenge — device demands authentication.
@ IGNORE_UNRELATED
Frame doesn't match endpoints or failed parse — keep waiting.
@ COMPLETE_DIRECT
Matching non-challenge frame — operation complete, no auth needed.
PairingDiscoveryDisposition classify_pairing_discovery_response(const IoFrame &candidate)
Decide if a frame is a valid discovery response (0x29) during pairing.
uint32_t response_wait_slice_ms(uint32_t remaining_ms)
Slice remaining wait time into bounded intervals to allow frequency hopping.
bool frame_matches_exchange_endpoints(const IoFrame &request, const IoFrame &candidate)
Check if candidate frame endpoints are the reverse of the request (dst==request.src,...
ExchangeFinalResponseDisposition classify_exchange_final_response(const IoFrame &request, const IoFrame &candidate)
Decide if a candidate frame is an acceptable final response after authentication.
static constexpr uint8_t NODE_ID_SIZE
Device/node addresses are 3 bytes (e.g., "123ABC").
Definition proto_frame.h:81
static constexpr uint8_t HMAC_SIZE
Authentication HMAC is 6 bytes (truncated AES output).
Definition proto_frame.h:83
static constexpr uint8_t CMD_CHALLENGE_REQ
Device sends 6-byte random challenge.
static constexpr uint8_t CMD_DISCOVER_RESP
Device responds with its ID and type.
static constexpr uint8_t CMD_CHALLENGE_RESP
Controller responds with HMAC proof.
static constexpr int32_t RESPONSE_CHANNEL_WAIT_MS
Per-channel dwell while waiting for an exchange response.
Definition proto_frame.h:61
IO-Homecontrol 2W protocol definitions.
Parsed IO‑Homecontrol frame (CTRL0/1 + addresses + command + data).
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.