Send large BLE host frames across multiple notifies.

Prefer ATT MTU 517 and chunk oversized responses so CLIENT_INFO/BATTERY work with many pods; reset the goTool parser after timeouts so partial frames do not poison the next exchange.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-11-01 23:53:40 +01:00
co-authored by Cursor
parent 9046677460
commit 141176a824
7 changed files with 114 additions and 42 deletions
+1 -1
View File
@@ -109,7 +109,7 @@ sequenceDiagram
└─────────────────────────────────────────────────────────────┘ └─────────────────────────────────────────────────────────────┘
``` ```
**Host UART vs BLE:** Nur einer aktiv (DIP8). Gleiches Framing (`0xAA`…`0xCC`) und dieselbe `cmd_queue`. BLE: NUS-ähnliche GATT-Char, Write=RX, Notify=TX. `gotool serve` verbindet UART oder BLE über Dashboard/REST/WS (`managedHost`); **OTA** über UART oder BLE (ATT MTU ≥ ~219). Bei BLE-Host und aktivem Accel-Stream pusht `ble_stream` periodisch `BLE_STREAM_TICK` (10–16 ms, Default 10; pausiert während OTA) mit `esp_timestamp_us` (Live-Pfad bevorzugt Tick statt CACHE_STATUS-Poll). **Host UART vs BLE:** Nur einer aktiv (DIP8). Gleiches Framing (`0xAA`…`0xCC`) und dieselbe `cmd_queue`. BLE: NUS-ähnliche GATT-Char, Write=RX, Notify=TX; Frames größer als ATT MTU-3 werden in aufeinanderfolgende Notifies gestückelt (Host-Parser reassembliert). `gotool serve` verbindet UART oder BLE über Dashboard/REST/WS (`managedHost`); **OTA** über UART oder BLE (ATT MTU ≥ ~219, preferred 517). Bei BLE-Host und aktivem Accel-Stream pusht `ble_stream` periodisch `BLE_STREAM_TICK` (10–16 ms, Default 10; pausiert während OTA) mit `esp_timestamp_us` (Live-Pfad bevorzugt Tick statt CACHE_STATUS-Poll).
--- ---
+1 -1
View File
@@ -370,7 +370,7 @@ Implementierung: `ota_uart.c`, Steuerung `cmd/cmd_ota.c`. Gleiches Protokoll üb
- Inaktive Partition: `esp_ota_get_next_update_partition()`. - Inaktive Partition: `esp_ota_get_next_update_partition()`.
- LED-Ring zeigt Fortschritt (blau Schreiben, grün Verteilung). - LED-Ring zeigt Fortschritt (blau Schreiben, grün Verteilung).
- BLE: ATT MTU ≥ ~219 (preferred 247); `BLE_STREAM_TICK` pausiert während `ota_session_busy()`. - BLE: ATT MTU ≥ ~219 (preferred 517); große Antworten werden über mehrere Notifies gestückelt; `BLE_STREAM_TICK` pausiert während `ota_session_busy()`.
- **App-Rollback** (`CONFIG_BOOTLOADER_APP_ROLLBACK_ENABLE`): neues Image bootet als `PENDING_VERIFY`. `ota_confirm` markiert nach ~8 s stabilem Lauf `mark_app_valid`; Crash/Reset davor → Bootloader rollt auf die vorherige App zurück. Während `PENDING_VERIFY` ist ein neues `OTA_START` blockiert. - **App-Rollback** (`CONFIG_BOOTLOADER_APP_ROLLBACK_ENABLE`): neues Image bootet als `PENDING_VERIFY`. `ota_confirm` markiert nach ~8 s stabilem Lauf `mark_app_valid`; Crash/Reset davor → Bootloader rollt auf die vorherige App zurück. Während `PENDING_VERIFY` ist ein neues `OTA_START` blockiert.
### 8.2 ESP-NOW-OTA (Master → Slaves) ### 8.2 ESP-NOW-OTA (Master → Slaves)
+13
View File
@@ -22,6 +22,7 @@ type bleLink struct {
device bluetooth.Device device bluetooth.Device
char bluetooth.DeviceCharacteristic char bluetooth.DeviceCharacteristic
mu sync.Mutex mu sync.Mutex
parserMu sync.Mutex
parser *uartframe.Parser parser *uartframe.Parser
respCh chan []byte respCh chan []byte
streamCh chan []byte streamCh chan []byte
@@ -162,6 +163,8 @@ func connectBLEDevice(addr bluetooth.Address, settle time.Duration) (*bleLink, e
} }
func (b *bleLink) onNotify(buf []byte) { func (b *bleLink) onNotify(buf []byte) {
b.parserMu.Lock()
defer b.parserMu.Unlock()
for _, by := range buf { for _, by := range buf {
payload, ok, err := b.parser.Feed(by) payload, ok, err := b.parser.Feed(by)
if err != nil || !ok { if err != nil || !ok {
@@ -195,6 +198,12 @@ func (b *bleLink) onNotify(buf []byte) {
} }
} }
func (b *bleLink) resetParser() {
b.parserMu.Lock()
b.parser = uartframe.NewParser()
b.parserMu.Unlock()
}
func (b *bleLink) Close() error { func (b *bleLink) Close() error {
return b.device.Disconnect() return b.device.Disconnect()
} }
@@ -243,6 +252,9 @@ sent:
for { for {
remain := time.Until(deadline) remain := time.Until(deadline)
if remain <= 0 { if remain <= 0 {
// Drop a half-parsed frame (e.g. aborted multi-notify) so the next
// exchange does not treat a new 0xAA as payload data.
b.resetParser()
return nil, fmt.Errorf("BLE read timeout for %s (check DIP8=BLE / -ble)", cmdName) return nil, fmt.Errorf("BLE read timeout for %s (check DIP8=BLE / -ble)", cmdName)
} }
timer := time.NewTimer(remain) timer := time.NewTimer(remain)
@@ -264,6 +276,7 @@ sent:
} }
return resp, nil return resp, nil
case <-timer.C: case <-timer.C:
b.resetParser()
return nil, fmt.Errorf("BLE read timeout for %s (check DIP8=BLE / -ble)", cmdName) return nil, fmt.Errorf("BLE read timeout for %s (check DIP8=BLE / -ble)", cmdName)
} }
} }
+84 -13
View File
@@ -4,6 +4,7 @@
#include "host_transport.h" #include "host_transport.h"
#include "nvs_flash.h" #include "nvs_flash.h"
#include "freertos/FreeRTOS.h" #include "freertos/FreeRTOS.h"
#include "freertos/semphr.h"
#include "freertos/task.h" #include "freertos/task.h"
#include "nimble/nimble_port.h" #include "nimble/nimble_port.h"
@@ -22,6 +23,12 @@ static const char *DEVICE_NAME = "Powerpod";
#define BLE_CONN_ITVL_MIN_UNITS 6 /* 7.5 ms */ #define BLE_CONN_ITVL_MIN_UNITS 6 /* 7.5 ms */
#define BLE_CONN_ITVL_MAX_UNITS 6 #define BLE_CONN_ITVL_MAX_UNITS 6
#define BLE_CONN_TUNE_MAX_TRIES 6 #define BLE_CONN_TUNE_MAX_TRIES 6
/** Prefer large ATT MTU so CLIENT_INFO / BATTERY fit in fewer notify chunks. */
#define BLE_PREFERRED_ATT_MTU 517
#define BLE_NOTIFY_CHUNK_RETRIES 24
#define BLE_NOTIFY_CHUNK_RETRY_MS 5
/** Pace multi-chunk notifies so the central parser sees contiguous frame bytes. */
#define BLE_NOTIFY_CHUNK_GAP_MS 8
/* /*
* Nordic UART style: * Nordic UART style:
@@ -43,6 +50,7 @@ static bool notify_enabled;
static uint16_t att_mtu = 23; static uint16_t att_mtu = 23;
static esp_timer_handle_t conn_tune_timer; static esp_timer_handle_t conn_tune_timer;
static uint8_t conn_tune_tries; static uint8_t conn_tune_tries;
static SemaphoreHandle_t s_tx_mu;
static int ble_gap_event(struct ble_gap_event *event, void *arg); static int ble_gap_event(struct ble_gap_event *event, void *arg);
static void ble_request_fast_conn_params(uint16_t handle); static void ble_request_fast_conn_params(uint16_t handle);
@@ -61,28 +69,83 @@ uint16_t ble_host_max_payload(void) {
return (uint16_t)(att_mtu - 3); return (uint16_t)(att_mtu - 3);
} }
static esp_err_t ble_host_tx(const uint8_t *frame, size_t frame_len) { /**
struct os_mbuf *om; * One ATT notification (≤ MTU-3). Retries on transient NimBLE buffer pressure.
int rc; */
static esp_err_t ble_notify_chunk(const uint8_t *data, uint16_t len) {
for (int attempt = 0; attempt < BLE_NOTIFY_CHUNK_RETRIES; attempt++) {
struct os_mbuf *om = ble_hs_mbuf_from_flat(data, len);
if (om == NULL) {
vTaskDelay(pdMS_TO_TICKS(BLE_NOTIFY_CHUNK_RETRY_MS));
continue;
}
int rc = ble_gatts_notify_custom(conn_handle, ble_chr_val_handle, om);
if (rc == 0) {
return ESP_OK;
}
/* Ownership stays with us on failure. */
os_mbuf_free_chain(om);
if (!ble_host_ready()) { if (!ble_host_ready()) {
return ESP_ERR_INVALID_STATE; return ESP_ERR_INVALID_STATE;
} }
if (frame_len == 0 || frame_len > ble_host_max_payload()) { ESP_LOGD(TAG, "notify chunk rc=%d attempt=%d len=%u", rc, attempt,
(unsigned)len);
vTaskDelay(pdMS_TO_TICKS(BLE_NOTIFY_CHUNK_RETRY_MS));
}
ESP_LOGW(TAG, "notify chunk gave up after retries len=%u", (unsigned)len);
return ESP_ERR_NO_MEM;
}
/**
* Send a full host frame. If larger than ATT MTU-3, split across consecutive
* notifies; goTool feeds every notify into the same byte parser, so the frame
* reassembles without a protocol change. Mutex keeps stream ticks from
* interleaving mid-frame.
*/
static esp_err_t ble_host_tx(const uint8_t *frame, size_t frame_len) {
if (!ble_host_ready()) {
return ESP_ERR_INVALID_STATE;
}
if (frame == NULL || frame_len == 0) {
return ESP_ERR_INVALID_ARG;
}
uint16_t max_chunk = ble_host_max_payload();
if (max_chunk == 0) {
return ESP_ERR_INVALID_SIZE; return ESP_ERR_INVALID_SIZE;
} }
om = ble_hs_mbuf_from_flat(frame, (uint16_t)frame_len); if (s_tx_mu == NULL ||
if (om == NULL) { xSemaphoreTake(s_tx_mu, pdMS_TO_TICKS(2000)) != pdTRUE) {
return ESP_ERR_NO_MEM; return ESP_ERR_TIMEOUT;
} }
rc = ble_gatts_notify_custom(conn_handle, ble_chr_val_handle, om); esp_err_t result = ESP_OK;
if (rc != 0) { size_t off = 0;
os_mbuf_free_chain(om); while (off < frame_len) {
return ESP_FAIL; if (!ble_host_ready()) {
result = ESP_ERR_INVALID_STATE;
break;
} }
return ESP_OK; size_t remain = frame_len - off;
uint16_t n =
(uint16_t)(remain > max_chunk ? max_chunk : remain);
result = ble_notify_chunk(&frame[off], n);
if (result != ESP_OK) {
ESP_LOGW(TAG, "TX abort at %u/%u (chunk %u, mtu_pay %u)",
(unsigned)off, (unsigned)frame_len, (unsigned)n,
(unsigned)max_chunk);
break;
}
off += n;
if (off < frame_len) {
vTaskDelay(pdMS_TO_TICKS(BLE_NOTIFY_CHUNK_GAP_MS));
}
}
xSemaphoreGive(s_tx_mu);
return result;
} }
static void ble_conn_tune_timer_cb(void *arg) { static void ble_conn_tune_timer_cb(void *arg) {
@@ -365,6 +428,12 @@ esp_err_t init_ble_host(QueueHandle_t cmd_queue) {
host_transport_set_cmd_queue(cmd_queue); host_transport_set_cmd_queue(cmd_queue);
host_transport_set_tx(ble_host_tx); host_transport_set_tx(ble_host_tx);
s_tx_mu = xSemaphoreCreateMutex();
if (s_tx_mu == NULL) {
ESP_LOGE(TAG, "failed to create TX mutex");
return ESP_ERR_NO_MEM;
}
/* NVS may already be init via pod_settings; ignore ALREADY_INIT. */ /* NVS may already be init via pod_settings; ignore ALREADY_INIT. */
ret = nvs_flash_init(); ret = nvs_flash_init();
if (ret == ESP_ERR_NVS_NO_FREE_PAGES || if (ret == ESP_ERR_NVS_NO_FREE_PAGES ||
@@ -404,9 +473,11 @@ esp_err_t init_ble_host(QueueHandle_t cmd_queue) {
return ESP_FAIL; return ESP_FAIL;
} }
rc = ble_att_set_preferred_mtu(247); rc = ble_att_set_preferred_mtu(BLE_PREFERRED_ATT_MTU);
if (rc != 0) { if (rc != 0) {
ESP_LOGW(TAG, "preferred MTU set failed: %d", rc); ESP_LOGW(TAG, "preferred MTU set failed: %d", rc);
} else {
ESP_LOGI(TAG, "preferred ATT MTU %u", (unsigned)BLE_PREFERRED_ATT_MTU);
} }
nimble_port_freertos_init(ble_host_task); nimble_port_freertos_init(ble_host_task);
+1 -1
View File
@@ -16,7 +16,7 @@ bool ble_host_ready(void);
/** Negotiated ATT MTU (default 23 until exchange). */ /** Negotiated ATT MTU (default 23 until exchange). */
uint16_t ble_host_att_mtu(void); uint16_t ble_host_att_mtu(void);
/** Max notify/write payload bytes (ATT MTU - 3). */ /** Max bytes per ATT notify/write (ATT MTU - 3). Frames may span multiple notifies. */
uint16_t ble_host_max_payload(void); uint16_t ble_host_max_payload(void);
#endif #endif
+12 -25
View File
@@ -7,10 +7,9 @@
#include "freertos/FreeRTOS.h" #include "freertos/FreeRTOS.h"
#include "freertos/task.h" #include "freertos/task.h"
#include "ota_session.h" #include "ota_session.h"
#include "pb_encode.h"
#include "uart.h"
#include "uart_cmd.h" #include "uart_cmd.h"
#include "uart_messages.pb.h" #include "uart_messages.pb.h"
#include "uart_proto.h"
#include <string.h> #include <string.h>
static const char *TAG = "[BLE_STREAM]"; static const char *TAG = "[BLE_STREAM]";
@@ -102,34 +101,22 @@ static bool any_accel_stream(void) {
} }
static esp_err_t send_tick_truncated(alox_UartMessage *msg) { static esp_err_t send_tick_truncated(alox_UartMessage *msg) {
uint8_t pb_buf[MAX_PAYLOAD_SIZE]; /*
pb_ostream_t stream = pb_ostream_from_buffer(pb_buf, sizeof(pb_buf)); * ble_host_tx splits frames larger than ATT MTU-3 across notifies. Only
* shrink the tick if it cannot encode into MAX_PAYLOAD_SIZE.
*/
while (true) { while (true) {
stream = pb_ostream_from_buffer(pb_buf, sizeof(pb_buf)); esp_err_t err = uart_send_uart_message(msg);
if (!pb_encode(&stream, alox_UartMessage_fields, msg)) { if (err == ESP_OK) {
return ESP_FAIL; return ESP_OK;
} }
size_t payload_len = stream.bytes_written + 1;
uint16_t max_pay = ble_host_max_payload();
/* Framed size = payload + UART_FRAME_OVERHEAD; notify must fit ATT MTU-3. */
size_t frame_len = payload_len + UART_FRAME_OVERHEAD;
if (payload_len <= MAX_PAYLOAD_SIZE && frame_len <= max_pay) {
uint8_t payload[MAX_PAYLOAD_SIZE];
payload[0] = (uint8_t)msg->type;
memcpy(&payload[1], pb_buf, stream.bytes_written);
return uart_send_framed(payload, payload_len);
}
if (msg->payload.ble_stream_tick.clients_count == 0) { if (msg->payload.ble_stream_tick.clients_count == 0) {
ESP_LOGW(TAG, "tick too large even with 0 clients (need %u max %u)", ESP_LOGW(TAG, "tick too large even with 0 clients: %s",
(unsigned)frame_len, (unsigned)max_pay); esp_err_to_name(err));
return ESP_ERR_NO_MEM; return err;
} }
msg->payload.ble_stream_tick.clients_count--; msg->payload.ble_stream_tick.clients_count--;
ESP_LOGW(TAG, "truncating tick clients to %u for MTU", ESP_LOGW(TAG, "truncating tick clients to %u for encode buffer",
(unsigned)msg->payload.ble_stream_tick.clients_count); (unsigned)msg->payload.ble_stream_tick.clients_count);
} }
} }
+2 -1
View File
@@ -42,7 +42,8 @@ void uart_cmd_init_response(alox_UartMessage *msg, alox_MessageType type,
esp_err_t uart_cmd_send(const alox_UartMessage *msg, const char *log_tag) { esp_err_t uart_cmd_send(const alox_UartMessage *msg, const char *log_tag) {
esp_err_t err = uart_send_uart_message(msg); esp_err_t err = uart_send_uart_message(msg);
if (err != ESP_OK && log_tag != NULL) { if (err != ESP_OK && log_tag != NULL) {
ESP_LOGE(log_tag, "failed to send UART response"); ESP_LOGE(log_tag, "failed to send UART response: %s",
esp_err_to_name(err));
} }
return err; return err;
} }