Skip to content

Commit b86d0e5

Browse files
committed
Fix I2C/OLED heap wedge: lazy Wi-Fi bring-up, drop coex preinit/park
wifi_coex_preinit() inited the full Wi-Fi driver at boot and kept it parked, draining the classic-ESP32 heap so I2C_INIT's Wire-buffer alloc plus the IDF i2c_driver_install wedged rx_task — the board hangs until a power cycle (the "everything broke after v0.3.0" OLED-over-BLE failure). Adopt the Esp-WiFi-BLE-Now model: don't pre-init, bring radios up lazily, leave IDF defaults alone. - Remove wifi_coex_preinit / wifi_ensure_started / wifi_parked / coex_pinned and the BRIDGE_WIFI_COEX flag. Wi-Fi comes up lazily via WiFi.mode(WIFI_STA) on the first Wi-Fi/ESP-NOW command, so a BLE-only board keeps full heap and I2C/OLED gets the full 2 KB Wire buffer. - Simplify mod_i2c init to a short heap-guarded buffer fallback; drop the esp_heap_caps math and the rx_task wedge watchdog (band-aids for the self-inflicted heap shortage). - Update firmware README + PROTOCOL coexistence notes; trim stale comments. Net -152/+41 lines. Host tests: 202 passed.
1 parent 3de051e commit b86d0e5

11 files changed

Lines changed: 41 additions & 153 deletions

File tree

docs/PROTOCOL.md

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -289,11 +289,11 @@ Hosts must treat the tail as optional for compatibility with older firmware.
289289
`SYS_LOG` warning event.
290290
- **SYS_RESET** replies OK first, then restarts; the host must expect a new
291291
`SYS_READY`.
292-
- **Radio coexistence** (classic ESP32): the Wi-Fi stack must initialize
293-
before Bluedroid, so the firmware brings the Wi-Fi driver up at boot when
294-
the BLE link is enabled (`BRIDGE_WIFI_COEX` in firmware.ino, default on;
295-
costs ~50 KB heap). Power save stays at the IDF default `WIFI_PS_MIN_MODEM`
296-
— never `WIFI_PS_NONE` while BT is active.
292+
- **Radio coexistence** (classic ESP32): the BLE link runs from boot; the
293+
Wi-Fi driver comes up lazily on the first Wi-Fi/ESP-NOW command, so a
294+
BLE-only board never pays its heap. The SW coex arbiter shares the radio
295+
with BLE; power save stays at the IDF default `WIFI_PS_MIN_MODEM` — never
296+
`WIFI_PS_NONE` while BT is active.
297297
- **Wi-Fi scan vs ESP-NOW**: a scan hops channels, so ESP-NOW packets are
298298
dropped while one is running.
299299
- **IDF logs become `SYS_LOG` events**: the Wi-Fi/BT stacks log through

