ESPHome 2026.8.0b4
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 } else if (state == ota::OTA_ERROR || state == ota::OTA_ABORT) {
75 // On success the device reboots, so restore only on a failed/aborted update;
76 // loop() restarts the scan on its next iteration (continuous idle branch).
78 this->scan_continuous_before_ota_ = false;
79 this->scan_continuous_ = true;
80 this->enable_loop(); // stop_scan() parked it
81 }
82 // A one-shot request that was still pending (latched, retrying) when the
83 // OTA paused scanning is re-latched, not dropped — loop() resumes the retry.
85 this->scan_requested_before_ota_ = false;
86 this->scan_requested_ = true;
87 this->enable_loop();
88 }
89 }
90}
91#endif // USE_OTA_STATE_LISTENER
92
95
96 // Deliver held scannable advertisements whose scan response never arrived —
97 // unmerged after the merger's timeout.
98 if (!this->merger_.empty())
99 this->merger_.sweep(now);
100
101 // Before the drop branch: a drop after a stable run starts a fresh streak.
102 if (this->scan_running_ && this->failed_start_count_ != 0 && now - this->scan_start_time_ >= SCAN_STABLE_RESET_MS)
103 this->failed_start_count_ = 0;
104
105 // A terminal failure while we report running recovers via the normal retry
106 // path; the drop charges the backoff so a flapping controller escalates.
108 ESP_LOGW(TAG, "Controller scan lost; retrying");
109 this->scan_requested_ = true;
110 this->count_failed_start_();
111 this->mark_scan_ended_(now);
112 }
113
114 if (this->scan_continuous_) {
115 if (!this->scan_running_) {
116 // One-iteration deferral; all stamps share this iteration's cached
117 // timestamp, so the period check below cannot underflow.
118 if (this->try_start_with_backoff_(now))
119 return;
120 }
121 // Period timer: fire on_scan_end() once per scan_duration_ window, mirroring
122 // esp32_ble_tracker::cleanup_scan_state_(). Gated on scan_started_once_ so a scan
123 // that never came up (start kept failing) does not fire spurious on_scan_end events.
124 if (this->scan_started_once_ && now - this->scan_period_start_ >= this->scan_duration_) {
125 this->fire_scan_end_();
126 this->scan_period_start_ = now;
127 }
128 return;
129 }
130
131 // Non-continuous mode: run for scan_duration_ ms, then stop and fire on_scan_end.
132 // Restart is driven externally (e.g. api: on_client_connected:).
133 //
134 // A requested start that failed (same controller failures the continuous branch
135 // absorbs) is retried with the same backoff — otherwise a failed one-shot start
136 // would be silent: the scan never runs, stop_scan_() is never reached and
137 // on_scan_end() never fires, leaving period-keyed consumers waiting forever.
138 if (this->scan_requested_ && !this->scan_running_) {
139 // Same one-iteration deferral as the continuous branch.
140 if (this->try_start_with_backoff_(now))
141 return;
142 }
143 if (this->scan_running_ && now - this->scan_start_time_ >= this->scan_duration_) {
144 // A full-duration run proves the controller healthy even when duration is
145 // shorter than SCAN_STABLE_RESET_MS.
146 this->failed_start_count_ = 0;
147 this->stop_scan_();
148 }
149}
150
152 // Rate-limit (re)start attempts. The controller start can fail (no idle activity
153 // handle, WiFi/BLE coexistence) and leave scan_running_ false; retrying every
154 // main-loop iteration would spin the single-core CPU and starve WiFi (device
155 // becomes unresponsive). The interval backs off with consecutive failures so a
156 // controller that never comes up polls slowly and quietly.
157 //
158 // force bypasses the gate for an explicit user start (start_scan()) — but
159 // only while the failure streak is clean. Once the controller is failing,
160 // even user-initiated attempts respect the backoff, so a start_scan() action
161 // on a short cadence cannot hammer a failing controller; the attempt stays
162 // inside the failure accounting below either way.
163 // Mid bring-up, observe instead of re-issuing (the hub self-advances). A
164 // SETTLED outcome completes immediately; only fresh attempts after FAILED
165 // are rate-limited.
166 const auto hub = this->parent_->last_scan_result();
168 return false;
170 if (this->start_attempt_open_) {
171 // Our bring-up gave up asynchronously; charge it to the backoff.
172 this->start_attempt_open_ = false;
173 this->count_failed_start_();
174 }
175 if ((!force || this->failed_start_count_ != 0) &&
176 now - this->last_scan_start_attempt_ < (SCAN_START_RETRY_MS << this->failed_start_count_))
177 return false;
178 }
179 this->start_scan_();
180 if (!this->scan_running_) {
182 this->start_attempt_open_ = true;
183 return false; // the controller is still bringing the scan up; not a failure
184 }
185 this->count_failed_start_();
186 }
187 return this->scan_running_;
188}
189
191 if (this->failed_start_count_ < SCAN_START_RETRY_MAX_DOUBLINGS) {
192 ++this->failed_start_count_;
193 if (this->failed_start_count_ == SCAN_START_RETRY_MAX_DOUBLINGS) {
194 ESP_LOGW(TAG, "Scan start keeps failing; retrying every %" PRIu32 " s",
195 (SCAN_START_RETRY_MS << SCAN_START_RETRY_MAX_DOUBLINGS) / 1000);
196 }
197 }
198}
199
201 ESP_LOGCONFIG(TAG,
202 "BK72xx BLE Tracker:\n"
203 " Scan Duration: %" PRIu32 " s\n"
204 " Scan Interval: %" PRIu32 " ms (%" PRIu32 " BLE units)\n"
205 " Scan Window: %" PRIu32 " ms (%" PRIu32 " BLE units)\n"
206 " Scan Type: %s (configured %s)\n"
207 " Continuous Scanning: %s",
209 ble_units_to_ms(this->scan_window_), this->scan_window_, this->scan_active_ ? "ACTIVE" : "PASSIVE",
210 this->scan_active_configured_ ? "ACTIVE" : "PASSIVE", YESNO(this->scan_continuous_));
211}
212
213// ---------------------------------------------------------------------------
214// Scan report — delivered by the controller's loop() on the ESPHome main task
215// (the controller queues reports from the BLE task), so publish_state() and
216// listener dispatch run in main-loop context with no cross-task handling here.
217// ---------------------------------------------------------------------------
218
219// GAPM report info byte (BLEScanReport::evt_type): bits 0-2 report type,
220// bit 5 scannable advertisement. Verified against both BDK stacks (5.1 and
221// 5.2 fill it from gapm_ext_adv_report_ind.info).
222static constexpr uint8_t GAPM_REPORT_TYPE_MASK = 0x07;
223static constexpr uint8_t GAPM_REPORT_TYPE_SCAN_RSP_EXT = 2;
224static constexpr uint8_t GAPM_REPORT_TYPE_SCAN_RSP_LEG = 3;
225static constexpr uint8_t GAPM_REPORT_INFO_SCAN_ADV_BIT = 1 << 5;
226
227// Demux advertisements vs scan responses into the shared merger: the BDK
228// delivers the pair as separate reports; a scannable advertisement is held
229// until its scan response arrives and delivered as one merged frame.
231 const uint8_t rtype = report.evt_type & GAPM_REPORT_TYPE_MASK;
232 if (rtype == GAPM_REPORT_TYPE_SCAN_RSP_LEG || rtype == GAPM_REPORT_TYPE_SCAN_RSP_EXT) {
233 this->merger_.submit_scan_rsp(report.mac, report.rssi, report.addr_type, report.data, report.data_len);
234 return;
235 }
236 // Stash only while an active scan runs: a passive scan never gets a
237 // response, and after a stop nothing would sweep the merger, so a late
238 // report would surface minutes later as a fresh advertisement.
239 if (this->scan_running_ && this->scan_active_ && (report.evt_type & GAPM_REPORT_INFO_SCAN_ADV_BIT)) {
240 this->merger_.stash_adv(report.mac, report.rssi, report.addr_type, report.data, report.data_len,
242 return;
243 }
244 this->dispatcher_.dispatch(report.mac, report.rssi, report.addr_type, report.data, report.data_len,
245 /*raw_only=*/false, this->scan_continuous_ ? nullptr : TAG);
246}
247
248// ---------------------------------------------------------------------------
249// Public scan control
250// ---------------------------------------------------------------------------
251
253 // Mirrors esp32_ble_tracker::start_scan(): caller sets scan_continuous_ via
254 // set_scan_continuous() first, then calls start_scan() to begin scanning.
255 //
256 // Nothing to do while a scan is already running: latching here would leave
257 // scan_requested_ set after that scan ends and silently restart a one-shot
258 // scan nobody asked for.
259 if (this->scan_running_)
260 return;
261
262 // The request is latched: if this immediate attempt fails (controller busy,
263 // WiFi/BLE coexistence), loop() keeps retrying it with backoff even in
264 // non-continuous mode, so a one-shot start cannot fail silently.
265 //
266 // Routed through the backoff helper (forced: the user asked for an immediate
267 // attempt) so a failure here still counts toward the backoff escalation and
268 // its WARN. The force bypass only applies while the failure streak is clean —
269 // against a failing controller, repeated start_scan() calls are rate-limited
270 // like any other attempt.
271 this->scan_requested_ = true;
272 this->enable_loop(); // an idle one-shot tracker parked it in stop_scan_()
274}
275
277 if (!this->scan_running_)
278 return;
279 // Re-anchor only the one-shot duration clock. scan_period_start_ (the
280 // continuous-mode on_scan_end period) is deliberately left alone: a
281 // start_scan action fired more often than scan_duration_ would otherwise
282 // suppress on_scan_end indefinitely — and absence detection (ble_rssi's NAN
283 // publish) rides on that period.
285}
286
288 this->scan_continuous_ = false;
289 this->scan_requested_ = false; // also cancels a pending (not yet successful) start
290 this->stop_scan_();
291}
292
293// ---------------------------------------------------------------------------
294// Internal scan start / stop
295// ---------------------------------------------------------------------------
296
299 return this->parent_->scan_start(static_cast<uint16_t>(this->scan_interval_),
300 static_cast<uint16_t>(this->scan_window_), this->scan_active_);
301}
302
304 if (this->scan_running_)
305 return;
306
308 return;
309
311 this->scan_running_ = true;
312 this->scan_requested_ = false; // the latched one-shot request is satisfied
313 this->start_attempt_open_ = false;
314 // failed_start_count_ deliberately not reset here; only a stable run clears it (loop()).
315 this->scan_start_time_ = now;
316 // Log every explicit start at DEBUG — stop_scan_() logs every stop at DEBUG, and
317 // in non-continuous mode each period is an explicit start, so asymmetric logging
318 // would read as the scanner failing to come back up.
319 ESP_LOGD(TAG, "Scan started (%s, window=%" PRIu32 "ms, interval=%" PRIu32 "ms)",
320 this->scan_active_ ? "active" : "passive", ble_units_to_ms(this->scan_window_),
322 // Re-anchor the on_scan_end period to every successful start — first start (so the
323 // period counts from the scan, not from boot) and every restart after a stop (so
324 // resuming after longer than scan_duration, e.g. a failed OTA restoring continuous
325 // mode 10 minutes later, does not fire on_scan_end before an advertisement can
326 // arrive). scan_started_once_ purely gates the period timer.
327 this->scan_period_start_ = now;
328 this->scan_started_once_ = true;
329}
330
331// Deliberate logical/physical split: on_scan_end() reports the tracker's
332// intent while the hub winds the radio down asynchronously; OTA is the one
333// path that must wait, and it flushes explicitly.
335 this->start_attempt_open_ = false; // an abandoned bring-up is not charged
336 this->parent_->scan_stop(); // idempotent: releases whatever the hub holds
337 if (this->scan_running_) {
338 ESP_LOGD(TAG, "Scan stopped");
340 }
341 // Park when idle (the hub drives its own teardown); re-check because an
342 // on_scan_end automation may have restarted the scan.
343 if (!this->scan_continuous_ && !this->scan_running_ && !this->scan_requested_)
344 this->disable_loop();
345}
346
347// The period re-anchor keeps on_scan_end from double-firing in one iteration.
349 this->scan_running_ = false;
350 this->fire_scan_end_();
351 this->scan_period_start_ = now;
352}
353
355 // Deliver held advertisements whose scan response never came (unmerged)
356 // BEFORE on_scan_end fires.
357 this->merger_.flush();
358 this->dispatcher_.on_scan_end();
359}
360
361// true = request latched, not applied: the reconciler applies it
362// asynchronously and loop() recovers a failed re-arm (ln882h parity).
364 if (this->scan_active_ == active)
365 return true;
366 this->scan_active_ = active;
367 // V: the proxy's "Setting scanner mode" line already narrates this at D.
368 ESP_LOGV(TAG, "Scan mode %s", active ? "active" : "passive");
369 // The controller reconciler restarts a running scan itself; the scan stays
370 // logically running. An idle scanner picks the mode up on its next start.
371 if (this->scan_running_)
373 return true;
374}
375
376} // namespace esphome::bk72xx_ble_tracker
377
378#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