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/// Whether an authenticated-but-unanswered request may be sent again.
109///
110/// CMD_EXECUTE is the only request the hub sends that moves something, so a retry there is a
111/// second side effect on a device already acting on the first copy. Every other request (status
112/// polls, name reads, management actions, config writes) is idempotent and keeps its full retry
113/// budget when the device authenticates but never closes the exchange.
114/// @param cmd Command byte of the outbound request.
115/// @return true when the remaining retries should still be spent.
116[[nodiscard]] inline bool retry_after_unconfirmed_accept_is_safe(uint8_t cmd) { return cmd != CMD_EXECUTE; }
117
118// == Pairing discovery & key-challenge classification ==
119
120/// Decide if a frame is a valid discovery response (0x29) during pairing.
121///
122/// Only the destination is checked, not the source: the discovery request goes out to a
123/// shared broadcast address, so a response arriving during the same window may be a device
124/// answering a *different* controller's concurrent discovery rather than ours — real hardware
125/// addresses its response back to the requesting controller's own node ID, so checking that is
126/// both possible and sufficient to reject it. The source can't be checked here — the device's
127/// node ID is exactly what discovery exists to learn, so there is nothing yet to compare it to.
128///
129/// @param candidate Parsed IoFrame.
130/// @param controller_id Node ID of this controller (expected destination).
131/// @return ACCEPT if the command is CMD_DISCOVER_RESP and addressed to this controller; INVALID otherwise.
133 const uint8_t controller_id[NODE_ID_SIZE]) {
134 return candidate.cmd == CMD_DISCOVER_RESP && std::memcmp(candidate.dst, controller_id, NODE_ID_SIZE) == 0
137}
138
139/// Decide if a frame is a valid key-challenge (0x3C) during pairing key exchange.
140///
141/// The challenge must:
142/// - be CMD_CHALLENGE_REQ,
143/// - have data_len == HMAC_SIZE (6),
144/// - originate from the discovered device node ID,
145/// - be addressed to this controller's node ID.
146///
147/// @param candidate Parsed IoFrame.
148/// @param device_id Node ID of the device being paired (expected sender).
149/// @param controller_id Node ID of this controller (expected destination).
150/// @return ACCEPT if all criteria met; IGNORE otherwise.
152 const uint8_t device_id[NODE_ID_SIZE],
153 const uint8_t controller_id[NODE_ID_SIZE]) {
154 // Pairing reuses the normal 0x3C primitive, but here the challenge is only valid when it comes
155 // from the device we just discovered and targets this controller. That keeps foreign traffic from
156 // contaminating key exchange on a busy channel.
157 return candidate.cmd == CMD_CHALLENGE_REQ && candidate.data_len == HMAC_SIZE &&
158 frame_matches_nodes(candidate, device_id, controller_id)
161}
162
163// == One-way (1W) remote frame handling ==
164
165/// @brief Key fields of the last processed 1W frame, used to collapse a remote's repeat burst.
166///
167/// 1W remotes repeat each command 4× at ~40ms intervals for reliability, and a held button keeps
168/// resending, so one logical press arrives as many identical frames. The key deliberately includes
169/// the decoded intent bytes and not just the command byte: a move and a stop are *both*
170/// CMD_EXECUTE and differ only in `main0`, so a command-only key silently discards a stop that
171/// follows a move within the window — losing the sender event, the optimistic-target clear, and
172/// the immediate poll that a stop is supposed to trigger.
174 std::string src_id; ///< Source node ID of the last processed frame; empty before the first.
175 uint8_t cmd{0}; ///< Command byte.
176 bool has_intent{false}; ///< Whether main0/main1 were decoded (execute / activate-mode only).
177 uint8_t main0{0}; ///< First main byte — what distinguishes a move from a stop.
178 uint8_t main1{0}; ///< Second main byte.
179 uint32_t timestamp{0}; ///< millis() when the frame was processed.
180};
181
182/// Decide whether an incoming 1W frame repeats the previous one inside the burst window.
183///
184/// @param last State recorded for the previously processed 1W frame.
185/// @param incoming Candidate frame's key fields, with `timestamp` set to now.
186/// @param window_ms Burst-suppression window.
187/// @return true if the frame should be dropped as a repeat of `last`.
188inline bool is_duplicate_1w_frame(const OneWayDedupState &last, const OneWayDedupState &incoming, uint32_t window_ms) {
189 // `last.src_id` is empty until the first 1W frame is processed, so a real frame never matches it.
190 if (last.src_id != incoming.src_id || last.cmd != incoming.cmd || last.has_intent != incoming.has_intent)
191 return false;
192 if (incoming.has_intent && (last.main0 != incoming.main0 || last.main1 != incoming.main1))
193 return false;
194 // Unsigned arithmetic makes this correct across the millis() wrap.
195 return (incoming.timestamp - last.timestamp) < window_ms;
196}
197
198/// Decide whether to hold back a queued background poll because a 1W remote is still transmitting.
199///
200/// The radio is half-duplex and an authenticated exchange blocks for 1–3 s, during which no frame
201/// can be received at all. A press on a linked remote schedules a status poll, so without this gate
202/// the hub's own poll can start on top of the burst that triggered it and go deaf to the rest of it.
203///
204/// Only background polls are deferred. A user command must never wait on a remote the user may not
205/// even own — 1W broadcasts carry no ownership marker, so the activity could be a neighbour's.
206///
207/// The hold re-arms on every 1W frame received while it is already active, so a real burst from one
208/// remote (~160 ms, well under `quiet_ms`) never gets cut short mid-transmission. Left unchecked
209/// that re-arming has no cap: sustained sub-`quiet_ms` 1W traffic from any source — including a
210/// neighbour's, since these broadcasts carry no ownership marker — would hold background polls back
211/// indefinitely. @p max_defer_ms bounds that: once that much time has passed since the burst
212/// *started* (not the most recent frame), the poll is let through regardless of ongoing traffic.
213/// The gate only ever delays a poll, never drops one — it stays queued and fires as soon as it is
214/// no longer deferred.
215///
216/// @param next_op_is_background True if the queue front is a REQUEST_STATUS / REQUEST_NAME.
217/// @param first_1w_activity_ms millis() of the first frame in the current 1W burst; 0 if none seen
218/// since boot.
219/// @param last_1w_activity_ms millis() of the most recent 1W frame; 0 if none seen since boot.
220/// @param now Current millis().
221/// @param quiet_ms How long after 1W activity to hold background polls back.
222/// @param max_defer_ms Hard cap on total defer time, measured from first_1w_activity_ms.
223/// @return true if the caller should skip dispatching this loop iteration.
224inline bool defer_background_poll_for_1w_activity(bool next_op_is_background, uint32_t first_1w_activity_ms,
225 uint32_t last_1w_activity_ms, uint32_t now, uint32_t quiet_ms,
226 uint32_t max_defer_ms) {
227 if (!next_op_is_background || last_1w_activity_ms == 0)
228 return false;
229 if (now - first_1w_activity_ms >= max_defer_ms)
230 return false;
231 return (now - last_1w_activity_ms) < quiet_ms;
232}
233
234/// @brief Transmit-attempt budget for a scheduler-owned status poll, by backoff-ladder position.
235///
236/// See SCHEDULED_POLL_MAX_TRIES and SCHEDULED_POLL_RETRY_GRACE_FIRST_FAILURE (proto_timing.h) for
237/// why the full budget belongs to a middle band of the ladder rather than to its start or its tail.
238///
239/// The two counters are mutually exclusive by construction — StatusPollPolicy::on_exchange_failed()
240/// zeroes one while incrementing the other — so an auth-shaped streak reads status_poll_failures
241/// as 0 and would otherwise fall into the band's own "fresh window" case. It is rejected first,
242/// deliberately, so the predicate stays correct even if that exclusivity is ever relaxed.
243///
244/// @param status_poll_failures Consecutive silent failures already recorded for this device.
245/// @param auth_poll_failures Consecutive challenge-seen failures already recorded.
246/// @return EXCHANGE_RETRY_COUNT inside the band, SCHEDULED_POLL_MAX_TRIES everywhere else.
247inline uint8_t scheduled_poll_max_tries(uint8_t status_poll_failures, uint8_t auth_poll_failures) {
248 if (auth_poll_failures != 0)
250 if (status_poll_failures < SCHEDULED_POLL_RETRY_GRACE_FIRST_FAILURE ||
251 status_poll_failures > SCHEDULED_POLL_RETRY_GRACE_LAST_FAILURE)
254}
255
256/// True if a frame's shape matches a 1W remote's pairing gesture (issue #27/#65): CTRL0 1W bit
257/// set, addressed to the 1W broadcast address (0x00003F), with one of the three command bytes
258/// observed in the field capture — 0x20 (WRITE_PRIVATE), 0x39 (1W remove), or 0x2E (alternate
259/// discovery, 1W-flagged). Shared between PairingAdvisor (classifying recorded telemetry events,
260/// pairing_advisor.cpp) and the hub's normal passive RX path (hub_status.cpp), which remembers a
261/// recent sighting so a PROG press completed just before "Discover & Pair" is pressed isn't
262/// invisible to the advisor purely because of when the discovery telemetry window happened to
263/// open — see PairingTelemetry::record_recent_one_way_sighting().
264///
265/// @param oneway CTRL0 1W-protocol bit.
266/// @param dst Frame destination node ID.
267/// @param cmd Frame command byte.
268inline bool is_one_way_pairing_gesture(bool oneway, const uint8_t dst[NODE_ID_SIZE], uint8_t cmd) {
269 if (!oneway)
270 return false;
271 if (std::memcmp(dst, BROADCAST_DISCOVER_ALT, NODE_ID_SIZE) != 0)
272 return false;
273 return cmd == CMD_WRITE_PRIVATE || cmd == CMD_ONEWAY_REMOVE || cmd == CMD_DISCOVER_ALT_REQ;
274}
275
276/// Whether a 1W frame arriving at `now` starts a new burst rather than extending the current one
277/// — true if no frame has been seen yet, or the gap since the last one reached `quiet_ms` (the
278/// previous burst already released any deferred poll). Callers use this to decide whether to
279/// reset a burst's start-time tracking; see defer_background_poll_for_1w_activity() for why the
280/// burst start (not just the latest frame) needs its own timestamp.
281///
282/// @param last_1w_activity_ms millis() of the most recent 1W frame before this one; 0 if none
283/// seen since boot.
284/// @param now Current millis() (this frame's arrival time).
285/// @param quiet_ms Gap after which a previous burst is considered over.
286[[nodiscard]] inline bool oneway_burst_started_fresh(uint32_t last_1w_activity_ms, uint32_t now, uint32_t quiet_ms) {
287 return last_1w_activity_ms == 0 || (now - last_1w_activity_ms) >= quiet_ms;
288}
289
290} // namespace decisions
291} // namespace home_io_control
292} // 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 is_one_way_pairing_gesture(bool oneway, const uint8_t dst[NODE_ID_SIZE], uint8_t cmd)
True if a frame's shape matches a 1W remote's pairing gesture (issue #27/#65): CTRL0 1W bit set,...
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.
bool retry_after_unconfirmed_accept_is_safe(uint8_t cmd)
Whether an authenticated-but-unanswered request may be sent again.
uint8_t scheduled_poll_max_tries(uint8_t status_poll_failures, uint8_t auth_poll_failures)
Transmit-attempt budget for a scheduler-owned status poll, by backoff-ladder position.
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 CMD_DISCOVER_ALT_REQ
Alternate discovery.
static constexpr uint8_t SCHEDULED_POLL_MAX_TRIES
Exchange tries for a status poll the scheduler owns — every status poll issued while StatusPollPolicy...
static constexpr uint8_t HMAC_SIZE
Authentication HMAC is 6 bytes (truncated AES output).
Definition proto_sizes.h:22
static constexpr uint8_t CMD_WRITE_PRIVATE
Write private register (climate/heating devices).
static constexpr uint8_t SCHEDULED_POLL_RETRY_GRACE_FIRST_FAILURE
Ladder positions at which a scheduler-owned status poll gets the full EXCHANGE_RETRY_COUNT back.
static constexpr uint8_t EXCHANGE_RETRY_COUNT
Attempts per command before reporting failure.
static constexpr uint8_t CMD_EXECUTE
Set position/open/close/stop — requires authentication.
static constexpr uint8_t CMD_CHALLENGE_REQ
6-byte random challenge.
static constexpr uint8_t CMD_ONEWAY_REMOVE
1W "remove controller" (un-pair a 1W remote from a device); same payload shape as 0x2E.
static constexpr uint8_t CMD_DISCOVER_RESP
Device responds with its ID and type.
static constexpr uint8_t BROADCAST_DISCOVER_ALT[NODE_ID_SIZE]
Alternate discovery / 1W broadcast address (0x00003F).
static constexpr uint8_t CMD_CHALLENGE_RESP
HMAC proof answering a 0x3C.
static constexpr uint8_t SCHEDULED_POLL_RETRY_GRACE_LAST_FAILURE
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:88
uint8_t src[NODE_ID_SIZE]
Source node ID (3 bytes).
Definition proto_frame.h:92
uint8_t dst[NODE_ID_SIZE]
Destination node ID (3 bytes).
Definition proto_frame.h:91
uint8_t data_len
Actual length of data.
Definition proto_frame.h:95
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).