Skip to content

Commit 3de051e

Browse files
committed
ble stability
1 parent 1535b41 commit 3de051e

23 files changed

Lines changed: 428 additions & 54 deletions

README.md

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -184,6 +184,26 @@ with espbridge.connect_all() as boards: # or just open all of them
184184
boards.by_name("relays").gpio.write(2, 1)
185185
```
186186

187+
## Troubleshooting
188+
189+
Errors name the command and say what to check (`I2C_WRITE (0x4003) failed:
190+
IO — no ACK on the wire — check wiring, power, device address and pull-ups`).
191+
A timeout additionally pings the board so the message tells you whether the
192+
link itself died or a single frame got lost. Useful knobs:
193+
194+
```python
195+
esp = Bridge(retries=1) # default: re-send safe commands once on timeout
196+
esp.free_heap() # heap + dropped-frame counters from the firmware
197+
```
198+
199+
```bash
200+
ESPBRIDGE_DEBUG=1 python app.py # trace every request/response with names
201+
```
202+
203+
Lost frames on a busy link are also *prevented* now: pipelined bursts
204+
(OLED frames, NeoPixel updates) are automatically throttled to what the
205+
firmware's link buffer can absorb, on both USB serial and Bluetooth.
206+
187207
## Repo layout
188208

189209
| path | what |

docs/PROTOCOL.md

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -101,6 +101,24 @@ compiled in via `BRIDGE_PASSWORD` at the top of `firmware.ino`; empty string
101101
`SYS_READY` banner to that client, so the handshake proceeds exactly like
102102
USB. Auth state is per-connection and resets on disconnect.
103103

104+
## Link flow control (fire-and-forget bursts)
105+
106+
Fire-and-forget requests (`seq = 0`) get no reply, so nothing paces them. The
107+
firmware buffers inbound bytes (UART ring `SERIAL_RX_BUF` = 4096 B; BLE link
108+
stream buffer `LINK_RX_BUF` = 6144 B) and drains them only as fast as handlers
109+
execute — a pipelined burst (an OLED frame push is ~9 KB of `I2C_WRITE`s, each
110+
1 KB page taking ~23 ms on a 400 kHz bus) can overrun the buffer, and overrun
111+
bytes are **dropped** (CRC then discards the mangled frames; the host-visible
112+
symptom is a timeout on the next *waited* request).
113+
114+
Hosts must therefore cap un-acknowledged fire-and-forget bytes and insert any
115+
cheap request (`SYS_PING`) as a **fence**: frames are consumed in arrival
116+
order, so its reply proves the firmware drained every byte sent before it.
117+
The Python library does this automatically (transport `burst_window`:
118+
3072 serial / 4096 BLE). The firmware counts BLE-side overflow drops and
119+
reports them in `SYS_FREE_HEAP` (5th u32, fw ≥ 0.3.2) alongside a
120+
rate-limited `SYS_LOG` warning — drops are never silent.
121+
104122
## NET flow control (socket proxy)
105123

106124
The serial link is slower than Wi-Fi, so each proxied TCP socket has a credit

examples/uv.lock

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

firmware/firmware.ino

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -76,9 +76,12 @@ void setup() {
7676
uint8_t info[64];
7777
uint16_t n = sys_build_info(info);
7878
proto_send_event(SYS_READY, info, n);
79-
}
8079

81-
void loop() {
82-
// Everything runs in dedicated FreeRTOS tasks; park the Arduino loop task.
83-
vTaskDelay(portMAX_DELAY);
80+
// Everything runs in dedicated FreeRTOS tasks — delete the Arduino loop
81+
// task instead of parking it: its 8 KB stack goes back to the heap, which
82+
// a classic ESP32 running Wi-Fi + Bluedroid needs badly (Bluedroid stops
83+
// delivering notifications below ~8 KB free, killing the BLE link).
84+
vTaskDelete(nullptr); // never returns
8485
}
86+
87+
void loop() {} // never runs (loopTask deleted at end of setup)

firmware/src/espbridge/commands.h

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
#define PROTOCOL_VERSION 1
77
#define FW_VERSION_MAJOR 0
88
#define FW_VERSION_MINOR 3
9-
#define FW_VERSION_PATCH 1
9+
#define FW_VERSION_PATCH 2
1010

1111
// Frame (logical, pre-COBS):
1212
// flags u8 | seq u8 | cmd u16 BE | payload .. | crc16 BE

firmware/src/espbridge/link.h

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ void bt_prepare_ble_only();
3535
bool link_ble_enabled(); // link_ble_init() succeeded
3636
bool link_ble_connected(); // a BLE central is currently connected
3737
bool link_ble_authed(); // ...and presented the correct password
38+
uint32_t link_ble_rx_dropped(); // bytes lost to RX buffer overflow (diagnostics)
3839
void link_ble_set_authed(bool v);
3940
const char* link_ble_password();
4041

firmware/src/espbridge/protocol.cpp

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -223,8 +223,36 @@ 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+
226252
static void dispatch(uint8_t seq, uint16_t cmd, const uint8_t* p, uint16_t len) {
227253
uint8_t mod = cmd >> 8, op = cmd & 0xFF;
254+
cur_cmd = cmd;
255+
disp_n++;
228256
switch (mod) {
229257
// Fast handlers: run inline on rx_task.
230258
case MOD_SYS: sys_handle(op, seq, p, len); break;
@@ -253,6 +281,7 @@ static void dispatch(uint8_t seq, uint16_t cmd, const uint8_t* p, uint16_t len)
253281
case MOD_CAM: net_enqueue(cmd, seq, p, len); break;
254282
default: proto_reply_err(seq, cmd, ST_UNKNOWN_CMD); break;
255283
}
284+
cur_cmd = 0xFFFF;
256285
}
257286

258287
// Constant-time-ish password compare (no early exit on mismatch).
@@ -366,4 +395,6 @@ void proto_start() {
366395
xTaskCreatePinnedToCore(tx_task, "bridge_tx", 4096, nullptr, 12, nullptr, BRIDGE_CORE);
367396
xTaskCreatePinnedToCore(rx_task, "bridge_rx", 8192, nullptr, 10, nullptr, BRIDGE_CORE);
368397
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);
369400
}

firmware/src/link_ble.cpp

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,7 @@ static const char* link_password = "";
5353
static BLEServer* server = nullptr;
5454
static BLECharacteristic* tx_chr = nullptr;
5555
static StreamBufferHandle_t rx_buf = nullptr;
56+
static volatile uint32_t rx_dropped = 0; // bytes lost to RX buffer overflow
5657

5758
// Hands-off link policy (lesson from Esp-WiFi-BLE-Now, which runs all three
5859
// radios stably): the peripheral issues NO link-layer control procedures —
@@ -89,7 +90,21 @@ class LinkRxCb : public BLECharacteristicCallbacks {
8990
// Bluedroid callbacks run on a BT task: plain (non-ISR) send is fine.
9091
// Allow a short block so pipelined bursts backpressure the BT task
9192
// instead of dropping bytes; past that the host times out & retries.
92-
xStreamBufferSend(rx_buf, c->getData(), c->getLength(), pdMS_TO_TICKS(50));
93+
size_t n = xStreamBufferSend(rx_buf, c->getData(), c->getLength(),
94+
pdMS_TO_TICKS(50));
95+
if (n < c->getLength()) {
96+
// Never drop silently — corrupted frames look like random timeouts on
97+
// the host. Count (SYS_FREE_HEAP reports it) and warn, rate-limited.
98+
rx_dropped += c->getLength() - n;
99+
static uint32_t last_warn = 0;
100+
uint32_t now = millis();
101+
if (now - last_warn > 1000) {
102+
last_warn = now;
103+
proto_log(2, "ble: rx overflow — frames lost (host writing faster "
104+
"than commands execute; update python-esp-bridge for "
105+
"burst throttling)");
106+
}
107+
}
93108
}
94109
};
95110
static LinkRxCb link_rx_cb;
@@ -164,6 +179,7 @@ void link_ble_init(const char* password) {
164179

165180
bool link_ble_enabled() { return enabled; }
166181
bool link_ble_connected() { return connected; }
182+
uint32_t link_ble_rx_dropped() { return rx_dropped; }
167183
bool link_ble_authed() { return connected && authed; }
168184
void link_ble_set_authed(bool v) { authed = v; }
169185
const char* link_ble_password() { return link_password; }
@@ -203,6 +219,7 @@ void link_ble_init(const char*) {}
203219
void bt_prepare_ble_only() {}
204220
bool link_ble_enabled() { return false; }
205221
bool link_ble_connected() { return false; }
222+
uint32_t link_ble_rx_dropped() { return 0; }
206223
bool link_ble_authed() { return false; }
207224
void link_ble_set_authed(bool) {}
208225
const char* link_ble_password() { return ""; }

firmware/src/mod_i2c.cpp

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

67
static bool i2c_inited[2];
78

@@ -32,11 +33,28 @@ void i2c_handle(uint8_t op, uint8_t seq, const uint8_t* p, uint16_t len) {
3233
// BLE link connected runs within a few KB of — so fall back to
3334
// smaller buffers instead of failing, and report the size that stuck
3435
// (hosts chunk their writes to it).
35-
static const uint16_t sizes[] = {MAX_PAYLOAD, 512, 128};
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};
3648
uint16_t got = 0;
37-
for (uint8_t i = 0; i < 3 && !got; i++)
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+
}
3855
if (w->setBufferSize(sizes[i]) && w->begin(p[1], p[2], rd32(p + 3)))
3956
got = sizes[i];
57+
}
4058
if (!got) {
4159
proto_log_heap("i2c: init failed"); // ST_IO alone is opaque
4260
proto_reply_err(seq, cmd, ST_IO);
@@ -67,12 +85,29 @@ void i2c_handle(uint8_t op, uint8_t seq, const uint8_t* p, uint16_t len) {
6785
w->beginTransmission(p[1]);
6886
// write() returns bytes buffered — short means TX buffer overflow,
6987
// which would silently corrupt the transfer (drops the tail bytes)
88+
uint8_t st = ST_OK;
7089
if (len > 2 && w->write(p + 2, len - 2) != (size_t)(len - 2)) {
7190
w->endTransmission();
72-
proto_reply_err(seq, cmd, ST_BAD_ARGS);
91+
st = ST_BAD_ARGS;
92+
} else if (w->endTransmission() != 0) {
93+
st = ST_IO;
94+
}
95+
if (st != ST_OK) {
96+
// Fire-and-forget (seq 0) gets no error reply, so a failed write
97+
// in a pipelined burst (OLED frame push) would vanish without a
98+
// trace — visible only as display corruption. Warn, rate-limited.
99+
if (seq == 0) {
100+
static uint32_t last_warn = 0;
101+
uint32_t now = millis();
102+
if (now - last_warn > 1000) {
103+
last_warn = now;
104+
proto_log(2, st == ST_IO ? "i2c: unacked write failed (NACK/bus)"
105+
: "i2c: unacked write exceeds wire buffer");
106+
}
107+
}
108+
proto_reply_err(seq, cmd, st);
73109
return;
74110
}
75-
if (w->endTransmission() != 0) { proto_reply_err(seq, cmd, ST_IO); return; }
76111
proto_reply_ok(seq, cmd);
77112
break;
78113
}

firmware/src/mod_sys.cpp

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -211,13 +211,14 @@ void sys_handle(uint8_t op, uint8_t seq, const uint8_t* p, uint16_t len) {
211211
break;
212212
#endif
213213

214-
case 0x05: { // FREE_HEAP
215-
uint8_t buf[16];
214+
case 0x05: { // FREE_HEAP -> free, min_free, largest, dropped_evts, rx_dropped
215+
uint8_t buf[20];
216216
wr32(buf, ESP.getFreeHeap());
217217
wr32(buf + 4, ESP.getMinFreeHeap());
218218
wr32(buf + 8, heap_caps_get_largest_free_block(MALLOC_CAP_8BIT));
219219
wr32(buf + 12, proto_dropped_events());
220-
proto_reply(seq, cmd, buf, 16);
220+
wr32(buf + 16, link_ble_rx_dropped()); // fw >= 0.3.2 (host len-checks)
221+
proto_reply(seq, cmd, buf, 20);
221222
break;
222223
}
223224

0 commit comments

Comments
 (0)