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