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#include "tuning_config.h"
17
18#include <algorithm>
19#include <array>
20#include <cstdint>
21#include <cstring>
22#include <optional>
23#include <string>
24
25namespace esphome {
26namespace home_io_control {
27namespace decisions {
28
29/// @brief Disposition for the first response in an authenticated exchange.
31 IGNORE_UNRELATED, ///< Frame doesn't match endpoints or failed parse — keep waiting.
32 COMPLETE_DIRECT, ///< Matching non-challenge frame — operation complete, no auth needed.
33 REQUIRE_AUTH, ///< Matching 0x3C challenge — device demands authentication.
34};
35
36/// @brief Disposition for the final response after authentication.
38 IGNORE_UNRELATED, ///< Frame doesn't match endpoints — ignore.
39 ACCEPT, ///< Frame matches expected response — exchange succeeds.
40};
41
42/// @brief Disposition during pairing discovery phase.
43enum class PairingDiscoveryDisposition : uint8_t {
44 NO_RESPONSE, ///< No packets received on the channel within timeout.
45 INVALID, ///< Packets seen but none were valid discovery (0x29) frames.
46 ACCEPT, ///< Valid discovery response received.
47};
48
49/// @brief Disposition during pairing key-challenge phase.
50enum class PairingKeyChallengeDisposition : uint8_t {
51 IGNORE, ///< Not a valid challenge (wrong cmd, length, or sender).
52 ACCEPT, ///< Valid 0x3C challenge from target device.
53};
54
55/// @brief Disposition for a candidate reply to a discovery-confirm request (0x2C).
57 IGNORE, ///< Not from/to the expected endpoints, or a frame the step does not act on (e.g. a
58 ///< repeated 0x29) — keep waiting.
59 ACK, ///< CMD_DISCOVER_CONFIRM_ACK (0x2D) from the device — it wants to proceed.
60 ERROR, ///< CMD_ERROR_RESP from the device — an explicit, named refusal.
61};
62
63/// @brief Disposition for a candidate reply to the key-transfer (0x32) confirm wait — the
64/// slow-turnaround path's expanded classification of @ref PairingKeyChallengeDisposition's device
65/// challenge to also cover a direct confirm or an explicit refusal.
66enum class PairingKeyConfirmDisposition : uint8_t {
67 IGNORE, ///< Not from/to the expected endpoints — keep waiting.
68 CONFIRM, ///< CMD_KEY_CONFIRM (0x33) — the device accepted the key.
69 CHALLENGE, ///< A fresh CMD_CHALLENGE_REQ (0x3C) — the device may challenge the key transfer
70 ///< before confirming it; answered via ExchangeEngine::answer_challenge().
71 REFUSE, ///< Anything else (including CMD_ERROR_RESP) — an explicit or implicit refusal;
72 ///< ends the whole wait, not just the current try.
73};
74
75// == Passive RX filtering ==
76
77/// Returns true for commands that are internal to an exchange handshake and carry
78/// no useful information for a passive observer (challenge request/response).
79/// These frames appear in every authenticated exchange between other controllers and
80/// devices on the network, but contain only ephemeral cryptographic data.
81inline bool is_exchange_internal_command(uint8_t cmd) { return cmd == CMD_CHALLENGE_REQ || cmd == CMD_CHALLENGE_RESP; }
82
83/// Returns true for a frame that reports a device event rather than a status: a CMD_SET_CONFIG1_RESP,
84/// or a well-formed CMD_STATUS_UPDATE whose event is not EVENT_STATUS_CHANGE (a subscribed sensor
85/// report, see decode_status_update_event()). Such a frame is recorded on the "Last Device Event"
86/// sensor and never decoded as a position, whether it arrives on its own or inside an exchange.
87inline bool is_device_event_report(const IoFrame &frame) {
88 if (frame.cmd == CMD_SET_CONFIG1_RESP)
89 return true;
91 return decode_status_update_event(frame, event) && event.event_id != EVENT_STATUS_CHANGE;
92}
93
94/// Returns true when @p response is the answer to the subscription @p request: the request is a
95/// CMD_SUBSCRIBE_EVENT with the subscribe action, and the response a well-formed CMD_STATUS_UPDATE
96/// carrying the same event, from the device the request went to. A device answers a subscription
97/// with the event's current value (tests/corpus/captures/probe/somfy_rs100_probe_subscribe_event_*),
98/// so inside the exchange the hub started for that request this event report is the reply; every
99/// other event report is still recorded and never taken as a reply (is_device_event_report()).
100inline bool is_subscription_answer(const IoFrame &request, const IoFrame &response) {
101 if (request.cmd != CMD_SUBSCRIBE_EVENT || request.data_len != SUBSCRIBE_EVENT_DATA_SIZE ||
102 request.data[0] != EVENT_SUBSCRIPTION_SUBSCRIBE)
103 return false;
104 if (memcmp(response.src, request.dst, NODE_ID_SIZE) != 0)
105 return false;
106 StatusUpdateEvent event;
107 if (!decode_status_update_event(response, event))
108 return false;
109 const auto requested = static_cast<uint16_t>((request.data[1] << BITS_PER_BYTE) | request.data[2]);
110 return event.event_id == requested;
111}
112
113/// Returns true for the commands that write to a device: an execute, a mode activation or a private
114/// register write. A heard write changes the device's state, so this hub's view of it is stale; a heard
115/// read (any other command) changes nothing on the device. Deliberately a short list of writes rather
116/// than a list of reads, so an unknown command counts as a read.
117inline bool overheard_command_writes(uint8_t cmd) {
118 return cmd == CMD_EXECUTE || cmd == CMD_ACTIVATE_MODE || cmd == CMD_WRITE_PRIVATE;
119}
120
121// == Utility: endpoint matching ==
122
123/// Check if two frames have identical src/dst node IDs.
124inline bool frame_matches_nodes(const IoFrame &frame, const uint8_t expected_src[NODE_ID_SIZE],
125 const uint8_t expected_dst[NODE_ID_SIZE]) {
126 return std::memcmp(frame.src, expected_src, NODE_ID_SIZE) == 0 &&
127 std::memcmp(frame.dst, expected_dst, NODE_ID_SIZE) == 0;
128}
129
130/// Check if candidate frame endpoints are the reverse of the request (dst==request.src, src==request.dst).
131inline bool frame_matches_exchange_endpoints(const IoFrame &request, const IoFrame &candidate) {
132 return frame_matches_nodes(candidate, request.dst, request.src);
133}
134
135// == Exchange first-response classification ==
136
137/// Decide how to handle the first response packet in an authenticated exchange.
138///
139/// Used by wait_for_first_response_() to determine whether the exchange:
140/// - completes immediately (direct response),
141/// - requires authentication (challenge received), or
142/// - should ignore the frame and keep waiting.
143///
144/// @param request Original outbound request frame.
145/// @param candidate Parsed IoFrame from the device.
146/// @return Disposition indicating next step.
148 const IoFrame &candidate) {
149 if (!frame_matches_exchange_endpoints(request, candidate))
151 // A matching non-0x3C frame is the entire answer for direct-response exchanges such as plain
152 // status reads, so the caller must not force it through the authenticated path.
153 if (candidate.cmd == CMD_CHALLENGE_REQ)
156}
157
158/// Decide if a candidate frame is an acceptable final response after authentication.
159///
160/// Only endpoint matching is checked here; command validity is encoded in the
161/// disposition mapping by the caller.
162///
163/// @param request Original outbound request frame.
164/// @param candidate Parsed IoFrame from the device.
165/// @return ACCEPT if endpoints match; IGNORE_UNRELATED otherwise.
171
172/// True for a CMD_EXECUTE that has the same effect sent twice as sent once. Every EXECUTE this hub
173/// sends names an absolute target (a position, open/close, STOP, a tilt angle, a light level)
174/// except the stored-position selector POS_FAVORITE, used by favourite, vent, partial and pedestrian
175/// (every NAMED_POSITIONS entry): on a Somfy motor "My" while moving means stop, so a
176/// second copy can undo what the first one started.
177/// @param request Outbound request frame.
178[[nodiscard]] inline bool is_repeatable_execute(const IoFrame &request) {
179 return request.cmd == CMD_EXECUTE && request.data_len > EXECUTE_MAIN_BYTE_OFFSET &&
180 request.data[EXECUTE_MAIN_BYTE_OFFSET] != POS_FAVORITE;
181}
182
183/// Whether an authenticated-but-unanswered request may be sent again.
184///
185/// Every request except CMD_EXECUTE and CMD_SUBSCRIBE_EVENT is idempotent (status polls, name
186/// reads, management actions, config writes) and keeps its full retry budget when the device
187/// authenticates but never closes the exchange.
188///
189/// CMD_EXECUTE moves something, and a missing closing reply means two different things depending
190/// on the device. Some devices never close an EXECUTE exchange within the response window and
191/// report through a later status update instead; for them silence is normal and a re-send only
192/// repeats a command already being carried out. For a device that normally does close it, silence
193/// can mean our challenge answer never arrived and the command was not carried out at all (a STOP
194/// that left an awning moving). So an EXECUTE is sent again only to a device known to confirm,
195/// only when repeating it is harmless (is_repeatable_execute()), and at most
196/// UNCONFIRMED_EXECUTE_MAX_RESENDS times per exchange.
197///
198/// CMD_SUBSCRIBE_EVENT stores a subscription in the device, and the device answers it with the
199/// event's current value (is_subscription_answer()). A try the device authenticated has stored the
200/// subscription, but its answer may still have been lost, so it is sent once more
201/// (UNCONFIRMED_SUBSCRIBE_MAX_RESENDS), never more: the device has accepted repeated subscriptions
202/// (issue #156), and a reading is what the request is for.
203///
204/// @param request Outbound request frame.
205/// @param target_confirms_execute The target has closed an EXECUTE exchange with a reply before
206/// (TargetEvidence::confirms_execute).
207/// @param unconfirmed_tries Tries of this exchange that ended accepted without a closing
208/// reply, including the one just ended (1-based).
209/// @return true when the exchange should spend another try.
210[[nodiscard]] inline bool retry_after_unconfirmed_accept_is_safe(const IoFrame &request, bool target_confirms_execute,
211 uint8_t unconfirmed_tries) {
212 if (request.cmd == CMD_SUBSCRIBE_EVENT)
213 return unconfirmed_tries <= UNCONFIRMED_SUBSCRIBE_MAX_RESENDS;
214 if (request.cmd != CMD_EXECUTE)
215 return true;
216 return target_confirms_execute && is_repeatable_execute(request) &&
217 unconfirmed_tries <= UNCONFIRMED_EXECUTE_MAX_RESENDS;
218}
219
220// == Pairing discovery & key-challenge classification ==
221
222/// Decide if a frame is a valid discovery response (0x29) during pairing.
223///
224/// Only the destination is checked, not the source: the discovery request goes out to a
225/// shared broadcast address, so a response arriving during the same window may be a device
226/// answering a *different* controller's concurrent discovery rather than ours — real hardware
227/// addresses its response back to the requesting controller's own node ID, so checking that is
228/// both possible and sufficient to reject it. The source can't be checked here — the device's
229/// node ID is exactly what discovery exists to learn, so there is nothing yet to compare it to.
230///
231/// @param candidate Parsed IoFrame.
232/// @param controller_id Node ID of this controller (expected destination).
233/// @return ACCEPT if the command is CMD_DISCOVER_RESP and addressed to this controller; INVALID otherwise.
235 const uint8_t controller_id[NODE_ID_SIZE]) {
236 return candidate.cmd == CMD_DISCOVER_RESP && std::memcmp(candidate.dst, controller_id, NODE_ID_SIZE) == 0
239}
240
241/// Decide if a frame is a valid key-challenge (0x3C) during pairing key exchange.
242///
243/// The challenge must:
244/// - be CMD_CHALLENGE_REQ,
245/// - have data_len == HMAC_SIZE (6),
246/// - originate from the discovered device node ID,
247/// - be addressed to this controller's node ID.
248///
249/// @param candidate Parsed IoFrame.
250/// @param device_id Node ID of the device being paired (expected sender).
251/// @param controller_id Node ID of this controller (expected destination).
252/// @return ACCEPT if all criteria met; IGNORE otherwise.
254 const uint8_t device_id[NODE_ID_SIZE],
255 const uint8_t controller_id[NODE_ID_SIZE]) {
256 // Pairing reuses the normal 0x3C primitive, but here the challenge is only valid when it comes
257 // from the device we just discovered and targets this controller. That keeps foreign traffic from
258 // contaminating key exchange on a busy channel.
259 return candidate.cmd == CMD_CHALLENGE_REQ && candidate.data_len == HMAC_SIZE &&
260 frame_matches_nodes(candidate, device_id, controller_id)
263}
264
265/// Decide how to handle a candidate reply to a discovery-confirm request (0x2C) during pairing.
266///
267/// A non-matching or unrecognised frame is IGNORE, keeping the listen open rather than ending it:
268/// the discover-confirm step never fails a pairing attempt (see run_discover_confirm_step_()'s
269/// doc) — this classification only decides whether to keep listening or stop early.
270///
271/// @param candidate Parsed IoFrame.
272/// @param device_id Node ID of the device being paired (expected sender).
273/// @param controller_id Node ID of this controller (expected destination).
274/// @return ACK for a matching 0x2D, ERROR for a matching CMD_ERROR_RESP, IGNORE otherwise.
276 const IoFrame &candidate, const uint8_t device_id[NODE_ID_SIZE], const uint8_t controller_id[NODE_ID_SIZE]) {
277 if (!frame_matches_nodes(candidate, device_id, controller_id))
279 if (candidate.cmd == CMD_DISCOVER_CONFIRM_ACK)
281 if (candidate.cmd == CMD_ERROR_RESP)
283 // E.g. a repeated CMD_DISCOVER_RESP (0x29) from a device that missed our 0x2C and is still
284 // announcing itself — not an answer to this step, but not a foreign frame either.
286}
287
288/// Decide how to handle a candidate reply to the key-transfer (0x32) confirm wait
289/// (wait_for_key_confirm_(), slow-turnaround chips only).
290///
291/// @param request The outbound CMD_KEY_TRANSFER (0x32) this reply answers.
292/// @param candidate Parsed IoFrame from the device.
293/// @return CONFIRM/CHALLENGE/REFUSE for a matching frame of that shape; IGNORE for a frame from
294/// the wrong endpoints.
305
306/// @brief Whether discover-confirm try `try_index` (1-based) should rotate channels rather than
307/// hold the request channel.
308///
309/// Tries 1 and 3 hold: a 0x2D is a unicast reply to a unicast 0x2C, and every other unicast
310/// pairing wait in this project holds the request channel on that same expectation (see
311/// listen_for_key_confirm_()'s own reasoning), and every 0x2D a Somfy Izymo dimmer sent came back on
312/// the request channel. But the one real VELUX-system sample on record (from a hopping monitor, a
313/// weak source) logged a 0x2D on a *different* channel than its own 0x2C, so the middle try hedges
314/// by rotating instead, until a VELUX device's reply channel is measured.
315/// See ADR 0039 for why this is not simply one fixed policy for every try, unlike every other
316/// listen in this project (ADR 0028).
317/// @param try_index 1-based try number (1..PAIRING_DISCOVER_CONFIRM_TRIES).
318/// @return true if this try should use ListenPolicy::ROTATE_ALL_CHANNELS; false for
319/// ListenPolicy::HOLD_REQUEST_CHANNEL.
320[[nodiscard]] inline bool discover_confirm_try_rotates(uint8_t try_index) { return try_index == 2; }
321
322// == One-way (1W) remote frame handling ==
323
324/// @brief Key fields of the last processed 1W frame, used to collapse a remote's repeat burst.
325///
326/// 1W remotes repeat each command 4× at ~40ms intervals for reliability, and a held button keeps
327/// resending, so one logical press arrives as many identical frames. The key deliberately includes
328/// the decoded intent bytes and not just the command byte: a move and a stop are *both*
329/// CMD_EXECUTE and differ only in `main0`, so a command-only key silently discards a stop that
330/// follows a move within the window — losing the sender event, the optimistic-target clear, and
331/// the immediate poll that a stop is supposed to trigger.
332///
333/// For a frame with no decoded intent the destination is part of the key as well. A VELUX KLI's
334/// Gear press sends its `0x2E` to several device classes a few hundred ms apart, and the classes it
335/// names are the ones a new controller has to enroll on; keying on src+cmd alone would log only the
336/// first of them.
337/// Intent-bearing frames keep ignoring the destination: they fire sender events and optimistic
338/// state, where one press must stay one press.
340 std::string src_id; ///< Source node ID of the last processed frame; empty before the first.
341 uint8_t cmd{0}; ///< Command byte.
342 bool has_intent{false}; ///< Whether main0/main1 were decoded (execute / activate-mode only).
343 uint8_t main0{0}; ///< First main byte — what distinguishes a move from a stop.
344 uint8_t main1{0}; ///< Second main byte.
345 uint32_t timestamp{0}; ///< millis() when the frame was processed.
346 uint8_t dst[NODE_ID_SIZE]{}; ///< Destination address; compared only when `has_intent` is false.
347};
348
349/// Decide whether an incoming 1W frame repeats the previous one inside the burst window.
350///
351/// @param last State recorded for the previously processed 1W frame.
352/// @param incoming Candidate frame's key fields, with `timestamp` set to now.
353/// @param window_ms Burst-suppression window.
354/// @return true if the frame should be dropped as a repeat of `last`.
355inline bool is_duplicate_1w_frame(const OneWayDedupState &last, const OneWayDedupState &incoming, uint32_t window_ms) {
356 // `last.src_id` is empty until the first 1W frame is processed, so a real frame never matches it.
357 if (last.src_id != incoming.src_id || last.cmd != incoming.cmd || last.has_intent != incoming.has_intent)
358 return false;
359 if (incoming.has_intent && (last.main0 != incoming.main0 || last.main1 != incoming.main1))
360 return false;
361 if (!incoming.has_intent && std::memcmp(last.dst, incoming.dst, NODE_ID_SIZE) != 0)
362 return false;
363 // Unsigned arithmetic makes this correct across the millis() wrap.
364 return (incoming.timestamp - last.timestamp) < window_ms;
365}
366
367/// Decide whether to hold back a queued background poll because a 1W remote is still transmitting.
368///
369/// The radio is half-duplex and an authenticated exchange blocks for 1–3 s, during which no frame
370/// can be received at all. A press on a linked remote schedules a status poll, so without this gate
371/// the hub's own poll can start on top of the burst that triggered it and go deaf to the rest of it.
372///
373/// Only background polls are deferred. A user command must never wait on a remote the user may not
374/// even own — 1W broadcasts carry no ownership marker, so the activity could be a neighbour's.
375///
376/// The hold re-arms on every 1W frame received while it is already active, so a real burst from one
377/// remote (~160 ms, well under `quiet_ms`) never gets cut short mid-transmission. Left unchecked
378/// that re-arming has no cap: sustained sub-`quiet_ms` 1W traffic from any source — including a
379/// neighbour's, since these broadcasts carry no ownership marker — would hold background polls back
380/// indefinitely. @p max_defer_ms bounds that: once that much time has passed since the burst
381/// *started* (not the most recent frame), the poll is let through regardless of ongoing traffic.
382/// The gate only ever delays a poll, never drops one — it stays queued and fires as soon as it is
383/// no longer deferred.
384///
385/// @param next_op_is_background True if the queue front is a background poll (OperationQueue::is_background_op()).
386/// @param first_1w_activity_ms millis() of the first frame in the current 1W burst; 0 if none seen
387/// since boot.
388/// @param last_1w_activity_ms millis() of the most recent 1W frame; 0 if none seen since boot.
389/// @param now Current millis().
390/// @param quiet_ms How long after 1W activity to hold background polls back.
391/// @param max_defer_ms Hard cap on total defer time, measured from first_1w_activity_ms.
392/// @return true if the caller should skip dispatching this loop iteration.
393inline bool defer_background_poll_for_1w_activity(bool next_op_is_background, uint32_t first_1w_activity_ms,
394 uint32_t last_1w_activity_ms, uint32_t now, uint32_t quiet_ms,
395 uint32_t max_defer_ms) {
396 if (!next_op_is_background || last_1w_activity_ms == 0)
397 return false;
398 if (now - first_1w_activity_ms >= max_defer_ms)
399 return false;
400 return (now - last_1w_activity_ms) < quiet_ms;
401}
402
403/// @brief True when a scheduler-owned status poll sits in the middle band of the backoff ladder.
404///
405/// See SCHEDULED_POLL_RETRY_GRACE_FIRST_FAILURE (proto_timing.h) for why the band is where a poll
406/// is worth more than one attempt: the slot that lands just after a manoeuvre ends, rather than the
407/// start or the tail of the ladder.
408///
409/// The two counters are mutually exclusive by construction (StatusPollPolicy::on_exchange_failed()
410/// zeroes one while incrementing the other), so an auth-shaped streak reads status_poll_failures as
411/// 0 and would otherwise fall into the band's own "fresh window" case. It is rejected first,
412/// deliberately, so the predicate stays correct even if that exclusivity is ever relaxed.
413///
414/// @param status_poll_failures Consecutive silent failures already recorded for this device.
415/// @param auth_poll_failures Consecutive challenge-seen failures already recorded.
416[[nodiscard]] inline bool scheduled_poll_in_retry_band(uint8_t status_poll_failures, uint8_t auth_poll_failures) {
417 return auth_poll_failures == 0 && status_poll_failures >= SCHEDULED_POLL_RETRY_GRACE_FIRST_FAILURE &&
418 status_poll_failures <= SCHEDULED_POLL_RETRY_GRACE_LAST_FAILURE;
419}
420
421/// @brief Transmit-attempt budget for a scheduler-owned status poll of a heater, by ladder position.
422///
423/// Heaters keep this count-based budget; a cover's polls take their plan from
424/// scheduled_poll_read_kind() instead.
425///
426/// @param status_poll_failures Consecutive silent failures already recorded for this device.
427/// @param auth_poll_failures Consecutive challenge-seen failures already recorded.
428/// @return EXCHANGE_RETRY_COUNT inside the band, SCHEDULED_POLL_MAX_TRIES everywhere else.
429inline uint8_t scheduled_poll_max_tries(uint8_t status_poll_failures, uint8_t auth_poll_failures) {
430 return scheduled_poll_in_retry_band(status_poll_failures, auth_poll_failures) ? EXCHANGE_RETRY_COUNT
431 : SCHEDULED_POLL_MAX_TRIES;
432}
433
434// == Attempt plan ==
435
436/// @brief What a request is for, which decides how its attempts are spent.
437enum class ReadKind : uint8_t {
438 PROGRESS, ///< A look at a device that is probably still busy (a mid-travel poll): one attempt, the
439 ///< backoff ladder is the retry.
440 SETTLING, ///< A read that decides where a device ended up: as many attempts as fit the budget.
441 COMMAND, ///< A request that is harmless to repeat (is_repeat_safe_command()): to a mains device
442 ///< as many attempts as fit the budget, to a low-power one today's few with a gap.
443};
444
445/// @brief How one exchange spends its attempts. Handed to ExchangeEngine::send_and_receive().
447 uint8_t max_attempts{EXCHANGE_RETRY_COUNT}; ///< Most transmissions of the request, `[1, EXCHANGE_MAX_ATTEMPTS]`.
448 uint16_t gap_ms{EXCHANGE_RETRY_DELAY_MS}; ///< Wait before each attempt after the first; 0 for none.
449 uint16_t window_ms{RESPONSE_START_WAIT_MS}; ///< How long the last attempt listens for the first reply.
450 /// How long every attempt before the last listens; 0 means the same as @ref window_ms. A mains
451 /// target answers within tens of milliseconds or not at all, so the early attempts need not wait
452 /// the whole window: the last one does, to still catch a device that answers late.
453 uint16_t early_window_ms{0};
454 /// True when the first attempt is a short lead to a low-power receiver in an unknown state
455 /// (WakeBelief::UNKNOWN): the short start preamble, a brief window of its own
456 /// (LOW_POWER_SHORT_LEAD_WINDOW_MS), and the wake-up attempts after it. A moving receiver hears
457 /// the short one and a resting one the wake-up. It counts in @ref max_attempts.
458 bool short_lead{false};
459 /// True when the caller asked for the read to treat the receiver's state as unknown
460 /// (AttemptRequest::with_state_unknown()). The exchange engine resolves the wake belief with it on
461 /// sending too, so the plan and the tries agree (wake_belief()).
462 bool state_unknown{false};
463 /// Time the exchange may run past `exchange_total_budget_ms`. 0 unless the short lead does not fit
464 /// with its wake-up attempts inside the budget; then the cost of the lead (short_lead_cost_ms()), so
465 /// the attempts after it get the budget the plan without it had and none is dropped. The engine's
466 /// guard adds it to the budget, so plan and guard agree. The exchange never runs past the budget by
467 /// more than this.
469
470 /// @param attempt 1-based number of the attempt about to listen.
471 /// @param attempts Attempts the exchange makes in all (the last one listens @ref window_ms).
472 /// @return How long that attempt listens for the first reply.
473 [[nodiscard]] uint16_t window_for_attempt(uint8_t attempt, uint8_t attempts) const {
474 if (short_lead && attempt == 1)
475 return LOW_POWER_SHORT_LEAD_WINDOW_MS;
476 return (attempt < attempts && early_window_ms != 0) ? early_window_ms : window_ms;
477 }
478};
479
480/// @brief What the short lead attempt costs the exchange: its transmission, its brief listen and the
481/// attempt overhead (the same terms attempt_fits() counts for any attempt).
482/// @param short_tx_ms The lead's transmit time as the radio estimates it (short start preamble).
483[[nodiscard]] inline uint32_t short_lead_cost_ms(uint32_t short_tx_ms) {
484 return short_tx_ms + EXCHANGE_ATTEMPT_OVERHEAD_MS + LOW_POWER_SHORT_LEAD_WINDOW_MS;
485}
486
487/// @brief The attempt budget rule: an attempt starts only if the whole attempt, up to the end of the
488/// last wait it can make, ends inside @p budget_ms.
489///
490/// Both the engine's guard (ExchangeEngine::try_fits_budget_()) and the plan that counts attempts
491/// (attempts_that_fit()) use it, so a plan never promises an attempt the guard would refuse for a
492/// target that stays silent. The attempt's overhead (EXCHANGE_ATTEMPT_OVERHEAD_MS) is counted here
493/// and nowhere else.
494///
495/// @param elapsed_ms Time since the exchange began.
496/// @param gap_ms Wait before the attempt is sent.
497/// @param tx_ms The attempt's transmit time as the radio estimates it.
498/// @param window_ms How long the attempt then listens for the first reply.
499/// @param auth_tail_ms The final-reply window a challenged attempt adds, 0 until the target has challenged.
500/// @param budget_ms `exchange_total_budget_ms`.
501[[nodiscard]] inline bool attempt_fits(uint32_t elapsed_ms, uint32_t gap_ms, uint32_t tx_ms, uint32_t window_ms,
502 uint32_t auth_tail_ms, uint32_t budget_ms) {
503 return elapsed_ms + gap_ms + tx_ms + EXCHANGE_ATTEMPT_OVERHEAD_MS + window_ms + auth_tail_ms < budget_ms;
504}
505
506/// @brief How many attempts to a silent target fit the budget when none waits a gap, capped at @p cap.
507///
508/// The first `n - 1` attempts listen @p early_window_ms and the last listens @p last_window_ms, and
509/// the last must still pass attempt_fits(), so n is the largest with
510/// `(n - 1) * (tx + overhead + early) + tx + overhead + last < budget`. At least one attempt: the
511/// first always runs. A zero budget is no limit the plan can honour (the engine's guard still
512/// applies), so it yields @p cap.
513///
514/// @param budget_ms `exchange_total_budget_ms`.
515/// @param attempt_tx_ms One attempt's transmit time as the radio estimates it.
516/// @param early_window_ms Listen time of every attempt but the last.
517/// @param last_window_ms Listen time of the last attempt.
518/// @param cap Most attempts the caller allows.
519[[nodiscard]] inline uint8_t attempts_that_fit(uint32_t budget_ms, uint32_t attempt_tx_ms, uint32_t early_window_ms,
520 uint32_t last_window_ms, uint8_t cap) {
521 const uint32_t early_ms = attempt_tx_ms + EXCHANGE_ATTEMPT_OVERHEAD_MS + early_window_ms;
522 const uint32_t last_ms = attempt_tx_ms + EXCHANGE_ATTEMPT_OVERHEAD_MS + last_window_ms;
523 if (budget_ms == 0 || early_ms == 0)
524 return cap;
525 if (last_ms >= budget_ms)
526 return 1;
527 const uint32_t fitting = 1 + ((budget_ms - 1 - last_ms) / early_ms);
528 return static_cast<uint8_t>(std::max<uint32_t>(1, std::min<uint32_t>(fitting, cap)));
529}
530
531/// @brief The plan for a caller that names only a number of attempts: that many (at least one, at
532/// most EXCHANGE_RETRY_COUNT), the default gap, and @p window_ms.
533/// @param max_tries Attempts the caller asks for.
534/// @param window_ms First-reply window of every attempt.
535[[nodiscard]] inline AttemptPlan attempt_plan_for_tries(uint8_t max_tries, uint16_t window_ms) {
536 AttemptPlan plan;
537 plan.max_attempts = std::max<uint8_t>(1, std::min<uint8_t>(max_tries, EXCHANGE_RETRY_COUNT));
538 plan.window_ms = window_ms;
539 return plan;
540}
541
542/// @brief The attempt plan for a request, from what it is for and who it asks.
543///
544/// A settling read, and a repeat-safe command to a mains device, repeat without a gap: every attempt
545/// listens from the end of its own transmission, so a silent attempt is already followed by the next
546/// one as soon as its window ends. The attempts before the last listen only
547/// `exchange_early_response_wait_ms` (a mains device answers within tens of milliseconds or not at
548/// all) and the last listens the whole window, so how many fit is attempts_that_fit(), capped at
549/// EXCHANGE_MAX_ATTEMPTS. A settling read of a low-power device makes LOW_POWER_SETTLING_ATTEMPTS
550/// attempts of LOW_POWER_RESPONSE_WAIT_MS each. The same rule the engine's guard applies at run time,
551/// so a plan never promises an attempt the guard would refuse when the target stays silent. A
552/// challenged attempt costs more (the final reply window), so the guard can still stop a plan early.
553///
554/// PROGRESS and a COMMAND to a low-power device keep today's shapes: one attempt, and
555/// EXCHANGE_RETRY_COUNT attempts with the default gap, both with the tuned start window.
556///
557/// @param kind What the request is for.
558/// @param low_power The target is a low-power receiver (YAML `low_power`, CTRL1 low-power bit).
559/// @param tuning Reply windows and exchange budget.
560/// @param attempt_tx_ms One attempt's transmit time as the radio estimates it (long wake-up
561/// preamble for a low-power target); only a gapless plan uses it.
562/// @param last_window_ms Listen time of the last attempt of a mains target, or of every attempt of a
563/// gapped plan; 0 for the tuned start window.
564/// @param short_lead_tx_ms Set to lead a settling read of a low-power target with one short attempt
565/// (the receiver's state is unknown, see WakeBelief::UNKNOWN): that attempt's
566/// transmit time. The lead is added to the wake-up attempts the budget admits,
567/// which are the same as without it; when all of them do not fit with the lead,
568/// the exchange may run past the budget by the lead's cost
569/// (AttemptPlan::budget_allowance_ms). No lead when only one wake-up attempt
570/// fits, or for any other read.
571[[nodiscard]] inline AttemptPlan attempt_plan(ReadKind kind, bool low_power, const TuningConfig &tuning,
572 uint32_t attempt_tx_ms, uint16_t last_window_ms = 0,
573 std::optional<uint32_t> short_lead_tx_ms = std::nullopt) {
574 AttemptPlan plan =
575 attempt_plan_for_tries(kind == ReadKind::PROGRESS ? SCHEDULED_POLL_MAX_TRIES : EXCHANGE_RETRY_COUNT,
576 last_window_ms != 0 ? last_window_ms : tuning.exchange_start_response_wait_ms);
577 if (kind == ReadKind::PROGRESS || (kind == ReadKind::COMMAND && low_power))
578 return plan;
579 plan.gap_ms = 0;
580 if (low_power) {
581 plan.window_ms = LOW_POWER_RESPONSE_WAIT_MS;
582 plan.max_attempts = attempts_that_fit(tuning.exchange_total_budget_ms, attempt_tx_ms, plan.window_ms,
583 plan.window_ms, LOW_POWER_SETTLING_ATTEMPTS);
584 if (short_lead_tx_ms.has_value() && plan.max_attempts > 1) {
585 const uint32_t lead_cost_ms = short_lead_cost_ms(*short_lead_tx_ms);
586 const uint32_t budget_ms = tuning.exchange_total_budget_ms;
587 // The wake-up attempts start after the lead, so they see the budget less its cost.
588 const bool all_fit_with_lead =
589 budget_ms == 0 || (budget_ms > lead_cost_ms &&
590 attempts_that_fit(budget_ms - lead_cost_ms, attempt_tx_ms, plan.window_ms, plan.window_ms,
591 LOW_POWER_SETTLING_ATTEMPTS) >= plan.max_attempts);
592 plan.short_lead = true;
593 plan.max_attempts++;
594 plan.budget_allowance_ms = all_fit_with_lead ? 0 : lead_cost_ms;
595 }
596 return plan;
597 }
598 plan.early_window_ms = std::min(tuning.exchange_early_response_wait_ms, plan.window_ms);
600 plan.window_ms, EXCHANGE_MAX_ATTEMPTS);
601 return plan;
602}
603
604/// @brief Whether a request may be sent again to a device that said nothing, without a second action.
605///
606/// A device that hears a request acts on it and answers; silence therefore means it did not hear,
607/// or its reply was lost. For a request whose copies have one effect (an absolute position, open,
608/// close, STOP, a read) repeating it is harmless either way. CMD_EXECUTE with POS_FAVORITE is not
609/// (is_repeatable_execute()): on a Somfy motor it stops a moving cover, so a second copy can undo
610/// the first. Every request the hub sends that is not a CMD_EXECUTE is idempotent.
611///
612/// @param request Outbound request frame.
613[[nodiscard]] inline bool is_repeat_safe_command(const IoFrame &request) {
614 return request.cmd != CMD_EXECUTE || is_repeatable_execute(request);
615}
616
617/// @brief What a cover's status poll is for, from where it sits and what it settles.
618///
619/// A read that decides where the cover ended up is a SETTLING read: the poll after an accepted STOP,
620/// the one confirming a cover that stopped short of its target (@p settles_a_position), and the
621/// polls in the ladder's retry band (scheduled_poll_in_retry_band()). Every other scheduled poll is
622/// a look at a device that is probably still busy or unreachable (PROGRESS): the ladder is its
623/// retry.
624///
625/// @param status_poll_failures Consecutive silent failures already recorded for this device.
626/// @param auth_poll_failures Consecutive challenge-seen failures already recorded.
627/// @param settles_a_position True for the poll after an accepted STOP or one confirming a stop off
628/// target (StatusPollPolicy::take_settling_read()).
629[[nodiscard]] inline ReadKind scheduled_poll_read_kind(uint8_t status_poll_failures, uint8_t auth_poll_failures,
630 bool settles_a_position) {
631 return (settles_a_position || scheduled_poll_in_retry_band(status_poll_failures, auth_poll_failures))
634}
635
636/// @brief What a caller asks of one exchange's attempts: a plain cap on tries (today's shape), or a
637/// read kind whose plan the engine builds for the request (ExchangeEngine::plan_read()).
638///
639/// Converts implicitly from a number of tries, so callers that only name a count are unchanged.
641 uint8_t max_tries{EXCHANGE_RETRY_COUNT}; ///< Cap on attempts; ignored once @ref kind is set.
642 bool planned{false}; ///< True: build the plan for @ref kind instead of using a count.
643 ReadKind kind{ReadKind::COMMAND}; ///< What the read is for; only read when @ref planned.
644 /// True for the read at a moving cover's predicted end of travel (@ref with_wake_preamble_first()).
646 /// True for a read whose evidence about the receiver may be stale (@ref with_state_unknown()).
647 bool state_unknown{false};
648
649 AttemptRequest() = default;
650 /// @param tries Cap on attempts (the `max_tries` form of ExchangeEngine::send_and_receive()).
651 AttemptRequest(uint8_t tries)
652 : max_tries(tries) {} // NOLINT(google-explicit-constructor): count callers stay as they are
653 /// @param read_kind What the read is for.
654 [[nodiscard]] static AttemptRequest of_kind(ReadKind read_kind) {
655 AttemptRequest request;
656 request.planned = true;
657 request.kind = read_kind;
658 return request;
659 }
660 /// @brief Mark the read as aimed at the moment the receiver has just come to rest: to a low-power
661 /// target every attempt sends the wake-up preamble, which a resting receiver hears, whatever the
662 /// wake belief says (ADR 0077). Without effect for a target that is not low-power.
663 /// \todo Untested on hardware: needs a low-power cover that reports a remaining time.
665 AttemptRequest request = *this;
666 request.wake_preamble_first = true;
667 return request;
668 }
669 /// @brief Mark a status read as made without trustworthy news of the receiver: activity the hub
670 /// may have missed can have set it moving since its last report, so a recent "stopped" report
671 /// does not prove it rests. The wake belief is then UNKNOWN unless fresh moving evidence says
672 /// AWAKE (wake_belief()), and a settling read leads with one short attempt before the wake-up
673 /// attempts. Only a planned read (@ref of_kind) carries it to the engine; without effect for a
674 /// target that is not low-power, for a command, and wherever the belief is not applied: a fixed
675 /// preamble (@ref with_wake_preamble_first() wins) or `low_power_wake_belief` switched off.
676 /// \todo Untested on hardware, like WakeBelief::UNKNOWN itself.
677 [[nodiscard]] AttemptRequest with_state_unknown() const {
678 AttemptRequest request = *this;
679 request.state_unknown = true;
680 return request;
681 }
682 /// True for a look at a device that is probably still busy: one attempt, the ladder is the retry
683 /// (ReadKind::PROGRESS, or a count of SCHEDULED_POLL_MAX_TRIES).
684 [[nodiscard]] bool is_progress_look() const {
685 return planned ? kind == ReadKind::PROGRESS : max_tries == SCHEDULED_POLL_MAX_TRIES;
686 }
687};
688
689// == Low-power wake belief ==
690
691/// @brief Whether a low-power receiver is believed to be travelling right now, judged from what this
692/// hub has seen of it. Orders the tries of a directed exchange: a moving receiver hears the short
693/// start preamble and ignores the long wake-up one, a resting one needs the long one.
694///
695/// Having merely heard from the device recently is deliberately not a reason to lead short: a
696/// resting VELUX SSL solar roller shutter ignored the short preamble on every try in field logs,
697/// including tries sent 34 ms after it had answered (ADR 0040, amendment).
698enum class WakeBelief : uint8_t {
699 ASLEEP, ///< Not known to be moving — lead with the wake-up preamble.
700 AWAKE, ///< Moving, or about to be stopped — the short preamble is the one it hears.
701 /// A status read to a receiver the hub has no recent news of (no moving evidence, and either no status
702 /// report within LOW_POWER_MAX_TRAVEL_MS — boot, a status refresh — or a read its caller marked with
703 /// AttemptRequest::with_state_unknown(), such as a catch-up read): it may be travelling,
704 /// when it ignores the wake-up preamble, or at rest, when it hears only that one. Every try of a
705 /// single-try read keeps the wake-up preamble; a settling read leads with one short attempt of its
706 /// own (AttemptPlan::short_lead) and then sends the wake-up attempts.
707 /// \todo Experimental: rests on a moving VELUX screen ignoring the wake-up preamble (issues #114
708 /// and #209); no bench run has met a read to a receiver in this state yet.
710};
711
712/// @brief What the hub knows about the device an exchange is addressed to, as the exchange engine
713/// sees it. The engine has no device registry of its own; the hub hands this over through
714/// ExchangeEngine::set_target_evidence_provider(), and every per-target decision the engine makes
715/// reads from it: wake_belief() and retry_after_unconfirmed_accept_is_safe(). Timestamps are `millis()` values, 0 =
716/// never.
718 uint32_t last_moving_evidence_ms; ///< Last sign the receiver is travelling (see note_moving_evidence(),
719 ///< clear_moving_evidence()).
720 uint32_t last_seen_ms; ///< Last frame received from the receiver, any command. Only logged
721 ///< (`age_ms=`); it does not feed the belief.
722 bool confirms_execute; ///< The device has closed a CMD_EXECUTE exchange with a reply before.
723 uint32_t moving_until_ms{0}; ///< When the remaining time of its last trusted moving report has run
724 ///< out (`millis()`), 0 = unknown (see moving_until_holds()).
725 uint32_t last_status_ms{0}; ///< Last status report from the receiver (`millis()`), 0 = never. A recent
726 ///< one is news of its state, so a status read is not UNKNOWN then.
727};
728
729/// Build the exchange engine's view of a device record.
730/// @param dev Device record to read.
731[[nodiscard]] inline TargetEvidence target_evidence(const IoDevice &dev) {
733}
734
735/// True while the remaining time the receiver last reported has not run out: by its own account it
736/// is still travelling, so a silent exchange is not evidence that it has come to rest. False when no
737/// remaining time is known (0). Wrap-safe.
738/// \todo Untested on hardware: needs a low-power cover that reports a remaining time (ADR 0077).
739/// @param evidence Stamps for the target device.
740/// @param now Current millis().
741[[nodiscard]] inline bool moving_until_holds(const TargetEvidence &evidence, uint32_t now) {
742 return evidence.moving_until_ms != 0 && static_cast<int32_t>(evidence.moving_until_ms - now) > 0;
743}
744
745/// True for a CMD_EXECUTE whose main byte is POS_STOP. A STOP is only ever sent to a receiver that
746/// is (believed) moving, so it is treated as awake whatever the stamps say.
747/// @param request Outbound request frame.
748[[nodiscard]] inline bool is_stop_request(const IoFrame &request) {
749 return request.cmd == CMD_EXECUTE && request.data_len > EXECUTE_MAIN_BYTE_OFFSET &&
750 request.data[EXECUTE_MAIN_BYTE_OFFSET] == POS_STOP;
751}
752
753/// Judge whether a low-power receiver is travelling. `AWAKE` for a STOP request or while moving
754/// evidence is younger than LOW_POWER_MAX_TRAVEL_MS. For a status read with neither moving evidence nor
755/// a status report within that time the answer is `UNKNOWN`; otherwise `ASLEEP`. A zero stamp means
756/// never and is never recent. Ages use unsigned subtraction, so `millis()` wrap-around is safe.
757/// @param evidence Stamps for the target device.
758/// @param now Current millis().
759/// @param is_stop True when the request being sent is a STOP (see is_stop_request()).
760/// @param is_status_read True when the request is a status read (CMD_PRIVATE): only a read can be
761/// UNKNOWN, a command keeps the rule it had.
762/// @param state_unknown True when the caller marked the read as made without trustworthy news
763/// (AttemptRequest::with_state_unknown()): a recent status report then does not make
764/// the belief ASLEEP. Only a status read takes it; fresh moving evidence still wins.
765[[nodiscard]] inline WakeBelief wake_belief(const TargetEvidence &evidence, uint32_t now, bool is_stop,
766 bool is_status_read = false, bool state_unknown = false) {
767 const uint32_t moving = evidence.last_moving_evidence_ms;
768 if (is_stop || (moving != 0 && (now - moving) < LOW_POWER_MAX_TRAVEL_MS))
769 return WakeBelief::AWAKE;
770 const uint32_t reported = evidence.last_status_ms;
771 if (is_status_read && (state_unknown || reported == 0 || (now - reported) >= LOW_POWER_MAX_TRAVEL_MS))
772 return WakeBelief::UNKNOWN;
773 return WakeBelief::ASLEEP;
774}
775
776/// Start-preamble length for one try of a directed exchange to a low-power receiver. The plans
777/// (short = `short_preamble`, wake = `wake_preamble`): AWAKE short/wake/wake, ASLEEP and UNKNOWN wake on
778/// every try (the short lead of an UNKNOWN settling read is an attempt of its own, AttemptPlan::short_lead). A moving
779/// receiver answers the first short try; when it does not, it has most likely come to rest, where only the wake-up
780/// preamble reaches it, so every later try uses that one. A single-try exchange (most scheduler-owned status polls)
781/// sends only try 1's preamble; its backoff ladder, and the evidence a silent AWAKE exchange spends
782/// (ExchangeEngine::set_wake_evidence_spent_handler()), cover a wrong belief there.
783/// @param belief See wake_belief().
784/// @param try_index 1-based try number, clamped to [1, EXCHANGE_RETRY_COUNT].
785/// @param short_preamble Preamble for a receiver known to be moving (`normal_start_preamble`).
786/// @param wake_preamble Wake-up preamble for a resting receiver (`low_power_wake_preamble`).
787[[nodiscard]] inline uint16_t low_power_try_preamble(WakeBelief belief, uint8_t try_index, uint16_t short_preamble,
788 uint16_t wake_preamble) {
789 const uint8_t try_1based = std::max<uint8_t>(1, std::min<uint8_t>(try_index, EXCHANGE_RETRY_COUNT));
790 if (belief == WakeBelief::AWAKE && try_1based == 1)
791 return short_preamble;
792 return wake_preamble;
793}
794
795/// Lowercase name of a belief for log lines.
796[[nodiscard]] inline const char *wake_belief_name(WakeBelief belief) {
797 switch (belief) {
799 return "awake";
801 return "unknown";
803 default:
804 return "asleep";
805 }
806}
807
808/// True if a frame's shape matches a 1W remote's pairing gesture (issue #27/#65): CTRL0 1W bit
809/// set, addressed to the 1W broadcast address (0x00003F), with one of the three command bytes
810/// observed in the field capture — 0x20 (WRITE_PRIVATE), 0x39 (1W remove), or 0x2E (alternate
811/// discovery, 1W-flagged). Shared between PairingAdvisor (classifying recorded telemetry events,
812/// pairing_advisor.cpp) and the hub's normal passive RX path (hub_status.cpp), which remembers a
813/// recent sighting so a PROG press completed just before "Discover & Pair" is pressed isn't
814/// invisible to the advisor purely because of when the discovery telemetry window happened to
815/// open — see PairingTelemetry::record_recent_one_way_sighting().
816///
817/// @param oneway CTRL0 1W-protocol bit.
818/// @param dst Frame destination node ID.
819/// @param cmd Frame command byte.
820inline bool is_one_way_pairing_gesture(bool oneway, const uint8_t dst[NODE_ID_SIZE], uint8_t cmd) {
821 if (!oneway)
822 return false;
823 if (std::memcmp(dst, BROADCAST_DISCOVER_ALT, NODE_ID_SIZE) != 0)
824 return false;
825 return cmd == CMD_WRITE_PRIVATE || cmd == CMD_ONEWAY_REMOVE || cmd == CMD_DISCOVER_ALT_REQ;
826}
827
828/// The order in which a 0x72 status-update acknowledgement is sent on the three channels: the channel
829/// the 0x71 exchange ran on first, then the other two in their usual CH1, CH2, CH3 order. The device
830/// is still listening on the channel its exchange used, so the copy that matters is not left to the
831/// end of the sequence. A frequency that is not one of the three channels gives the usual order.
832///
833/// @param receive_freq_hz Frequency the 0x71 frame (and the challenge exchange) arrived on.
834/// @return The three channel frequencies in transmit order.
835inline std::array<uint32_t, 3> status_update_ack_channel_order(uint32_t receive_freq_hz) {
836 std::array<uint32_t, 3> order{FREQ_CH1, FREQ_CH2, FREQ_CH3};
837 if (receive_freq_hz != FREQ_CH1 && receive_freq_hz != FREQ_CH2 && receive_freq_hz != FREQ_CH3)
838 return order;
839 std::array<uint32_t, 3> result{receive_freq_hz, 0, 0};
840 size_t next = 1;
841 for (const uint32_t freq : order) {
842 if (freq != receive_freq_hz)
843 result[next++] = freq;
844 }
845 return result;
846}
847
848/// Whether a frame issue (a frame the hub did not act on) is logged at WARN rather than DEBUG. WARN only
849/// when the frame is addressed to a registered device or to the hub itself: those are exchanges the hub
850/// is part of, so an unhandled or rejected frame is worth a look. A frame from a registered device to a
851/// broadcast address or to another controller is traffic the hub merely overheard (a heater's menu
852/// beacon, a gateway polling the same device), so it stays at DEBUG however well the source is known.
853///
854/// @param dst_registered Frame destination is a registered device.
855/// @param dst_is_us Frame destination is the hub's own node ID.
856inline bool frame_issue_is_warning(bool dst_registered, bool dst_is_us) { return dst_registered || dst_is_us; }
857
858/// Whether a 1W frame arriving at `now` starts a new burst rather than extending the current one
859/// — true if no frame has been seen yet, or the gap since the last one reached `quiet_ms` (the
860/// previous burst already released any deferred poll). Callers use this to decide whether to
861/// reset a burst's start-time tracking; see defer_background_poll_for_1w_activity() for why the
862/// burst start (not just the latest frame) needs its own timestamp.
863///
864/// @param last_1w_activity_ms millis() of the most recent 1W frame before this one; 0 if none
865/// seen since boot.
866/// @param now Current millis() (this frame's arrival time).
867/// @param quiet_ms Gap after which a previous burst is considered over.
868[[nodiscard]] inline bool oneway_burst_started_fresh(uint32_t last_1w_activity_ms, uint32_t now, uint32_t quiet_ms) {
869 return last_1w_activity_ms == 0 || (now - last_1w_activity_ms) >= quiet_ms;
870}
871
872/// Namespace tag for `remote_poll_timer_id()` below: the node address occupies the low 24 bits,
873/// and this is OR-ed in above them. Each numeric-id timer caller has its own tag (see
874/// HEATING_POLL_TIMER_ID_TAG), so two timers for the same device never replace one another and an id
875/// says which caller it belongs to.
876constexpr uint32_t REMOTE_POLL_TIMER_ID_TAG = 0x01000000;
877
878/// Namespace tag for `heating_poll_timer_id()`: the same node-address scheme under its own bit, so
879/// a heater's regular-cadence timer and a remote-activity poll timer for the same device never replace
880/// one another.
881constexpr uint32_t HEATING_POLL_TIMER_ID_TAG = 0x02000000;
882
883/// @brief Numeric `set_timeout()` id for a device's remote-activity poll timer (hub_status.cpp's
884/// `schedule_status_poll_()`).
885///
886/// Keyed by the device's node address rather than a name, because `Component::set_timeout(const
887/// char *name, ...)` stores the caller's *pointer*, not a copy of the string (its header documents
888/// this: static lifetime required, use the numeric-id overload for a dynamically-built name) — see
889/// `schedule_status_poll_()`'s own comment for why a per-device name can't satisfy that here. A
890/// node address is unique per device, so this id is collision-free by construction.
891///
892/// @param node_id 3-byte device node address.
893/// @return Numeric timer id, unique per device.
894inline uint32_t remote_poll_timer_id(const uint8_t node_id[NODE_ID_SIZE]) {
895 return REMOTE_POLL_TIMER_ID_TAG | (static_cast<uint32_t>(node_id[0]) << (2 * BITS_PER_BYTE)) |
896 (static_cast<uint32_t>(node_id[1]) << BITS_PER_BYTE) | static_cast<uint32_t>(node_id[2]);
897}
898
899/// @brief Numeric `set_timeout()` id for a heater's regular-cadence poll timer
900/// (`IOHomeControlComponent::schedule_heating_poll_()`). Same keying as remote_poll_timer_id(), in
901/// the HEATING_POLL_TIMER_ID_TAG namespace, so a poll that a linked remote triggers cannot cancel it.
902/// @param node_id 3-byte device node address.
903/// @return Numeric timer id, unique per device and distinct from remote_poll_timer_id().
904inline uint32_t heating_poll_timer_id(const uint8_t node_id[NODE_ID_SIZE]) {
906}
907
908// === Heater model check ===
909
910/// @brief Runs of the one-off heater model check before the hub gives up on it for this boot. Each run
911/// costs at most two register reads, and runs only on a poll the device answered.
912constexpr uint8_t HEATER_MODEL_CHECK_MAX_RUNS = 3;
913
914/// @brief What the model check suggests about a heater's declared `io_model:`.
915enum class HeaterModelHint : uint8_t {
916 NONE, ///< The answer fits the declaration: nothing to say.
917 LOOKS_LIKE_RADIATOR, ///< Declared a towel dryer, but the towel-dryer mode register holds no value.
918 LOOKS_LIKE_TOWEL_DRYER, ///< Declared as no towel dryer, but the towel-dryer mode register holds a value.
919};
920
921/// @brief Compare a heater's declared model with whether it answered the towel-dryer mode register
922/// (0x0136) with a value.
923/// @details Two Atlantic Adelis towel dryers answer that register with their mode; an AGILIA radiator
924/// and a heater with a night light have not been read there yet, so the hint is worded "looks like" and never
925/// changes behaviour. The caller only asks after the device answered the read (with or without a value).
926/// @param declared The YAML model.
927/// @param towel_mode_has_value True when the register's reply carried a value byte.
928/// @return The hint to log, or NONE.
929inline HeaterModelHint classify_heater_model_hint(DeviceModel declared, bool towel_mode_has_value) {
930 if (declared == DeviceModel::TOWEL_DRYER && !towel_mode_has_value)
932 if (declared != DeviceModel::TOWEL_DRYER && towel_mode_has_value)
935}
936
937/// First and last byte format_heater_model_name() prints as itself: printable ASCII, space to '~'.
938constexpr uint8_t MODEL_NAME_PRINTABLE_FIRST = 0x20;
939constexpr uint8_t MODEL_NAME_PRINTABLE_LAST = 0x7E; ///< See MODEL_NAME_PRINTABLE_FIRST.
940
941/// @brief Render a heater's model-name register (0x0119, ASCII) for a log line.
942/// @details Stops at the first NUL, turns any other non-printable byte into '?', and trims trailing
943/// spaces, so a padded or garbled reply still logs as one readable line.
944/// @param bytes The value bytes of the reply.
945/// @param len Their number.
946/// @return The name; empty when the reply carried none.
947inline std::string format_heater_model_name(const uint8_t *bytes, size_t len) {
948 std::string name;
949 for (size_t i = 0; i < len && bytes[i] != 0; i++) {
950 const bool printable = bytes[i] >= MODEL_NAME_PRINTABLE_FIRST && bytes[i] <= MODEL_NAME_PRINTABLE_LAST;
951 name.push_back(printable ? static_cast<char>(bytes[i]) : '?');
952 }
953 while (!name.empty() && name.back() == ' ')
954 name.pop_back();
955 return name;
956}
957
958// === Pairing: the device behind a pairing node ===
959
960/// @brief Does a pairing node that answered discovery this way control a heater at the next address?
961///
962/// An Atlantic radiator in its display's pairing mode answers discovery as a remote controller (node
963/// type 1023), hands over its key, and is controlled at the next node address up: three AGILIA IO
964/// radiators follow this (issue #165,
965/// tests/corpus/captures/pairing/atlantic_agilia_pairing_key_pull_heater_rollcall.yaml). The heater
966/// answers a roll-call only now and then, so the pairing asks that address directly first.
967/// @param type Node type the pairing node answered discovery with.
968/// @param manufacturer Manufacturer byte of that discovery reply (0 when the reply carried none).
969/// @return true for an Atlantic remote-controller node.
970inline bool pairing_node_has_heater_at_next_address(DeviceType type, uint8_t manufacturer) {
971 return type == DeviceType::REMOTE_CONTROLLER && manufacturer == MANUFACTURER_ATLANTIC_GROUP;
972}
973
974/// @brief The node address one above `node`, as a 24-bit number (`B6BDFB` -> `B6BDFC`).
975///
976/// A carry into the higher bytes (`xxxxFF`) is computed as plain arithmetic; no radiator with such an
977/// address has been seen, so whether the device counts the same way is unconfirmed.
978/// @param node Address to count from.
979/// @param[out] next The next address; untouched when `false` is returned.
980/// @return false for `FFFFFF`, which has no next address.
981inline bool next_node_id(const uint8_t node[NODE_ID_SIZE], uint8_t next[NODE_ID_SIZE]) {
982 uint8_t up[NODE_ID_SIZE];
983 memcpy(up, node, NODE_ID_SIZE);
984 // Count up from the last byte; a byte that wraps to 0 carries into the one before it.
985 for (size_t i = NODE_ID_SIZE; i-- > 0;) {
986 if (++up[i] != 0) {
987 memcpy(next, up, NODE_ID_SIZE);
988 return true;
989 }
990 }
991 return false; // FFFFFF wrapped to 000000.
992}
993
994// === Rain limitation ===
995
996/// @brief Which evidence path labelled a device as limited by rain.
997enum class RainLimitationRule : uint8_t {
998 NONE = 0, ///< No evidence of a rain limitation.
999 RAIN_ORIGINATOR, ///< The device names the rain sensor as the originator of its last command.
1000 CLAMPED_COMMAND, ///< A position command was accepted but the stopped device ended elsewhere, while
1001 ///< rain protection was recently active.
1002};
1003
1004/// @brief What one decoded status reply said, plus what the hub had predicted.
1006 bool has_last_command; ///< The device record holds a well-formed last-command record.
1007 uint8_t originator; ///< Its Command Originator (ORIGINATOR_* in proto_constants.h).
1008 bool rain_evidence_recent; ///< A rain originator was seen from this device within RAIN_EVIDENCE_HOLD_MS.
1009 float predicted_target; ///< Hub's prediction before this reply (UNKNOWN_POSITION when none stood).
1010 float observed_target; ///< dev.target after decoding this reply.
1011 bool stopped; ///< The device reported itself stopped.
1012};
1013
1014/// @brief Decide which rain-limitation evidence, if any, a status reply provides.
1015///
1016/// A rain limitation is an *inference from observations*, never a prediction (ADR 0030 keeps
1017/// predictions apart): windows clamp an `open` to their ventilation position without sending
1018/// CMD_ERROR_RESP / RESULT_LIMITATION_BY_RAIN, so the only evidence is what the device reports
1019/// in its ordinary status replies. Rule order matters: the originator rule is the direct
1020/// evidence and wins; the clamp rule only applies while earlier rain evidence is still fresh.
1021/// @param in What the reply said and what had been predicted.
1022/// @return The rule that fired, or RainLimitationRule::NONE.
1024 if (in.has_last_command && in.originator == ORIGINATOR_RAIN_SENSOR)
1026 if (in.rain_evidence_recent && in.stopped && in.predicted_target != UNKNOWN_POSITION &&
1030}
1031
1032/// @brief True when rain_limitation_rule() finds evidence of a rain limitation.
1033[[nodiscard]] inline bool is_rain_limited(const RainLimitationInput &in) {
1035}
1036
1037/// @brief Name of a rain-limitation rule for log lines.
1038[[nodiscard]] inline const char *rain_limitation_rule_name(RainLimitationRule rule) {
1039 switch (rule) {
1041 return "rain_originator";
1043 return "clamped_command";
1045 break;
1046 }
1047 return "none";
1048}
1049
1050// === Rain sensor from the minimum limitation ===
1051
1052/// A window with a wired rain sensor limits its own opening while it rains. Home Assistant's VELUX
1053/// integration reads that limit and calls it rain from this minimum limit upward (observed values
1054/// 89, 91, 93 and 100 %).
1055static constexpr uint8_t RAIN_MINIMUM_LIMIT_PERCENT = 89;
1056/// Percent value of a fully limited position, the divisor of the raw position coding.
1057static constexpr uint16_t POSITION_FULL_PERCENT = 100;
1058/// Raw position units per percent (512).
1059static constexpr uint16_t POSITION_RAW_PER_PERCENT = STATUS_POS_MAX / POSITION_FULL_PERCENT;
1060static_assert(POSITION_RAW_PER_PERCENT * POSITION_FULL_PERCENT == STATUS_POS_MAX,
1061 "the raw rain threshold must be an exact multiple of one percent");
1062/// RAIN_MINIMUM_LIMIT_PERCENT in the raw position coding (0xB200).
1064
1065/// @brief Rain state from the raw value of a minimum-limitation reply.
1066/// @param value_raw Big-endian raw value in position coding (LimitationStatus::value_raw).
1067/// @return UNKNOWN for a value above STATUS_POS_MAX (not a percentage), RAIN at or above
1068/// RAIN_MINIMUM_LIMIT_RAW, DRY below it.
1069[[nodiscard]] inline RainSensorState rain_state_from_minimum_limit(uint16_t value_raw) {
1070 if (value_raw > STATUS_POS_MAX)
1073}
1074
1075/// @brief Rain state from a decoded limitation reply: the one place that knows which reply fields
1076/// decide. Only the main parameter's value counts; originator and remaining time do not.
1077/// @param status A decoded CMD_LIMITATION_STATUS_RESP payload.
1078/// @return UNKNOWN unless the reply is for the main parameter, else rain_state_from_minimum_limit().
1080 if (status.parameter_id != LIMITATION_PARAM_MP)
1083}
1084
1085// === Execution state ===
1086
1087/// @brief True when a device in this state is moving or about to: waiting for its turn (2), waiting for
1088/// power (3) or executing (4).
1089///
1090/// A queued cover is part of the move that follows, so it is followed like a running one. 0 (unable to
1091/// execute), 1 (error during the last move) and 5 (done) are at rest. UNDEFINED is not a state; the
1092/// caller falls back on the stopped bit (reads_stopped_by_status_byte()).
1093/// @param state State read from byte 0 of a status reply (execution_state_of()).
1094[[nodiscard]] inline bool is_executing(ExecutionState state) {
1097}
1098
1099/// @brief Does byte 0 of a status reply say the device is at rest?
1100///
1101/// The execution state decides (is_executing()). A byte whose bits [2:0] hold 6 or 7 has no state, and
1102/// its STATUS_STOPPED bit decides as it did before the state was read.
1103/// @param status_byte Byte 0 of the reply's payload.
1104[[nodiscard]] inline bool reads_stopped_by_status_byte(uint8_t status_byte) {
1105 const ExecutionState state = execution_state_of(status_byte);
1106 if (state == ExecutionState::UNDEFINED)
1107 return (status_byte & STATUS_STOPPED) != 0;
1108 return !is_executing(state);
1109}
1110
1111// === Stopped reports ===
1112
1113/// @brief What the hub makes of one status report's movement state.
1114enum class StoppedReport : uint8_t {
1115 MOVING, ///< The device reports itself moving.
1116 STOPPED, ///< Stopped on its target (within STATUS_POS_TOLERANCE_RAW), or with no
1117 ///< known target or position to compare.
1118 STOPPED_CONFIRMED, ///< Stopped off its target, as in the remembered report: at rest there.
1119 STOPPED_UNCONFIRMABLE, ///< Stopped off its target and no follow-up poll will run, so the
1120 ///< device's own flag stands.
1121 SETTLING, ///< Stopped off its target for the first time: treated as moving until
1122 ///< the next report confirms it.
1123};
1124
1125/// @brief One decoded status report, plus what the hub remembers and whether it will poll again.
1127 bool reported_stopped{true}; ///< The report says at rest (decisions::reads_stopped_by_status_byte()).
1128 float target{UNKNOWN_POSITION}; ///< Decoded target, or UNKNOWN_POSITION.
1129 float position{UNKNOWN_POSITION}; ///< Decoded current position, or UNKNOWN_POSITION.
1130 StopCandidate candidate{}; ///< The last off-target "stopped" report, if any.
1131 bool follow_up_possible{false}; ///< A tracked follow-up poll will run (StatusPollPolicy is tracking).
1132 bool has_motion{true}; ///< False for a device without motion (device_has_no_motion()): a
1133 ///< heater's target and current are a setpoint and a reading, and
1134 ///< their difference is no movement.
1135};
1136
1137/// @brief Decide whether a "stopped" report means at rest.
1138///
1139/// A cover can come to rest off its target and report STOPPED there (corpus captures named on
1140/// StopCandidate). The device's flag is trusted once two reports agree on the same target
1141/// and the same current position; the first such report keeps the device "moving" for one more
1142/// poll. Comparing the target as well keeps a device safe that answers "stopped" with a newly
1143/// commanded target before its motor starts: the report before the command had the old target,
1144/// so it can never confirm. With no follow-up poll to confirm by, the device's flag stands, or the
1145/// device would read moving with nothing left to correct it.
1146///
1147/// Known bound: a device that answers "stopped, new target, old position" on two polls in a row
1148/// before its motor starts (a start delay longer than one settle poll, about 3 s) is taken as at
1149/// rest and polling ends. No captured device does this.
1150/// @param in The report and the hub's memory of the previous one.
1151/// @return The verdict; stopped_report_at_rest() says whether it means at rest.
1153 if (!in.reported_stopped)
1154 return StoppedReport::MOVING;
1155 if (!in.has_motion || in.target == UNKNOWN_POSITION || in.position == UNKNOWN_POSITION ||
1161 if (!in.follow_up_possible)
1164}
1165
1166/// @brief True when a verdict means the device is at rest.
1167[[nodiscard]] inline bool stopped_report_at_rest(StoppedReport report) {
1168 return report != StoppedReport::MOVING && report != StoppedReport::SETTLING;
1169}
1170
1171/// @brief True when a verdict leaves an off-target report to remember for the next one.
1172///
1173/// Kept after a confirmation too, so a later poll that finds the device still resting at the same
1174/// place confirms at once instead of settling again.
1175[[nodiscard]] inline bool stopped_report_keeps_candidate(StoppedReport report) {
1177 report == StoppedReport::SETTLING;
1178}
1179
1180} // namespace decisions
1181} // namespace home_io_control
1182} // 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 stopped_report_at_rest(StoppedReport report)
True when a verdict means the device is at rest.
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?
bool moving_until_holds(const TargetEvidence &evidence, uint32_t now)
True while the remaining time the receiver last reported has not run out: by its own account it is st...
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 ...
AttemptPlan attempt_plan(ReadKind kind, bool low_power, const TuningConfig &tuning, uint32_t attempt_tx_ms, uint16_t last_window_ms=0, std::optional< uint32_t > short_lead_tx_ms=std::nullopt)
The attempt plan for a request, from what it is for and who it asks.
static constexpr uint16_t POSITION_FULL_PERCENT
Percent value of a fully limited position, the divisor of the raw position coding.
bool is_repeat_safe_command(const IoFrame &request)
Whether a request may be sent again to a device that said nothing, without a second action.
StoppedReport classify_stopped_report(const StoppedReportInput &in)
Decide whether a "stopped" report means at rest.
bool scheduled_poll_in_retry_band(uint8_t status_poll_failures, uint8_t auth_poll_failures)
True when a scheduler-owned status poll sits in the middle band of the backoff ladder.
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).
bool attempt_fits(uint32_t elapsed_ms, uint32_t gap_ms, uint32_t tx_ms, uint32_t window_ms, uint32_t auth_tail_ms, uint32_t budget_ms)
The attempt budget rule: an attempt starts only if the whole attempt, up to the end of the last wait ...
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.
bool stopped_report_keeps_candidate(StoppedReport report)
True when a verdict leaves an off-target report to remember for the next one.
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.
std::string format_heater_model_name(const uint8_t *bytes, size_t len)
Render a heater's model-name register (0x0119, ASCII) for a log line.
bool is_device_event_report(const IoFrame &frame)
Returns true for a frame that reports a device event rather than a status: a CMD_SET_CONFIG1_RESP,...
ExchangeFirstResponseDisposition classify_exchange_first_response(const IoFrame &request, const IoFrame &candidate)
Decide how to handle the first response packet in an authenticated exchange.
AttemptPlan attempt_plan_for_tries(uint8_t max_tries, uint16_t window_ms)
The plan for a caller that names only a number of attempts: that many (at least one,...
constexpr uint8_t MODEL_NAME_PRINTABLE_LAST
See MODEL_NAME_PRINTABLE_FIRST.
ReadKind scheduled_poll_read_kind(uint8_t status_poll_failures, uint8_t auth_poll_failures, bool settles_a_position)
What a cover's status poll is for, from where it sits and what it settles.
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.
bool is_subscription_answer(const IoFrame &request, const IoFrame &response)
Returns true when response is the answer to the subscription request: the request is a CMD_SUBSCRIBE_...
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).
HeaterModelHint
What the model check suggests about a heater's declared io_model:.
@ LOOKS_LIKE_TOWEL_DRYER
Declared as no towel dryer, but the towel-dryer mode register holds a value.
@ LOOKS_LIKE_RADIATOR
Declared a towel dryer, but the towel-dryer mode register holds no value.
@ NONE
The answer fits the declaration: nothing to say.
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_...
HeaterModelHint classify_heater_model_hint(DeviceModel declared, bool towel_mode_has_value)
Compare a heater's declared model with whether it answered the towel-dryer mode register (0x0136) wit...
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.
bool overheard_command_writes(uint8_t cmd)
Returns true for the commands that write to a device: an execute, a mode activation or a private regi...
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.
ReadKind
What a request is for, which decides how its attempts are spent.
@ SETTLING
A read that decides where a device ended up: as many attempts as fit the budget.
@ COMMAND
A request that is harmless to repeat (is_repeat_safe_command()): to a mains device as many attempts a...
@ PROGRESS
A look at a device that is probably still busy (a mid-travel poll): one attempt, the backoff ladder i...
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.
uint8_t attempts_that_fit(uint32_t budget_ms, uint32_t attempt_tx_ms, uint32_t early_window_ms, uint32_t last_window_ms, uint8_t cap)
How many attempts to a silent target fit the budget when none waits a gap, capped at cap.
uint32_t short_lead_cost_ms(uint32_t short_tx_ms)
What the short lead attempt costs the exchange: its transmission, its brief listen and the attempt ov...
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.
@ UNKNOWN
A status read to a receiver the hub has no recent news of (no moving evidence, and either no status r...
@ 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,...
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.
WakeBelief wake_belief(const TargetEvidence &evidence, uint32_t now, bool is_stop, bool is_status_read=false, bool state_unknown=false)
Judge whether a low-power receiver is travelling.
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,...
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 of a heater, by ladder position.
RainSensorState rain_state_from_minimum_limit(uint16_t value_raw)
Rain state from the raw value of a minimum-limitation reply.
std::array< uint32_t, 3 > status_update_ack_channel_order(uint32_t receive_freq_hz)
The order in which a 0x72 status-update acknowledgement is sent on the three channels: the channel th...
constexpr uint8_t MODEL_NAME_PRINTABLE_FIRST
First and last byte format_heater_model_name() prints as itself: printable ASCII, space to '~'.
StoppedReport
What the hub makes of one status report's movement state.
@ STOPPED
Stopped on its target (within STATUS_POS_TOLERANCE_RAW), or with no known target or position to compa...
@ SETTLING
Stopped off its target for the first time: treated as moving until the next report confirms it.
@ STOPPED_UNCONFIRMABLE
Stopped off its target and no follow-up poll will run, so the device's own flag stands.
@ MOVING
The device reports itself moving.
@ STOPPED_CONFIRMED
Stopped off its target, as in the remembered report: at rest there.
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 is_executing(ExecutionState state)
True when a device in this state is moving or about to: waiting for its turn (2), waiting for power (...
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,...
bool frame_issue_is_warning(bool dst_registered, bool dst_is_us)
Whether a frame issue (a frame the hub did not act on) is logged at WARN rather than DEBUG.
bool reads_stopped_by_status_byte(uint8_t status_byte)
Does byte 0 of a status reply say the device is at rest?
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.
constexpr uint8_t HEATER_MODEL_CHECK_MAX_RUNS
Runs of the one-off heater model check before the hub gives up on it for this boot.
ExecutionState execution_state_of(uint8_t status_byte)
Read the execution state out of byte 0 of a status reply.
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.
bool decode_status_update_event(const IoFrame &frame, StatusUpdateEvent &out)
Decode the event header of a CMD_STATUS_UPDATE.
DeviceModel
A product model below a (node type, subtype): for products that report the same wire type but need di...
@ TOWEL_DRYER
Towel dryer under the electrical heater with an adjustable setpoint (52/1).
DeviceType
IO-Homecontrol node types.
@ REMOTE_CONTROLLER
Remote controller (all ten type bits set, packed bytes FF C0).
ExecutionState
What a device says it is doing, from bits [2:0] of byte 0 of a status reply (CMD_PRIVATE_RESP,...
@ WAITING_FOR_POWER
Accepted, waiting for power (queued behind another device on a shared supply).
@ UNDEFINED
Bits [2:0] hold 6 or 7, which no source defines.
@ WAITING_FOR_EXECUTING
Accepted, waiting for its turn to run.
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 moving_until_ms
millis() at which the remaining time of the last trusted moving report has run out (plus PREDICTED_EN...
uint32_t last_status
millis() timestamp of last received status.
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 %).
The event header of a well-formed CMD_STATUS_UPDATE payload.
uint16_t event_id
EVENT_STATUS_CHANGE for a position update, any other id for a device event report.
The last "stopped" report a device sent away from its own target, kept to confirm the next.
float position
Current position it reported with it.
bool armed() const
True when a report is remembered.
float target
Target the device reported with the off-target "stopped".
All runtime tunable parameters for pairing and radio diagnostics.
uint16_t exchange_early_response_wait_ms
Reply window of every attempt before the last when a mains target is asked more than once (decisions:...
uint16_t exchange_start_response_wait_ms
Reply window for a start frame (wakes a sleeping device).
uint16_t exchange_total_budget_ms
Ceiling on one whole exchange, retries included.
How one exchange spends its attempts. Handed to ExchangeEngine::send_and_receive().
uint16_t window_ms
How long the last attempt listens for the first reply.
bool short_lead
True when the first attempt is a short lead to a low-power receiver in an unknown state (WakeBelief::...
uint16_t early_window_ms
How long every attempt before the last listens; 0 means the same as window_ms.
uint16_t gap_ms
Wait before each attempt after the first; 0 for none.
uint32_t budget_allowance_ms
Time the exchange may run past exchange_total_budget_ms.
bool state_unknown
True when the caller asked for the read to treat the receiver's state as unknown (AttemptRequest::wit...
uint8_t max_attempts
Most transmissions of the request, [1, EXCHANGE_MAX_ATTEMPTS].
uint16_t window_for_attempt(uint8_t attempt, uint8_t attempts) const
bool state_unknown
True for a read whose evidence about the receiver may be stale (with_state_unknown()).
bool wake_preamble_first
True for the read at a moving cover's predicted end of travel (with_wake_preamble_first()).
ReadKind kind
What the read is for; only read when planned.
bool is_progress_look() const
True for a look at a device that is probably still busy: one attempt, the ladder is the retry (ReadKi...
AttemptRequest with_state_unknown() const
Mark a status read as made without trustworthy news of the receiver: activity the hub may have missed...
static AttemptRequest of_kind(ReadKind read_kind)
bool planned
True: build the plan for kind instead of using a count.
AttemptRequest with_wake_preamble_first() const
Mark the read as aimed at the moment the receiver has just come to rest: to a low-power target every ...
uint8_t max_tries
Cap on attempts; ignored once kind is set.
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.
One decoded status report, plus what the hub remembers and whether it will poll again.
bool reported_stopped
The report says at rest (decisions::reads_stopped_by_status_byte()).
bool follow_up_possible
A tracked follow-up poll will run (StatusPollPolicy is tracking).
float position
Decoded current position, or UNKNOWN_POSITION.
float target
Decoded target, or UNKNOWN_POSITION.
bool has_motion
False for a device without motion (device_has_no_motion()): a heater's target and current are a setpo...
StopCandidate candidate
The last off-target "stopped" report, if any.
What the hub knows about the device an exchange is addressed to, as the exchange engine sees it.
uint32_t last_status_ms
Last status report from the receiver (millis()), 0 = never.
uint32_t last_seen_ms
Last frame received from the receiver, any command.
uint32_t moving_until_ms
When the remaining time of its last trusted moving report has run out (millis()), 0 = unknown (see mo...
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()).
Runtime tuning configuration for pairing and radio diagnostics.