firmware/README.md

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -19,13 +19,14 @@ USB serial **or Bluetooth**.
1919
#define BRIDGE_ENABLE_CAM 0 // opt-in: camera (esp32/s2/s3 + PSRAM)
2020
#define BRIDGE_PASSWORD "espbridge" // Bluetooth password ("" = open access)
2121
#define BRIDGE_BLE_LINK 1 // 0 = USB only
22-
#define BRIDGE_WIFI_COEX 1 // classic ESP32: Wi-Fi up before BLE (coex)
2322
```
2423
2524
Change the password here and reflash. USB never asks for a password.
26-
`BRIDGE_WIFI_COEX` pre-starts the Wi-Fi driver before Bluedroid so Wi-Fi /
27-
ESP-NOW / BLE can all run together (required init order on classic ESP32);
28-
set it to 0 to reclaim ~50 KB heap on boards that never use the radio.
25+
Wi-Fi / ESP-NOW / BLE all coexist: the BLE link is up at boot, and the Wi-Fi
26+
driver comes up lazily on the host's first Wi-Fi/ESP-NOW command — so a
27+
BLE-only board never pays the Wi-Fi driver's ~30–50 KB heap (which is what
28+
used to starve I2C/SPI). The SW coex arbiter shares the radio; IDF defaults
29+
are left untouched (never `WIFI_PS_NONE` with BT).
2930
Ethernet and camera stay compile-time opt-ins because they cost real flash;
3031
board pin maps live on the Python side (`espbridge.eth` / `espbridge.camera`
3132
presets), so one firmware build serves every board.

firmware/firmware.ino

Lines changed: 5 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -32,15 +32,6 @@
3232
// Set to 0 to turn the Bluetooth link off entirely (USB only).
3333
#define BRIDGE_BLE_LINK 1
3434

35-
// Classic-ESP32 coexistence: bring the Wi-Fi stack up BEFORE Bluedroid so the
36-
// coex arbiter sees the radios in the right order (Wi-Fi -> ESP-NOW -> BLE),
37-
// then park the radio until the host first uses Wi-Fi/ESP-NOW. A parked
38-
// radio gives Bluetooth uncontested airtime and ~25 KB extra heap, so an
39-
// idle board's BLE link is as solid as a no-Wi-Fi build. Set 0 if this
40-
// board never uses Wi-Fi/ESP-NOW (saves the remaining driver heap too).
41-
// Ignored on chips other than the classic ESP32 (they have no ordering rule).
42-
#define BRIDGE_WIFI_COEX 1
43-
4435
void setup() {
4536
// Route IDF Wi-Fi/BT logs into SYS_LOG events; raw log bytes on UART0 would
4637
// corrupt protocol frames. Must precede any radio bring-up.
@@ -60,12 +51,11 @@ void setup() {
6051
wifi_init();
6152
proto_start(); // spawn bridge_tx / bridge_rx / bridge_net tasks
6253

63-
#if BRIDGE_WIFI_COEX && BRIDGE_BLE && defined(CONFIG_IDF_TARGET_ESP32)
64-
// Wi-Fi driver up before BLEDevice::init() inside link_ble_init(). Power
65-
// save stays at the default WIFI_PS_MIN_MODEM — never WIFI_PS_NONE with BT.
66-
wifi_coex_preinit();
67-
#endif
68-
54+
// Wi-Fi/ESP-NOW stay off until the host's first radio command, then come up
55+
// lazily (WiFi.mode in the wifi/espnow handlers). A BLE-only board never
56+
// pays the Wi-Fi driver's heap — which is what kept I2C/SPI from wedging.
57+
// The SW coex arbiter shares the radio with BLE; leave its defaults alone
58+
// (never WIFI_PS_NONE with BT). See mod_wifi.cpp / mod_espnow.cpp.
6959
#if BRIDGE_BLE_LINK
7060
// Bluetooth link: same protocol, no USB cable. Clients authenticate with
7161
// SYS_AUTH (BRIDGE_PASSWORD above) before any other command is accepted.

firmware/src/espbridge/commands.h

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -243,7 +243,7 @@ enum ChipModel : uint8_t {
243243
#define NET_UDP_EVT CMD(MOD_NET, 0x83) // handle u8|ip[4]|port u16|data..
244244

245245
// ESP-NOW (connectionless 2.4 GHz messaging; coexists with Wi-Fi STA/AP + BLE).
246-
// Init order on classic ESP32: Wi-Fi -> ESP-NOW -> BLE (see BRIDGE_WIFI_COEX).
246+
// ESPNOW_INIT brings the Wi-Fi driver up lazily (STA) if it's still off.
247247
// Channel rule: when Wi-Fi STA is connected ESP-NOW inherits its channel and
248248
// the requested channel is ignored (changing it would drop the AP).
249249
#define ESPNOW_INIT CMD(MOD_ESPNOW, 0x01) // channel u8 (0=auto/inherit)|flags u8 (bit0=long range) -> mac[6]

firmware/src/espbridge/modules.h

Lines changed: 0 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -30,13 +30,6 @@ void mcpwm_handle(uint8_t op, uint8_t seq, const uint8_t* p, uint16_t len);
3030
void gpio_init();
3131
void wifi_init();
3232

33-
// Classic-ESP32 coexistence: bring the Wi-Fi driver up before Bluedroid,
34-
// then park the radio (called from setup() ahead of link_ble_init when
35-
// BRIDGE_WIFI_COEX is set). wifi_ensure_started() resumes the parked radio
36-
// on first Wi-Fi/ESP-NOW use; no-op everywhere else.
37-
void wifi_coex_preinit();
38-
void wifi_ensure_started();
39-
4033
// Pollers: gpio/uart run on rx_task, wifi/net on net_task; must not block.
4134
void gpio_poll();
4235
void uart_poll();

firmware/src/espbridge/protocol.cpp

Lines changed: 0 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -223,36 +223,8 @@ struct RxState {
223223
static RxState rxstate[2]; // [LINK_USB], [LINK_BLE]
224224
static uint8_t rxframe[MAX_FRAME];
225225

226-
// Wedge watchdog: some IDF calls can block rx_task forever with no panic
227-
// and no error (seen: i2c driver install at exhausted heap). tx_task stays
228-
// alive, so a watcher can still tell the host WHERE the firmware is stuck
229-
// instead of leaving it guessing at timeouts.
230-
static volatile uint16_t cur_cmd = 0xFFFF; // command rx_task is inside, if any
231-
static volatile uint32_t disp_n = 0;
232-
233-
static void watch_task(void*) {
234-
uint32_t last_n = 0;
235-
uint8_t stuck = 0;
236-
for (;;) {
237-
vTaskDelay(pdMS_TO_TICKS(1000));
238-
uint16_t c = cur_cmd;
239-
if (c != 0xFFFF && disp_n == last_n) {
240-
if (++stuck >= 2 && (stuck & 1) == 0) { // after 2 s, then every 2 s
241-
char msg[56];
242-
snprintf(msg, sizeof(msg), "rx_task stuck in cmd 0x%04X for %u s", c, stuck);
243-
proto_log(2, msg);
244-
}
245-
} else {
246-
stuck = 0;
247-
}
248-
last_n = disp_n;
249-
}
250-
}
251-
252226
static void dispatch(uint8_t seq, uint16_t cmd, const uint8_t* p, uint16_t len) {
253227
uint8_t mod = cmd >> 8, op = cmd & 0xFF;
254-
cur_cmd = cmd;
255-
disp_n++;
256228
switch (mod) {
257229
// Fast handlers: run inline on rx_task.
258230
case MOD_SYS: sys_handle(op, seq, p, len); break;
@@ -281,7 +253,6 @@ static void dispatch(uint8_t seq, uint16_t cmd, const uint8_t* p, uint16_t len)
281253
case MOD_CAM: net_enqueue(cmd, seq, p, len); break;
282254
default: proto_reply_err(seq, cmd, ST_UNKNOWN_CMD); break;
283255
}
284-
cur_cmd = 0xFFFF;
285256
}
286257

287258
// Constant-time-ish password compare (no early exit on mismatch).
@@ -395,6 +366,4 @@ void proto_start() {
395366
xTaskCreatePinnedToCore(tx_task, "bridge_tx", 4096, nullptr, 12, nullptr, BRIDGE_CORE);
396367
xTaskCreatePinnedToCore(rx_task, "bridge_rx", 8192, nullptr, 10, nullptr, BRIDGE_CORE);
397368
xTaskCreatePinnedToCore(net_task, "bridge_net", 8192, nullptr, 9, nullptr, BRIDGE_CORE);
398-
// Above rx_task so a wedged handler can't starve it; sleeps 1 s between looks.
399-
xTaskCreatePinnedToCore(watch_task, "bridge_watch", 2048, nullptr, 11, nullptr, BRIDGE_CORE);
400369
}

firmware/src/link_ble.cpp

Lines changed: 4 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -21,10 +21,9 @@
2121
// Classic ESP32 ships a dual-mode (Classic BT + BLE) controller and the
2222
// Arduino sdkconfig keeps it that way, but this firmware is BLE-only.
2323
// Hand the Classic-BT memory back to the heap BEFORE any BT init and start
24-
// the controller in BLE-only mode: with the Wi-Fi driver already up for
25-
// coexistence (BRIDGE_WIFI_COEX) there otherwise isn't enough heap for
26-
// Bluedroid, and its failed init crashes on core 0 (BTE_InitStack error
27-
// path -> OBEX_Deinit dereferences a never-allocated control block).
24+
// the controller in BLE-only mode — frees heap and avoids a dual-mode
25+
// Bluedroid init that can crash on core 0 (BTE_InitStack -> OBEX_Deinit
26+
// dereferences a never-allocated control block).
2827
// One-way until reboot; nothing in this firmware uses Classic BT.
2928
void bt_prepare_ble_only() {
3029
#if defined(CONFIG_IDF_TARGET_ESP32)
@@ -126,8 +125,7 @@ void link_ble_init(const char* password) {
126125
bt_prepare_ble_only();
127126
proto_log_heap("ble: controller up");
128127
if (ESP.getFreeHeap() < BLE_MIN_FREE_HEAP) {
129-
proto_log(2, "ble: link disabled, not enough free heap "
130-
"(set BRIDGE_WIFI_COEX 0 if Wi-Fi/ESP-NOW is unused)");
128+
proto_log(2, "ble: link disabled, not enough free heap at boot");
131129
return;
132130
}
133131
link_password = password ? password : "";

firmware/src/mod_espnow.cpp

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -155,7 +155,6 @@ static void handle_send(uint8_t seq, uint16_t cmd, const uint8_t* p, uint16_t le
155155
void espnow_handle(uint8_t op, uint8_t seq, const uint8_t* p, uint16_t len) {
156156
uint16_t cmd = CMD(MOD_ESPNOW, op);
157157
if (!inited && op != 0x01) { proto_reply_err(seq, cmd, ST_NOT_INIT); return; }
158-
wifi_ensure_started(); // coex: the radio is parked until first radio use
159158
switch (op) {
160159
case 0x01: // INIT
161160
handle_init(seq, cmd, p, len);

firmware/src/mod_i2c.cpp

Lines changed: 8 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,6 @@
22
#include "espbridge/protocol.h"
33
#include "espbridge/modules.h"
44
#include <Wire.h>
5-
#include <esp_heap_caps.h>
65

76
static bool i2c_inited[2];
87

@@ -27,39 +26,16 @@ void i2c_handle(uint8_t op, uint8_t seq, const uint8_t* p, uint16_t len) {
2726
case 0x01: { // INIT: bus, sda, scl, freq u32 -> wire_buf u16
2827
if (len < 7) { proto_reply_err(seq, cmd, ST_BAD_ARGS); return; }
2928
if (i2c_inited[bus]) w->end();
30-
// Wire's default TX buffer is 128 bytes and write() silently drops
31-
// anything beyond it; size it to fit any I2C_WRITE payload. begin()
32-
// allocates 2x this from the heap — which a classic ESP32 with the
33-
// BLE link connected runs within a few KB of — so fall back to
34-
// smaller buffers instead of failing, and report the size that stuck
35-
// (hosts chunk their writes to it).
36-
//
37-
// The fallback must be heap-aware, not try-and-see (all measured on
38-
// classic ESP32 + BLE link, core 3.3.6):
39-
// - the IDF i2c driver install after the buffer allocation eats
40-
// ~2 KB and WEDGES rx_task silently below ~7 KB free (no panic,
41-
// no error return — the board just stops answering);
42-
// - the radio stacks need ~6.5 KB free *for the rest of the
43-
// session*, or Bluedroid starts dropping replies under load
44-
// (6.7 KB rest = solid; 5.9 KB rest = host-visible timeouts).
45-
// So a buffer above the 128-byte floor must leave 2*size + ~9 KB,
46-
// plus a contiguous chunk to spare.
47-
static const uint16_t sizes[] = {MAX_PAYLOAD, 1024, 512, 256, 128};
29+
// Wire's default 128-byte TX buffer silently truncates longer writes, so
30+
// grow it; begin() allocates 2x from the heap, so fall back to a smaller
31+
// buffer when heap is tight (full Wi-Fi+BLE coexistence) and report the
32+
// size that stuck — the host chunks its writes to it.
4833
uint16_t got = 0;
49-
for (uint8_t i = 0; i < 5 && !got; i++) {
50-
if (sizes[i] > 128) {
51-
if (ESP.getFreeHeap() < 2u * sizes[i] + 9216) continue;
52-
if (heap_caps_get_largest_free_block(MALLOC_CAP_8BIT)
53-
< (size_t)sizes[i] + 2048) continue;
54-
}
55-
if (w->setBufferSize(sizes[i]) && w->begin(p[1], p[2], rd32(p + 3)))
56-
got = sizes[i];
57-
}
58-
if (!got) {
59-
proto_log_heap("i2c: init failed"); // ST_IO alone is opaque
60-
proto_reply_err(seq, cmd, ST_IO);
61-
return;
34+
for (uint16_t sz : {MAX_PAYLOAD, 512, 128}) {
35+
if (sz > 128 && ESP.getFreeHeap() < 2u * sz + 8192) continue;
36+
if (w->setBufferSize(sz) && w->begin(p[1], p[2], rd32(p + 3))) { got = sz; break; }
6237
}
38+
if (!got) { proto_reply_err(seq, cmd, ST_NO_MEM); return; }
6339
i2c_inited[bus] = true;
6440
uint8_t out[2] = {(uint8_t)(got >> 8), (uint8_t)got};
6541
proto_reply(seq, cmd, out, 2);

firmware/src/mod_wifi.cpp

Lines changed: 7 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -4,49 +4,15 @@
44
#include "espbridge/protocol.h"
55
#include "espbridge/modules.h"
66
#include <WiFi.h>
7-
#include <esp_wifi.h>
87

98
static bool scanning = false;
109
static bool ap_active = false;
1110
static bool sta_started = false;
12-
static bool coex_pinned = false; // Wi-Fi pre-inited for BLE coex: never drop to MODE_NULL
13-
static bool wifi_parked = false; // driver inited but radio stopped (coex idle)
1411

15-
bool wifi_is_active() {
16-
// Parked = driver up, RF off: no ADC2 conflict, no coex airtime claimed.
17-
return WiFi.getMode() != WIFI_MODE_NULL && !wifi_parked;
18-
}
19-
20-
// Classic-ESP32 coexistence: the Wi-Fi driver must come up BEFORE Bluedroid
21-
// (heap ordering — Bluedroid's init crashes if the Wi-Fi driver grabs its
22-
// buffers afterwards). Called from setup() ahead of link_ble_init() when
23-
// BRIDGE_WIFI_COEX is set. Power save stays at the Arduino default
24-
// WIFI_PS_MIN_MODEM — customizing it (especially WIFI_PS_NONE) destabilizes
25-
// BLE coexistence per the IDF guide.
26-
//
27-
// Lesson from Esp-WiFi-BLE-Now: only bring the radio up when it is actually
28-
// used. An idle-but-started STA makes the coex arbiter timeslice the
29-
// 2.4 GHz radio against BLE forever (an unassociated STA never modem-
30-
// sleeps) and holds ~25 KB of RX buffers — both destabilize the BLE link.
31-
// So: init the driver in order, then PARK the radio until the host first
32-
// asks for Wi-Fi/ESP-NOW (wifi_ensure_started below).
33-
void wifi_coex_preinit() {
34-
WiFi.mode(WIFI_STA);
35-
esp_wifi_stop();
36-
wifi_parked = true;
37-
coex_pinned = true;
38-
proto_log_heap("coex: wifi inited, radio parked");
39-
}
40-
41-
// Resume the parked radio before any Wi-Fi/ESP-NOW use. Arduino's layer
42-
// still believes Wi-Fi is started (we stopped underneath it), so this is
43-
// the matching low-level start; a no-op everywhere else.
44-
void wifi_ensure_started() {
45-
if (!wifi_parked) return;
46-
wifi_parked = false;
47-
esp_wifi_start();
48-
proto_log_heap("coex: wifi radio resumed");
49-
}
12+
// Wi-Fi is off until the first command here brings it up (WiFi.mode below);
13+
// a BLE-only board never pays the driver's heap. Leave coex/power-save at
14+
// IDF defaults (never WIFI_PS_NONE with BT) — the SW arbiter handles slotting.
15+
bool wifi_is_active() { return WiFi.getMode() != WIFI_MODE_NULL; }
5016

5117
static void on_wifi_event(WiFiEvent_t event, WiFiEventInfo_t info) {
5218
uint8_t buf[5] = {0};
@@ -117,7 +83,6 @@ static bool take_str(const uint8_t*& p, uint16_t& left, char* out, uint8_t cap)
11783

11884
void wifi_handle(uint8_t op, uint8_t seq, const uint8_t* p, uint16_t len) {
11985
uint16_t cmd = CMD(MOD_WIFI, op);
120-
wifi_ensure_started(); // coex: the radio is parked until first Wi-Fi use
12186
switch (op) {
12287
case 0x01: { // SCAN (async)
12388
// Quirk: a scan hops channels, so ESP-NOW packets are dropped while it
@@ -150,9 +115,8 @@ void wifi_handle(uint8_t op, uint8_t seq, const uint8_t* p, uint16_t len) {
150115
case 0x03: // DISCONNECT
151116
WiFi.disconnect(true /*wifioff if no AP*/, false);
152117
sta_started = false;
153-
// Keep the radio in STA mode while ESP-NOW rides on it or the driver is
154-
// coex-pinned (turning it off would break espnow / the BLE coex order).
155-
if (!ap_active && !espnow_is_active() && !coex_pinned) WiFi.mode(WIFI_MODE_NULL);
118+
// Drop the radio (frees its heap) unless an AP or ESP-NOW still needs it.
119+
if (!ap_active && !espnow_is_active()) WiFi.mode(WIFI_MODE_NULL);
156120
proto_reply_ok(seq, cmd);
157121
break;
158122

@@ -196,7 +160,7 @@ void wifi_handle(uint8_t op, uint8_t seq, const uint8_t* p, uint16_t len) {
196160
case 0x06: // AP_STOP
197161
WiFi.softAPdisconnect(true);
198162
ap_active = false;
199-
if (!sta_started && !espnow_is_active() && !coex_pinned) WiFi.mode(WIFI_MODE_NULL);
163+
if (!sta_started && !espnow_is_active()) WiFi.mode(WIFI_MODE_NULL);
200164
proto_reply_ok(seq, cmd);
201165
break;
202166

0 commit comments

Comments
 (0)