@@ -22,19 +22,23 @@ namespace {
2222constexpr size_t kAdvertisingNameCapacity = 32 ;
2323constexpr TickType_t kConnTuneDelayTicks = pdMS_TO_TICKS(1200 );
2424constexpr TickType_t kPairingTimeoutTicks = pdMS_TO_TICKS(60000 );
25+ constexpr TickType_t kReconnectWindowTicks = pdMS_TO_TICKS(300000 ); // 5 min
2526constexpr TickType_t kAdvertisingWatchdogTicks = pdMS_TO_TICKS(1000 );
2627TimerHandle_t gConnTuneTimer = nullptr ;
2728TimerHandle_t gPairingTimer = nullptr ;
29+ TimerHandle_t gReconnectWindowTimer = nullptr ;
2830TimerHandle_t gAdvertisingWatchdogTimer = nullptr ;
2931char gAdvertisingName [kAdvertisingNameCapacity ];
3032bool pairingModeActive = false ;
3133bool pairingModeTransitionActive = false ;
34+ bool reconnectWindowActive = false ;
3235
3336// Store the active connection handle for conn param updates
3437uint16_t activeConnHandle = 0 ;
3538
3639bool shouldAdvertiseWhilePowered ();
3740bool startAdvertising (NimBLEServer *server);
41+ void updateBLEStatusIcon ();
3842
3943// Builds gAdvertisingName from the BT MAC and returns the full MAC address
4044// as an uppercase string for use as a unique device ID. Must be called before
@@ -63,6 +67,41 @@ void stopPairingModeTimer() {
6367 }
6468}
6569
70+ void stopReconnectWindowTimer () {
71+ if (gReconnectWindowTimer != nullptr ) {
72+ xTimerStop (gReconnectWindowTimer , 0 );
73+ }
74+ }
75+
76+ void onReconnectWindowTimeout (TimerHandle_t timer) {
77+ (void )timer;
78+ reconnectWindowActive = false ;
79+ USBSerial.println (" [BLE] Reconnect window expired; stopping advertising" );
80+ restartBLEAdvertising ();
81+ }
82+
83+ void startReconnectWindowTimer () {
84+ reconnectWindowActive = true ;
85+ if (gReconnectWindowTimer == nullptr ) {
86+ gReconnectWindowTimer = xTimerCreate (" bleReconn" , kReconnectWindowTicks ,
87+ pdFALSE, nullptr ,
88+ onReconnectWindowTimeout);
89+ }
90+ if (gReconnectWindowTimer != nullptr ) {
91+ xTimerStop (gReconnectWindowTimer , 0 );
92+ xTimerStart (gReconnectWindowTimer , 0 );
93+ }
94+ }
95+
96+ void initReconnectWindowFromBoot () {
97+ if (NimBLEDevice::getNumBonds () > 0 ) {
98+ startReconnectWindowTimer ();
99+ USBSerial.println (" [BLE] Reconnect window active (5 min)" );
100+ } else {
101+ reconnectWindowActive = false ;
102+ }
103+ }
104+
66105size_t syncWhiteListFromBonds () {
67106 // Reconcile the whitelist to the current bond store. Advertising must be
68107 // stopped before calling this — the BLE controller rejects whitelist changes
@@ -89,7 +128,6 @@ size_t syncWhiteListFromBonds() {
89128void onPairingTimeout (TimerHandle_t timer) {
90129 (void )timer;
91130 pairingModeActive = false ;
92- stopBLEPairingIconFlash ();
93131 USBSerial.println (" [BLE] Pairing mode expired, re-enabling whitelist" );
94132 restartBLEAdvertising ();
95133}
@@ -112,11 +150,25 @@ void applyPreferredLinkParams(TimerHandle_t timer) {
112150
113151bool shouldAdvertiseWhilePowered () {
114152 return !pairingModeTransitionActive &&
115- (pairingModeActive || NimBLEDevice::getNumBonds () > 0 );
153+ (pairingModeActive || reconnectWindowActive);
154+ }
155+
156+ void updateBLEStatusIcon () {
157+ if (deviceConnected) {
158+ showBLEStatusIcon ();
159+ return ;
160+ }
161+
162+ if (pairingModeActive) {
163+ startBLEPairingIconFlash ();
164+ } else {
165+ hideBLEStatusIcon ();
166+ }
116167}
117168
118169bool startAdvertising (NimBLEServer *server) {
119170 if (server == nullptr || pairingModeTransitionActive) {
171+ updateBLEStatusIcon ();
120172 return false ;
121173 }
122174
@@ -133,6 +185,14 @@ bool startAdvertising(NimBLEServer *server) {
133185 if (!allowOpenAdvertising && bondCount == 0 ) {
134186 USBSerial.println (
135187 " [BLE] No bonds present and pairing mode inactive; advertising stopped" );
188+ updateBLEStatusIcon ();
189+ return false ;
190+ }
191+
192+ if (!allowOpenAdvertising && bondCount > 0 && !reconnectWindowActive) {
193+ USBSerial.println (
194+ " [BLE] Reconnect window inactive; whitelist advertising not started" );
195+ updateBLEStatusIcon ();
136196 return false ;
137197 }
138198
@@ -161,28 +221,16 @@ bool startAdvertising(NimBLEServer *server) {
161221 // Flutter app's `startScan()` filters for CONFIG_SERVICE_UUID.
162222 adv.addServiceUUID (NimBLEUUID (CONFIG_SERVICE_UUID));
163223
164- // Scan response: manufacturer data with pairing-mode flag so the Flutter app
165- // can hide non-pairable controllers from the connect list.
166- // Format: Espressif company ID (0x02E5 LE) + 1 flag byte.
167- NimBLEExtAdvertisement scanRsp (BLE_HCI_LE_PHY_1M, BLE_HCI_LE_PHY_1M);
168- scanRsp.setLegacyAdvertising (true );
169- scanRsp.setScannable (true );
170- const uint8_t mfrData[] = {0xE5 , 0x02 ,
171- static_cast <uint8_t >(allowOpenAdvertising ? 0x01 : 0x00 )};
172- scanRsp.setManufacturerData (mfrData, sizeof (mfrData));
173-
174224 advertising->removeAll ();
175225 const bool configured = advertising->setInstanceData (kExtAdvInstance , adv);
176- const bool scanRspConfigured =
177- configured ? advertising->setScanResponseData (kExtAdvInstance , scanRsp)
178- : false ;
179226 const bool started =
180- configured && scanRspConfigured && advertising->start (kExtAdvInstance );
227+ configured && advertising->start (kExtAdvInstance );
181228 USBSerial.printf (
182- " [BLE] Ext adv cfg=%d scanRsp=%d start=%d mode=%s bonds=%u wl=%u\n " ,
183- configured, scanRspConfigured, started,
229+ " [BLE] Ext adv cfg=%d start=%d mode=%s bonds=%u wl=%u\n " ,
230+ configured, started,
184231 allowOpenAdvertising ? " OPEN" : " BONDED" ,
185232 static_cast <unsigned >(bondCount), static_cast <unsigned >(whiteListCount));
233+ updateBLEStatusIcon ();
186234 return started;
187235#else
188236 // Configure payload once — NimBLE accumulates addServiceUUID calls
@@ -206,18 +254,13 @@ bool startAdvertising(NimBLEServer *server) {
206254 advertising->setScanFilter (false , true );
207255 }
208256
209- // Manufacturer data with pairing-mode flag (updated every restart).
210- // Espressif company ID (0x02E5 LE) + 1 flag byte.
211- const std::string mfrPayload = {' \xE5 ' , ' \x02 ' ,
212- static_cast <char >(allowOpenAdvertising ? 0x01 : 0x00 )};
213- advertising->setManufacturerData (mfrPayload);
214-
215257 const bool started = advertising->start ();
216258 USBSerial.printf (" [BLE] Legacy adv start=%s mode=%s bonds=%u whitelist=%u\n " ,
217259 started ? " OK" : " FAIL" ,
218260 allowOpenAdvertising ? " OPEN" : " BONDED" ,
219261 static_cast <unsigned >(bondCount),
220262 static_cast <unsigned >(whiteListCount));
263+ updateBLEStatusIcon ();
221264 return started;
222265#endif
223266}
@@ -249,6 +292,9 @@ class BleServerConnectionCallbacks : public NimBLEServerCallbacks {
249292 deviceConnected = true ;
250293 connectedHandle = connInfo.getConnHandle ();
251294
295+ stopReconnectWindowTimer ();
296+ reconnectWindowActive = false ;
297+
252298 if (gConnTuneTimer != nullptr ) {
253299 xTimerStop (gConnTuneTimer , 0 );
254300 xTimerStart (gConnTuneTimer , 0 );
@@ -258,6 +304,7 @@ class BleServerConnectionCallbacks : public NimBLEServerCallbacks {
258304 connectedHandle, connInfo.getAddress ().toString ().c_str (),
259305 connInfo.isBonded () ? 1 : 0 , connInfo.isEncrypted () ? 1 : 0 ,
260306 pairingModeActive ? 1 : 0 );
307+ updateBLEStatusIcon ();
261308
262309 // During pairing mode, proactively request fresh security negotiation.
263310 // This helps recover from stale iOS bonds where iOS tries to restore
@@ -287,8 +334,15 @@ class BleServerConnectionCallbacks : public NimBLEServerCallbacks {
287334 // Suppress the immediate advertising restart during a pairing transition —
288335 // enterBLEPairingMode() issues its own startAdvertising after clearing bonds.
289336 if (!pairingModeTransitionActive) {
337+ if (NimBLEDevice::getNumBonds () > 0 ) {
338+ startReconnectWindowTimer ();
339+ } else {
340+ reconnectWindowActive = false ;
341+ stopReconnectWindowTimer ();
342+ }
290343 startAdvertising (server);
291344 }
345+ updateBLEStatusIcon ();
292346 }
293347
294348 void onAuthenticationComplete (NimBLEConnInfo &connInfo) override {
@@ -298,7 +352,6 @@ class BleServerConnectionCallbacks : public NimBLEServerCallbacks {
298352 if (pairingModeActive) {
299353 pairingModeActive = false ;
300354 stopPairingModeTimer ();
301- stopBLEPairingIconFlash ();
302355 }
303356 }
304357
@@ -327,6 +380,7 @@ class BleServerConnectionCallbacks : public NimBLEServerCallbacks {
327380 }
328381 }
329382 }
383+ updateBLEStatusIcon ();
330384 }
331385
332386 void onIdentity (NimBLEConnInfo &connInfo) override {
@@ -388,8 +442,14 @@ void setupBLE() {
388442#ifdef BLE_PAIR_ON_BOOT
389443 USBSerial.println (" [BLE] BLE_PAIR_ON_BOOT: entering pairing mode automatically" );
390444 enterBLEPairingMode ();
391- startBLEPairingIconFlash ();
392445#endif
446+ // After optional boot pairing: start reconnect window only if bonds remain.
447+ initReconnectWindowFromBoot ();
448+ if (shouldAdvertiseWhilePowered ()) {
449+ restartBLEAdvertising ();
450+ } else {
451+ updateBLEStatusIcon ();
452+ }
393453}
394454
395455void requestFastConnParams () {
@@ -410,6 +470,7 @@ void requestNormalConnParams() {
410470
411471void restartBLEAdvertising () {
412472 if (pServer == nullptr ) {
473+ updateBLEStatusIcon ();
413474 return ;
414475 }
415476
@@ -419,6 +480,8 @@ void restartBLEAdvertising() {
419480void enterBLEPairingMode () {
420481 // Block advertising restarts (e.g. from onDisconnect) during this transition.
421482 pairingModeTransitionActive = true ;
483+ stopReconnectWindowTimer ();
484+ reconnectWindowActive = false ;
422485
423486 // Single-bond model: disconnect the current peer so we can safely clear bonds.
424487 if (deviceConnected && pServer != nullptr &&
0 commit comments