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_constants.h"
12#include "proto_frame.h"
13#include "proto_timing.h"
14
15#include <cstdint>
16#include <cstring>
17#include <string>
18
19namespace esphome {
20namespace home_io_control {
21namespace decisions {
22
23/// @brief Disposition for the first response in an authenticated exchange.
25 IGNORE_UNRELATED, ///< Frame doesn't match endpoints or failed parse — keep waiting.
26 COMPLETE_DIRECT, ///< Matching non-challenge frame — operation complete, no auth needed.
27 REQUIRE_AUTH, ///< Matching 0x3C challenge — device demands authentication.
28};
29
30/// @brief Disposition for the final response after authentication.
32 IGNORE_UNRELATED, ///< Frame doesn't match endpoints — ignore.
33 ACCEPT, ///< Frame matches expected response — exchange succeeds.
34};
35
36/// @brief Disposition during pairing discovery phase.
37enum class PairingDiscoveryDisposition : uint8_t {
38 NO_RESPONSE, ///< No packets received on the channel within timeout.
39 INVALID, ///< Packets seen but none were valid discovery (0x29) frames.
40 ACCEPT, ///< Valid discovery response received.
41};
42
43/// @brief Disposition during pairing key-challenge phase.
44enum class PairingKeyChallengeDisposition : uint8_t {
45 IGNORE, ///< Not a valid challenge (wrong cmd, length, or sender).
46 ACCEPT, ///< Valid 0x3C challenge from target device.
47};
48
49// == Passive RX filtering ==
50
51/// Returns true for commands that are internal to an exchange handshake and carry
52/// no useful information for a passive observer (challenge request/response).
53/// These frames appear in every authenticated exchange between other controllers and
54/// devices on the network, but contain only ephemeral cryptographic data.
55inline bool is_exchange_internal_command(uint8_t cmd) { return cmd == CMD_CHALLENGE_REQ || cmd == CMD_CHALLENGE_RESP; }
56
57// == Utility: endpoint matching ==
58
59/// Check if two frames have identical src/dst node IDs.
60inline bool frame_matches_nodes(const IoFrame &frame, const uint8_t expected_src[NODE_ID_SIZE],
61 const uint8_t expected_dst[NODE_ID_SIZE]) {
62 return std::memcmp(frame.src, expected_src, NODE_ID_SIZE) == 0 &&
63 std::memcmp(frame.dst, expected_dst, NODE_ID_SIZE) == 0;
64}
65
66/// Check if candidate frame endpoints are the reverse of the request (dst==request.src, src==request.dst).
67inline bool frame_matches_exchange_endpoints(const IoFrame &request, const IoFrame &candidate) {
68 return frame_matches_nodes(candidate, request.dst, request.src);
69}
70
71// == Exchange first-response classification ==
72
73/// Decide how to handle the first response packet in an authenticated exchange.
74///
75/// Used by wait_for_first_response_() to determine whether the exchange:
76/// - completes immediately (direct response),
77/// - requires authentication (challenge received), or
78/// - should ignore the frame and keep waiting.
79///
80/// @param request Original outbound request frame.
81/// @param candidate Parsed IoFrame from the device.
82/// @return Disposition indicating next step.
84 const IoFrame &candidate) {
85 if (!frame_matches_exchange_endpoints(request, candidate))
87 // A matching non-0x3C frame is the entire answer for direct-response exchanges such as plain
88 // status reads, so the caller must not force it through the authenticated path.
89 if (candidate.cmd == CMD_CHALLENGE_REQ)
92}
93
94/// Decide if a candidate frame is an acceptable final response after authentication.
95///
96/// Only endpoint matching is checked here; command validity is encoded in the
97/// disposition mapping by the caller.
98///
99/// @param request Original outbound request frame.
100/// @param candidate Parsed IoFrame from the device.
101/// @return ACCEPT if endpoints match; IGNORE_UNRELATED otherwise.
107
108// == Pairing discovery & key-challenge classification ==
109
110/// Decide if a frame is a valid discovery response (0x29) during pairing.
111///
112/// Only the destination is checked, not the source: the discovery request goes out to a
113/// shared broadcast address, so a response arriving during the same window may be a device
114/// answering a *different* controller's concurrent discovery rather than ours — real hardware
115/// addresses its response back to the requesting controller's own node ID, so checking that is
116/// both possible and sufficient to reject it. The source can't be checked here — the device's
117/// node ID is exactly what discovery exists to learn, so there is nothing yet to compare it to.
118///
119/// @param candidate Parsed IoFrame.
120/// @param controller_id Node ID of this controller (expected destination).
121/// @return ACCEPT if the command is CMD_DISCOVER_RESP and addressed to this controller; INVALID otherwise.
123 const uint8_t controller_id[NODE_ID_SIZE]) {
124 return candidate.cmd == CMD_DISCOVER_RESP && std::memcmp(candidate.dst, controller_id, NODE_ID_SIZE) == 0
127}
128
129/// Decide if a frame is a valid key-challenge (0x3C) during pairing key exchange.
130///
131/// The challenge must:
132/// - be CMD_CHALLENGE_REQ,
133/// - have data_len == HMAC_SIZE (6),
134/// - originate from the discovered device node ID,
135/// - be addressed to this controller's node ID.
136///
137/// @param candidate Parsed IoFrame.
138/// @param device_id Node ID of the device being paired (expected sender).
139/// @param controller_id Node ID of this controller (expected destination).
140/// @return ACCEPT if all criteria met; IGNORE otherwise.
142 const uint8_t device_id[NODE_ID_SIZE],
143 const uint8_t controller_id[NODE_ID_SIZE]) {
144 // Pairing reuses the normal 0x3C primitive, but here the challenge is only valid when it comes
145 // from the device we just discovered and targets this controller. That keeps foreign traffic from
146 // contaminating key exchange on a busy channel.
147 return candidate.cmd == CMD_CHALLENGE_REQ && candidate.data_len == HMAC_SIZE &&
148 frame_matches_nodes(candidate, device_id, controller_id)
151}
152
153// == One-way (1W) remote frame handling ==
154
155/// @brief Key fields of the last processed 1W frame, used to collapse a remote's repeat burst.
156///
157/// 1W remotes repeat each command 4× at ~40ms intervals for reliability, and a held button keeps
158/// resending, so one logical press arrives as many identical frames. The key deliberately includes
159/// the decoded intent bytes and not just the command byte: a move and a stop are *both*
160/// CMD_EXECUTE and differ only in `main0`, so a command-only key silently discards a stop that
161/// follows a move within the window — losing the sender event, the optimistic-target clear, and
162/// the immediate poll that a stop is supposed to trigger.
164 std::string src_id; ///< Source node ID of the last processed frame; empty before the first.
165 uint8_t cmd{0}; ///< Command byte.
166 bool has_intent{false}; ///< Whether main0/main1 were decoded (execute / activate-mode only).
167 uint8_t main0{0}; ///< First main byte — what distinguishes a move from a stop.
168 uint8_t main1{0}; ///< Second main byte.
169 uint32_t timestamp{0}; ///< millis() when the frame was processed.
170};
171
172/// Decide whether an incoming 1W frame repeats the previous one inside the burst window.
173///
174/// @param last State recorded for the previously processed 1W frame.
175/// @param incoming Candidate frame's key fields, with `timestamp` set to now.
176/// @param window_ms Burst-suppression window.
177/// @return true if the frame should be dropped as a repeat of `last`.
178inline bool is_duplicate_1w_frame(const OneWayDedupState &last, const OneWayDedupState &incoming, uint32_t window_ms) {
179 // `last.src_id` is empty until the first 1W frame is processed, so a real frame never matches it.
180 if (last.src_id != incoming.src_id || last.cmd != incoming.cmd || last.has_intent != incoming.has_intent)
181 return false;
182 if (incoming.has_intent && (last.main0 != incoming.main0 || last.main1 != incoming.main1))
183 return false;
184 // Unsigned arithmetic makes this correct across the millis() wrap.
185 return (incoming.timestamp - last.timestamp) < window_ms;
186}
187
188/// Decide whether to hold back a queued background poll because a 1W remote is still transmitting.
189///
190/// The radio is half-duplex and an authenticated exchange blocks for 1–3 s, during which no frame
191/// can be received at all. A press on a linked remote schedules a status poll, so without this gate
192/// the hub's own poll can start on top of the burst that triggered it and go deaf to the rest of it.
193///
194/// Only background polls are deferred. A user command must never wait on a remote the user may not
195/// even own — 1W broadcasts carry no ownership marker, so the activity could be a neighbour's.
196///
197/// The hold re-arms on every 1W frame received while it is already active, so a real burst from one
198/// remote (~160 ms, well under `quiet_ms`) never gets cut short mid-transmission. Left unchecked
199/// that re-arming has no cap: sustained sub-`quiet_ms` 1W traffic from any source — including a
200/// neighbour's, since these broadcasts carry no ownership marker — would hold background polls back
201/// indefinitely. @p max_defer_ms bounds that: once that much time has passed since the burst
202/// *started* (not the most recent frame), the poll is let through regardless of ongoing traffic.
203/// The gate only ever delays a poll, never drops one — it stays queued and fires as soon as it is
204/// no longer deferred.
205///
206/// @param next_op_is_background True if the queue front is a REQUEST_STATUS / REQUEST_NAME.
207/// @param first_1w_activity_ms millis() of the first frame in the current 1W burst; 0 if none seen
208/// since boot.
209/// @param last_1w_activity_ms millis() of the most recent 1W frame; 0 if none seen since boot.
210/// @param now Current millis().
211/// @param quiet_ms How long after 1W activity to hold background polls back.
212/// @param max_defer_ms Hard cap on total defer time, measured from first_1w_activity_ms.
213/// @return true if the caller should skip dispatching this loop iteration.
214inline bool defer_background_poll_for_1w_activity(bool next_op_is_background, uint32_t first_1w_activity_ms,
215 uint32_t last_1w_activity_ms, uint32_t now, uint32_t quiet_ms,
216 uint32_t max_defer_ms) {
217 if (!next_op_is_background || last_1w_activity_ms == 0)
218 return false;
219 if (now - first_1w_activity_ms >= max_defer_ms)
220 return false;
221 return (now - last_1w_activity_ms) < quiet_ms;
222}
223
224/// Whether a 1W frame arriving at `now` starts a new burst rather than extending the current one
225/// — true if no frame has been seen yet, or the gap since the last one reached `quiet_ms` (the
226/// previous burst already released any deferred poll). Callers use this to decide whether to
227/// reset a burst's start-time tracking; see defer_background_poll_for_1w_activity() for why the
228/// burst start (not just the latest frame) needs its own timestamp.
229///
230/// @param last_1w_activity_ms millis() of the most recent 1W frame before this one; 0 if none
231/// seen since boot.
232/// @param now Current millis() (this frame's arrival time).
233/// @param quiet_ms Gap after which a previous burst is considered over.
234[[nodiscard]] inline bool oneway_burst_started_fresh(uint32_t last_1w_activity_ms, uint32_t now, uint32_t quiet_ms) {
235 return last_1w_activity_ms == 0 || (now - last_1w_activity_ms) >= quiet_ms;
236}
237
238// == Timing/slicing helper ==
239
240/// Slice remaining wait time into bounded intervals to allow frequency hopping.
241///
242/// The wait loops (exchange and pairing) use this to avoid blocking the radio
243/// for too long without hopping. Each slice is at most RESPONSE_CHANNEL_WAIT_MS.
244///
245/// @param remaining_ms Total time left in the wait window.
246/// @return Time slice to wait in milliseconds.
247inline uint32_t response_wait_slice_ms(uint32_t remaining_ms) {
248 return std::min<uint32_t>(remaining_ms, RESPONSE_CHANNEL_WAIT_MS);
249}
250
251} // namespace decisions
252} // namespace home_io_control
253} // 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_duplicate_1w_frame(const OneWayDedupState &last, const OneWayDedupState &incoming, uint32_t window_ms)
Decide whether an incoming 1W frame repeats the previous one inside the burst window.
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.
bool oneway_burst_started_fresh(uint32_t last_1w_activity_ms, uint32_t now, uint32_t quiet_ms)
Whether a 1W frame arriving at now starts a new burst rather than extending the current one — true if...
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.
bool defer_background_poll_for_1w_activity(bool next_op_is_background, uint32_t first_1w_activity_ms, uint32_t last_1w_activity_ms, uint32_t now, uint32_t quiet_ms, uint32_t max_defer_ms)
Decide whether to hold back a queued background poll because a 1W remote is still transmitting.
PairingDiscoveryDisposition classify_pairing_discovery_response(const IoFrame &candidate, const uint8_t controller_id[NODE_ID_SIZE])
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_sizes.h:20
static constexpr uint8_t HMAC_SIZE
Authentication HMAC is 6 bytes (truncated AES output).
Definition proto_sizes.h:22
static constexpr uint8_t CMD_CHALLENGE_REQ
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
HMAC proof answering a 0x3C.
static constexpr int32_t RESPONSE_CHANNEL_WAIT_MS
Per-channel dwell while waiting for an exchange response.
IO-Homecontrol command IDs, result codes and protocol enumerations.
IO-Homecontrol 2W frame container: control bytes, IoFrame and (de)serialization.
Physical-layer radio and timing parameters for the IO-Homecontrol protocol.
Parsed IO‑Homecontrol frame (CTRL0/1 + addresses + command + data).
Definition proto_frame.h:71
uint8_t src[NODE_ID_SIZE]
Source node ID (3 bytes).
Definition proto_frame.h:75
uint8_t dst[NODE_ID_SIZE]
Destination node ID (3 bytes).
Definition proto_frame.h:74
uint8_t data_len
Actual length of data.
Definition proto_frame.h:78
Key fields of the last processed 1W frame, used to collapse a remote's repeat burst.
std::string src_id
Source node ID of the last processed frame; empty before the first.
uint8_t main0
First main byte — what distinguishes a move from a stop.
uint32_t timestamp
millis() when the frame was processed.
bool has_intent
Whether main0/main1 were decoded (execute / activate-mode only).