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_device_model.h"
13#include "proto_frame.h"
14#include "proto_timing.h"
15
16#include <algorithm>
17#include <cstdint>
18#include <cstring>
19#include <string>
20
21namespace esphome {
22namespace home_io_control {
23namespace decisions {
24
25/// @brief Disposition for the first response in an authenticated exchange.
27 IGNORE_UNRELATED, ///< Frame doesn't match endpoints or failed parse — keep waiting.
28 COMPLETE_DIRECT, ///< Matching non-challenge frame — operation complete, no auth needed.
29 REQUIRE_AUTH, ///< Matching 0x3C challenge — device demands authentication.
30};
31
32/// @brief Disposition for the final response after authentication.
34 IGNORE_UNRELATED, ///< Frame doesn't match endpoints — ignore.
35 ACCEPT, ///< Frame matches expected response — exchange succeeds.
36};
37
38/// @brief Disposition during pairing discovery phase.
39enum class PairingDiscoveryDisposition : uint8_t {
40 NO_RESPONSE, ///< No packets received on the channel within timeout.
41 INVALID, ///< Packets seen but none were valid discovery (0x29) frames.
42 ACCEPT, ///< Valid discovery response received.
43};
44
45/// @brief Disposition during pairing key-challenge phase.
46enum class PairingKeyChallengeDisposition : uint8_t {
47 IGNORE, ///< Not a valid challenge (wrong cmd, length, or sender).
48 ACCEPT, ///< Valid 0x3C challenge from target device.
49};
50
51/// @brief Disposition for a candidate reply to a discovery-confirm request (0x2C).
53 IGNORE, ///< Not from/to the expected endpoints, or a frame the step does not act on (e.g. a
54 ///< repeated 0x29) — keep waiting.
55 ACK, ///< CMD_DISCOVER_CONFIRM_ACK (0x2D) from the device — it wants to proceed.
56 ERROR, ///< CMD_ERROR_RESP from the device — an explicit, named refusal.
57};
58
59/// @brief Disposition for a candidate reply to the key-transfer (0x32) confirm wait — the
60/// slow-turnaround path's expanded classification of @ref PairingKeyChallengeDisposition's device
61/// challenge to also cover a direct confirm or an explicit refusal.
62enum class PairingKeyConfirmDisposition : uint8_t {
63 IGNORE, ///< Not from/to the expected endpoints — keep waiting.
64 CONFIRM, ///< CMD_KEY_CONFIRM (0x33) — the device accepted the key.
65 CHALLENGE, ///< A fresh CMD_CHALLENGE_REQ (0x3C) — the device may challenge the key transfer
66 ///< before confirming it; answered via ExchangeEngine::answer_challenge().
67 REFUSE, ///< Anything else (including CMD_ERROR_RESP) — an explicit or implicit refusal;
68 ///< ends the whole wait, not just the current try.
69};
70
71// == Passive RX filtering ==
72
73/// Returns true for commands that are internal to an exchange handshake and carry
74/// no useful information for a passive observer (challenge request/response).
75/// These frames appear in every authenticated exchange between other controllers and
76/// devices on the network, but contain only ephemeral cryptographic data.
77inline bool is_exchange_internal_command(uint8_t cmd) { return cmd == CMD_CHALLENGE_REQ || cmd == CMD_CHALLENGE_RESP; }
78
79// == Utility: endpoint matching ==
80
81/// Check if two frames have identical src/dst node IDs.
82inline bool frame_matches_nodes(const IoFrame &frame, const uint8_t expected_src[NODE_ID_SIZE],
83 const uint8_t expected_dst[NODE_ID_SIZE]) {
84 return std::memcmp(frame.src, expected_src, NODE_ID_SIZE) == 0 &&
85 std::memcmp(frame.dst, expected_dst, NODE_ID_SIZE) == 0;
86}
87
88/// Check if candidate frame endpoints are the reverse of the request (dst==request.src, src==request.dst).
89inline bool frame_matches_exchange_endpoints(const IoFrame &request, const IoFrame &candidate) {
90 return frame_matches_nodes(candidate, request.dst, request.src);
91}
92
93// == Exchange first-response classification ==
94
95/// Decide how to handle the first response packet in an authenticated exchange.
96///
97/// Used by wait_for_first_response_() to determine whether the exchange:
98/// - completes immediately (direct response),
99/// - requires authentication (challenge received), or
100/// - should ignore the frame and keep waiting.
101///
102/// @param request Original outbound request frame.
103/// @param candidate Parsed IoFrame from the device.
104/// @return Disposition indicating next step.
106 const IoFrame &candidate) {
107 if (!frame_matches_exchange_endpoints(request, candidate))
109 // A matching non-0x3C frame is the entire answer for direct-response exchanges such as plain
110 // status reads, so the caller must not force it through the authenticated path.
111 if (candidate.cmd == CMD_CHALLENGE_REQ)
114}
115
116/// Decide if a candidate frame is an acceptable final response after authentication.
117///
118/// Only endpoint matching is checked here; command validity is encoded in the
119/// disposition mapping by the caller.
120///
121/// @param request Original outbound request frame.
122/// @param candidate Parsed IoFrame from the device.
123/// @return ACCEPT if endpoints match; IGNORE_UNRELATED otherwise.
129
130/// True for a CMD_EXECUTE that has the same effect sent twice as sent once. Every EXECUTE this hub
131/// sends names an absolute target (a position, open/close, STOP, a tilt angle, a light level)
132/// except the stored-position selector POS_FAVORITE, used by favourite and vent: on a Somfy motor
133/// "My" while moving means stop, so a second copy can undo what the first one started.
134/// @param request Outbound request frame.
135[[nodiscard]] inline bool is_repeatable_execute(const IoFrame &request) {
136 return request.cmd == CMD_EXECUTE && request.data_len > EXECUTE_MAIN_BYTE_OFFSET &&
137 request.data[EXECUTE_MAIN_BYTE_OFFSET] != POS_FAVORITE;
138}
139
140/// Whether an authenticated-but-unanswered request may be sent again.
141///
142/// Every request except CMD_EXECUTE is idempotent (status polls, name reads, management actions,
143/// config writes) and keeps its full retry budget when the device authenticates but never closes
144/// the exchange.
145///
146/// CMD_EXECUTE moves something, and a missing closing reply means two different things depending
147/// on the device. Some devices never close an EXECUTE exchange within the response window and
148/// report through a later status update instead; for them silence is normal and a re-send only
149/// repeats a command already being carried out. For a device that normally does close it, silence
150/// can mean our challenge answer never arrived and the command was not carried out at all (a STOP
151/// that left an awning moving). So an EXECUTE is sent again only to a device known to confirm,
152/// only when repeating it is harmless (is_repeatable_execute()), and at most
153/// UNCONFIRMED_EXECUTE_MAX_RESENDS times per exchange.
154/// @param request Outbound request frame.
155/// @param target_confirms_execute The target has closed an EXECUTE exchange with a reply before
156/// (TargetEvidence::confirms_execute).
157/// @param unconfirmed_tries Tries of this exchange that ended accepted without a closing
158/// reply, including the one just ended (1-based).
159/// @return true when the exchange should spend another try.
160[[nodiscard]] inline bool retry_after_unconfirmed_accept_is_safe(const IoFrame &request, bool target_confirms_execute,
161 uint8_t unconfirmed_tries) {
162 if (request.cmd != CMD_EXECUTE)
163 return true;
164 return target_confirms_execute && is_repeatable_execute(request) &&
165 unconfirmed_tries <= UNCONFIRMED_EXECUTE_MAX_RESENDS;
166}
167
168// == Pairing discovery & key-challenge classification ==
169
170/// Decide if a frame is a valid discovery response (0x29) during pairing.
171///
172/// Only the destination is checked, not the source: the discovery request goes out to a
173/// shared broadcast address, so a response arriving during the same window may be a device
174/// answering a *different* controller's concurrent discovery rather than ours — real hardware
175/// addresses its response back to the requesting controller's own node ID, so checking that is
176/// both possible and sufficient to reject it. The source can't be checked here — the device's
177/// node ID is exactly what discovery exists to learn, so there is nothing yet to compare it to.
178///
179/// @param candidate Parsed IoFrame.
180/// @param controller_id Node ID of this controller (expected destination).
181/// @return ACCEPT if the command is CMD_DISCOVER_RESP and addressed to this controller; INVALID otherwise.
183 const uint8_t controller_id[NODE_ID_SIZE]) {
184 return candidate.cmd == CMD_DISCOVER_RESP && std::memcmp(candidate.dst, controller_id, NODE_ID_SIZE) == 0
187}
188
189/// Decide if a frame is a valid key-challenge (0x3C) during pairing key exchange.
190///
191/// The challenge must:
192/// - be CMD_CHALLENGE_REQ,
193/// - have data_len == HMAC_SIZE (6),
194/// - originate from the discovered device node ID,
195/// - be addressed to this controller's node ID.
196///
197/// @param candidate Parsed IoFrame.
198/// @param device_id Node ID of the device being paired (expected sender).
199/// @param controller_id Node ID of this controller (expected destination).
200/// @return ACCEPT if all criteria met; IGNORE otherwise.
202 const uint8_t device_id[NODE_ID_SIZE],
203 const uint8_t controller_id[NODE_ID_SIZE]) {
204 // Pairing reuses the normal 0x3C primitive, but here the challenge is only valid when it comes
205 // from the device we just discovered and targets this controller. That keeps foreign traffic from
206 // contaminating key exchange on a busy channel.
207 return candidate.cmd == CMD_CHALLENGE_REQ && candidate.data_len == HMAC_SIZE &&
208 frame_matches_nodes(candidate, device_id, controller_id)
211}
212
213/// Decide how to handle a candidate reply to a discovery-confirm request (0x2C) during pairing.
214///
215/// A non-matching or unrecognised frame is IGNORE, keeping the listen open rather than ending it:
216/// the discover-confirm step never fails a pairing attempt (see run_discover_confirm_step_()'s
217/// doc) — this classification only decides whether to keep listening or stop early.
218///
219/// @param candidate Parsed IoFrame.
220/// @param device_id Node ID of the device being paired (expected sender).
221/// @param controller_id Node ID of this controller (expected destination).
222/// @return ACK for a matching 0x2D, ERROR for a matching CMD_ERROR_RESP, IGNORE otherwise.
224 const IoFrame &candidate, const uint8_t device_id[NODE_ID_SIZE], const uint8_t controller_id[NODE_ID_SIZE]) {
225 if (!frame_matches_nodes(candidate, device_id, controller_id))
227 if (candidate.cmd == CMD_DISCOVER_CONFIRM_ACK)
229 if (candidate.cmd == CMD_ERROR_RESP)
231 // E.g. a repeated CMD_DISCOVER_RESP (0x29) from a device that missed our 0x2C and is still
232 // announcing itself — not an answer to this step, but not a foreign frame either.
234}
235
236/// Decide how to handle a candidate reply to the key-transfer (0x32) confirm wait
237/// (wait_for_key_confirm_(), slow-turnaround chips only).
238///
239/// @param request The outbound CMD_KEY_TRANSFER (0x32) this reply answers.
240/// @param candidate Parsed IoFrame from the device.
241/// @return CONFIRM/CHALLENGE/REFUSE for a matching frame of that shape; IGNORE for a frame from
242/// the wrong endpoints.
253
254/// @brief Whether discover-confirm try `try_index` (1-based) should rotate channels rather than
255/// hold the request channel.
256///
257/// Tries 1 and 3 hold: a 0x2D is a unicast reply to a unicast 0x2C, and every other unicast
258/// pairing wait in this project holds the request channel on that same expectation (see
259/// listen_for_key_confirm_()'s own reasoning), and every 0x2D a Somfy Izymo dimmer sent came back on
260/// the request channel. But the one real VELUX-system sample on record (from a hopping monitor, a
261/// weak source) logged a 0x2D on a *different* channel than its own 0x2C, so the middle try hedges
262/// by rotating instead, until a VELUX device's reply channel is measured.
263/// See ADR 0039 for why this is not simply one fixed policy for every try, unlike every other
264/// listen in this project (ADR 0028).
265/// @param try_index 1-based try number (1..PAIRING_DISCOVER_CONFIRM_TRIES).
266/// @return true if this try should use ListenPolicy::ROTATE_ALL_CHANNELS; false for
267/// ListenPolicy::HOLD_REQUEST_CHANNEL.
268[[nodiscard]] inline bool discover_confirm_try_rotates(uint8_t try_index) { return try_index == 2; }
269
270// == One-way (1W) remote frame handling ==
271
272/// @brief Key fields of the last processed 1W frame, used to collapse a remote's repeat burst.
273///
274/// 1W remotes repeat each command 4× at ~40ms intervals for reliability, and a held button keeps
275/// resending, so one logical press arrives as many identical frames. The key deliberately includes
276/// the decoded intent bytes and not just the command byte: a move and a stop are *both*
277/// CMD_EXECUTE and differ only in `main0`, so a command-only key silently discards a stop that
278/// follows a move within the window — losing the sender event, the optimistic-target clear, and
279/// the immediate poll that a stop is supposed to trigger.
280///
281/// For a frame with no decoded intent the destination is part of the key as well. A VELUX KLI's
282/// Gear press sends its `0x2E` to several device classes a few hundred ms apart, and the classes it
283/// names are the ones a new controller has to enroll on; keying on src+cmd alone would log only the
284/// first of them.
285/// Intent-bearing frames keep ignoring the destination: they fire sender events and optimistic
286/// state, where one press must stay one press.
288 std::string src_id; ///< Source node ID of the last processed frame; empty before the first.
289 uint8_t cmd{0}; ///< Command byte.
290 bool has_intent{false}; ///< Whether main0/main1 were decoded (execute / activate-mode only).
291 uint8_t main0{0}; ///< First main byte — what distinguishes a move from a stop.
292 uint8_t main1{0}; ///< Second main byte.
293 uint32_t timestamp{0}; ///< millis() when the frame was processed.
294 uint8_t dst[NODE_ID_SIZE]{}; ///< Destination address; compared only when `has_intent` is false.
295};
296
297/// Decide whether an incoming 1W frame repeats the previous one inside the burst window.
298///
299/// @param last State recorded for the previously processed 1W frame.
300/// @param incoming Candidate frame's key fields, with `timestamp` set to now.
301/// @param window_ms Burst-suppression window.
302/// @return true if the frame should be dropped as a repeat of `last`.
303inline bool is_duplicate_1w_frame(const OneWayDedupState &last, const OneWayDedupState &incoming, uint32_t window_ms) {
304 // `last.src_id` is empty until the first 1W frame is processed, so a real frame never matches it.
305 if (last.src_id != incoming.src_id || last.cmd != incoming.cmd || last.has_intent != incoming.has_intent)
306 return false;
307 if (incoming.has_intent && (last.main0 != incoming.main0 || last.main1 != incoming.main1))
308 return false;
309 if (!incoming.has_intent && std::memcmp(last.dst, incoming.dst, NODE_ID_SIZE) != 0)
310 return false;
311 // Unsigned arithmetic makes this correct across the millis() wrap.
312 return (incoming.timestamp - last.timestamp) < window_ms;
313}
314
315/// Decide whether to hold back a queued background poll because a 1W remote is still transmitting.
316///
317/// The radio is half-duplex and an authenticated exchange blocks for 1–3 s, during which no frame
318/// can be received at all. A press on a linked remote schedules a status poll, so without this gate
319/// the hub's own poll can start on top of the burst that triggered it and go deaf to the rest of it.
320///
321/// Only background polls are deferred. A user command must never wait on a remote the user may not
322/// even own — 1W broadcasts carry no ownership marker, so the activity could be a neighbour's.
323///
324/// The hold re-arms on every 1W frame received while it is already active, so a real burst from one
325/// remote (~160 ms, well under `quiet_ms`) never gets cut short mid-transmission. Left unchecked
326/// that re-arming has no cap: sustained sub-`quiet_ms` 1W traffic from any source — including a
327/// neighbour's, since these broadcasts carry no ownership marker — would hold background polls back
328/// indefinitely. @p max_defer_ms bounds that: once that much time has passed since the burst
329/// *started* (not the most recent frame), the poll is let through regardless of ongoing traffic.
330/// The gate only ever delays a poll, never drops one — it stays queued and fires as soon as it is
331/// no longer deferred.
332///
333/// @param next_op_is_background True if the queue front is a REQUEST_STATUS / REQUEST_NAME.
334/// @param first_1w_activity_ms millis() of the first frame in the current 1W burst; 0 if none seen
335/// since boot.
336/// @param last_1w_activity_ms millis() of the most recent 1W frame; 0 if none seen since boot.
337/// @param now Current millis().
338/// @param quiet_ms How long after 1W activity to hold background polls back.
339/// @param max_defer_ms Hard cap on total defer time, measured from first_1w_activity_ms.
340/// @return true if the caller should skip dispatching this loop iteration.
341inline bool defer_background_poll_for_1w_activity(bool next_op_is_background, uint32_t first_1w_activity_ms,
342 uint32_t last_1w_activity_ms, uint32_t now, uint32_t quiet_ms,
343 uint32_t max_defer_ms) {
344 if (!next_op_is_background || last_1w_activity_ms == 0)
345 return false;
346 if (now - first_1w_activity_ms >= max_defer_ms)
347 return false;
348 return (now - last_1w_activity_ms) < quiet_ms;
349}
350
351/// @brief Transmit-attempt budget for a scheduler-owned status poll, by backoff-ladder position.
352///
353/// See SCHEDULED_POLL_MAX_TRIES and SCHEDULED_POLL_RETRY_GRACE_FIRST_FAILURE (proto_timing.h) for
354/// why the full budget belongs to a middle band of the ladder rather than to its start or its tail.
355///
356/// The two counters are mutually exclusive by construction — StatusPollPolicy::on_exchange_failed()
357/// zeroes one while incrementing the other — so an auth-shaped streak reads status_poll_failures
358/// as 0 and would otherwise fall into the band's own "fresh window" case. It is rejected first,
359/// deliberately, so the predicate stays correct even if that exclusivity is ever relaxed.
360///
361/// The settle poll after an accepted STOP (@p settles_a_stop) gets the full budget whatever the
362/// counters say: see STOP_SETTLE_POLL_TRIES (proto_timing.h).
363///
364/// @param status_poll_failures Consecutive silent failures already recorded for this device.
365/// @param auth_poll_failures Consecutive challenge-seen failures already recorded.
366/// @param settles_a_stop True for the first poll after an accepted STOP
367/// (StatusPollPolicy::take_stop_settle()).
368/// @return EXCHANGE_RETRY_COUNT inside the band or after a STOP, SCHEDULED_POLL_MAX_TRIES everywhere else.
369inline uint8_t scheduled_poll_max_tries(uint8_t status_poll_failures, uint8_t auth_poll_failures,
370 bool settles_a_stop = false) {
371 if (settles_a_stop)
372 return STOP_SETTLE_POLL_TRIES;
373 if (auth_poll_failures != 0)
374 return SCHEDULED_POLL_MAX_TRIES;
375 if (status_poll_failures < SCHEDULED_POLL_RETRY_GRACE_FIRST_FAILURE ||
376 status_poll_failures > SCHEDULED_POLL_RETRY_GRACE_LAST_FAILURE)
377 return SCHEDULED_POLL_MAX_TRIES;
378 return EXCHANGE_RETRY_COUNT;
379}
380
381// == Low-power wake belief ==
382
383/// @brief How likely a low-power receiver is to be awake right now, judged from what this hub has
384/// seen of it. Orders the tries of a directed exchange: an awake receiver hears the short start
385/// preamble and ignores the long wake-up one, a sleeping one needs the long one.
386enum class WakeBelief : uint8_t {
387 ASLEEP, ///< No recent sign of life — lead with the wake-up preamble.
388 MAYBE_AWAKE, ///< Recently heard from — try the short preamble first, keep the wake-up as fallback.
389 AWAKE, ///< Moving, or about to be stopped — the short preamble is the one it hears.
390};
391
392static_assert(LOW_POWER_AWAKE_HOLD_MS <= LOW_POWER_MAX_TRAVEL_MS,
393 "wake_belief() relies on moving evidence outliving the maybe-awake hold");
394
395/// @brief What the hub knows about the device an exchange is addressed to, as the exchange engine
396/// sees it. The engine has no device registry of its own; the hub hands this over through
397/// ExchangeEngine::set_target_evidence_provider(), and every per-target decision the engine makes
398/// reads from it: wake_belief() and retry_after_unconfirmed_accept_is_safe(). Timestamps are `millis()` values, 0 =
399/// never.
401 uint32_t last_moving_evidence_ms; ///< Last sign the receiver is travelling (see note_moving_evidence(),
402 ///< clear_moving_evidence()).
403 uint32_t last_seen_ms; ///< Last frame received from the receiver, any command.
404 bool confirms_execute; ///< The device has closed a CMD_EXECUTE exchange with a reply before.
405};
406
407/// Build the exchange engine's view of a device record.
408/// @param dev Device record to read.
409[[nodiscard]] inline TargetEvidence target_evidence(const IoDevice &dev) {
411}
412
413/// True for a CMD_EXECUTE whose main byte is POS_STOP. A STOP is only ever sent to a receiver that
414/// is (believed) moving, so it is treated as awake whatever the stamps say.
415/// @param request Outbound request frame.
416[[nodiscard]] inline bool is_stop_request(const IoFrame &request) {
417 return request.cmd == CMD_EXECUTE && request.data_len > EXECUTE_MAIN_BYTE_OFFSET &&
418 request.data[EXECUTE_MAIN_BYTE_OFFSET] == POS_STOP;
419}
420
421/// Judge how awake a low-power receiver is. `AWAKE` for a STOP request or while moving evidence is
422/// younger than LOW_POWER_MAX_TRAVEL_MS; otherwise `MAYBE_AWAKE` while the newest of moving evidence
423/// and last frame heard is younger than LOW_POWER_AWAKE_HOLD_MS; otherwise `ASLEEP`. A zero stamp
424/// means never and is never recent. Ages use unsigned subtraction, so `millis()` wrap-around is safe.
425/// @param evidence Stamps for the target device.
426/// @param now Current millis().
427/// @param is_stop True when the request being sent is a STOP (see is_stop_request()).
428[[nodiscard]] inline WakeBelief wake_belief(const TargetEvidence &evidence, uint32_t now, bool is_stop) {
429 const auto recent = [now](uint32_t stamp, uint32_t window_ms) { return stamp != 0 && (now - stamp) < window_ms; };
430 if (is_stop || recent(evidence.last_moving_evidence_ms, LOW_POWER_MAX_TRAVEL_MS))
431 return WakeBelief::AWAKE;
432 // Moving evidence needs no check here: any that is younger than the hold window already returned
433 // AWAKE above (the hold is the shorter window), and older evidence is not recent by either measure.
434 if (recent(evidence.last_seen_ms, LOW_POWER_AWAKE_HOLD_MS))
436 return WakeBelief::ASLEEP;
437}
438
439/// Start-preamble length for one try of a directed exchange to a low-power receiver. The plans
440/// (short = `short_preamble`, LONG = LONG_PREAMBLE): AWAKE short/LONG/short, MAYBE_AWAKE
441/// short/LONG/LONG, ASLEEP LONG on every try — so an exchange allowed more than one try still tries
442/// the wake-up preamble at least once, and a wrong belief costs one try, not the exchange. A
443/// single-try exchange (most scheduler-owned status polls) sends only try 1's preamble; its backoff
444/// ladder, whose three-try slots include the wake-up preamble, covers a wrong belief there.
445/// @param belief See wake_belief().
446/// @param try_index 1-based try number, clamped to [1, EXCHANGE_RETRY_COUNT].
447/// @param short_preamble Preamble for a receiver known to be awake (`normal_start_preamble`).
448[[nodiscard]] inline uint16_t low_power_try_preamble(WakeBelief belief, uint8_t try_index, uint16_t short_preamble) {
449 const uint8_t try_1based = std::max<uint8_t>(1, std::min<uint8_t>(try_index, EXCHANGE_RETRY_COUNT));
450 switch (belief) {
452 return try_1based == 2 ? LONG_PREAMBLE : short_preamble;
454 return try_1based == 1 ? short_preamble : LONG_PREAMBLE;
456 default:
457 return LONG_PREAMBLE;
458 }
459}
460
461/// Lowercase name of a belief for log lines.
462[[nodiscard]] inline const char *wake_belief_name(WakeBelief belief) {
463 switch (belief) {
465 return "awake";
467 return "maybe_awake";
469 default:
470 return "asleep";
471 }
472}
473
474/// True if a frame's shape matches a 1W remote's pairing gesture (issue #27/#65): CTRL0 1W bit
475/// set, addressed to the 1W broadcast address (0x00003F), with one of the three command bytes
476/// observed in the field capture — 0x20 (WRITE_PRIVATE), 0x39 (1W remove), or 0x2E (alternate
477/// discovery, 1W-flagged). Shared between PairingAdvisor (classifying recorded telemetry events,
478/// pairing_advisor.cpp) and the hub's normal passive RX path (hub_status.cpp), which remembers a
479/// recent sighting so a PROG press completed just before "Discover & Pair" is pressed isn't
480/// invisible to the advisor purely because of when the discovery telemetry window happened to
481/// open — see PairingTelemetry::record_recent_one_way_sighting().
482///
483/// @param oneway CTRL0 1W-protocol bit.
484/// @param dst Frame destination node ID.
485/// @param cmd Frame command byte.
486inline bool is_one_way_pairing_gesture(bool oneway, const uint8_t dst[NODE_ID_SIZE], uint8_t cmd) {
487 if (!oneway)
488 return false;
489 if (std::memcmp(dst, BROADCAST_DISCOVER_ALT, NODE_ID_SIZE) != 0)
490 return false;
491 return cmd == CMD_WRITE_PRIVATE || cmd == CMD_ONEWAY_REMOVE || cmd == CMD_DISCOVER_ALT_REQ;
492}
493
494/// Whether a 1W frame arriving at `now` starts a new burst rather than extending the current one
495/// — true if no frame has been seen yet, or the gap since the last one reached `quiet_ms` (the
496/// previous burst already released any deferred poll). Callers use this to decide whether to
497/// reset a burst's start-time tracking; see defer_background_poll_for_1w_activity() for why the
498/// burst start (not just the latest frame) needs its own timestamp.
499///
500/// @param last_1w_activity_ms millis() of the most recent 1W frame before this one; 0 if none
501/// seen since boot.
502/// @param now Current millis() (this frame's arrival time).
503/// @param quiet_ms Gap after which a previous burst is considered over.
504[[nodiscard]] inline bool oneway_burst_started_fresh(uint32_t last_1w_activity_ms, uint32_t now, uint32_t quiet_ms) {
505 return last_1w_activity_ms == 0 || (now - last_1w_activity_ms) >= quiet_ms;
506}
507
508/// Namespace tag for `remote_poll_timer_id()` below: the node address occupies the low 24 bits,
509/// and this is OR-ed in above them. Nothing else uses `Component::set_timeout`'s numeric-id
510/// overload today, so the tag has no collision to avoid yet — it exists so the id space stays
511/// self-describing (which caller a given id belongs to) the day a second numeric-id timer is added.
512constexpr uint32_t REMOTE_POLL_TIMER_ID_TAG = 0x01000000;
513
514/// @brief Numeric `set_timeout()` id for a device's remote-activity poll timer (hub_status.cpp's
515/// `schedule_status_poll_()`).
516///
517/// Keyed by the device's node address rather than a name, because `Component::set_timeout(const
518/// char *name, ...)` stores the caller's *pointer*, not a copy of the string (its header documents
519/// this: static lifetime required, use the numeric-id overload for a dynamically-built name) — see
520/// `schedule_status_poll_()`'s own comment for why a per-device name can't satisfy that here. A
521/// node address is unique per device, so this id is collision-free by construction.
522///
523/// @param node_id 3-byte device node address.
524/// @return Numeric timer id, unique per device.
525inline uint32_t remote_poll_timer_id(const uint8_t node_id[NODE_ID_SIZE]) {
526 return REMOTE_POLL_TIMER_ID_TAG | (static_cast<uint32_t>(node_id[0]) << (2 * BITS_PER_BYTE)) |
527 (static_cast<uint32_t>(node_id[1]) << BITS_PER_BYTE) | static_cast<uint32_t>(node_id[2]);
528}
529
530} // namespace decisions
531} // namespace home_io_control
532} // namespace esphome
bool is_repeatable_execute(const IoFrame &request)
True for a CMD_EXECUTE that has the same effect sent twice as sent once.
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.
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.
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
How likely a low-power receiver is to be awake right now, judged from what this hub has seen of it.
@ MAYBE_AWAKE
Recently heard from — try the short preamble first, keep the wake-up as fallback.
@ ASLEEP
No recent sign of life — lead with the wake-up preamble.
@ AWAKE
Moving, or about to be stopped — the short preamble is the one it hears.
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 how awake a low-power receiver is.
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.
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 ...
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,...
uint16_t low_power_try_preamble(WakeBelief belief, uint8_t try_index, uint16_t short_preamble)
Start-preamble length for one try of a directed exchange to a low-power receiver.
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.
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.
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 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()).