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_codecs.h"
12#include "proto_constants.h"
13#include "proto_device_model.h"
14#include "proto_frame.h"
15#include "proto_timing.h"
16
17#include <algorithm>
18#include <cstdint>
19#include <cstring>
20#include <string>
21
22namespace esphome {
23namespace home_io_control {
24namespace decisions {
25
26/// @brief Disposition for the first response in an authenticated exchange.
28 IGNORE_UNRELATED, ///< Frame doesn't match endpoints or failed parse — keep waiting.
29 COMPLETE_DIRECT, ///< Matching non-challenge frame — operation complete, no auth needed.
30 REQUIRE_AUTH, ///< Matching 0x3C challenge — device demands authentication.
31};
32
33/// @brief Disposition for the final response after authentication.
35 IGNORE_UNRELATED, ///< Frame doesn't match endpoints — ignore.
36 ACCEPT, ///< Frame matches expected response — exchange succeeds.
37};
38
39/// @brief Disposition during pairing discovery phase.
40enum class PairingDiscoveryDisposition : uint8_t {
41 NO_RESPONSE, ///< No packets received on the channel within timeout.
42 INVALID, ///< Packets seen but none were valid discovery (0x29) frames.
43 ACCEPT, ///< Valid discovery response received.
44};
45
46/// @brief Disposition during pairing key-challenge phase.
47enum class PairingKeyChallengeDisposition : uint8_t {
48 IGNORE, ///< Not a valid challenge (wrong cmd, length, or sender).
49 ACCEPT, ///< Valid 0x3C challenge from target device.
50};
51
52/// @brief Disposition for a candidate reply to a discovery-confirm request (0x2C).
54 IGNORE, ///< Not from/to the expected endpoints, or a frame the step does not act on (e.g. a
55 ///< repeated 0x29) — keep waiting.
56 ACK, ///< CMD_DISCOVER_CONFIRM_ACK (0x2D) from the device — it wants to proceed.
57 ERROR, ///< CMD_ERROR_RESP from the device — an explicit, named refusal.
58};
59
60/// @brief Disposition for a candidate reply to the key-transfer (0x32) confirm wait — the
61/// slow-turnaround path's expanded classification of @ref PairingKeyChallengeDisposition's device
62/// challenge to also cover a direct confirm or an explicit refusal.
63enum class PairingKeyConfirmDisposition : uint8_t {
64 IGNORE, ///< Not from/to the expected endpoints — keep waiting.
65 CONFIRM, ///< CMD_KEY_CONFIRM (0x33) — the device accepted the key.
66 CHALLENGE, ///< A fresh CMD_CHALLENGE_REQ (0x3C) — the device may challenge the key transfer
67 ///< before confirming it; answered via ExchangeEngine::answer_challenge().
68 REFUSE, ///< Anything else (including CMD_ERROR_RESP) — an explicit or implicit refusal;
69 ///< ends the whole wait, not just the current try.
70};
71
72// == Passive RX filtering ==
73
74/// Returns true for commands that are internal to an exchange handshake and carry
75/// no useful information for a passive observer (challenge request/response).
76/// These frames appear in every authenticated exchange between other controllers and
77/// devices on the network, but contain only ephemeral cryptographic data.
78inline bool is_exchange_internal_command(uint8_t cmd) { return cmd == CMD_CHALLENGE_REQ || cmd == CMD_CHALLENGE_RESP; }
79
80// == Utility: endpoint matching ==
81
82/// Check if two frames have identical src/dst node IDs.
83inline bool frame_matches_nodes(const IoFrame &frame, const uint8_t expected_src[NODE_ID_SIZE],
84 const uint8_t expected_dst[NODE_ID_SIZE]) {
85 return std::memcmp(frame.src, expected_src, NODE_ID_SIZE) == 0 &&
86 std::memcmp(frame.dst, expected_dst, NODE_ID_SIZE) == 0;
87}
88
89/// Check if candidate frame endpoints are the reverse of the request (dst==request.src, src==request.dst).
90inline bool frame_matches_exchange_endpoints(const IoFrame &request, const IoFrame &candidate) {
91 return frame_matches_nodes(candidate, request.dst, request.src);
92}
93
94// == Exchange first-response classification ==
95
96/// Decide how to handle the first response packet in an authenticated exchange.
97///
98/// Used by wait_for_first_response_() to determine whether the exchange:
99/// - completes immediately (direct response),
100/// - requires authentication (challenge received), or
101/// - should ignore the frame and keep waiting.
102///
103/// @param request Original outbound request frame.
104/// @param candidate Parsed IoFrame from the device.
105/// @return Disposition indicating next step.
107 const IoFrame &candidate) {
108 if (!frame_matches_exchange_endpoints(request, candidate))
110 // A matching non-0x3C frame is the entire answer for direct-response exchanges such as plain
111 // status reads, so the caller must not force it through the authenticated path.
112 if (candidate.cmd == CMD_CHALLENGE_REQ)
115}
116
117/// Decide if a candidate frame is an acceptable final response after authentication.
118///
119/// Only endpoint matching is checked here; command validity is encoded in the
120/// disposition mapping by the caller.
121///
122/// @param request Original outbound request frame.
123/// @param candidate Parsed IoFrame from the device.
124/// @return ACCEPT if endpoints match; IGNORE_UNRELATED otherwise.
130
131/// True for a CMD_EXECUTE that has the same effect sent twice as sent once. Every EXECUTE this hub
132/// sends names an absolute target (a position, open/close, STOP, a tilt angle, a light level)
133/// except the stored-position selector POS_FAVORITE, used by favourite and vent: on a Somfy motor
134/// "My" while moving means stop, so a second copy can undo what the first one started.
135/// @param request Outbound request frame.
136[[nodiscard]] inline bool is_repeatable_execute(const IoFrame &request) {
137 return request.cmd == CMD_EXECUTE && request.data_len > EXECUTE_MAIN_BYTE_OFFSET &&
138 request.data[EXECUTE_MAIN_BYTE_OFFSET] != POS_FAVORITE;
139}
140
141/// Whether an authenticated-but-unanswered request may be sent again.
142///
143/// Every request except CMD_EXECUTE is idempotent (status polls, name reads, management actions,
144/// config writes) and keeps its full retry budget when the device authenticates but never closes
145/// the exchange.
146///
147/// CMD_EXECUTE moves something, and a missing closing reply means two different things depending
148/// on the device. Some devices never close an EXECUTE exchange within the response window and
149/// report through a later status update instead; for them silence is normal and a re-send only
150/// repeats a command already being carried out. For a device that normally does close it, silence
151/// can mean our challenge answer never arrived and the command was not carried out at all (a STOP
152/// that left an awning moving). So an EXECUTE is sent again only to a device known to confirm,
153/// only when repeating it is harmless (is_repeatable_execute()), and at most
154/// UNCONFIRMED_EXECUTE_MAX_RESENDS times per exchange.
155/// @param request Outbound request frame.
156/// @param target_confirms_execute The target has closed an EXECUTE exchange with a reply before
157/// (TargetEvidence::confirms_execute).
158/// @param unconfirmed_tries Tries of this exchange that ended accepted without a closing
159/// reply, including the one just ended (1-based).
160/// @return true when the exchange should spend another try.
161[[nodiscard]] inline bool retry_after_unconfirmed_accept_is_safe(const IoFrame &request, bool target_confirms_execute,
162 uint8_t unconfirmed_tries) {
163 if (request.cmd != CMD_EXECUTE)
164 return true;
165 return target_confirms_execute && is_repeatable_execute(request) &&
166 unconfirmed_tries <= UNCONFIRMED_EXECUTE_MAX_RESENDS;
167}
168
169// == Pairing discovery & key-challenge classification ==
170
171/// Decide if a frame is a valid discovery response (0x29) during pairing.
172///
173/// Only the destination is checked, not the source: the discovery request goes out to a
174/// shared broadcast address, so a response arriving during the same window may be a device
175/// answering a *different* controller's concurrent discovery rather than ours — real hardware
176/// addresses its response back to the requesting controller's own node ID, so checking that is
177/// both possible and sufficient to reject it. The source can't be checked here — the device's
178/// node ID is exactly what discovery exists to learn, so there is nothing yet to compare it to.
179///
180/// @param candidate Parsed IoFrame.
181/// @param controller_id Node ID of this controller (expected destination).
182/// @return ACCEPT if the command is CMD_DISCOVER_RESP and addressed to this controller; INVALID otherwise.
184 const uint8_t controller_id[NODE_ID_SIZE]) {
185 return candidate.cmd == CMD_DISCOVER_RESP && std::memcmp(candidate.dst, controller_id, NODE_ID_SIZE) == 0
188}
189
190/// Decide if a frame is a valid key-challenge (0x3C) during pairing key exchange.
191///
192/// The challenge must:
193/// - be CMD_CHALLENGE_REQ,
194/// - have data_len == HMAC_SIZE (6),
195/// - originate from the discovered device node ID,
196/// - be addressed to this controller's node ID.
197///
198/// @param candidate Parsed IoFrame.
199/// @param device_id Node ID of the device being paired (expected sender).
200/// @param controller_id Node ID of this controller (expected destination).
201/// @return ACCEPT if all criteria met; IGNORE otherwise.
203 const uint8_t device_id[NODE_ID_SIZE],
204 const uint8_t controller_id[NODE_ID_SIZE]) {
205 // Pairing reuses the normal 0x3C primitive, but here the challenge is only valid when it comes
206 // from the device we just discovered and targets this controller. That keeps foreign traffic from
207 // contaminating key exchange on a busy channel.
208 return candidate.cmd == CMD_CHALLENGE_REQ && candidate.data_len == HMAC_SIZE &&
209 frame_matches_nodes(candidate, device_id, controller_id)
212}
213
214/// Decide how to handle a candidate reply to a discovery-confirm request (0x2C) during pairing.
215///
216/// A non-matching or unrecognised frame is IGNORE, keeping the listen open rather than ending it:
217/// the discover-confirm step never fails a pairing attempt (see run_discover_confirm_step_()'s
218/// doc) — this classification only decides whether to keep listening or stop early.
219///
220/// @param candidate Parsed IoFrame.
221/// @param device_id Node ID of the device being paired (expected sender).
222/// @param controller_id Node ID of this controller (expected destination).
223/// @return ACK for a matching 0x2D, ERROR for a matching CMD_ERROR_RESP, IGNORE otherwise.
225 const IoFrame &candidate, const uint8_t device_id[NODE_ID_SIZE], const uint8_t controller_id[NODE_ID_SIZE]) {
226 if (!frame_matches_nodes(candidate, device_id, controller_id))
228 if (candidate.cmd == CMD_DISCOVER_CONFIRM_ACK)
230 if (candidate.cmd == CMD_ERROR_RESP)
232 // E.g. a repeated CMD_DISCOVER_RESP (0x29) from a device that missed our 0x2C and is still
233 // announcing itself — not an answer to this step, but not a foreign frame either.
235}
236
237/// Decide how to handle a candidate reply to the key-transfer (0x32) confirm wait
238/// (wait_for_key_confirm_(), slow-turnaround chips only).
239///
240/// @param request The outbound CMD_KEY_TRANSFER (0x32) this reply answers.
241/// @param candidate Parsed IoFrame from the device.
242/// @return CONFIRM/CHALLENGE/REFUSE for a matching frame of that shape; IGNORE for a frame from
243/// the wrong endpoints.
254
255/// @brief Whether discover-confirm try `try_index` (1-based) should rotate channels rather than
256/// hold the request channel.
257///
258/// Tries 1 and 3 hold: a 0x2D is a unicast reply to a unicast 0x2C, and every other unicast
259/// pairing wait in this project holds the request channel on that same expectation (see
260/// listen_for_key_confirm_()'s own reasoning), and every 0x2D a Somfy Izymo dimmer sent came back on
261/// the request channel. But the one real VELUX-system sample on record (from a hopping monitor, a
262/// weak source) logged a 0x2D on a *different* channel than its own 0x2C, so the middle try hedges
263/// by rotating instead, until a VELUX device's reply channel is measured.
264/// See ADR 0039 for why this is not simply one fixed policy for every try, unlike every other
265/// listen in this project (ADR 0028).
266/// @param try_index 1-based try number (1..PAIRING_DISCOVER_CONFIRM_TRIES).
267/// @return true if this try should use ListenPolicy::ROTATE_ALL_CHANNELS; false for
268/// ListenPolicy::HOLD_REQUEST_CHANNEL.
269[[nodiscard]] inline bool discover_confirm_try_rotates(uint8_t try_index) { return try_index == 2; }
270
271// == One-way (1W) remote frame handling ==
272
273/// @brief Key fields of the last processed 1W frame, used to collapse a remote's repeat burst.
274///
275/// 1W remotes repeat each command 4× at ~40ms intervals for reliability, and a held button keeps
276/// resending, so one logical press arrives as many identical frames. The key deliberately includes
277/// the decoded intent bytes and not just the command byte: a move and a stop are *both*
278/// CMD_EXECUTE and differ only in `main0`, so a command-only key silently discards a stop that
279/// follows a move within the window — losing the sender event, the optimistic-target clear, and
280/// the immediate poll that a stop is supposed to trigger.
281///
282/// For a frame with no decoded intent the destination is part of the key as well. A VELUX KLI's
283/// Gear press sends its `0x2E` to several device classes a few hundred ms apart, and the classes it
284/// names are the ones a new controller has to enroll on; keying on src+cmd alone would log only the
285/// first of them.
286/// Intent-bearing frames keep ignoring the destination: they fire sender events and optimistic
287/// state, where one press must stay one press.
289 std::string src_id; ///< Source node ID of the last processed frame; empty before the first.
290 uint8_t cmd{0}; ///< Command byte.
291 bool has_intent{false}; ///< Whether main0/main1 were decoded (execute / activate-mode only).
292 uint8_t main0{0}; ///< First main byte — what distinguishes a move from a stop.
293 uint8_t main1{0}; ///< Second main byte.
294 uint32_t timestamp{0}; ///< millis() when the frame was processed.
295 uint8_t dst[NODE_ID_SIZE]{}; ///< Destination address; compared only when `has_intent` is false.
296};
297
298/// Decide whether an incoming 1W frame repeats the previous one inside the burst window.
299///
300/// @param last State recorded for the previously processed 1W frame.
301/// @param incoming Candidate frame's key fields, with `timestamp` set to now.
302/// @param window_ms Burst-suppression window.
303/// @return true if the frame should be dropped as a repeat of `last`.
304inline bool is_duplicate_1w_frame(const OneWayDedupState &last, const OneWayDedupState &incoming, uint32_t window_ms) {
305 // `last.src_id` is empty until the first 1W frame is processed, so a real frame never matches it.
306 if (last.src_id != incoming.src_id || last.cmd != incoming.cmd || last.has_intent != incoming.has_intent)
307 return false;
308 if (incoming.has_intent && (last.main0 != incoming.main0 || last.main1 != incoming.main1))
309 return false;
310 if (!incoming.has_intent && std::memcmp(last.dst, incoming.dst, NODE_ID_SIZE) != 0)
311 return false;
312 // Unsigned arithmetic makes this correct across the millis() wrap.
313 return (incoming.timestamp - last.timestamp) < window_ms;
314}
315
316/// Decide whether to hold back a queued background poll because a 1W remote is still transmitting.
317///
318/// The radio is half-duplex and an authenticated exchange blocks for 1–3 s, during which no frame
319/// can be received at all. A press on a linked remote schedules a status poll, so without this gate
320/// the hub's own poll can start on top of the burst that triggered it and go deaf to the rest of it.
321///
322/// Only background polls are deferred. A user command must never wait on a remote the user may not
323/// even own — 1W broadcasts carry no ownership marker, so the activity could be a neighbour's.
324///
325/// The hold re-arms on every 1W frame received while it is already active, so a real burst from one
326/// remote (~160 ms, well under `quiet_ms`) never gets cut short mid-transmission. Left unchecked
327/// that re-arming has no cap: sustained sub-`quiet_ms` 1W traffic from any source — including a
328/// neighbour's, since these broadcasts carry no ownership marker — would hold background polls back
329/// indefinitely. @p max_defer_ms bounds that: once that much time has passed since the burst
330/// *started* (not the most recent frame), the poll is let through regardless of ongoing traffic.
331/// The gate only ever delays a poll, never drops one — it stays queued and fires as soon as it is
332/// no longer deferred.
333///
334/// @param next_op_is_background True if the queue front is a background poll (OperationQueue::is_background_op()).
335/// @param first_1w_activity_ms millis() of the first frame in the current 1W burst; 0 if none seen
336/// since boot.
337/// @param last_1w_activity_ms millis() of the most recent 1W frame; 0 if none seen since boot.
338/// @param now Current millis().
339/// @param quiet_ms How long after 1W activity to hold background polls back.
340/// @param max_defer_ms Hard cap on total defer time, measured from first_1w_activity_ms.
341/// @return true if the caller should skip dispatching this loop iteration.
342inline bool defer_background_poll_for_1w_activity(bool next_op_is_background, uint32_t first_1w_activity_ms,
343 uint32_t last_1w_activity_ms, uint32_t now, uint32_t quiet_ms,
344 uint32_t max_defer_ms) {
345 if (!next_op_is_background || last_1w_activity_ms == 0)
346 return false;
347 if (now - first_1w_activity_ms >= max_defer_ms)
348 return false;
349 return (now - last_1w_activity_ms) < quiet_ms;
350}
351
352/// @brief Transmit-attempt budget for a scheduler-owned status poll, by backoff-ladder position.
353///
354/// See SCHEDULED_POLL_MAX_TRIES and SCHEDULED_POLL_RETRY_GRACE_FIRST_FAILURE (proto_timing.h) for
355/// why the full budget belongs to a middle band of the ladder rather than to its start or its tail.
356///
357/// The two counters are mutually exclusive by construction — StatusPollPolicy::on_exchange_failed()
358/// zeroes one while incrementing the other — so an auth-shaped streak reads status_poll_failures
359/// as 0 and would otherwise fall into the band's own "fresh window" case. It is rejected first,
360/// deliberately, so the predicate stays correct even if that exclusivity is ever relaxed.
361///
362/// The settle poll after an accepted STOP (@p settles_a_stop) gets the full budget whatever the
363/// counters say: see STOP_SETTLE_POLL_TRIES (proto_timing.h).
364///
365/// @param status_poll_failures Consecutive silent failures already recorded for this device.
366/// @param auth_poll_failures Consecutive challenge-seen failures already recorded.
367/// @param settles_a_stop True for the first poll after an accepted STOP
368/// (StatusPollPolicy::take_stop_settle()).
369/// @return EXCHANGE_RETRY_COUNT inside the band or after a STOP, SCHEDULED_POLL_MAX_TRIES everywhere else.
370inline uint8_t scheduled_poll_max_tries(uint8_t status_poll_failures, uint8_t auth_poll_failures,
371 bool settles_a_stop = false) {
372 if (settles_a_stop)
373 return STOP_SETTLE_POLL_TRIES;
374 if (auth_poll_failures != 0)
375 return SCHEDULED_POLL_MAX_TRIES;
376 if (status_poll_failures < SCHEDULED_POLL_RETRY_GRACE_FIRST_FAILURE ||
377 status_poll_failures > SCHEDULED_POLL_RETRY_GRACE_LAST_FAILURE)
378 return SCHEDULED_POLL_MAX_TRIES;
379 return EXCHANGE_RETRY_COUNT;
380}
381
382// == Low-power wake belief ==
383
384/// @brief Whether a low-power receiver is believed to be travelling right now, judged from what this
385/// hub has seen of it. Orders the tries of a directed exchange: a moving receiver hears the short
386/// start preamble and ignores the long wake-up one, a resting one needs the long one.
387///
388/// Having merely heard from the device recently is deliberately not a reason to lead short: a
389/// resting VELUX SSL solar roller shutter ignored the short preamble on every try in field logs,
390/// including tries sent 34 ms after it had answered (ADR 0040, amendment).
391enum class WakeBelief : uint8_t {
392 ASLEEP, ///< Not known to be moving — lead with the wake-up preamble.
393 AWAKE, ///< Moving, or about to be stopped — the short preamble is the one it hears.
394};
395
396/// @brief What the hub knows about the device an exchange is addressed to, as the exchange engine
397/// sees it. The engine has no device registry of its own; the hub hands this over through
398/// ExchangeEngine::set_target_evidence_provider(), and every per-target decision the engine makes
399/// reads from it: wake_belief() and retry_after_unconfirmed_accept_is_safe(). Timestamps are `millis()` values, 0 =
400/// never.
402 uint32_t last_moving_evidence_ms; ///< Last sign the receiver is travelling (see note_moving_evidence(),
403 ///< clear_moving_evidence()).
404 uint32_t last_seen_ms; ///< Last frame received from the receiver, any command. Only logged
405 ///< (`age_ms=`); it does not feed the belief.
406 bool confirms_execute; ///< The device has closed a CMD_EXECUTE exchange with a reply before.
407};
408
409/// Build the exchange engine's view of a device record.
410/// @param dev Device record to read.
411[[nodiscard]] inline TargetEvidence target_evidence(const IoDevice &dev) {
413}
414
415/// True for a CMD_EXECUTE whose main byte is POS_STOP. A STOP is only ever sent to a receiver that
416/// is (believed) moving, so it is treated as awake whatever the stamps say.
417/// @param request Outbound request frame.
418[[nodiscard]] inline bool is_stop_request(const IoFrame &request) {
419 return request.cmd == CMD_EXECUTE && request.data_len > EXECUTE_MAIN_BYTE_OFFSET &&
420 request.data[EXECUTE_MAIN_BYTE_OFFSET] == POS_STOP;
421}
422
423/// Judge whether a low-power receiver is travelling. `AWAKE` for a STOP request or while moving
424/// evidence is younger than LOW_POWER_MAX_TRAVEL_MS; `ASLEEP` otherwise. A zero stamp means never and
425/// is never recent. Ages use unsigned subtraction, so `millis()` wrap-around is safe.
426/// @param evidence Stamps for the target device.
427/// @param now Current millis().
428/// @param is_stop True when the request being sent is a STOP (see is_stop_request()).
429[[nodiscard]] inline WakeBelief wake_belief(const TargetEvidence &evidence, uint32_t now, bool is_stop) {
430 const uint32_t moving = evidence.last_moving_evidence_ms;
431 if (is_stop || (moving != 0 && (now - moving) < LOW_POWER_MAX_TRAVEL_MS))
432 return WakeBelief::AWAKE;
433 return WakeBelief::ASLEEP;
434}
435
436/// Start-preamble length for one try of a directed exchange to a low-power receiver. The plans
437/// (short = `short_preamble`, wake = `wake_preamble`): AWAKE short/wake/wake, ASLEEP wake on every
438/// try. A moving receiver answers the first short try; when it does not, it has most likely come to
439/// rest, where only the wake-up preamble reaches it, so every later try uses that one. A single-try
440/// exchange (most scheduler-owned status polls) sends only try 1's preamble; its backoff ladder, and
441/// the evidence a silent AWAKE exchange spends (ExchangeEngine::set_wake_evidence_spent_handler()),
442/// cover a wrong belief there.
443/// @param belief See wake_belief().
444/// @param try_index 1-based try number, clamped to [1, EXCHANGE_RETRY_COUNT].
445/// @param short_preamble Preamble for a receiver known to be moving (`normal_start_preamble`).
446/// @param wake_preamble Wake-up preamble for a resting receiver (`low_power_wake_preamble`).
447[[nodiscard]] inline uint16_t low_power_try_preamble(WakeBelief belief, uint8_t try_index, uint16_t short_preamble,
448 uint16_t wake_preamble) {
449 const uint8_t try_1based = std::max<uint8_t>(1, std::min<uint8_t>(try_index, EXCHANGE_RETRY_COUNT));
450 if (belief == WakeBelief::AWAKE && try_1based == 1)
451 return short_preamble;
452 return wake_preamble;
453}
454
455/// Lowercase name of a belief for log lines.
456[[nodiscard]] inline const char *wake_belief_name(WakeBelief belief) {
457 switch (belief) {
459 return "awake";
461 default:
462 return "asleep";
463 }
464}
465
466/// True if a frame's shape matches a 1W remote's pairing gesture (issue #27/#65): CTRL0 1W bit
467/// set, addressed to the 1W broadcast address (0x00003F), with one of the three command bytes
468/// observed in the field capture — 0x20 (WRITE_PRIVATE), 0x39 (1W remove), or 0x2E (alternate
469/// discovery, 1W-flagged). Shared between PairingAdvisor (classifying recorded telemetry events,
470/// pairing_advisor.cpp) and the hub's normal passive RX path (hub_status.cpp), which remembers a
471/// recent sighting so a PROG press completed just before "Discover & Pair" is pressed isn't
472/// invisible to the advisor purely because of when the discovery telemetry window happened to
473/// open — see PairingTelemetry::record_recent_one_way_sighting().
474///
475/// @param oneway CTRL0 1W-protocol bit.
476/// @param dst Frame destination node ID.
477/// @param cmd Frame command byte.
478inline bool is_one_way_pairing_gesture(bool oneway, const uint8_t dst[NODE_ID_SIZE], uint8_t cmd) {
479 if (!oneway)
480 return false;
481 if (std::memcmp(dst, BROADCAST_DISCOVER_ALT, NODE_ID_SIZE) != 0)
482 return false;
483 return cmd == CMD_WRITE_PRIVATE || cmd == CMD_ONEWAY_REMOVE || cmd == CMD_DISCOVER_ALT_REQ;
484}
485
486/// Whether a 1W frame arriving at `now` starts a new burst rather than extending the current one
487/// — true if no frame has been seen yet, or the gap since the last one reached `quiet_ms` (the
488/// previous burst already released any deferred poll). Callers use this to decide whether to
489/// reset a burst's start-time tracking; see defer_background_poll_for_1w_activity() for why the
490/// burst start (not just the latest frame) needs its own timestamp.
491///
492/// @param last_1w_activity_ms millis() of the most recent 1W frame before this one; 0 if none
493/// seen since boot.
494/// @param now Current millis() (this frame's arrival time).
495/// @param quiet_ms Gap after which a previous burst is considered over.
496[[nodiscard]] inline bool oneway_burst_started_fresh(uint32_t last_1w_activity_ms, uint32_t now, uint32_t quiet_ms) {
497 return last_1w_activity_ms == 0 || (now - last_1w_activity_ms) >= quiet_ms;
498}
499
500/// Namespace tag for `remote_poll_timer_id()` below: the node address occupies the low 24 bits,
501/// and this is OR-ed in above them. Each numeric-id timer caller has its own tag (see
502/// HEATING_POLL_TIMER_ID_TAG), so two timers for the same device never replace one another and an id
503/// says which caller it belongs to.
504constexpr uint32_t REMOTE_POLL_TIMER_ID_TAG = 0x01000000;
505
506/// Namespace tag for `heating_poll_timer_id()`: the same node-address scheme under its own bit, so
507/// a heater's regular-cadence timer and a remote-activity poll timer for the same device never replace
508/// one another.
509constexpr uint32_t HEATING_POLL_TIMER_ID_TAG = 0x02000000;
510
511/// @brief Numeric `set_timeout()` id for a device's remote-activity poll timer (hub_status.cpp's
512/// `schedule_status_poll_()`).
513///
514/// Keyed by the device's node address rather than a name, because `Component::set_timeout(const
515/// char *name, ...)` stores the caller's *pointer*, not a copy of the string (its header documents
516/// this: static lifetime required, use the numeric-id overload for a dynamically-built name) — see
517/// `schedule_status_poll_()`'s own comment for why a per-device name can't satisfy that here. A
518/// node address is unique per device, so this id is collision-free by construction.
519///
520/// @param node_id 3-byte device node address.
521/// @return Numeric timer id, unique per device.
522inline uint32_t remote_poll_timer_id(const uint8_t node_id[NODE_ID_SIZE]) {
523 return REMOTE_POLL_TIMER_ID_TAG | (static_cast<uint32_t>(node_id[0]) << (2 * BITS_PER_BYTE)) |
524 (static_cast<uint32_t>(node_id[1]) << BITS_PER_BYTE) | static_cast<uint32_t>(node_id[2]);
525}
526
527/// @brief Numeric `set_timeout()` id for a heater's regular-cadence poll timer
528/// (`IOHomeControlComponent::schedule_heating_poll_()`). Same keying as remote_poll_timer_id(), in
529/// the HEATING_POLL_TIMER_ID_TAG namespace, so a poll that a linked remote triggers cannot cancel it.
530/// @param node_id 3-byte device node address.
531/// @return Numeric timer id, unique per device and distinct from remote_poll_timer_id().
532inline uint32_t heating_poll_timer_id(const uint8_t node_id[NODE_ID_SIZE]) {
534}
535
536// === Pairing: the device behind a pairing node ===
537
538/// @brief Does a pairing node that answered discovery this way control a heater at the next address?
539///
540/// An Atlantic radiator in its display's pairing mode answers discovery as a remote controller (node
541/// type 1023), hands over its key, and is controlled at the next node address up: three AGILIA IO
542/// radiators follow this (issue #165,
543/// tests/corpus/captures/pairing/atlantic_agilia_pairing_key_pull_heater_rollcall.yaml). The heater
544/// answers a roll-call only now and then, so the pairing asks that address directly first.
545/// @param type Node type the pairing node answered discovery with.
546/// @param manufacturer Manufacturer byte of that discovery reply (0 when the reply carried none).
547/// @return true for an Atlantic remote-controller node.
548inline bool pairing_node_has_heater_at_next_address(DeviceType type, uint8_t manufacturer) {
549 return type == DeviceType::REMOTE_CONTROLLER && manufacturer == MANUFACTURER_ATLANTIC_GROUP;
550}
551
552/// @brief The node address one above `node`, as a 24-bit number (`B6BDFB` -> `B6BDFC`).
553///
554/// A carry into the higher bytes (`xxxxFF`) is computed as plain arithmetic; no radiator with such an
555/// address has been seen, so whether the device counts the same way is unconfirmed.
556/// @param node Address to count from.
557/// @param[out] next The next address; untouched when `false` is returned.
558/// @return false for `FFFFFF`, which has no next address.
559inline bool next_node_id(const uint8_t node[NODE_ID_SIZE], uint8_t next[NODE_ID_SIZE]) {
560 uint8_t up[NODE_ID_SIZE];
561 memcpy(up, node, NODE_ID_SIZE);
562 // Count up from the last byte; a byte that wraps to 0 carries into the one before it.
563 for (size_t i = NODE_ID_SIZE; i-- > 0;) {
564 if (++up[i] != 0) {
565 memcpy(next, up, NODE_ID_SIZE);
566 return true;
567 }
568 }
569 return false; // FFFFFF wrapped to 000000.
570}
571
572// === Rain limitation ===
573
574/// @brief Which evidence path labelled a device as limited by rain.
575enum class RainLimitationRule : uint8_t {
576 NONE = 0, ///< No evidence of a rain limitation.
577 RAIN_ORIGINATOR, ///< The device names the rain sensor as the originator of its last command.
578 CLAMPED_COMMAND, ///< A position command was accepted but the stopped device ended elsewhere, while
579 ///< rain protection was recently active.
580};
581
582/// @brief What one decoded status reply said, plus what the hub had predicted.
584 bool has_last_command; ///< The device record holds a well-formed last-command record.
585 uint8_t originator; ///< Its Command Originator (ORIGINATOR_* in proto_constants.h).
586 bool rain_evidence_recent; ///< A rain originator was seen from this device within RAIN_EVIDENCE_HOLD_MS.
587 float predicted_target; ///< Hub's prediction before this reply (UNKNOWN_POSITION when none stood).
588 float observed_target; ///< dev.target after decoding this reply.
589 bool stopped; ///< The device reported itself stopped.
590};
591
592/// @brief Decide which rain-limitation evidence, if any, a status reply provides.
593///
594/// A rain limitation is an *inference from observations*, never a prediction (ADR 0030 keeps
595/// predictions apart): windows clamp an `open` to their ventilation position without sending
596/// CMD_ERROR_RESP / RESULT_LIMITATION_BY_RAIN, so the only evidence is what the device reports
597/// in its ordinary status replies. Rule order matters: the originator rule is the direct
598/// evidence and wins; the clamp rule only applies while earlier rain evidence is still fresh.
599/// @param in What the reply said and what had been predicted.
600/// @return The rule that fired, or RainLimitationRule::NONE.
602 if (in.has_last_command && in.originator == ORIGINATOR_RAIN_SENSOR)
604 if (in.rain_evidence_recent && in.stopped && in.predicted_target != UNKNOWN_POSITION &&
608}
609
610/// @brief True when rain_limitation_rule() finds evidence of a rain limitation.
611[[nodiscard]] inline bool is_rain_limited(const RainLimitationInput &in) {
613}
614
615/// @brief Name of a rain-limitation rule for log lines.
616[[nodiscard]] inline const char *rain_limitation_rule_name(RainLimitationRule rule) {
617 switch (rule) {
619 return "rain_originator";
621 return "clamped_command";
623 break;
624 }
625 return "none";
626}
627
628// === Rain sensor from the minimum limitation ===
629
630/// A window with a wired rain sensor limits its own opening while it rains. Home Assistant's VELUX
631/// integration reads that limit and calls it rain from this minimum limit upward (observed values
632/// 89, 91, 93 and 100 %).
633static constexpr uint8_t RAIN_MINIMUM_LIMIT_PERCENT = 89;
634/// Percent value of a fully limited position, the divisor of the raw position coding.
635static constexpr uint16_t POSITION_FULL_PERCENT = 100;
636/// Raw position units per percent (512).
637static constexpr uint16_t POSITION_RAW_PER_PERCENT = STATUS_POS_MAX / POSITION_FULL_PERCENT;
638static_assert(POSITION_RAW_PER_PERCENT * POSITION_FULL_PERCENT == STATUS_POS_MAX,
639 "the raw rain threshold must be an exact multiple of one percent");
640/// RAIN_MINIMUM_LIMIT_PERCENT in the raw position coding (0xB200).
642
643/// @brief Rain state from the raw value of a minimum-limitation reply.
644/// @param value_raw Big-endian raw value in position coding (LimitationStatus::value_raw).
645/// @return UNKNOWN for a value above STATUS_POS_MAX (not a percentage), RAIN at or above
646/// RAIN_MINIMUM_LIMIT_RAW, DRY below it.
647[[nodiscard]] inline RainSensorState rain_state_from_minimum_limit(uint16_t value_raw) {
648 if (value_raw > STATUS_POS_MAX)
651}
652
653/// @brief Rain state from a decoded limitation reply: the one place that knows which reply fields
654/// decide. Only the main parameter's value counts; originator and remaining time do not.
655/// @param status A decoded CMD_LIMITATION_STATUS_RESP payload.
656/// @return UNKNOWN unless the reply is for the main parameter, else rain_state_from_minimum_limit().
658 if (status.parameter_id != LIMITATION_PARAM_MP)
661}
662
663} // namespace decisions
664} // namespace home_io_control
665} // namespace esphome
bool is_repeatable_execute(const IoFrame &request)
True for a CMD_EXECUTE that has the same effect sent twice as sent once.
static constexpr uint16_t POSITION_RAW_PER_PERCENT
Raw position units per percent (512).
bool pairing_node_has_heater_at_next_address(DeviceType type, uint8_t manufacturer)
Does a pairing node that answered discovery this way control a heater at the next address?
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.
RainSensorState rain_state_from_limitation_reply(const LimitationStatus &status)
Rain state from a decoded limitation reply: the one place that knows which reply fields decide.
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 ...
static constexpr uint16_t POSITION_FULL_PERCENT
Percent value of a fully limited position, the divisor of the raw position coding.
bool next_node_id(const uint8_t node[NODE_ID_SIZE], uint8_t next[NODE_ID_SIZE])
The node address one above node, as a 24-bit number (B6BDFB -> B6BDFC).
static constexpr uint16_t RAIN_MINIMUM_LIMIT_RAW
RAIN_MINIMUM_LIMIT_PERCENT in the raw position coding (0xB200).
const char * rain_limitation_rule_name(RainLimitationRule rule)
Name of a rain-limitation rule for log lines.
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.
bool retry_after_unconfirmed_accept_is_safe(const IoFrame &request, bool target_confirms_execute, uint8_t unconfirmed_tries)
Whether an authenticated-but-unanswered request may be sent again.
uint16_t low_power_try_preamble(WakeBelief belief, uint8_t try_index, uint16_t short_preamble, uint16_t wake_preamble)
Start-preamble length for one try of a directed exchange to a low-power receiver.
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).
bool is_stop_request(const IoFrame &request)
True for a CMD_EXECUTE whose main byte is POS_STOP.
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.
PairingKeyConfirmDisposition classify_pairing_key_confirm_reply(const IoFrame &request, const IoFrame &candidate)
Decide how to handle a candidate reply to the key-transfer (0x32) confirm wait (wait_for_key_confirm_...
PairingKeyConfirmDisposition
Disposition for a candidate reply to the key-transfer (0x32) confirm wait — the slow-turnaround path'...
@ REFUSE
Anything else (including CMD_ERROR_RESP) — an explicit or implicit refusal; ends the whole wait,...
@ CHALLENGE
A fresh CMD_CHALLENGE_REQ (0x3C) — the device may challenge the key transfer before confirming it; an...
@ IGNORE
Not from/to the expected endpoints — keep waiting.
@ CONFIRM
CMD_KEY_CONFIRM (0x33) — the device accepted the key.
TargetEvidence target_evidence(const IoDevice &dev)
Build the exchange engine's view of a device record.
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.
const char * wake_belief_name(WakeBelief belief)
Lowercase name of a belief for log lines.
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.
PairingDiscoverConfirmDisposition classify_pairing_discover_confirm_reply(const IoFrame &candidate, const uint8_t device_id[NODE_ID_SIZE], const uint8_t controller_id[NODE_ID_SIZE])
Decide how to handle a candidate reply to a discovery-confirm request (0x2C) during pairing.
WakeBelief
Whether a low-power receiver is believed to be travelling right now, judged from what this hub has se...
@ ASLEEP
Not known to be moving — lead with the wake-up preamble.
@ AWAKE
Moving, or about to be stopped — the short preamble is the one it hears.
static constexpr uint8_t RAIN_MINIMUM_LIMIT_PERCENT
A window with a wired rain sensor limits its own opening while it rains.
constexpr uint32_t REMOTE_POLL_TIMER_ID_TAG
Namespace tag for remote_poll_timer_id() below: the node address occupies the low 24 bits,...
WakeBelief wake_belief(const TargetEvidence &evidence, uint32_t now, bool is_stop)
Judge whether a low-power receiver is travelling.
uint32_t heating_poll_timer_id(const uint8_t node_id[NODE_ID_SIZE])
Numeric set_timeout() id for a heater's regular-cadence poll timer (IOHomeControlComponent::schedule_...
bool is_rain_limited(const RainLimitationInput &in)
True when rain_limitation_rule() finds evidence of a rain limitation.
PairingDiscoverConfirmDisposition
Disposition for a candidate reply to a discovery-confirm request (0x2C).
@ ACK
CMD_DISCOVER_CONFIRM_ACK (0x2D) from the device — it wants to proceed.
@ IGNORE
Not from/to the expected endpoints, or a frame the step does not act on (e.g.
@ ERROR
CMD_ERROR_RESP from the device — an explicit, named refusal.
constexpr uint32_t HEATING_POLL_TIMER_ID_TAG
Namespace tag for heating_poll_timer_id(): the same node-address scheme under its own bit,...
RainSensorState rain_state_from_minimum_limit(uint16_t value_raw)
Rain state from the raw value of a minimum-limitation reply.
bool discover_confirm_try_rotates(uint8_t try_index)
Whether discover-confirm try try_index (1-based) should rotate channels rather than hold the request ...
RainLimitationRule rain_limitation_rule(const RainLimitationInput &in)
Decide which rain-limitation evidence, if any, a status reply provides.
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,...
RainLimitationRule
Which evidence path labelled a device as limited by rain.
@ RAIN_ORIGINATOR
The device names the rain sensor as the originator of its last command.
@ CLAMPED_COMMAND
A position command was accepted but the stopped device ended elsewhere, while rain protection was rec...
uint32_t remote_poll_timer_id(const uint8_t node_id[NODE_ID_SIZE])
Numeric set_timeout() id for a device's remote-activity poll timer (hub_status.cpp's schedule_status_...
ExchangeFinalResponseDisposition classify_exchange_final_response(const IoFrame &request, const IoFrame &candidate)
Decide if a candidate frame is an acceptable final response after authentication.
uint8_t scheduled_poll_max_tries(uint8_t status_poll_failures, uint8_t auth_poll_failures, bool settles_a_stop=false)
Transmit-attempt budget for a scheduler-owned status poll, by backoff-ladder position.
RainSensorState
What the hub's latest minimum-limitation read says about a rain-protection limit.
@ UNKNOWN
No usable reading: never read, or the last reads went unanswered.
@ DRY
The device answered and reports no rain-sized opening limit.
@ RAIN
The device answered and reports an opening limit of rain size.
DeviceType
IO-Homecontrol node types.
@ REMOTE_CONTROLLER
Remote controller (all ten type bits set, packed bytes FF C0).
bool has_reached_target_position(float target, float position)
Has the device reached its target within tolerance?
Device-name, address-classification and 1W-frame codecs.
IO-Homecontrol command IDs, result codes and protocol enumerations.
IO-Homecontrol device-type model, capabilities and runtime device state.
IO-Homecontrol 2W frame container: control bytes, IoFrame and (de)serialization.
Physical-layer radio and timing parameters for the IO-Homecontrol protocol.
Runtime state of a paired IO‑Homecontrol device.
uint32_t last_moving_evidence_ms
millis() of the last sign this device is travelling (see note_moving_evidence(), clear_moving_evidenc...
uint32_t last_seen_ms
millis() of the last frame received from this device (any command), 0 = never.
bool confirms_execute
True once this device has answered a CMD_EXECUTE with a reply that closed the exchange (a status,...
Parsed IO‑Homecontrol frame (CTRL0/1 + addresses + command + data).
Definition proto_frame.h:93
uint8_t data[FRAME_MAX_DATA_SIZE]
Command parameters (0–23 bytes). Never includes mac.
Definition proto_frame.h:99
uint8_t src[NODE_ID_SIZE]
Source node ID (3 bytes).
Definition proto_frame.h:97
uint8_t dst[NODE_ID_SIZE]
Destination node ID (3 bytes).
Definition proto_frame.h:96
uint8_t data_len
Actual length of data.
One decoded CMD_LIMITATION_STATUS_RESP payload.
uint8_t parameter_id
data[0]; LIMITATION_PARAM_MP for the main parameter.
uint16_t value_raw
data[1..2], big-endian, position coding (STATUS_POS_MAX = 100 %).
Key fields of the last processed 1W frame, used to collapse a remote's repeat burst.
uint8_t dst[NODE_ID_SIZE]
Destination address; compared only when has_intent is false.
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).
What one decoded status reply said, plus what the hub had predicted.
bool stopped
The device reported itself stopped.
uint8_t originator
Its Command Originator (ORIGINATOR_* in proto_constants.h).
float observed_target
dev.target after decoding this reply.
float predicted_target
Hub's prediction before this reply (UNKNOWN_POSITION when none stood).
bool has_last_command
The device record holds a well-formed last-command record.
bool rain_evidence_recent
A rain originator was seen from this device within RAIN_EVIDENCE_HOLD_MS.
What the hub knows about the device an exchange is addressed to, as the exchange engine sees it.
uint32_t last_seen_ms
Last frame received from the receiver, any command.
bool confirms_execute
The device has closed a CMD_EXECUTE exchange with a reply before.
uint32_t last_moving_evidence_ms
Last sign the receiver is travelling (see note_moving_evidence(), clear_moving_evidence()).