ESPHome 2026.9.0-dev
Loading...
Searching...
No Matches
bk72xx_ble_tracker.cpp
Go to the documentation of this file.
1// bk72xx_ble_tracker.cpp
2//
3// BLE scan policy for the BK72xx BLE-5.x chips: parameters, duration/period
4// timers and the rate-limited start retry. All controller access (stack
5// bring-up, scan primitives, the BLE-task → main-task report queue) goes
6// through the bk72xx_ble component — no SDK calls and no cross-task state here.
7
8#ifdef USE_LIBRETINY
9
10#include "bk72xx_ble_tracker.h"
11
12#include <cinttypes>
13
15#include "esphome/core/log.h"
16
18
19static const char *const TAG = "bk72xx_ble_tracker";
20
21// Minimum interval between scan (re)start attempts, so a failing controller start
22// cannot be retried every main-loop iteration (single-core CPU starvation). The
23// interval doubles with consecutive failed starts (1 s up to 64 s) so a controller
24// that never comes up — the controller logs each failure at ERROR — settles into a
25// slow, quiet poll instead of an error line every second for the rest of uptime;
26// a single WARN is emitted when the retry interval first saturates.
27static constexpr uint32_t SCAN_START_RETRY_MS = 1000;
28static constexpr uint8_t SCAN_START_RETRY_MAX_DOUBLINGS = 6; // 1 s << 6 = 64 s
29// Stable-run time before the failure streak clears; reset-on-start would keep
30// a flapping controller at the 1 s gate.
31static constexpr uint32_t SCAN_STABLE_RESET_MS = 30000;
32
33// Radio-idle deadline for the bounded stop drain at OTA start.
34static constexpr uint32_t OTA_STOP_FLUSH_MS = 100;
35
36// 0.625 ms BLE units; integer math avoids soft-float on this FPU-less part.
37constexpr uint32_t ble_units_to_ms(uint32_t units) { return units * 5 / 8; }
38
39// ---------------------------------------------------------------------------
40// Component lifecycle
41// ---------------------------------------------------------------------------
42
44 // Receive the controller's scan reports; the controller queues them from the
45 // BLE task and delivers here on the main task.
47 // Merged (and unmerged) frames go to the shared dispatcher; unclaimed
48 // devices are logged only on one-shot scans (continuous would spam).
49 this->merger_.bind(&this->dispatcher_, &this->scan_continuous_, TAG);
50#ifdef USE_OTA_STATE_LISTENER
51 // Pause scanning while an OTA update is in flight — on the single-core BK72xx the
52 // BLE scan competes with the OTA flash writes. Mirrors esp32_ble_tracker.
54#endif
55 // scan_requested_ check: an on_boot start_scan latched before this setup()
56 // must keep the retry loop running (rp2/ln882h parity).
57 if (!this->scan_continuous_ && !this->scan_requested_) {
58 // Nothing to time until an explicit start_scan(); it re-enables the loop.
59 this->disable_loop();
60 }
61}
62
63#ifdef USE_OTA_STATE_LISTENER
64void BK72xxBLETracker::on_ota_global_state(ota::OTAState state, float progress, uint8_t error,
65 ota::OTAComponent *comp) {
66 if (state == ota::OTA_STARTED) {
69 this->stop_scan();
70 // The transfer starves the loop; a deferred stop would leave the radio
71 // scanning for the whole update, so drain it here, bounded.
72 if (!this->parent_->flush_pending_stop(OTA_STOP_FLUSH_MS)) {
73 ESP_LOGE(TAG, "Scan still stopping at OTA start; the radio may contend with the update");
74 }
75 } else if (state == ota::OTA_ERROR || state == ota::OTA_ABORT) {
76 // On success the device reboots, so restore only on a failed/aborted update;
77 // loop() restarts the scan on its next iteration (continuous idle branch).
79 this->scan_continuous_before_ota_ = false;
80 this->scan_continuous_ = true;
81 this->enable_loop(); // stop_scan() parked it
82 }
83 // A one-shot request that was still pending (latched, retrying) when the
84 // OTA paused scanning is re-latched, not dropped — loop() resumes the retry.
86 this->scan_requested_before_ota_ = false;
87 this->scan_requested_ = true;
88 this->enable_loop();
89 }
90 }
91}
92#endif // USE_OTA_STATE_LISTENER
93
96
97 // Deliver held scannable advertisements whose scan response never arrived —
98 // unmerged after the merger's timeout.
99 if (!this->merger_.empty())
100 this->merger_.sweep(now);
101
102 // Before the drop branch: a drop after a stable run starts a fresh streak.
103 if (this->scan_running_ && this->failed_start_count_ != 0 && now - this->scan_start_time_ >= SCAN_STABLE_RESET_MS)
104 this->failed_start_count_ = 0;
105
106 // A terminal failure while we report running recovers via the normal retry
107 // path; the drop charges the backoff so a flapping controller escalates.
109 ESP_LOGW(TAG, "Controller scan lost; retrying");
110 this->scan_requested_ = true;
111 this->count_failed_start_();
112 this->mark_scan_ended_(now);
113 }
114
115 if (this->scan_continuous_) {
116 if (!this->scan_running_) {
117 // One-iteration deferral; all stamps share this iteration's cached
118 // timestamp, so the period check below cannot underflow.
119 if (this->try_start_with_backoff_(now))
120 return;
121 }
122 // Period timer: fire on_scan_end() once per scan_duration_ window, mirroring
123 // esp32_ble_tracker::cleanup_scan_state_(). Gated on scan_started_once_ so a scan
124 // that never came up (start kept failing) does not fire spurious on_scan_end events.
125 if (this->scan_started_once_ && now - this->scan_period_start_ >= this->scan_duration_) {
126 this->fire_scan_end_();
127 this->scan_period_start_ = now;
128 }
129 return;
130 }
131
132 // Non-continuous mode: run for scan_duration_ ms, then stop and fire on_scan_end.
133 // Restart is driven externally (e.g. api: on_client_connected:).
134 //
135 // A requested start that failed (same controller failures the continuous branch
136 // absorbs) is retried with the same backoff — otherwise a failed one-shot start
137 // would be silent: the scan never runs, stop_scan_() is never reached and
138 // on_scan_end() never fires, leaving period-keyed consumers waiting forever.
139 if (this->scan_requested_ && !this->scan_running_) {
140 // Same one-iteration deferral as the continuous branch.
141 if (this->try_start_with_backoff_(now))
142 return;
143 }
144 if (this->scan_running_ && now - this->scan_start_time_ >= this->scan_duration_) {
145 // A full-duration run proves the controller healthy even when duration is
146 // shorter than SCAN_STABLE_RESET_MS.
147 this->failed_start_count_ = 0;
148 this->stop_scan_();
149 }
150}
151
153 // Rate-limit (re)start attempts. The controller start can fail (no idle activity
154 // handle, WiFi/BLE coexistence) and leave scan_running_ false; retrying every
155 // main-loop iteration would spin the single-core CPU and starve WiFi (device
156 // becomes unresponsive). The interval backs off with consecutive failures so a
157 // controller that never comes up polls slowly and quietly.
158 //
159 // force bypasses the gate for an explicit user start (start_scan()) — but
160 // only while the failure streak is clean. Once the controller is failing,
161 // even user-initiated attempts respect the backoff, so a start_scan() action
162 // on a short cadence cannot hammer a failing controller; the attempt stays
163 // inside the failure accounting below either way.
164 // Mid bring-up, observe instead of re-issuing (the hub self-advances). A
165 // SETTLED outcome completes immediately; only fresh attempts after FAILED
166 // are rate-limited.
167 const auto hub = this->parent_->last_scan_result();
169 return false;
171 if (this->start_attempt_open_) {
172 // Our bring-up gave up asynchronously; charge it to the backoff.
173 this->start_attempt_open_ = false;
174 this->count_failed_start_();
175 }
176 if ((!force || this->failed_start_count_ != 0) &&
177 now - this->last_scan_start_attempt_ < (SCAN_START_RETRY_MS << this->failed_start_count_))
178 return false;
179 }
180 this->start_scan_();
181 if (!this->scan_running_) {
183 this->start_attempt_open_ = true;
184 return false; // the controller is still bringing the scan up; not a failure
185 }
186 this->count_failed_start_();
187 }
188 return this->scan_running_;
189}
190
192 if (this->failed_start_count_ < SCAN_START_RETRY_MAX_DOUBLINGS) {
193 ++this->failed_start_count_;
194 if (this->failed_start_count_ == SCAN_START_RETRY_MAX_DOUBLINGS) {
195 ESP_LOGW(TAG, "Scan start keeps failing; retrying every %" PRIu32 " s",
196 (SCAN_START_RETRY_MS << SCAN_START_RETRY_MAX_DOUBLINGS) / 1000);
197 }
198 }
199}
200
202 ESP_LOGCONFIG(TAG,
203 "BK72xx BLE Tracker:\n"
204 " Scan Duration: %" PRIu32 " s\n"
205 " Scan Interval: %" PRIu32 " ms (%" PRIu32 " BLE units)\n"
206 " Scan Window: %" PRIu32 " ms (%" PRIu32 " BLE units)\n"
207 " Scan Type: %s (configured %s)\n"
208 " Continuous Scanning: %s",
210 ble_units_to_ms(this->scan_window_), this->scan_window_, this->scan_active_ ? "ACTIVE" : "PASSIVE",
211 this->scan_active_configured_ ? "ACTIVE" : "PASSIVE", YESNO(this->scan_continuous_));
212}
213
214// ---------------------------------------------------------------------------
215// Scan report — delivered by the controller's loop() on the ESPHome main task
216// (the controller queues reports from the BLE task), so publish_state() and
217// listener dispatch run in main-loop context with no cross-task handling here.
218// ---------------------------------------------------------------------------
219
220// GAPM report info byte (BLEScanReport::evt_type): bits 0-2 report type,
221// bit 5 scannable advertisement. Verified against both BDK stacks (5.1 and
222// 5.2 fill it from gapm_ext_adv_report_ind.info).
223static constexpr uint8_t GAPM_REPORT_TYPE_MASK = 0x07;
224static constexpr uint8_t GAPM_REPORT_TYPE_SCAN_RSP_EXT = 2;
225static constexpr uint8_t GAPM_REPORT_TYPE_SCAN_RSP_LEG = 3;
226static constexpr uint8_t GAPM_REPORT_INFO_SCAN_ADV_BIT = 1 << 5;
227
228// Demux advertisements vs scan responses into the shared merger: the BDK
229// delivers the pair as separate reports; a scannable advertisement is held
230// until its scan response arrives and delivered as one merged frame.
232 const uint8_t rtype = report.evt_type & GAPM_REPORT_TYPE_MASK;
233 if (rtype == GAPM_REPORT_TYPE_SCAN_RSP_LEG || rtype == GAPM_REPORT_TYPE_SCAN_RSP_EXT) {
234 this->merger_.submit_scan_rsp(report.mac, report.rssi, report.addr_type, report.data, report.data_len);
235 return;
236 }
237 // Stash only while an active scan runs: a passive scan never gets a
238 // response, and after a stop nothing would sweep the merger, so a late
239 // report would surface minutes later as a fresh advertisement.
240 if (this->scan_running_ && this->scan_active_ && (report.evt_type & GAPM_REPORT_INFO_SCAN_ADV_BIT)) {
241 this->merger_.stash_adv(report.mac, report.rssi, report.addr_type, report.data, report.data_len,
243 return;
244 }
245 this->dispatcher_.dispatch(report.mac, report.rssi, report.addr_type, report.data, report.data_len,
246 /*raw_only=*/false, this->scan_continuous_ ? nullptr : TAG);
247}
248
249// ---------------------------------------------------------------------------
250// Public scan control
251// ---------------------------------------------------------------------------
252
254 // Mirrors esp32_ble_tracker::start_scan(): caller sets scan_continuous_ via
255 // set_scan_continuous() first, then calls start_scan() to begin scanning.
256 //
257 // Nothing to do while a scan is already running: latching here would leave
258 // scan_requested_ set after that scan ends and silently restart a one-shot
259 // scan nobody asked for.
260 if (this->scan_running_)
261 return;
262
263 // The request is latched: if this immediate attempt fails (controller busy,
264 // WiFi/BLE coexistence), loop() keeps retrying it with backoff even in
265 // non-continuous mode, so a one-shot start cannot fail silently.
266 //
267 // Routed through the backoff helper (forced: the user asked for an immediate
268 // attempt) so a failure here still counts toward the backoff escalation and
269 // its WARN. The force bypass only applies while the failure streak is clean —
270 // against a failing controller, repeated start_scan() calls are rate-limited
271 // like any other attempt.
272 this->scan_requested_ = true;
273 this->enable_loop(); // an idle one-shot tracker parked it in stop_scan_()
275}
276
278 if (!this->scan_running_)
279 return;
280 // Re-anchor only the one-shot duration clock. scan_period_start_ (the
281 // continuous-mode on_scan_end period) is deliberately left alone: a
282 // start_scan action fired more often than scan_duration_ would otherwise
283 // suppress on_scan_end indefinitely — and absence detection (ble_rssi's NAN
284 // publish) rides on that period.
286}
287
289 this->scan_continuous_ = false;
290 this->scan_requested_ = false; // also cancels a pending (not yet successful) start
291 this->stop_scan_();
292}
293
294// ---------------------------------------------------------------------------
295// Internal scan start / stop
296// ---------------------------------------------------------------------------
297
300 return this->parent_->scan_start(static_cast<uint16_t>(this->scan_interval_),
301 static_cast<uint16_t>(this->scan_window_), this->scan_active_);
302}
303
305 if (this->scan_running_)
306 return;
307
309 return;
310
312 this->scan_running_ = true;
313 this->scan_requested_ = false; // the latched one-shot request is satisfied
314 this->start_attempt_open_ = false;
315 // failed_start_count_ deliberately not reset here; only a stable run clears it (loop()).
316 this->scan_start_time_ = now;
317 // Log every explicit start at DEBUG — stop_scan_() logs every stop at DEBUG, and
318 // in non-continuous mode each period is an explicit start, so asymmetric logging
319 // would read as the scanner failing to come back up.
320 ESP_LOGD(TAG, "Scan started (%s, window=%" PRIu32 "ms, interval=%" PRIu32 "ms)",
321 this->scan_active_ ? "active" : "passive", ble_units_to_ms(this->scan_window_),
323 // Re-anchor the on_scan_end period to every successful start — first start (so the
324 // period counts from the scan, not from boot) and every restart after a stop (so
325 // resuming after longer than scan_duration, e.g. a failed OTA restoring continuous
326 // mode 10 minutes later, does not fire on_scan_end before an advertisement can
327 // arrive). scan_started_once_ purely gates the period timer.
328 this->scan_period_start_ = now;
329 this->scan_started_once_ = true;
330}
331
332// Deliberate logical/physical split: on_scan_end() reports the tracker's
333// intent while the hub winds the radio down asynchronously; OTA is the one
334// path that must wait, and it flushes explicitly.
336 this->start_attempt_open_ = false; // an abandoned bring-up is not charged
337 this->parent_->scan_stop(); // idempotent: releases whatever the hub holds
338 if (this->scan_running_) {
339 ESP_LOGD(TAG, "Scan stopped");
341 }
342 // Park when idle (the hub drives its own teardown); re-check because an
343 // on_scan_end automation may have restarted the scan.
344 if (!this->scan_continuous_ && !this->scan_running_ && !this->scan_requested_)
345 this->disable_loop();
346}
347
348// The period re-anchor keeps on_scan_end from double-firing in one iteration.
350 this->scan_running_ = false;
351 this->fire_scan_end_();
352 this->scan_period_start_ = now;
353}
354
356 // Deliver held advertisements whose scan response never came (unmerged)
357 // BEFORE on_scan_end fires.
358 this->merger_.flush();
359 this->dispatcher_.on_scan_end();
360}
361
362// true = request latched, not applied: the reconciler applies it
363// asynchronously and loop() recovers a failed re-arm (ln882h parity).
365 if (this->scan_active_ == active)
366 return true;
367 this->scan_active_ = active;
368 // V: the proxy's "Setting scanner mode" line already narrates this at D.
369 ESP_LOGV(TAG, "Scan mode %s", active ? "active" : "passive");
370 // The controller reconciler restarts a running scan itself; the scan stays
371 // logically running. An idle scanner picks the mode up on its next start.
372 if (this->scan_running_)
374 return true;
375}
376
377} // namespace esphome::bk72xx_ble_tracker
378
379#endif // USE_LIBRETINY
uint32_t IRAM_ATTR HOT get_loop_component_start_time() const
Get the cached time in milliseconds from when the current component started its loop execution.
void enable_loop()
Enable this component's loop.
Definition component.h:246
void disable_loop()
Disable this component's loop.
ScanOpResult scan_start(uint16_t interval, uint16_t window, bool active)
Request a scan (interval/window in 0.625 ms BLE units); enables the stack first if needed.
ScanOpResult last_scan_result() const
Last reconciliation outcome; on FAILED the consumer's retry policy owns recovery.
Definition bk72xx_ble.h:107
void register_scan_listener(BLEScanListener *listener)
Register a consumer for scan reports (delivered on the main task via loop()).
Definition bk72xx_ble.h:92
bool flush_pending_stop(uint32_t timeout_ms)
Drive a requested stop until the radio is observed idle, bounded by timeout_ms (for OTA).
void scan_stop()
Request the scanner stopped and the activity released; steps that cannot run yet are completed from l...
void on_ota_global_state(ota::OTAState state, float progress, uint8_t error, ota::OTAComponent *comp) override
bool try_start_with_backoff_(uint32_t now, bool force=false)
Rate-limited (re)start; true when the scan is running (the caller must not reuse a now older than the...
void on_scan_report(const bk72xx_ble::BLEScanReport &report) override
void restart_scan_duration()
Re-anchor the one-shot duration clock of a running scan to now — used when an action changes the scan...
ble_device_base::ScanResponseMerger merger_
bk72xx_ble::ScanOpResult controller_scan_start_()
Stamp-and-start for every controller scan attempt, so the retry rate limit covers all callers.
void on_scan_end()
Fire listeners' on_scan_end and reset the per-scan discovered-log dedup.
void dispatch(const uint8_t *mac, int8_t rssi, uint8_t addr_type, const uint8_t *data, uint8_t data_len, bool raw_only, const char *log_unclaimed_tag)
Dispatch one (possibly merged) advertisement: the raw callback, and — unless raw_only — parsing for l...
void stash_adv(const uint8_t *mac, int8_t rssi, uint8_t addr_type, const uint8_t *data, uint8_t data_len, uint32_t now)
Hold a scannable advertisement, waiting for its scan response.
void sweep(uint32_t now)
Timeout flush (call from loop() with the stash_adv() clock): deliver held advertisements whose scan r...
void flush()
Deliver every held advertisement now (scan period/scan is ending, before on_scan_end fires): unmerged...
void submit_scan_rsp(const uint8_t *mac, int8_t rssi, uint8_t addr_type, const uint8_t *data, uint8_t data_len)
A scan response arrived: append it to the held advertisement from the same device and deliver the pai...
bool empty() const
Lets loop() skip the cross-TU sweep() call in the common case (empty: passive scan,...
void bind(AdvDispatcher *dispatcher, const bool *scan_continuous, const char *log_tag)
Wire the merger's output; call once in the tracker's setup().
void add_global_state_listener(OTAGlobalStateListener *listener)
bool state
Definition fan.h:2
constexpr uint32_t ble_units_to_ms(uint32_t units)
ScanOpResult
Outcome of one reconciliation step.
Definition bk72xx_ble.h:25
@ SETTLED
The request is reached: scan observed running, or stopped with the activity fully released.
@ FAILED
The controller rejected a step; retry later.
@ PENDING
A step is in flight; loop() keeps advancing — call scan_start() again to learn the outcome.
OTAGlobalCallback * get_global_ota_callback()
Application App
Global storage of Application pointer - only one Application can exist.
static void uint32_t
One advertisement report from the controller.
Definition bk72xx_ble.h:42
uint8_t mac[MAC_ADDRESS_SIZE]
Definition bk72xx_ble.h:43