Home IO Control
ESPHome add-on for IO-Homecontrol devices
Loading...
Searching...
No Matches
status_poll_policy.cpp
Go to the documentation of this file.
1/// @file status_poll_policy.cpp
2/// @brief StatusPollPolicy implementation.
3/// @ingroup hioc_hub
4
6
7#include "hub_decisions.h"
8
9#include <algorithm>
10#include <array>
11
12namespace esphome {
13namespace home_io_control {
14
15namespace {
16/// Stamp value for "now": millis() == 0 would read as "never", so it is nudged to 1.
17uint32_t stamp(uint32_t now) { return now == 0 ? 1 : now; }
18/// True when @p t has been reached at @p now (wrap-safe; @p t is assumed within half the range).
19bool reached(uint32_t now, uint32_t t) { return static_cast<int32_t>(now - t) > 0; }
20
21/// Forget everything about the predicted-end read: the aim, the count it came from and the mark.
22void drop_predicted_end(PollTracking &t) {
23 t.settle_update = 0;
24 t.last_remaining_s.reset();
25 t.stale_reports = 0;
26 t.predicted_end_read = false;
27}
28} // namespace
29
30void StatusPollPolicy::set_interval(const std::string &device_id, uint32_t interval_ms) {
31 tracking_[device_id].interval_ms = interval_ms;
32}
33
34uint32_t StatusPollPolicy::get_interval(const std::string &device_id) const {
35 auto it = tracking_.find(device_id);
36 return (it != tracking_.end()) ? it->second.interval_ms : 0;
37}
38
39void StatusPollPolicy::begin_tracking(const std::string &device_id, uint32_t initial_delay_ms, uint32_t now) {
40 state_unknown_.erase(device_id);
41 PollTracking &t = tracking_[device_id];
42 t.poll_deadline = now + MAX_TRACKED_STATUS_POLL_WINDOW_MS;
43 t.next_update = (initial_delay_ms == 0) ? 0 : (now + initial_delay_ms);
47 t.stop_settle = false;
48 t.confirm_stopped = false;
49 drop_predicted_end(t);
50}
51
52void StatusPollPolicy::clear(const std::string &device_id) {
53 state_unknown_.erase(device_id);
54 auto it = tracking_.find(device_id);
55 if (it == tracking_.end())
56 return;
57 PollTracking &t = it->second;
58 t.next_update = 0;
59 t.poll_deadline = 0;
63 t.stop_settle = false;
64 t.confirm_stopped = false;
65 drop_predicted_end(t);
66 // interval_ms is configuration — NOT cleared; remains set for future tracking cycles.
67}
68
69MovingReportAction StatusPollPolicy::on_moving_report(const std::string &device_id, uint32_t now, bool may_open) {
70 if (is_tracking_active(device_id, now))
72 if (get_poll_deadline(device_id) != 0) {
73 // The window ran out before this report: a final outcome, so it is closed rather than reopened.
74 note_ended_(device_id);
75 clear(device_id);
77 }
78 if (!may_open)
80 begin_tracking(device_id, 0, now);
82}
83
84void StatusPollPolicy::on_trusted_report(const std::string &device_id, std::optional<uint16_t> remaining_s,
85 uint32_t now) {
86 auto it = tracking_.find(device_id);
87 if (it == tracking_.end())
88 return;
89 PollTracking &t = it->second;
90 t.predicted_end_read = false;
91 if (!remaining_s.has_value()) {
92 drop_predicted_end(t);
93 return;
94 }
95 if (t.last_remaining_s.has_value() && *remaining_s >= *t.last_remaining_s) {
96 if (t.stale_reports < UINT8_MAX)
97 t.stale_reports++;
98 } else {
99 t.stale_reports = 0;
100 }
101 t.last_remaining_s = remaining_s;
102 if (t.stale_reports >= PREDICTED_END_STALE_REPORTS) {
103 t.settle_update = 0;
104 return;
105 }
106 const uint32_t aim = now + (*remaining_s * 1000U) + PREDICTED_END_MARGIN_MS;
107 // A read the window would not live to see is not planned: the progress reads and the window's own
108 // end handling stand.
109 const bool beyond_window = t.poll_deadline != 0 && static_cast<int32_t>(aim - t.poll_deadline) > 0;
110 t.settle_update = beyond_window ? 0 : stamp(aim);
111}
112
113uint32_t StatusPollPolicy::frozen_count_delay_ms(const std::string &device_id) const {
114 auto it = tracking_.find(device_id);
115 if (it == tracking_.end() || it->second.stale_reports < PREDICTED_END_STALE_REPORTS)
116 return 0;
117 switch (it->second.stale_reports - PREDICTED_END_STALE_REPORTS) {
118 case 0:
119 return FROZEN_COUNT_BACKOFF_FIRST_MS;
120 case 1:
121 return FROZEN_COUNT_BACKOFF_SECOND_MS;
122 default:
123 return FROZEN_COUNT_BACKOFF_MAX_MS;
124 }
125}
126
127uint32_t StatusPollPolicy::get_settle_update(const std::string &device_id) const {
128 auto it = tracking_.find(device_id);
129 return (it != tracking_.end()) ? it->second.settle_update : 0;
130}
131
132void StatusPollPolicy::set_next_update(const std::string &device_id, uint32_t abs_time) {
133 tracking_[device_id].next_update = abs_time;
134}
135
136uint32_t StatusPollPolicy::get_next_update(const std::string &device_id) const {
137 auto it = tracking_.find(device_id);
138 return (it != tracking_.end()) ? it->second.next_update : 0;
139}
140
141uint32_t StatusPollPolicy::get_poll_deadline(const std::string &device_id) const {
142 auto it = tracking_.find(device_id);
143 return (it != tracking_.end()) ? it->second.poll_deadline : 0;
144}
145
146bool StatusPollPolicy::is_tracking_active(const std::string &device_id, uint32_t now) const {
147 auto it = tracking_.find(device_id);
148 if (it == tracking_.end())
149 return false;
150 const PollTracking &t = it->second;
151 return t.poll_deadline != 0 && now <= t.poll_deadline;
152}
153
154uint32_t StatusPollPolicy::on_exchange_failed(const std::string &device_id, bool auth_like, uint32_t now,
155 bool device_moving) {
156 auto it = tracking_.find(device_id);
157 if (it == tracking_.end())
158 return 0;
159 PollTracking &t = it->second;
160 // A one-off poll of an untracked device fails here too; only a window that was open can run out.
161 const bool was_tracking = t.poll_deadline != 0;
162
163 // A device believed travelling keeps its normal cadence for its first misses rather than leave a
164 // gap (MOVING_POLL_MISSES_SKIPPED), but never waits longer than the ladder's first step would.
165 // Only a miss outside a failure streak is skipped: a streak already runs the ladder.
166 if (device_moving && t.moving_poll_misses < MOVING_POLL_MISSES_SKIPPED && t.status_poll_failures == 0 &&
167 t.auth_poll_failures == 0 && is_tracking_active(device_id, now)) {
168 const uint32_t delay_ms =
169 std::min(settle_delay_ms(t.interval_ms, 0, /*cap_for_stop=*/false), STATUS_RETRY_AFTER_FAIL_MS);
170 if (now + delay_ms <= t.poll_deadline) {
172 t.next_update = now + delay_ms;
173 return delay_ms;
174 }
175 }
176
177 uint32_t backoff_ms;
178 if (auth_like) {
180 if (t.auth_poll_failures < UINT8_MAX)
182 backoff_ms = retry_delay_ms(t.auth_poll_failures, true);
183 } else {
184 t.auth_poll_failures = 0;
185 if (t.status_poll_failures < UINT8_MAX)
187 backoff_ms = retry_delay_ms(t.status_poll_failures, false);
188 }
189
190 if (is_tracking_active(device_id, now) && now + backoff_ms <= t.poll_deadline) {
191 t.next_update = now + backoff_ms;
192 return backoff_ms;
193 }
194
195 if (was_tracking)
196 note_ended_(device_id);
197 clear(device_id);
198 return 0;
199}
200
201void StatusPollPolicy::clear_failure_streaks(const std::string &device_id) {
202 auto it = tracking_.find(device_id);
203 if (it == tracking_.end())
204 return;
205 it->second.status_poll_failures = 0;
206 it->second.auth_poll_failures = 0;
207 it->second.moving_poll_misses = 0;
208}
209
210void StatusPollPolicy::mark_stop_settle(const std::string &device_id) {
211 auto it = tracking_.find(device_id);
212 if (it != tracking_.end())
213 it->second.stop_settle = true;
214}
215
216bool StatusPollPolicy::take_stop_settle(const std::string &device_id) {
217 auto it = tracking_.find(device_id);
218 if (it == tracking_.end())
219 return false;
220 const bool marked = it->second.stop_settle;
221 it->second.stop_settle = false;
222 return marked;
223}
224
225void StatusPollPolicy::set_confirm_stopped(const std::string &device_id, bool needed) {
226 auto it = tracking_.find(device_id);
227 if (it != tracking_.end())
228 it->second.confirm_stopped = needed;
229}
230
231bool StatusPollPolicy::take_confirm_stopped(const std::string &device_id) {
232 auto it = tracking_.find(device_id);
233 if (it == tracking_.end())
234 return false;
235 const bool marked = it->second.confirm_stopped;
236 it->second.confirm_stopped = false;
237 return marked;
238}
239
240bool StatusPollPolicy::is_predicted_end_read(const std::string &device_id) const {
241 auto it = tracking_.find(device_id);
242 return it != tracking_.end() && it->second.predicted_end_read;
243}
244
245bool StatusPollPolicy::take_settling_read(const std::string &device_id) {
246 const bool after_stop = take_stop_settle(device_id);
247 const bool confirms_stop = take_confirm_stopped(device_id);
248 bool predicted_end = false;
249 auto it = tracking_.find(device_id);
250 if (it != tracking_.end()) {
251 predicted_end = it->second.predicted_end_read;
252 it->second.predicted_end_read = false;
253 }
254 return after_stop || confirms_stop || predicted_end;
255}
256
257uint8_t StatusPollPolicy::get_status_poll_failures(const std::string &device_id) const {
258 auto it = tracking_.find(device_id);
259 return (it != tracking_.end()) ? it->second.status_poll_failures : 0;
260}
261
262uint8_t StatusPollPolicy::get_auth_poll_failures(const std::string &device_id) const {
263 auto it = tracking_.find(device_id);
264 return (it != tracking_.end()) ? it->second.auth_poll_failures : 0;
265}
266
267uint8_t StatusPollPolicy::get_moving_poll_misses(const std::string &device_id) const {
268 auto it = tracking_.find(device_id);
269 return (it != tracking_.end()) ? it->second.moving_poll_misses : 0;
270}
271
272namespace {
273/// True when @p t is 0 (never) or at least @p span_ms old (wrap-safe).
274bool older_than(uint32_t now, uint32_t t, uint32_t span_ms) { return t == 0 || now - t >= span_ms; }
275} // namespace
276
277bool StatusPollPolicy::is_remote_poll_pending(const std::string &device_id, uint32_t now) const {
278 auto it = foreign_.find(device_id);
279 if (it == foreign_.end() || it->second.pending_due_ms == 0)
280 return false;
281 // Wrap-safe: before the due time `now - due` is a huge unsigned value (a negative distance), so
282 // it is still pending; more than the delay past the due time means the "ran" signal was lost.
283 const uint32_t since_due = now - it->second.pending_due_ms;
284 return since_due > INT32_MAX || since_due <= REMOTE_ACTIVITY_STATUS_POLL_DELAY_MS;
285}
286
287void StatusPollPolicy::on_remote_poll_ran(const std::string &device_id) {
288 auto it = foreign_.find(device_id);
289 if (it != foreign_.end())
290 it->second.pending_due_ms = 0;
291}
292
294 const std::string &controller, uint8_t cmd, uint32_t now,
295 uint32_t last_status_ms) {
296 ForeignTraffic &f = foreign_[device_id];
297 const bool pending = is_remote_poll_pending(device_id, now);
298 const ForeignTraffic before = f;
299
301 f.last_request_cmd = cmd;
302 f.last_request_from = controller;
303 f.last_request_ms = stamp(now);
304 }
305
306 if (pending) {
307 f.pending_due_ms = stamp(now + REMOTE_ACTIVITY_STATUS_POLL_DELAY_MS);
309 }
310
311 bool arm;
314 arm = true;
315 } else {
316 arm = older_than(now, last_status_ms, FOREIGN_READ_RESYNC_MS) &&
317 older_than(now, f.last_read_poll_ms, FOREIGN_READ_RESYNC_MS);
318 if (arm)
319 f.last_read_poll_ms = stamp(now);
320 }
321 } else {
322 // A challenge answers the request just before it: ignore it only when that request, from this
323 // same controller, was a read.
324 const bool follows_read = before.last_request_ms != 0 && before.last_request_from == controller &&
325 now - before.last_request_ms <= FOREIGN_CHALLENGE_AFTER_REQUEST_MS &&
327 arm = !follows_read;
328 }
329
330 if (!arm)
332 f.pending_due_ms = stamp(now + REMOTE_ACTIVITY_STATUS_POLL_DELAY_MS);
333 return ForeignAction::ARM;
334}
335
336std::optional<std::string> StatusPollPolicy::pop_due_device(uint32_t now) {
337 for (auto &pair : tracking_) {
338 PollTracking &t = pair.second;
339 const bool predicted_end_due = t.settle_update != 0 && reached(now, t.settle_update);
340 const bool progress_due = t.next_update != 0 && now > t.next_update;
341 if (!predicted_end_due && !progress_due)
342 continue;
343 if (!is_tracking_active(pair.first, now)) {
344 if (t.poll_deadline != 0)
345 note_ended_(pair.first);
346 clear(pair.first);
347 continue;
348 }
349 if (predicted_end_due) {
350 t.settle_update = 0;
351 t.next_update = 0;
352 t.predicted_end_read = true;
353 return pair.first;
354 }
355 // A progress read right in front of the predicted-end read is not worth its own exchange.
356 if (t.settle_update != 0 &&
357 static_cast<int32_t>(t.settle_update - now) <= static_cast<int32_t>(PREDICTED_END_DROPS_PROGRESS_WITHIN_MS)) {
358 t.next_update = 0;
359 continue;
360 }
361 t.next_update = 0;
362 return pair.first;
363 }
364 return std::nullopt;
365}
366
367std::optional<std::string> StatusPollPolicy::pop_ended_device() {
368 if (ended_.empty())
369 return std::nullopt;
370 std::string id = ended_.front();
371 ended_.erase(ended_.begin());
372 return id;
373}
374
375void StatusPollPolicy::on_linked_remote_activity() { catchup_burst_open_ = true; }
376
377void StatusPollPolicy::on_exchange_ended(uint32_t now, uint32_t delay_ms) {
378 if (!catchup_burst_open_)
379 return;
380 if (delay_ms == 0) {
381 catchup_burst_open_ = false;
382 catchup_due_ms_ = 0;
383 return;
384 }
385 catchup_due_ms_ = stamp(now + delay_ms);
386}
387
388void StatusPollPolicy::mark_state_unknown(const std::string &device_id) { state_unknown_.insert(device_id); }
389
390bool StatusPollPolicy::take_state_unknown(const std::string &device_id) { return state_unknown_.erase(device_id) != 0; }
391
393 // Wrap-safe: before the due time `now - due` is a huge unsigned value (a negative distance).
394 if (!catchup_burst_open_ || catchup_due_ms_ == 0 || now - catchup_due_ms_ > INT32_MAX)
395 return false;
396 catchup_burst_open_ = false;
397 catchup_due_ms_ = 0;
398 return true;
399}
400
401void StatusPollPolicy::note_ended_(const std::string &device_id) {
402 if (std::find(ended_.begin(), ended_.end(), device_id) == ended_.end())
403 ended_.push_back(device_id);
404}
405
406namespace {
407
408/// The two backoff ladders, one delay per consecutive failure; the last entry repeats.
409constexpr std::array<uint32_t, 5> SILENT_RETRY_LADDER_MS{
410 STATUS_RETRY_AFTER_FAIL_MS, STATUS_RETRY_AFTER_FAIL_STEP2_MS, STATUS_RETRY_AFTER_FAIL_STEP3_MS,
411 STATUS_RETRY_AFTER_FAIL_STEP4_MS, STATUS_RETRY_AFTER_FAIL_MAX_MS};
412constexpr std::array<uint32_t, 4> AUTH_RETRY_LADDER_MS{
413 STATUS_AUTH_RETRY_AFTER_FAIL_MS, STATUS_AUTH_RETRY_AFTER_FAIL_STEP2_MS, STATUS_AUTH_RETRY_AFTER_FAIL_STEP3_MS,
414 STATUS_AUTH_RETRY_AFTER_FAIL_MAX_MS};
415
416/// The delay for the @p consecutive_failures-th failure in a row (0 is read as 1) on @p ladder.
417template<size_t N> uint32_t ladder_step(const std::array<uint32_t, N> &ladder, uint8_t consecutive_failures) {
418 const size_t index = consecutive_failures <= 1 ? 0 : consecutive_failures - 1;
419 return ladder[std::min(index, N - 1)];
420}
421
422} // namespace
423
424uint32_t StatusPollPolicy::retry_delay_ms(uint8_t consecutive_failures, bool auth_like) {
425 return auth_like ? ladder_step(AUTH_RETRY_LADDER_MS, consecutive_failures)
426 : ladder_step(SILENT_RETRY_LADDER_MS, consecutive_failures);
427}
428
429} // namespace home_io_control
430} // namespace esphome
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).
Pure transition helpers for hub-owned exchange and pairing frame decisions.
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...
ForeignFrameKind
What an overheard frame of another controller is.
@ REQUEST_TO_DEVICE
Another controller's command to a registered device.
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.
MovingReportAction
What the hub does with a status report that reads as moving.
@ FOLLOW
A window is open: schedule the next poll as usual.
@ OPEN
No window: one was opened now; schedule the next poll as usual.
@ END
The window ran out: it is closed and reported ended; no further poll.
@ NONE
No window and none may be opened: no poll.
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.
@ IGNORE
Nothing: no poll, no tracking change.
Per-device poll scheduling, failure backoff, and follow-up-poll state machine.
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.