Home IO Control
ESPHome add-on for IO-Homecontrol devices
Loading...
Searching...
No Matches
status_poll_policy.h
Go to the documentation of this file.
1#pragma once
2
3/// @file status_poll_policy.h
4/// @brief Per-device poll scheduling, failure backoff, and follow-up-poll state machine.
5/// @ingroup hioc_hub
6///
7/// Owns all per-device poll bookkeeping (intervals, deadlines, failure counters).
8/// Pure logic with injected timestamps — fully host-testable without a clock mock.
9
10#include "proto_timing.h"
11
12#include <algorithm>
13#include <cstdint>
14#include <map>
15#include <optional>
16#include <set>
17#include <string>
18#include <vector>
19
20namespace esphome {
21namespace home_io_control {
22
23/// @name Background-poll retry delays after silent failures (no reply received)
24/// @{
25static constexpr uint32_t STATUS_RETRY_AFTER_FAIL_MS = 5000; ///< First retry after a silent failure.
26static constexpr uint32_t STATUS_RETRY_AFTER_FAIL_STEP2_MS = 15000; ///< Second retry after a silent failure.
27static constexpr uint32_t STATUS_RETRY_AFTER_FAIL_STEP3_MS = 30000; ///< Third retry after a silent failure.
28static constexpr uint32_t STATUS_RETRY_AFTER_FAIL_STEP4_MS = 60000; ///< Fourth retry after a silent failure.
29/// Steady-state backoff after many silent failures. Longer than what is left of a follow-up window
30/// (MAX_TRACKED_STATUS_POLL_WINDOW_MS) by the time it is reached, so inside a window this step ends
31/// the window (StatusPollPolicy::on_exchange_failed()).
32static constexpr uint32_t STATUS_RETRY_AFTER_FAIL_MAX_MS = 300000;
33/// @}
34
35/// @name Background-poll retry delays after auth-shaped failures (0x3C challenge seen)
36///
37/// A device that challenges the poll is awake and heard it. A single such miss is usually a
38/// closing reply lost while the device switched its load — on a cover, the motor stopping at the
39/// end of its travel (issue #178) — so the first retry waits no longer than after a silent miss.
40/// Only a repeated one, which is what a wrong key looks like, backs off far: there every further
41/// try is a full authenticated exchange that cannot succeed.
42/// @{
43static constexpr uint32_t STATUS_AUTH_RETRY_AFTER_FAIL_MS =
44 STATUS_RETRY_AFTER_FAIL_MS; ///< First retry after a challenge-seen failure.
45static constexpr uint32_t STATUS_AUTH_RETRY_AFTER_FAIL_STEP2_MS = 30000; ///< Second retry.
46static constexpr uint32_t STATUS_AUTH_RETRY_AFTER_FAIL_STEP3_MS = 120000; ///< Third retry.
47/// Steady-state after repeated auth failures. Like STATUS_RETRY_AFTER_FAIL_MAX_MS, it ends a follow-up
48/// window rather than wait inside it.
49static constexpr uint32_t STATUS_AUTH_RETRY_AFTER_FAIL_MAX_MS = 300000;
50/// @}
51
52/// Consecutive failed polls of a moving device that are skipped rather than backed off.
53///
54/// A poll while a device is believed travelling is a single try (SCHEDULED_POLL_MAX_TRIES), so one
55/// lost frame fails it. Waiting the ladders' first step only leaves a gap in the position updates
56/// (issue #178). The first misses in a row keep the normal cadence instead (never slower than the
57/// ladder's first step); only the next one enters the ladder, which still catches a device that is
58/// really gone or answers with the wrong key, two polls later. The skipped misses do not count as
59/// ladder failures, so the next poll stays a single try.
60static constexpr uint8_t MOVING_POLL_MISSES_SKIPPED = 2;
61
62/// Hard stop for bounded follow-up polling after a command, remote activity or the first moving report,
63/// measured from the moment the window opens (ADR 0061, amended by ADR 0079).
64///
65/// The longest cover travel measured is about 65 s, a VELUX roof window at its silent speed
66/// (docs/configuration/cover.md, travel speeds). A cover queued behind another on a shared supply
67/// runs after that one, so the worst case is two such travels, about 130 s: 240 s covers it with
68/// almost twice the margin, and a cover that is still reporting moving after that is shown at rest.
69static constexpr uint32_t MAX_TRACKED_STATUS_POLL_WINDOW_MS = 240000;
70/// Delay before the first post-boot status request from an entity.
71static constexpr uint32_t INITIAL_STATUS_REQUEST_DELAY_MS = 5000;
72/// Interval between regular polls of a heater (its `status_poll_interval` overrides it). Slow on
73/// purpose: a heater's state moves over minutes, and a poll costs one exchange per register.
74static constexpr uint32_t HEATING_POLL_DEFAULT_INTERVAL_MS = 300000;
75
76/// @brief Delay of the extra heater poll after a register write the heater acknowledged.
77///
78/// The write is read back at once, but whether the heater now heats shows only in its regulator
79/// register, which the regular poll reads. Without this extra poll the card's heating / idle trails a
80/// setpoint change by up to one poll interval, and longer when a poll fails (issue #165). The
81/// regular cadence restarts from this poll.
82static constexpr uint32_t HEATING_POST_WRITE_POLL_DELAY_MS = 45000;
83// REMOTE_ACTIVITY_STATUS_POLL_DELAY_MS lives in proto_timing.h: it is also the default of the
84// `remote_activity_poll_delay_ms` tuning parameter, whose header may not include this one.
85/// How long a device's authenticated status or this hub's own read-triggered poll keeps a further
86/// overheard read from triggering another poll (0 = never polled, so the first read polls). Covers
87/// have no background poll unless they opt into `status_refresh_interval`, so a read by another
88/// controller is the cue that something the hub did not hear (a rain closure, a local button) may have
89/// changed.
90static constexpr uint32_t FOREIGN_READ_RESYNC_MS = 600000;
91/// A device challenges a request 2.5-3.3 ms after it; 100 ms is far above that and far below the next
92/// request. A challenge within this window of a heard request from the same controller answers that
93/// request; a challenge with no such request means the request frame was missed.
94static constexpr uint32_t FOREIGN_CHALLENGE_AFTER_REQUEST_MS = 100;
95/// Hold queued background polls back for this long after any 1W frame, so a poll the hub itself
96/// scheduled does not occupy the (half-duplex) radio while the remote is still transmitting.
97/// Comfortably longer than the ~160ms reliability burst; short enough to barely shift poll timing
98/// otherwise. Only background polls yield — see decisions::defer_background_poll_for_1w_activity().
99static constexpr uint32_t ONEWAY_QUIET_PERIOD_MS = 700;
100/// Hard cap on how long sustained 1W traffic may hold a background poll back in total, measured
101/// from the start of the burst rather than the most recent frame. ONEWAY_QUIET_PERIOD_MS re-arms on
102/// every frame, so without this cap traffic arriving faster than the quiet period apart — a stuck
103/// remote, a chatty neighbour's sensor — could starve background polls, and therefore device state
104/// freshness, indefinitely. Comfortably above a legitimate ~160ms burst; bounds the worst case
105/// without meaningfully affecting normal operation.
106static constexpr uint32_t ONEWAY_POLL_DEFER_CAP_MS = 5000;
107
108/// Default follow-up settle-poll delay used while a device may still be moving, when no explicit
109/// poll interval is configured (interval_ms == 0). Short on purpose so devices without a remaining time are
110/// re-checked promptly and the motion-tracking loop terminates soon after the device comes to rest.
111static constexpr uint32_t DEFAULT_SETTLE_POLL_DELAY_MS = 3000;
112/// Upper bound on the settle-poll delay after a STOP command. STOP should confirm the resting
113/// position quickly, so it always settles faster than a normal move, regardless of the configured
114/// interval or remaining time.
115static constexpr uint32_t STOP_SETTLE_POLL_CAP_MS = 1000;
116
117/// @name Predicted-end read
118///
119/// A moving cover that reports how long it has left is read once more, with the settling plan, a
120/// second after that time has run out: the position it reports then is the final one. That holds for
121/// every count, a short one or 0 included: the read at the end is the one most likely to meet the
122/// second in which the motor switches its load and a reply is lost. The ordinary progress reads keep
123/// running in between and stay single tries.
124/// The aim, the dropped progress read and the final-position read at count + 1 s are confirmed on a
125/// Somfy Sunea io awning
126/// (tests/corpus/captures/statuspoll/somfy_awning_statuspoll_close_with_remaining_time_predicted_end_read_sx1262.yaml).
127/// \todo Experimental: the stale-count rule has not met a frozen count on hardware, and a
128/// battery-powered cover's read at the end of its count has not run on one (ADR 0076).
129/// @{
130/// The predicted-end read is aimed this long after the remaining time has run out. The motor takes up
131/// to about a second after its count reaches zero to stop and switch its load.
132static constexpr uint32_t PREDICTED_END_MARGIN_MS = 1000;
133/// A progress read due within this long before the predicted-end read is dropped: the settling read
134/// follows at once and a read of its own in front of it only fills the air.
135static constexpr uint32_t PREDICTED_END_DROPS_PROGRESS_WITHIN_MS = 1000;
136/// Reports in a row whose remaining time did not fall below the previous one before the count is
137/// taken as frozen and the predicted-end read is given up (progress reads only).
138static constexpr uint8_t PREDICTED_END_STALE_REPORTS = 3;
139/// @}
140
141/// @name Frozen-count back-off
142///
143/// A count that has stopped falling is a cover that is waiting or stuck, not one that is nearly there,
144/// so the progress reads that follow back off instead of returning to the 3 s cadence (ADR 0079,
145/// amending ADR 0076). The steps grow with each further report whose count did not fall and stop at
146/// the last; a falling count starts over. The back-off only begins after PREDICTED_END_STALE_REPORTS
147/// reports in a row that did not fall, and the slowest falling counts seen drop at every poll (3 s
148/// between two polls on a VELUX SML, 1 s per second on a Somfy awning), so a slow but alive count
149/// is not taken as frozen. 10 s, 15 s and 20 s keep a cover that starts after waiting within 20 s of
150/// being seen, while a stuck one costs a read per 20 s instead of one per 3 s.
151/// \todo Experimental: no frozen count has been captured on hardware yet.
152/// @{
153static constexpr uint32_t FROZEN_COUNT_BACKOFF_FIRST_MS = 10000; ///< Delay after the first frozen report.
154static constexpr uint32_t FROZEN_COUNT_BACKOFF_SECOND_MS = 15000; ///< Delay after the second.
155static constexpr uint32_t FROZEN_COUNT_BACKOFF_MAX_MS = 20000; ///< Delay after the third and every later one.
156/// @}
157
158/// @brief Resolve the follow-up settle-poll delay while a device may still be moving.
159///
160/// This is the single source of truth for the motion-tracking cadence. The delay is the configured
161/// @p interval_ms, or DEFAULT_SETTLE_POLL_DELAY_MS when no interval is configured (interval_ms == 0).
162/// The device's remaining travel time plus a margin (@p remaining_delay_ms, 0 when absent) and a STOP command
163/// (@p cap_for_stop) may only shorten the delay, never lengthen it.
164///
165/// Note: this resolves the *settle* delay only — it is intentionally independent of whether a
166/// device keeps polling after coming to rest. Periodic monitoring after rest remains opt-in and is
167/// gated on interval_ms != 0 by the command paths, so defaulting the settle delay here does not turn
168/// every device into a periodic poller.
169///
170/// @return Settle delay in milliseconds (always non-zero).
171[[nodiscard]] inline uint32_t settle_delay_ms(uint32_t interval_ms, uint32_t remaining_delay_ms, bool cap_for_stop) {
172 uint32_t delay = (interval_ms != 0) ? interval_ms : DEFAULT_SETTLE_POLL_DELAY_MS;
173 if (remaining_delay_ms != 0)
174 delay = std::min(delay, remaining_delay_ms);
175 if (cap_for_stop)
176 delay = std::min(delay, STOP_SETTLE_POLL_CAP_MS);
177 return delay;
178}
179
180/// @brief Per-device poll scheduling state.
182 uint32_t interval_ms{0}; ///< Configured follow-up poll interval (0 = no configured cadence; delays fall back to
183 ///< remaining time / default settle delay).
184 uint32_t next_update{0}; ///< Absolute millis() timestamp for the next poll; 0 = idle.
185 uint32_t poll_deadline{0}; ///< Hard stop for bounded follow-up polling.
186 uint8_t status_poll_failures{0}; ///< Consecutive background poll failures (silent — no reply).
187 uint8_t auth_poll_failures{0}; ///< Consecutive background poll failures that saw a 0x3C challenge.
188 uint8_t moving_poll_misses{0}; ///< Consecutive failed polls of a moving device skipped so far (see
189 ///< MOVING_POLL_MISSES_SKIPPED); not counted in the two streaks above.
190 bool stop_settle{false}; ///< The next poll reads where an accepted STOP left the device (a settling read).
191 bool confirm_stopped{false}; ///< The next poll confirms a stop off target (a settling read, see
192 ///< decisions::StoppedReport::SETTLING).
193 uint32_t settle_update{0}; ///< Absolute millis() timestamp of the predicted-end read; 0 = none planned.
194 std::optional<uint16_t> last_remaining_s; ///< Remaining time of the last report that was aimed at (> 2 s).
195 uint8_t stale_reports{0}; ///< Reports in a row whose remaining time did not fall.
196 bool predicted_end_read{false}; ///< The next poll is the predicted-end read (a settling read).
197};
198
199/// @brief What an overheard frame of another controller is.
200enum class ForeignFrameKind : uint8_t {
201 REQUEST_TO_DEVICE, ///< Another controller's command to a registered device.
202 DEVICE_CHALLENGE_TO_OTHER ///< A registered device's challenge (0x3C) addressed to another controller.
203};
204
205/// @brief What the hub does about an overheard foreign frame.
206enum class ForeignAction : uint8_t {
207 IGNORE, ///< Nothing: no poll, no tracking change.
208 ARM, ///< Schedule a poll REMOTE_ACTIVITY_STATUS_POLL_DELAY_MS from now and begin tracking.
209 PUSH_PENDING, ///< A poll armed by foreign traffic is pending: move its timer out, change nothing else.
210};
211
212/// @brief Per-device bookkeeping of overheard foreign traffic.
213///
214/// Kept apart from PollTracking: it is not part of the polling window's lifecycle, so
215/// begin_tracking() and clear() never reset it. Timestamps are millis() values, 0 = never; all
216/// comparisons use unsigned `now - t`, which stays correct across a millis() wrap.
218 uint8_t last_request_cmd{0}; ///< Command of the last request heard to this device.
219 std::string last_request_from; ///< Controller that sent it (frame source); empty = none heard.
220 uint32_t last_request_ms{0}; ///< When it was heard; 0 = never.
221 uint32_t last_read_poll_ms{0}; ///< When a read last armed a poll; 0 = never.
222 uint32_t pending_due_ms{0}; ///< When the armed poll is due; 0 = none pending.
223};
224
225/// @brief What the hub does with a status report that reads as moving.
226enum class MovingReportAction : uint8_t {
227 FOLLOW, ///< A window is open: schedule the next poll as usual.
228 OPEN, ///< No window: one was opened now; schedule the next poll as usual.
229 END, ///< The window ran out: it is closed and reported ended; no further poll.
230 NONE, ///< No window and none may be opened: no poll.
231};
232
233/// @brief Per-hub poll scheduling and failure-backoff policy.
234///
235/// Maintains a PollTracking entry per device; all hub poll bookkeeping lives
236/// here and nowhere else. Callers inject 'now' timestamps so the class is
237/// fully testable without a hardware clock.
239 public:
240 // --- Interval configuration ---
241 /// Set the configured follow-up poll interval (ms; 0 = legacy one-shot settle only).
242 void set_interval(const std::string &device_id, uint32_t interval_ms);
243 /// Return the configured interval (0 when not set or unconfigured).
244 [[nodiscard]] uint32_t get_interval(const std::string &device_id) const;
245
246 // --- Tracking lifecycle ---
247 /// Begin bounded follow-up polling after a command or remote activity.
248 /// Sets a deadline regardless of whether a configured interval exists; interval-0 devices use
249 /// remaining-time or default settle delays computed at response time.
250 void begin_tracking(const std::string &device_id, uint32_t initial_delay_ms, uint32_t now);
251 /// Stop all polling for a device and reset failure counts. Preserves interval_ms.
252 void clear(const std::string &device_id);
253
254 /// Decide what a status report that reads as moving means for the follow-up window.
255 ///
256 /// An open window is followed and its deadline stays: a later report never extends it. A window
257 /// whose deadline has passed is closed and reported ended (see pop_ended_device()); the report
258 /// that finds it expired does not reopen it, so a poll the hub scheduled itself cannot chain
259 /// windows. Without a window, the report opens one at @p now when @p may_open allows it.
260 /// @param device_id Device the report belongs to.
261 /// @param now Current millis().
262 /// @param may_open True when this kind of device may start tracking from a report alone.
263 MovingReportAction on_moving_report(const std::string &device_id, uint32_t now, bool may_open);
264
265 /// Aim or re-aim the predicted-end read from a trusted status report of a moving device.
266 ///
267 /// A remaining time, whatever its size (0 included), plans the read at @p now + remaining time +
268 /// PREDICTED_END_MARGIN_MS, replacing an earlier aim; an aim past the end of the follow-up window is not planned. A
269 /// count that did not fall since the last aimed report counts as stale; after PREDICTED_END_STALE_REPORTS in a row
270 /// the read is given up and only progress reads remain (a falling count aims again). No count (@p remaining_s empty)
271 /// drops the aim: a report that stopped, or a device that sends none. A report also withdraws a predicted-end read
272 /// already popped but not yet sent, since it replaces the prediction that called for it.
273 ///
274 /// The poll that confirms a stop off target is never predicted: its report passes no count, and its
275 /// delay stays the default one (see decisions::classify_stopped_report()).
276 /// @param device_id Device the report belongs to; tracked devices only.
277 /// @param remaining_s Usable remaining travel time in seconds, empty when there is none.
278 /// @param now Current millis().
279 void on_trusted_report(const std::string &device_id, std::optional<uint16_t> remaining_s, uint32_t now);
280 /// How long the next progress read waits when the device's remaining time has stopped falling.
281 ///
282 /// Zero while the count falls or none is known. From the PREDICTED_END_STALE_REPORTS-th report in a row
283 /// whose count did not fall: FROZEN_COUNT_BACKOFF_FIRST_MS, then FROZEN_COUNT_BACKOFF_SECOND_MS, then
284 /// FROZEN_COUNT_BACKOFF_MAX_MS for every further one. A caller whose own cadence is longer keeps it.
285 /// @param device_id Device whose last trusted report was passed to on_trusted_report().
286 [[nodiscard]] uint32_t frozen_count_delay_ms(const std::string &device_id) const;
287 /// The planned predicted-end read's timestamp (0 = none).
288 [[nodiscard]] uint32_t get_settle_update(const std::string &device_id) const;
289
290 // --- Next-update scheduling ---
291 /// Schedule the next poll at an absolute millis() timestamp.
292 void set_next_update(const std::string &device_id, uint32_t abs_time);
293 [[nodiscard]] uint32_t get_next_update(const std::string &device_id) const;
294
295 // --- Failure tracking ---
296 /// Record a failed background poll; apply backoff or clear tracking if the window expired.
297 ///
298 /// When @p device_moving is true, no failure streak is running and fewer than
299 /// MOVING_POLL_MISSES_SKIPPED misses in a row have been skipped, the miss is skipped instead: the
300 /// next poll keeps the normal cadence (settle_delay_ms() without a remaining time, at most
301 /// STATUS_RETRY_AFTER_FAIL_MS) and the failure streaks are left alone.
302 /// @param device_id Device whose poll failed.
303 /// @param auth_like True when the failed exchange saw a 0x3C challenge.
304 /// @param now Current millis().
305 /// @param device_moving True when a single-try poll of a device believed travelling failed.
306 /// @return The delay applied in ms, or 0 if tracking was cleared.
307 uint32_t on_exchange_failed(const std::string &device_id, bool auth_like, uint32_t now, bool device_moving = false);
308 /// Reset failure streaks (and skipped moving misses) after a successful exchange response.
309 void clear_failure_streaks(const std::string &device_id);
310 [[nodiscard]] uint8_t get_status_poll_failures(const std::string &device_id) const;
311 [[nodiscard]] uint8_t get_auth_poll_failures(const std::string &device_id) const;
312 /// Consecutive failed polls of a moving device skipped so far.
313 [[nodiscard]] uint8_t get_moving_poll_misses(const std::string &device_id) const;
314 [[nodiscard]] uint32_t get_poll_deadline(const std::string &device_id) const;
315
316 // --- Post-STOP settle poll ---
317 /// Mark the device's next poll as the one reading where an accepted STOP left it, so it is a
318 /// settling read (decisions::ReadKind::SETTLING). Only a tracked device can be marked;
319 /// begin_tracking() and clear() drop the mark, so a newer command or remote activity never
320 /// inherits it.
321 void mark_stop_settle(const std::string &device_id);
322 /// Consume the mark set by mark_stop_settle(): true once, for the first poll dispatched after it.
323 [[nodiscard]] bool take_stop_settle(const std::string &device_id);
324 /// Mark (or unmark) the device's next poll as the one confirming a stop off target, so it is a
325 /// settling read too. Every trusted status report sets it from its own verdict, so a report that
326 /// no longer needs confirming (moving again, or at rest) drops a mark an earlier one left. Only a
327 /// tracked device can be marked; begin_tracking() and clear() drop the mark as for the STOP mark.
328 /// @param device_id Device whose next poll is meant.
329 /// @param needed True when the latest report stopped off target for the first time.
330 void set_confirm_stopped(const std::string &device_id, bool needed);
331 /// Consume the mark set by set_confirm_stopped(): true once, for the first poll dispatched after it.
332 [[nodiscard]] bool take_confirm_stopped(const std::string &device_id);
333 /// True when the next poll is the predicted-end read (the mark take_settling_read() consumes).
334 [[nodiscard]] bool is_predicted_end_read(const std::string &device_id) const;
335 /// Consume the marks (none short-circuits another) for the poll being dispatched.
336 /// @return True when that poll settles a position: it follows an accepted STOP, confirms a stop off
337 /// target, or is the predicted-end read.
338 [[nodiscard]] bool take_settling_read(const std::string &device_id);
339
340 // --- Queries ---
341 /// True when the device has an active bounded polling window (deadline set and not expired).
342 [[nodiscard]] bool is_tracking_active(const std::string &device_id, uint32_t now) const;
343
344 // --- Due-device scan ---
345 /// Return and consume the first device with a poll overdue.
346 ///
347 /// A device's predicted-end read (on_trusted_report()) wins over its progress read: when it is due,
348 /// the device is returned with its predicted-end mark set (take_settling_read()) and a progress
349 /// read due too is dropped. A progress read due while the predicted-end read follows within
350 /// PREDICTED_END_DROPS_PROGRESS_WITHIN_MS is dropped as well; the predicted-end read stays planned.
351 /// Clears expired tracking entries as a side effect. Returns nullopt if nothing is due.
352 [[nodiscard]] std::optional<std::string> pop_due_device(uint32_t now);
353
354 // --- Overheard foreign traffic ---
355 /// Decide what an overheard frame of another controller means for this device.
356 ///
357 /// Order: a pending foreign-armed poll answers first (PUSH_PENDING for any frame, timer pushed
358 /// out); a request that writes ARMs; any other request ARMs only when neither @p last_status_ms
359 /// nor the last read-triggered poll is younger than FOREIGN_READ_RESYNC_MS (0 = never, so the first
360 /// read polls); a challenge is IGNOREd when the last request heard from the same controller was
361 /// within FOREIGN_CHALLENGE_AFTER_REQUEST_MS and not a write, otherwise it ARMs. Every request
362 /// updates the last-request record; a challenge does not. ARM and PUSH_PENDING set the pending due
363 /// time; an ARM from a read stamps the read-poll time.
364 /// @param device_id Registered device the frame concerns.
365 /// @param kind Request to the device, or the device's challenge to another controller.
366 /// @param controller The other controller: the request's source, or the challenge's destination.
367 /// @param cmd Command of the frame.
368 /// @param now Current millis().
369 /// @param last_status_ms millis() of the device's last authenticated status; 0 = never.
370 ForeignAction on_foreign_frame(const std::string &device_id, ForeignFrameKind kind, const std::string &controller,
371 uint8_t cmd, uint32_t now, uint32_t last_status_ms);
372 /// The poll armed by foreign traffic ran (or was dropped): nothing is pending any more.
373 void on_remote_poll_ran(const std::string &device_id);
374 /// True while a poll armed by foreign traffic is pending. A pending poll whose signal was lost
375 /// expires by itself once @p now is more than REMOTE_ACTIVITY_STATUS_POLL_DELAY_MS past its due time.
376 [[nodiscard]] bool is_remote_poll_pending(const std::string &device_id, uint32_t now) const;
377
378 // --- Tracking that ran out ---
379 /// Return and consume a device whose polling window ended without the device being reported at
380 /// rest: the window expired with a poll still pending (pop_due_device()), or the failure ladder no
381 /// longer fits in it (on_exchange_failed()). An explicit clear() — a stopped report, a failed
382 /// build — is not "ran out" and is never returned. Returns nullopt when there is none.
383 [[nodiscard]] std::optional<std::string> pop_ended_device();
384
385 // --- Catch-up read after linked-remote activity (ADR 0080) ---
386 /// A linked remote was heard: open a burst, or keep the open one. Only the exchanges that end
387 /// while it is open set its due time (on_exchange_ended()), so a burst during which the hub never
388 /// used the radio never comes due.
390 /// An exchange ended at @p now. While a burst is open its catch-up becomes due @p delay_ms after
391 /// @p now, later or earlier than before; a @p delay_ms of 0 (the catch-up is switched off) closes
392 /// the burst instead. No-op without an open burst.
393 void on_exchange_ended(uint32_t now, uint32_t delay_ms);
394 /// True once when the open burst's catch-up is due at @p now. The burst is closed first, so the
395 /// catch-up's own reads, which are exchanges too, do not reopen it. Wrap-safe across a millis() rollover.
396 [[nodiscard]] bool take_due_catchup(uint32_t now);
397 /// Mark the device's next status read as a catch-up read: a press the hub missed may have set it
398 /// moving since its last report, so the read treats its state as unknown
399 /// (decisions::AttemptRequest::with_state_unknown()). Needs no tracked device; begin_tracking() and
400 /// clear() drop the mark, since heard activity or a report is news again.
401 void mark_state_unknown(const std::string &device_id);
402 /// Consume the mark set by mark_state_unknown(): true once, for the status read dispatched after it.
403 [[nodiscard]] bool take_state_unknown(const std::string &device_id);
404
405 private:
406 std::map<std::string, PollTracking> tracking_;
407 bool catchup_burst_open_{false}; ///< A linked remote was heard and its catch-up has not run yet.
408 uint32_t catchup_due_ms_{0}; ///< When the open burst's catch-up is due; 0 = no exchange has ended in it yet.
409 std::set<std::string> state_unknown_; ///< Devices whose next status read is a catch-up read.
410 std::map<std::string, ForeignTraffic> foreign_; ///< Overheard-traffic state, outside the tracking lifecycle.
411 std::vector<std::string> ended_; ///< Devices whose window ran out, for pop_ended_device(); no duplicates.
412
413 /// Record that a device's window ran out, once.
414 void note_ended_(const std::string &device_id);
415
416 /// Compute retry delay from failure count and failure class.
417 static uint32_t retry_delay_ms(uint8_t consecutive_failures, bool auth_like);
418};
419
420} // namespace home_io_control
421} // namespace esphome
Per-hub poll scheduling and failure-backoff policy.
void mark_stop_settle(const std::string &device_id)
Mark the device's next poll as the one reading where an accepted STOP left it, so it is a settling re...
uint32_t frozen_count_delay_ms(const std::string &device_id) const
How long the next progress read waits when the device's remaining time has stopped falling.
bool take_settling_read(const std::string &device_id)
Consume the marks (none short-circuits another) for the poll being dispatched.
ForeignAction on_foreign_frame(const std::string &device_id, ForeignFrameKind kind, const std::string &controller, uint8_t cmd, uint32_t now, uint32_t last_status_ms)
Decide what an overheard frame of another controller means for this device.
void on_exchange_ended(uint32_t now, uint32_t delay_ms)
An exchange ended at now.
bool take_state_unknown(const std::string &device_id)
Consume the mark set by mark_state_unknown(): true once, for the status read dispatched after it.
void set_interval(const std::string &device_id, uint32_t interval_ms)
Set the configured follow-up poll interval (ms; 0 = legacy one-shot settle only).
void set_confirm_stopped(const std::string &device_id, bool needed)
Mark (or unmark) the device's next poll as the one confirming a stop off target, so it is a settling ...
bool is_tracking_active(const std::string &device_id, uint32_t now) const
True when the device has an active bounded polling window (deadline set and not expired).
uint32_t get_next_update(const std::string &device_id) const
MovingReportAction on_moving_report(const std::string &device_id, uint32_t now, bool may_open)
Decide what a status report that reads as moving means for the follow-up window.
uint32_t get_interval(const std::string &device_id) const
Return the configured interval (0 when not set or unconfigured).
void clear_failure_streaks(const std::string &device_id)
Reset failure streaks (and skipped moving misses) after a successful exchange response.
void on_remote_poll_ran(const std::string &device_id)
The poll armed by foreign traffic ran (or was dropped): nothing is pending any more.
void on_linked_remote_activity()
A linked remote was heard: open a burst, or keep the open one.
void set_next_update(const std::string &device_id, uint32_t abs_time)
Schedule the next poll at an absolute millis() timestamp.
uint32_t on_exchange_failed(const std::string &device_id, bool auth_like, uint32_t now, bool device_moving=false)
Record a failed background poll; apply backoff or clear tracking if the window expired.
bool take_confirm_stopped(const std::string &device_id)
Consume the mark set by set_confirm_stopped(): true once, for the first poll dispatched after it.
uint8_t get_moving_poll_misses(const std::string &device_id) const
Consecutive failed polls of a moving device skipped so far.
void begin_tracking(const std::string &device_id, uint32_t initial_delay_ms, uint32_t now)
Begin bounded follow-up polling after a command or remote activity.
uint8_t get_status_poll_failures(const std::string &device_id) const
bool take_due_catchup(uint32_t now)
True once when the open burst's catch-up is due at now.
void on_trusted_report(const std::string &device_id, std::optional< uint16_t > remaining_s, uint32_t now)
Aim or re-aim the predicted-end read from a trusted status report of a moving device.
void mark_state_unknown(const std::string &device_id)
Mark the device's next status read as a catch-up read: a press the hub missed may have set it moving ...
uint8_t get_auth_poll_failures(const std::string &device_id) const
uint32_t get_poll_deadline(const std::string &device_id) const
bool take_stop_settle(const std::string &device_id)
Consume the mark set by mark_stop_settle(): true once, for the first poll dispatched after it.
std::optional< std::string > pop_ended_device()
Return and consume a device whose polling window ended without the device being reported at rest: the...
void clear(const std::string &device_id)
Stop all polling for a device and reset failure counts. Preserves interval_ms.
bool is_remote_poll_pending(const std::string &device_id, uint32_t now) const
True while a poll armed by foreign traffic is pending.
uint32_t get_settle_update(const std::string &device_id) const
The planned predicted-end read's timestamp (0 = none).
std::optional< std::string > pop_due_device(uint32_t now)
Return and consume the first device with a poll overdue.
bool is_predicted_end_read(const std::string &device_id) const
True when the next poll is the predicted-end read (the mark take_settling_read() consumes).
static constexpr uint32_t STATUS_RETRY_AFTER_FAIL_STEP4_MS
Fourth retry after a silent failure.
static constexpr uint32_t ONEWAY_QUIET_PERIOD_MS
Hold queued background polls back for this long after any 1W frame, so a poll the hub itself schedule...
static constexpr uint32_t FOREIGN_READ_RESYNC_MS
How long a device's authenticated status or this hub's own read-triggered poll keeps a further overhe...
static constexpr uint32_t FOREIGN_CHALLENGE_AFTER_REQUEST_MS
A device challenges a request 2.5-3.3 ms after it; 100 ms is far above that and far below the next re...
ForeignFrameKind
What an overheard frame of another controller is.
@ REQUEST_TO_DEVICE
Another controller's command to a registered device.
@ DEVICE_CHALLENGE_TO_OTHER
A registered device's challenge (0x3C) addressed to another controller.
static constexpr uint32_t STATUS_AUTH_RETRY_AFTER_FAIL_MAX_MS
Steady-state after repeated auth failures.
static constexpr uint32_t ONEWAY_POLL_DEFER_CAP_MS
Hard cap on how long sustained 1W traffic may hold a background poll back in total,...
static constexpr uint32_t DEFAULT_SETTLE_POLL_DELAY_MS
Default follow-up settle-poll delay used while a device may still be moving, when no explicit poll in...
static constexpr uint32_t PREDICTED_END_MARGIN_MS
static constexpr uint32_t STATUS_RETRY_AFTER_FAIL_STEP3_MS
Third retry after a silent failure.
static constexpr uint32_t FROZEN_COUNT_BACKOFF_FIRST_MS
Delay after the first frozen report.
static constexpr uint32_t FROZEN_COUNT_BACKOFF_SECOND_MS
Delay after the second.
static constexpr uint32_t MAX_TRACKED_STATUS_POLL_WINDOW_MS
Hard stop for bounded follow-up polling after a command, remote activity or the first moving report,...
static constexpr uint32_t PREDICTED_END_DROPS_PROGRESS_WITHIN_MS
A progress read due within this long before the predicted-end read is dropped: the settling read foll...
static constexpr uint32_t HEATING_POST_WRITE_POLL_DELAY_MS
Delay of the extra heater poll after a register write the heater acknowledged.
static constexpr uint8_t PREDICTED_END_STALE_REPORTS
Reports in a row whose remaining time did not fall below the previous one before the count is taken a...
static constexpr uint32_t STATUS_RETRY_AFTER_FAIL_MS
First retry after a silent failure.
static constexpr uint32_t STATUS_RETRY_AFTER_FAIL_MAX_MS
Steady-state backoff after many silent failures.
static constexpr uint32_t FROZEN_COUNT_BACKOFF_MAX_MS
Delay after the third and every later one.
static constexpr uint32_t STATUS_AUTH_RETRY_AFTER_FAIL_STEP3_MS
Third retry.
uint32_t settle_delay_ms(uint32_t interval_ms, uint32_t remaining_delay_ms, bool cap_for_stop)
Resolve the follow-up settle-poll delay while a device may still be moving.
static constexpr uint32_t INITIAL_STATUS_REQUEST_DELAY_MS
Delay before the first post-boot status request from an entity.
static constexpr uint32_t STATUS_AUTH_RETRY_AFTER_FAIL_STEP2_MS
Second retry.
static constexpr uint32_t STATUS_AUTH_RETRY_AFTER_FAIL_MS
First retry after a challenge-seen failure.
static constexpr uint32_t STOP_SETTLE_POLL_CAP_MS
Upper bound on the settle-poll delay after a STOP command.
MovingReportAction
What the hub does with a status report that reads as moving.
@ FOLLOW
A window is open: schedule the next poll as usual.
@ END
The window ran out: it is closed and reported ended; no further poll.
static constexpr uint8_t MOVING_POLL_MISSES_SKIPPED
Consecutive failed polls of a moving device that are skipped rather than backed off.
static constexpr uint32_t STATUS_RETRY_AFTER_FAIL_STEP2_MS
Second retry after a silent failure.
static constexpr uint32_t HEATING_POLL_DEFAULT_INTERVAL_MS
Interval between regular polls of a heater (its status_poll_interval overrides it).
ForeignAction
What the hub does about an overheard foreign frame.
@ PUSH_PENDING
A poll armed by foreign traffic is pending: move its timer out, change nothing else.
@ ARM
Schedule a poll REMOTE_ACTIVITY_STATUS_POLL_DELAY_MS from now and begin tracking.
Physical-layer radio and timing parameters for the IO-Homecontrol protocol.
Per-device bookkeeping of overheard foreign traffic.
std::string last_request_from
Controller that sent it (frame source); empty = none heard.
uint32_t last_request_ms
When it was heard; 0 = never.
uint32_t pending_due_ms
When the armed poll is due; 0 = none pending.
uint8_t last_request_cmd
Command of the last request heard to this device.
uint32_t last_read_poll_ms
When a read last armed a poll; 0 = never.
Per-device poll scheduling state.
uint8_t auth_poll_failures
Consecutive background poll failures that saw a 0x3C challenge.
uint32_t settle_update
Absolute millis() timestamp of the predicted-end read; 0 = none planned.
bool confirm_stopped
The next poll confirms a stop off target (a settling read, see decisions::StoppedReport::SETTLING).
bool stop_settle
The next poll reads where an accepted STOP left the device (a settling read).
uint8_t status_poll_failures
Consecutive background poll failures (silent — no reply).
uint32_t poll_deadline
Hard stop for bounded follow-up polling.
uint8_t stale_reports
Reports in a row whose remaining time did not fall.
bool predicted_end_read
The next poll is the predicted-end read (a settling read).
std::optional< uint16_t > last_remaining_s
Remaining time of the last report that was aimed at (> 2 s).
uint32_t interval_ms
Configured follow-up poll interval (0 = no configured cadence; delays fall back to remaining time / d...
uint8_t moving_poll_misses
Consecutive failed polls of a moving device skipped so far (see MOVING_POLL_MISSES_SKIPPED); not coun...
uint32_t next_update
Absolute millis() timestamp for the next poll; 0 = idle.