From 26b2dc27fad8bafdd0561a244aa8009357ddfead Mon Sep 17 00:00:00 2001 From: Aleksejs Popovs Date: Mon, 19 Jan 2026 17:03:53 -0500 Subject: [PATCH 1/8] Initial implementation of Bluetooth file transfer --- .../bluetooth/BluetoothActivity.cpp | 321 ++++++++++++++++++ src/activities/bluetooth/BluetoothActivity.h | 124 +++++++ src/activities/home/HomeActivity.cpp | 7 +- src/activities/home/HomeActivity.h | 4 +- src/main.cpp | 8 +- 5 files changed, 460 insertions(+), 4 deletions(-) create mode 100644 src/activities/bluetooth/BluetoothActivity.cpp create mode 100644 src/activities/bluetooth/BluetoothActivity.h diff --git a/src/activities/bluetooth/BluetoothActivity.cpp b/src/activities/bluetooth/BluetoothActivity.cpp new file mode 100644 index 00000000..f517733d --- /dev/null +++ b/src/activities/bluetooth/BluetoothActivity.cpp @@ -0,0 +1,321 @@ +#include "BluetoothActivity.h" + +#include +#include + +#include "MappedInputManager.h" +#include "fontIds.h" + +#define DEVICE_NAME "EPaper" +#define SERVICE_UUID "4ae29d01-499a-480a-8c41-a82192105125" +#define REQUEST_CHARACTERISTIC_UUID "a00e530d-b48b-48c8-aadb-d062a1b91792" +#define RESPONSE_CHARACTERISTIC_UUID "0c656023-dee6-47c5-9afb-e601dfbdaa1d" + +#define OUTPUT_DIRECTORY "/bt" + +#define PROTOCOL_ASSERT(cond, fmt, ...) \ + do { \ + if (!(cond)) \ + { \ + snprintf(errorMessage, sizeof(errorMessage), fmt, ##__VA_ARGS__); \ + intoState(STATE_ERROR); \ + return; \ + } \ + } while (0) + +void BluetoothActivity::taskTrampoline(void* param) { + auto* self = static_cast(param); + self->displayTaskLoop(); +} + +void BluetoothActivity::startAdvertising() { + BLEDevice::startAdvertising(); +} + +void BluetoothActivity::stopAdvertising() { + BLEDevice::stopAdvertising(); +} + +void BluetoothActivity::onEnter() { + Activity::onEnter(); + + BLEDevice::init(DEVICE_NAME); + pServer = BLEDevice::createServer(); + pServer->setCallbacks(&serverCallbacks); + pService = pServer->createService(SERVICE_UUID); + pRequestChar = pService->createCharacteristic( + REQUEST_CHARACTERISTIC_UUID, + BLECharacteristic::PROPERTY_WRITE | BLECharacteristic::PROPERTY_WRITE_NR + ); + pRequestChar->setCallbacks(&requestCallbacks); + pResponseChar = pService->createCharacteristic( + RESPONSE_CHARACTERISTIC_UUID, + BLECharacteristic::PROPERTY_INDICATE + ); + pResponseChar->addDescriptor(new BLE2902()); + pService->start(); + + BLEAdvertising *pAdvertising = BLEDevice::getAdvertising(); + pAdvertising->addServiceUUID(SERVICE_UUID); + pAdvertising->setScanResponse(true); + pAdvertising->setMinPreferred(0x06); + pAdvertising->setMinPreferred(0x12); + + renderingMutex = xSemaphoreCreateMutex(); + + state = STATE_INITIALIZING; + intoState(STATE_WAITING); + + xTaskCreate(&BluetoothActivity::taskTrampoline, "BluetoothTask", + // TODO: figure out how much stack we actually need + 4096, // Stack size + this, // Parameters + 1, // Priority + &displayTaskHandle // Task handle + ); +} + +void BluetoothActivity::intoState(State newState) { + if (state == newState) { + return; + } + + switch (newState) { + case STATE_WAITING: + file.close(); + startAdvertising(); + txnId = 0; + break; + case STATE_OFFERED: + // caller sets filename, totalBytes, file, txnId + receivedBytes = 0; + break; + case STATE_ERROR: + { + // caller sets errorMessage + file.close(); + auto connId = pServer->getConnId(); + if (connId != ESP_GATT_IF_NONE) { + // TODO: send back a response over BLE? + pServer->disconnect(connId); + } + break; + } + } + + state = newState; + updateRequired = true; +} + +void BluetoothActivity::onExit() { + Activity::onExit(); + + file.close(); + + stopAdvertising(); + + pService->stop(); + BLEDevice::deinit(); + + // Wait until not rendering to delete task + xSemaphoreTake(renderingMutex, portMAX_DELAY); + if (displayTaskHandle) { + vTaskDelete(displayTaskHandle); + displayTaskHandle = nullptr; + } + vSemaphoreDelete(renderingMutex); + renderingMutex = nullptr; +} + +void BluetoothActivity::loop() { + // Handle back button - cancel + if (mappedInput.wasPressed(MappedInputManager::Button::Back)) { + onCancel(); + return; + } + + if (state == STATE_ERROR) { + if (mappedInput.wasPressed(MappedInputManager::Button::Confirm)) { + // restart + intoState(STATE_WAITING); + } + } +} + +void BluetoothActivity::displayTaskLoop() { + while (true) { + if (updateRequired) { + updateRequired = false; + xSemaphoreTake(renderingMutex, portMAX_DELAY); + render(); + xSemaphoreGive(renderingMutex); + } + vTaskDelay(10 / portTICK_PERIOD_MS); + } +} + +void BluetoothActivity::render() const { + renderer.clearScreen(); + + renderer.drawCenteredText(UI_12_FONT_ID, 15, "Bluetooth", true, EpdFontFamily::BOLD); + renderer.drawCenteredText(UI_10_FONT_ID, 50, "Use the Longform app to transfer files."); + + std::string stateText; + switch (state) { + case STATE_WAITING: + stateText = "Waiting for a connection."; + break; + case STATE_CONNECTED: + stateText = "Connected."; + break; + case STATE_OFFERED: + stateText = "Ready to receive."; + break; + case STATE_RECEIVING: + stateText = "Receiving."; + break; + case STATE_DONE: + stateText = "Transfer complete."; + break; + case STATE_ERROR: + stateText = "An error occurred."; + break; + default: + stateText = "UNKNOWN STATE."; + } + renderer.drawCenteredText(UI_10_FONT_ID, 75, stateText.c_str()); + + if (state == STATE_OFFERED || state == STATE_RECEIVING || state == STATE_DONE) { + renderer.drawCenteredText(UI_12_FONT_ID, 110, filename); + } else if (state == STATE_ERROR) { + renderer.drawCenteredText(UI_10_FONT_ID, 110, errorMessage); + } + + if (state == STATE_RECEIVING) { + const int percent = (totalBytes > 0) ? (receivedBytes * 100) / totalBytes : 0; + + const int barWidth = renderer.getScreenWidth() * 3 / 4; + const int barHeight = 20; + const int boxX = (renderer.getScreenWidth() - barWidth) / 2; + const int boxY = 160; + renderer.drawRect(boxX, boxY, barWidth, barHeight); + const int fillWidth = (barWidth - 2) * percent / 100; + renderer.fillRect(boxX + 1, boxY + 1, fillWidth, barHeight - 2); + + char dynamicText[64]; + snprintf(dynamicText, sizeof(dynamicText), "Received %zu / %zu bytes (%d%%)", receivedBytes, totalBytes, percent); + renderer.drawCenteredText(UI_10_FONT_ID, 200, dynamicText); + } + + // Draw help text at bottom + const auto labels = mappedInput.mapLabels( + "« Back", + (state == STATE_ERROR) ? "Restart" : "", + "", + "" + ); + renderer.drawButtonHints(UI_10_FONT_ID, labels.btn1, labels.btn2, labels.btn3, labels.btn4); + + renderer.displayBuffer(); +} + +void BluetoothActivity::ServerCallbacks::onConnect(BLEServer* pServer) { + Serial.printf("BLE connected\n"); + activity->onConnected(true); +} + +void BluetoothActivity::ServerCallbacks::onDisconnect(BLEServer* pServer) { + Serial.printf("BLE disconnected\n"); + activity->onConnected(false); +} + +void BluetoothActivity::onConnected(bool isConnected) { + if (state == STATE_ERROR) { + // stay in error state so the user can read the error message even after disconnect + return; + } + + intoState(isConnected ? STATE_CONNECTED : STATE_WAITING); +} + +void BluetoothActivity::onRequest(lfbt_message* msg, size_t msg_len) { + if (state == STATE_ERROR) { + // ignore further messages in error state + return; + } + + PROTOCOL_ASSERT((txnId == 0) || (txnId == msg->txnId), "Multiple transfers happening at once (%x != %x)", txnId, msg->txnId); + + switch (msg->type) { + case 0: // client_offer + { + PROTOCOL_ASSERT(state == STATE_CONNECTED, "Invalid state for client_offer: %d", state); + PROTOCOL_ASSERT(msg->body.clientOffer.version == 1, "Unsupported protocol version: %u", msg->body.clientOffer.version); + + totalBytes = msg->body.clientOffer.bodyLength; + + size_t filenameLen = msg_len - 8 - sizeof(lfbt_msg_client_offer); + if (filenameLen > MAX_FILENAME) { + filenameLen = MAX_FILENAME; + } + + memcpy(filename, msg->body.clientOffer.name, filenameLen); + filename[filenameLen] = 0; + + // sanitize filename + for (char *p = filename; *p; p++) { + if (*p == '/' || *p == '\\' || *p == ':') { + *p = '_'; + } + } + + PROTOCOL_ASSERT(SdMan.ensureDirectoryExists(OUTPUT_DIRECTORY), "Couldn't create output directory %s", OUTPUT_DIRECTORY); + char filepath[MAX_FILENAME + strlen(OUTPUT_DIRECTORY) + 2]; + snprintf(filepath, sizeof(filepath), "%s/%s", OUTPUT_DIRECTORY, filename); + // TODO: we could check if file already exists and append a number to filename to avoid overwriting + PROTOCOL_ASSERT(SdMan.openFileForWrite("BT", filepath, file), "Couldn't open file %s for writing", filepath); + // TODO: would be neat to check if we have enough space, but SDCardManager doesn't seem to expose that info currently + + txnId = msg->txnId; + + intoState(STATE_OFFERED); + + lfbt_message response = { + .type = 1, // server_response + .txnId = txnId, + .body = {.serverResponse = {.status = 0}} + }; + pResponseChar->setValue((uint8_t*)&response, 8 + sizeof(lfbt_msg_server_response)); + pResponseChar->indicate(); // TODO: indicate should not be called in a callback, this ends up timing out + + updateRequired = true; + break; + } + case 2: // client_chunk + { + Serial.printf("Received client_chunk, offset %u, length %zu\n", msg->body.clientChunk.offset, msg_len - 8 - sizeof(lfbt_msg_client_chunk)); + PROTOCOL_ASSERT(state == STATE_OFFERED || state == STATE_RECEIVING, "Invalid state for client_chunk: %d", state); + PROTOCOL_ASSERT(msg->body.clientChunk.offset == receivedBytes, "Expected chunk %d, got %d", receivedBytes, msg->body.clientChunk.offset); + + size_t written = file.write((uint8_t*)msg->body.clientChunk.body, msg_len - 8 - sizeof(lfbt_msg_client_chunk)); + PROTOCOL_ASSERT(written > 0, "Couldn't write to file"); + receivedBytes += msg_len - 8 - sizeof(lfbt_msg_client_chunk); + if (receivedBytes >= totalBytes) { + PROTOCOL_ASSERT(receivedBytes == totalBytes, "Got more bytes than expected: %zu > %zu", receivedBytes, totalBytes); + PROTOCOL_ASSERT(file.close(), "Couldn't finalize writing the file"); + // TODO: automatically open file in reader + intoState(STATE_DONE); + } else { + intoState(STATE_RECEIVING); + } + updateRequired = true; + break; + } + } +} + +void BluetoothActivity::RequestCallbacks::onWrite(BLECharacteristic* pCharacteristic, esp_ble_gatts_cb_param_t* param) { + lfbt_message *msg = (lfbt_message*) param->write.value; + Serial.printf("Received BLE message of type %u, txnId %x, length %d\n", msg->type, msg->txnId, param->write.len); + activity->onRequest(msg, param->write.len); +} diff --git a/src/activities/bluetooth/BluetoothActivity.h b/src/activities/bluetooth/BluetoothActivity.h new file mode 100644 index 00000000..d8bba3be --- /dev/null +++ b/src/activities/bluetooth/BluetoothActivity.h @@ -0,0 +1,124 @@ +#pragma once +#include +#include +#include + +#include +#include +#include + +#include + +#include + +#include "../Activity.h" + +#define MAX_FILENAME 200 + +typedef struct __attribute__((packed)) { + uint32_t version; + uint32_t bodyLength; + uint32_t nameLength; + char name[]; +} lfbt_msg_client_offer; // msg type 0 + +typedef struct __attribute__((packed)) { + uint32_t status; +} lfbt_msg_server_response; // msg type 1 + +typedef struct __attribute__((packed)) { + uint32_t offset; + char body[]; +} lfbt_msg_client_chunk; // msg type 2 + +typedef union { + lfbt_msg_client_offer clientOffer; + lfbt_msg_server_response serverResponse; + lfbt_msg_client_chunk clientChunk; +} lfbt_message_body; + +typedef struct __attribute__((packed)) { + uint32_t type; + uint32_t txnId; + lfbt_message_body body; +} lfbt_message; + +/** + * BluetoothActivity receives files over a custom BLE protocol and stores them on the SD card. + * + * The onCancel callback is called if the user presses back. + */ +class BluetoothActivity final : public Activity { + TaskHandle_t displayTaskHandle = nullptr; + SemaphoreHandle_t renderingMutex = nullptr; + bool updateRequired = false; + const std::function onCancel; + + static void taskTrampoline(void* param); + [[noreturn]] void displayTaskLoop(); + void render() const; + + void onConnected(bool isConnected); + void onRequest(lfbt_message *msg, size_t msg_len); + + class ServerCallbacks : public BLEServerCallbacks { + friend class BluetoothActivity; + BluetoothActivity *activity; + + void onConnect(BLEServer* pServer); + void onDisconnect(BLEServer* pServer); + + protected: + explicit ServerCallbacks(BluetoothActivity *activity) : activity(activity) {} + }; + + ServerCallbacks serverCallbacks; + + class RequestCallbacks : public BLECharacteristicCallbacks { + friend class BluetoothActivity; + BluetoothActivity *activity; + + void onWrite(BLECharacteristic* pCharacteristic, esp_ble_gatts_cb_param_t* param); + + protected: + explicit RequestCallbacks(BluetoothActivity *activity) : activity(activity) {} + }; + + RequestCallbacks requestCallbacks; + + BLEServer *pServer; + BLEService *pService; + BLECharacteristic *pRequestChar, *pResponseChar; + BLEAdvertising *pAdvertising; + void startAdvertising(); + void stopAdvertising(); + + typedef enum { + STATE_INITIALIZING, + STATE_WAITING, + STATE_CONNECTED, + STATE_OFFERED, + STATE_RECEIVING, + STATE_DONE, + STATE_ERROR + } State; + + State state = STATE_INITIALIZING; + char filename[MAX_FILENAME + 1]; + FsFile file; + size_t receivedBytes = 0; + size_t totalBytes = 0; + char errorMessage[256]; + uint32_t txnId; + + void intoState(State newState); + + public: + explicit BluetoothActivity(GfxRenderer& renderer, MappedInputManager& mappedInput, + const std::function& onCancel) + : Activity("Bluetooth", renderer, mappedInput), onCancel(onCancel), + serverCallbacks(this), requestCallbacks(this) {} + void onEnter() override; + void onExit() override; + void loop() override; +}; diff --git a/src/activities/home/HomeActivity.cpp b/src/activities/home/HomeActivity.cpp index eb11ba95..9aadc699 100644 --- a/src/activities/home/HomeActivity.cpp +++ b/src/activities/home/HomeActivity.cpp @@ -23,7 +23,7 @@ void HomeActivity::taskTrampoline(void* param) { } int HomeActivity::getMenuItemCount() const { - int count = 3; // My Library, File transfer, Settings + int count = 4; // My Library, File transfer, Bluetooth, Settings if (hasContinueReading) count++; if (hasOpdsUrl) count++; return count; @@ -172,6 +172,7 @@ void HomeActivity::loop() { const int myLibraryIdx = idx++; const int opdsLibraryIdx = hasOpdsUrl ? idx++ : -1; const int fileTransferIdx = idx++; + const int bluetoothIdx = idx++; const int settingsIdx = idx; if (selectorIndex == continueIdx) { @@ -182,6 +183,8 @@ void HomeActivity::loop() { onOpdsBrowserOpen(); } else if (selectorIndex == fileTransferIdx) { onFileTransferOpen(); + } else if (selectorIndex == bluetoothIdx) { + onBluetoothOpen(); } else if (selectorIndex == settingsIdx) { onSettingsOpen(); } @@ -500,7 +503,7 @@ void HomeActivity::render() { // --- Bottom menu tiles --- // Build menu items dynamically - std::vector menuItems = {"My Library", "File Transfer", "Settings"}; + std::vector menuItems = {"My Library", "File Transfer", "Bluetooth", "Settings"}; if (hasOpdsUrl) { // Insert Calibre Library after My Library menuItems.insert(menuItems.begin() + 1, "Calibre Library"); diff --git a/src/activities/home/HomeActivity.h b/src/activities/home/HomeActivity.h index 52963514..be703c61 100644 --- a/src/activities/home/HomeActivity.h +++ b/src/activities/home/HomeActivity.h @@ -25,6 +25,7 @@ class HomeActivity final : public Activity { const std::function onMyLibraryOpen; const std::function onSettingsOpen; const std::function onFileTransferOpen; + const std::function onBluetoothOpen; const std::function onOpdsBrowserOpen; static void taskTrampoline(void* param); @@ -39,12 +40,13 @@ class HomeActivity final : public Activity { explicit HomeActivity(GfxRenderer& renderer, MappedInputManager& mappedInput, const std::function& onContinueReading, const std::function& onMyLibraryOpen, const std::function& onSettingsOpen, const std::function& onFileTransferOpen, - const std::function& onOpdsBrowserOpen) + const std::function& onBluetoothOpen, const std::function& onOpdsBrowserOpen) : Activity("Home", renderer, mappedInput), onContinueReading(onContinueReading), onMyLibraryOpen(onMyLibraryOpen), onSettingsOpen(onSettingsOpen), onFileTransferOpen(onFileTransferOpen), + onBluetoothOpen(onBluetoothOpen), onOpdsBrowserOpen(onOpdsBrowserOpen) {} void onEnter() override; void onExit() override; diff --git a/src/main.cpp b/src/main.cpp index c0222e0d..b5db102c 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -15,6 +15,7 @@ #include "KOReaderCredentialStore.h" #include "MappedInputManager.h" #include "RecentBooksStore.h" +#include "activities/bluetooth/BluetoothActivity.h" #include "activities/boot_sleep/BootActivity.h" #include "activities/boot_sleep/SleepActivity.h" #include "activities/browser/OpdsBookBrowserActivity.h" @@ -226,6 +227,11 @@ void onGoToFileTransfer() { enterNewActivity(new CrossPointWebServerActivity(renderer, mappedInputManager, onGoHome)); } +void onGoToBluetooth() { + exitActivity(); + enterNewActivity(new BluetoothActivity(renderer, mappedInputManager, onGoHome)); +} + void onGoToSettings() { exitActivity(); enterNewActivity(new SettingsActivity(renderer, mappedInputManager, onGoHome)); @@ -249,7 +255,7 @@ void onGoToBrowser() { void onGoHome() { exitActivity(); enterNewActivity(new HomeActivity(renderer, mappedInputManager, onContinueReading, onGoToMyLibrary, onGoToSettings, - onGoToFileTransfer, onGoToBrowser)); + onGoToFileTransfer, onGoToBluetooth, onGoToBrowser)); } void setupDisplayAndFonts() { From e45780674d488d2f66906a619abd2988f91e2358 Mon Sep 17 00:00:00 2001 From: Aleksejs Popovs Date: Tue, 20 Jan 2026 19:41:58 -0500 Subject: [PATCH 2/8] port to nimble --- platformio.ini | 1 + .../bluetooth/BluetoothActivity.cpp | 45 +++++++++---------- src/activities/bluetooth/BluetoothActivity.h | 24 +++++----- 3 files changed, 33 insertions(+), 37 deletions(-) diff --git a/platformio.ini b/platformio.ini index 7f42637d..fe10370b 100644 --- a/platformio.ini +++ b/platformio.ini @@ -48,6 +48,7 @@ lib_deps = bblanchon/ArduinoJson @ 7.4.2 ricmoo/QRCode @ 0.0.1 links2004/WebSockets @ 2.7.3 + h2zero/NimBLE-Arduino @ 2.3.7 [env:default] extends = base diff --git a/src/activities/bluetooth/BluetoothActivity.cpp b/src/activities/bluetooth/BluetoothActivity.cpp index f517733d..9bce65ea 100644 --- a/src/activities/bluetooth/BluetoothActivity.cpp +++ b/src/activities/bluetooth/BluetoothActivity.cpp @@ -1,7 +1,6 @@ #include "BluetoothActivity.h" #include -#include #include "MappedInputManager.h" #include "fontIds.h" @@ -29,37 +28,35 @@ void BluetoothActivity::taskTrampoline(void* param) { } void BluetoothActivity::startAdvertising() { - BLEDevice::startAdvertising(); + NimBLEDevice::startAdvertising(); } void BluetoothActivity::stopAdvertising() { - BLEDevice::stopAdvertising(); + NimBLEDevice::stopAdvertising(); } void BluetoothActivity::onEnter() { Activity::onEnter(); - BLEDevice::init(DEVICE_NAME); - pServer = BLEDevice::createServer(); - pServer->setCallbacks(&serverCallbacks); + NimBLEDevice::init(DEVICE_NAME); + pServer = NimBLEDevice::createServer(); + pServer->setCallbacks(&serverCallbacks, false); pService = pServer->createService(SERVICE_UUID); pRequestChar = pService->createCharacteristic( REQUEST_CHARACTERISTIC_UUID, - BLECharacteristic::PROPERTY_WRITE | BLECharacteristic::PROPERTY_WRITE_NR + NIMBLE_PROPERTY::WRITE | NIMBLE_PROPERTY::WRITE_NR ); pRequestChar->setCallbacks(&requestCallbacks); pResponseChar = pService->createCharacteristic( RESPONSE_CHARACTERISTIC_UUID, - BLECharacteristic::PROPERTY_INDICATE + NIMBLE_PROPERTY::INDICATE ); - pResponseChar->addDescriptor(new BLE2902()); pService->start(); - BLEAdvertising *pAdvertising = BLEDevice::getAdvertising(); - pAdvertising->addServiceUUID(SERVICE_UUID); - pAdvertising->setScanResponse(true); - pAdvertising->setMinPreferred(0x06); - pAdvertising->setMinPreferred(0x12); + NimBLEAdvertising *pAdvertising = NimBLEDevice::getAdvertising(); + pAdvertising->setName(DEVICE_NAME); + pAdvertising->addServiceUUID(pService->getUUID()); + pAdvertising->enableScanResponse(true); renderingMutex = xSemaphoreCreateMutex(); @@ -94,10 +91,9 @@ void BluetoothActivity::intoState(State newState) { { // caller sets errorMessage file.close(); - auto connId = pServer->getConnId(); - if (connId != ESP_GATT_IF_NONE) { + if (pServer->getConnectedCount() > 0) { // TODO: send back a response over BLE? - pServer->disconnect(connId); + pServer->disconnect(pServer->getPeerInfo(0)); } break; } @@ -114,8 +110,7 @@ void BluetoothActivity::onExit() { stopAdvertising(); - pService->stop(); - BLEDevice::deinit(); + NimBLEDevice::deinit(true); // Wait until not rendering to delete task xSemaphoreTake(renderingMutex, portMAX_DELAY); @@ -219,12 +214,12 @@ void BluetoothActivity::render() const { renderer.displayBuffer(); } -void BluetoothActivity::ServerCallbacks::onConnect(BLEServer* pServer) { +void BluetoothActivity::ServerCallbacks::onConnect(NimBLEServer* pServer, NimBLEConnInfo& connInfo) { Serial.printf("BLE connected\n"); activity->onConnected(true); } -void BluetoothActivity::ServerCallbacks::onDisconnect(BLEServer* pServer) { +void BluetoothActivity::ServerCallbacks::onDisconnect(NimBLEServer* pServer, NimBLEConnInfo& connInfo, int reason) { Serial.printf("BLE disconnected\n"); activity->onConnected(false); } @@ -314,8 +309,8 @@ void BluetoothActivity::onRequest(lfbt_message* msg, size_t msg_len) { } } -void BluetoothActivity::RequestCallbacks::onWrite(BLECharacteristic* pCharacteristic, esp_ble_gatts_cb_param_t* param) { - lfbt_message *msg = (lfbt_message*) param->write.value; - Serial.printf("Received BLE message of type %u, txnId %x, length %d\n", msg->type, msg->txnId, param->write.len); - activity->onRequest(msg, param->write.len); +void BluetoothActivity::RequestCallbacks::onWrite(NimBLECharacteristic* pCharacteristic, NimBLEConnInfo& connInfo) { + lfbt_message *msg = (lfbt_message*) pCharacteristic->getValue().data(); + Serial.printf("Received BLE message of type %u, txnId %x, length %d\n", msg->type, msg->txnId, pCharacteristic->getValue().length()); + activity->onRequest(msg, pCharacteristic->getValue().length()); } diff --git a/src/activities/bluetooth/BluetoothActivity.h b/src/activities/bluetooth/BluetoothActivity.h index d8bba3be..8b7cc1a4 100644 --- a/src/activities/bluetooth/BluetoothActivity.h +++ b/src/activities/bluetooth/BluetoothActivity.h @@ -3,9 +3,9 @@ #include #include -#include -#include -#include +#include +#include +#include #include @@ -61,12 +61,12 @@ class BluetoothActivity final : public Activity { void onConnected(bool isConnected); void onRequest(lfbt_message *msg, size_t msg_len); - class ServerCallbacks : public BLEServerCallbacks { + class ServerCallbacks : public NimBLEServerCallbacks { friend class BluetoothActivity; BluetoothActivity *activity; - void onConnect(BLEServer* pServer); - void onDisconnect(BLEServer* pServer); + void onConnect(NimBLEServer* pServer, NimBLEConnInfo& connInfo); + void onDisconnect(NimBLEServer* pServer, NimBLEConnInfo& connInfo, int reason); protected: explicit ServerCallbacks(BluetoothActivity *activity) : activity(activity) {} @@ -74,11 +74,11 @@ class BluetoothActivity final : public Activity { ServerCallbacks serverCallbacks; - class RequestCallbacks : public BLECharacteristicCallbacks { + class RequestCallbacks : public NimBLECharacteristicCallbacks { friend class BluetoothActivity; BluetoothActivity *activity; - void onWrite(BLECharacteristic* pCharacteristic, esp_ble_gatts_cb_param_t* param); + void onWrite(NimBLECharacteristic* pCharacteristic, NimBLEConnInfo& connInfo); protected: explicit RequestCallbacks(BluetoothActivity *activity) : activity(activity) {} @@ -86,10 +86,10 @@ class BluetoothActivity final : public Activity { RequestCallbacks requestCallbacks; - BLEServer *pServer; - BLEService *pService; - BLECharacteristic *pRequestChar, *pResponseChar; - BLEAdvertising *pAdvertising; + NimBLEServer *pServer; + NimBLEService *pService; + NimBLECharacteristic *pRequestChar, *pResponseChar; + NimBLEAdvertising *pAdvertising; void startAdvertising(); void stopAdvertising(); From 687b0b5ac0970a48951f9fc9a2792eabc11421a4 Mon Sep 17 00:00:00 2001 From: Aleksejs Popovs Date: Tue, 20 Jan 2026 19:49:59 -0500 Subject: [PATCH 3/8] clean up logging, comments --- .../bluetooth/BluetoothActivity.cpp | 20 ++++++++++++++----- 1 file changed, 15 insertions(+), 5 deletions(-) diff --git a/src/activities/bluetooth/BluetoothActivity.cpp b/src/activities/bluetooth/BluetoothActivity.cpp index 9bce65ea..96d26db3 100644 --- a/src/activities/bluetooth/BluetoothActivity.cpp +++ b/src/activities/bluetooth/BluetoothActivity.cpp @@ -215,12 +215,12 @@ void BluetoothActivity::render() const { } void BluetoothActivity::ServerCallbacks::onConnect(NimBLEServer* pServer, NimBLEConnInfo& connInfo) { - Serial.printf("BLE connected\n"); + Serial.printf("[%lu] [BT] connected\n", millis()); activity->onConnected(true); } void BluetoothActivity::ServerCallbacks::onDisconnect(NimBLEServer* pServer, NimBLEConnInfo& connInfo, int reason) { - Serial.printf("BLE disconnected\n"); + Serial.printf("[%lu] [BT] disconnected\n", millis()); activity->onConnected(false); } @@ -281,14 +281,19 @@ void BluetoothActivity::onRequest(lfbt_message* msg, size_t msg_len) { .body = {.serverResponse = {.status = 0}} }; pResponseChar->setValue((uint8_t*)&response, 8 + sizeof(lfbt_msg_server_response)); - pResponseChar->indicate(); // TODO: indicate should not be called in a callback, this ends up timing out + pResponseChar->indicate(); updateRequired = true; break; } case 2: // client_chunk { - Serial.printf("Received client_chunk, offset %u, length %zu\n", msg->body.clientChunk.offset, msg_len - 8 - sizeof(lfbt_msg_client_chunk)); + Serial.printf( + "[%lu] [BT] Received client_chunk, offset %u, length %zu\n", + millis(), + msg->body.clientChunk.offset, + msg_len - 8 - sizeof(lfbt_msg_client_chunk) + ); PROTOCOL_ASSERT(state == STATE_OFFERED || state == STATE_RECEIVING, "Invalid state for client_chunk: %d", state); PROTOCOL_ASSERT(msg->body.clientChunk.offset == receivedBytes, "Expected chunk %d, got %d", receivedBytes, msg->body.clientChunk.offset); @@ -311,6 +316,11 @@ void BluetoothActivity::onRequest(lfbt_message* msg, size_t msg_len) { void BluetoothActivity::RequestCallbacks::onWrite(NimBLECharacteristic* pCharacteristic, NimBLEConnInfo& connInfo) { lfbt_message *msg = (lfbt_message*) pCharacteristic->getValue().data(); - Serial.printf("Received BLE message of type %u, txnId %x, length %d\n", msg->type, msg->txnId, pCharacteristic->getValue().length()); + Serial.printf("[%lu] [BT] Received BLE message of type %u, txnId %x, length %d\n", + millis(), + msg->type, + msg->txnId, + pCharacteristic->getValue().length() + ); activity->onRequest(msg, pCharacteristic->getValue().length()); } From 6473689e3ef431ede56880facaf6850133deef5c Mon Sep 17 00:00:00 2001 From: Aleksejs Popovs Date: Tue, 20 Jan 2026 20:12:56 -0500 Subject: [PATCH 4/8] avoid overwriting on filename conflicts --- .../bluetooth/BluetoothActivity.cpp | 43 +++++++++++-------- src/activities/bluetooth/BluetoothActivity.h | 4 +- src/util/StringUtils.cpp | 8 ++++ src/util/StringUtils.h | 6 +++ 4 files changed, 40 insertions(+), 21 deletions(-) diff --git a/src/activities/bluetooth/BluetoothActivity.cpp b/src/activities/bluetooth/BluetoothActivity.cpp index 96d26db3..b00e5d14 100644 --- a/src/activities/bluetooth/BluetoothActivity.cpp +++ b/src/activities/bluetooth/BluetoothActivity.cpp @@ -4,6 +4,7 @@ #include "MappedInputManager.h" #include "fontIds.h" +#include "util/StringUtils.h" #define DEVICE_NAME "EPaper" #define SERVICE_UUID "4ae29d01-499a-480a-8c41-a82192105125" @@ -11,6 +12,7 @@ #define RESPONSE_CHARACTERISTIC_UUID "0c656023-dee6-47c5-9afb-e601dfbdaa1d" #define OUTPUT_DIRECTORY "/bt" +#define MAX_FILENAME_LENGTH 200 #define PROTOCOL_ASSERT(cond, fmt, ...) \ do { \ @@ -181,7 +183,7 @@ void BluetoothActivity::render() const { renderer.drawCenteredText(UI_10_FONT_ID, 75, stateText.c_str()); if (state == STATE_OFFERED || state == STATE_RECEIVING || state == STATE_DONE) { - renderer.drawCenteredText(UI_12_FONT_ID, 110, filename); + renderer.drawCenteredText(UI_12_FONT_ID, 110, filename.c_str()); } else if (state == STATE_ERROR) { renderer.drawCenteredText(UI_10_FONT_ID, 110, errorMessage); } @@ -249,25 +251,30 @@ void BluetoothActivity::onRequest(lfbt_message* msg, size_t msg_len) { totalBytes = msg->body.clientOffer.bodyLength; - size_t filenameLen = msg_len - 8 - sizeof(lfbt_msg_client_offer); - if (filenameLen > MAX_FILENAME) { - filenameLen = MAX_FILENAME; - } - - memcpy(filename, msg->body.clientOffer.name, filenameLen); - filename[filenameLen] = 0; - - // sanitize filename - for (char *p = filename; *p; p++) { - if (*p == '/' || *p == '\\' || *p == ':') { - *p = '_'; - } - } + size_t filenameLength = msg_len - 8 - sizeof(lfbt_msg_client_offer); + std::string originalFilename = StringUtils::sanitizeFilename( + std::string(msg->body.clientOffer.name, filenameLength), + MAX_FILENAME_LENGTH + ); PROTOCOL_ASSERT(SdMan.ensureDirectoryExists(OUTPUT_DIRECTORY), "Couldn't create output directory %s", OUTPUT_DIRECTORY); - char filepath[MAX_FILENAME + strlen(OUTPUT_DIRECTORY) + 2]; - snprintf(filepath, sizeof(filepath), "%s/%s", OUTPUT_DIRECTORY, filename); - // TODO: we could check if file already exists and append a number to filename to avoid overwriting + + // generate unique filepath + auto splitName = StringUtils::splitFileName(originalFilename); + filename = originalFilename; + std::string filepath = OUTPUT_DIRECTORY "/" + filename; + uint32_t duplicateIndex = 0; + while (SdMan.exists(filepath.c_str())) { + duplicateIndex++; + if (splitName.second.empty()) { + // no extension + filename = splitName.first + "-" + std::to_string(duplicateIndex); + } else { + filename = splitName.first + "-" + std::to_string(duplicateIndex) + splitName.second; + } + filepath = OUTPUT_DIRECTORY "/" + filename; + } + PROTOCOL_ASSERT(SdMan.openFileForWrite("BT", filepath, file), "Couldn't open file %s for writing", filepath); // TODO: would be neat to check if we have enough space, but SDCardManager doesn't seem to expose that info currently diff --git a/src/activities/bluetooth/BluetoothActivity.h b/src/activities/bluetooth/BluetoothActivity.h index 8b7cc1a4..bfb7d6e1 100644 --- a/src/activities/bluetooth/BluetoothActivity.h +++ b/src/activities/bluetooth/BluetoothActivity.h @@ -13,8 +13,6 @@ #include "../Activity.h" -#define MAX_FILENAME 200 - typedef struct __attribute__((packed)) { uint32_t version; uint32_t bodyLength; @@ -104,7 +102,7 @@ class BluetoothActivity final : public Activity { } State; State state = STATE_INITIALIZING; - char filename[MAX_FILENAME + 1]; + std::string filename; FsFile file; size_t receivedBytes = 0; size_t totalBytes = 0; diff --git a/src/util/StringUtils.cpp b/src/util/StringUtils.cpp index 2426b687..54ef363f 100644 --- a/src/util/StringUtils.cpp +++ b/src/util/StringUtils.cpp @@ -61,6 +61,14 @@ bool checkFileExtension(const String& fileName, const char* extension) { return localFile.endsWith(localExtension); } +std::pair splitFileName(const std::string& name) { + size_t lastDot = name.find_last_of('.'); + if (lastDot == std::string::npos) { + return std::make_pair(name, ""); + } + return std::make_pair(name.substr(0, lastDot), name.substr(lastDot)); +} + size_t utf8RemoveLastChar(std::string& str) { if (str.empty()) return 0; size_t pos = str.size() - 1; diff --git a/src/util/StringUtils.h b/src/util/StringUtils.h index 5c8332f0..7f36554a 100644 --- a/src/util/StringUtils.h +++ b/src/util/StringUtils.h @@ -19,6 +19,12 @@ std::string sanitizeFilename(const std::string& name, size_t maxLength = 100); bool checkFileExtension(const std::string& fileName, const char* extension); bool checkFileExtension(const String& fileName, const char* extension); +/** + * Split a filename into base name and extension. + * If there is no extension, the second element of the pair will be an empty string. + */ +std::pair splitFileName(const std::string& name); + // UTF-8 safe string truncation - removes one character from the end // Returns the new size after removing one UTF-8 character size_t utf8RemoveLastChar(std::string& str); From 2a1f7873f75e7ebfedaceccebd4f8ffd7a4a1f6c Mon Sep 17 00:00:00 2001 From: Aleksejs Popovs Date: Wed, 21 Jan 2026 20:09:16 -0500 Subject: [PATCH 5/8] add autoopen --- .../bluetooth/BluetoothActivity.cpp | 37 +++++++++++++++---- src/activities/bluetooth/BluetoothActivity.h | 12 ++++-- src/main.cpp | 13 ++++++- src/util/StringUtils.cpp | 5 +++ src/util/StringUtils.h | 5 +++ 5 files changed, 61 insertions(+), 11 deletions(-) diff --git a/src/activities/bluetooth/BluetoothActivity.cpp b/src/activities/bluetooth/BluetoothActivity.cpp index b00e5d14..83df6462 100644 --- a/src/activities/bluetooth/BluetoothActivity.cpp +++ b/src/activities/bluetooth/BluetoothActivity.cpp @@ -24,11 +24,24 @@ } \ } while (0) -void BluetoothActivity::taskTrampoline(void* param) { +void BluetoothActivity::displayTaskTrampoline(void* param) { auto* self = static_cast(param); self->displayTaskLoop(); } +void BluetoothActivity::reportTaskTrampoline(void* param) { + auto* self = static_cast(param); + self->report(); + vTaskDelete(nullptr); +} + +void BluetoothActivity::report() { + if (state != STATE_DONE) { + return; + } + onFileReceived(OUTPUT_DIRECTORY "/" + filename); +} + void BluetoothActivity::startAdvertising() { NimBLEDevice::startAdvertising(); } @@ -65,7 +78,7 @@ void BluetoothActivity::onEnter() { state = STATE_INITIALIZING; intoState(STATE_WAITING); - xTaskCreate(&BluetoothActivity::taskTrampoline, "BluetoothTask", + xTaskCreate(&BluetoothActivity::displayTaskTrampoline, "BluetoothTask", // TODO: figure out how much stack we actually need 4096, // Stack size this, // Parameters @@ -89,6 +102,16 @@ void BluetoothActivity::intoState(State newState) { // caller sets filename, totalBytes, file, txnId receivedBytes = 0; break; + case STATE_DONE: + // we cannot call onFileReceived here directly because it might cause onExit to be called, + // which calls NimBLEDevice::deinit, which cannot be called from inside a NimBLE callback. + xTaskCreate(&BluetoothActivity::reportTaskTrampoline, "BluetoothReportTask", + 2048, // Stack size + this, // Parameters + 1, // Priority, + nullptr + ); + break; case STATE_ERROR: { // caller sets errorMessage @@ -131,7 +154,7 @@ void BluetoothActivity::loop() { return; } - if (state == STATE_ERROR) { + if (state == STATE_ERROR || state == STATE_DONE) { if (mappedInput.wasPressed(MappedInputManager::Button::Confirm)) { // restart intoState(STATE_WAITING); @@ -207,7 +230,7 @@ void BluetoothActivity::render() const { // Draw help text at bottom const auto labels = mappedInput.mapLabels( "« Back", - (state == STATE_ERROR) ? "Restart" : "", + (state == STATE_ERROR || state == STATE_DONE) ? "Restart" : "", "", "" ); @@ -227,8 +250,9 @@ void BluetoothActivity::ServerCallbacks::onDisconnect(NimBLEServer* pServer, Nim } void BluetoothActivity::onConnected(bool isConnected) { - if (state == STATE_ERROR) { - // stay in error state so the user can read the error message even after disconnect + if (state == STATE_ERROR || state == STATE_DONE) { + // stay in error state so the user can read the error message even after disconnect. + // stay in done state so the user can see the transfer complete message. return; } @@ -310,7 +334,6 @@ void BluetoothActivity::onRequest(lfbt_message* msg, size_t msg_len) { if (receivedBytes >= totalBytes) { PROTOCOL_ASSERT(receivedBytes == totalBytes, "Got more bytes than expected: %zu > %zu", receivedBytes, totalBytes); PROTOCOL_ASSERT(file.close(), "Couldn't finalize writing the file"); - // TODO: automatically open file in reader intoState(STATE_DONE); } else { intoState(STATE_RECEIVING); diff --git a/src/activities/bluetooth/BluetoothActivity.h b/src/activities/bluetooth/BluetoothActivity.h index bfb7d6e1..c30c3eaf 100644 --- a/src/activities/bluetooth/BluetoothActivity.h +++ b/src/activities/bluetooth/BluetoothActivity.h @@ -45,17 +45,22 @@ typedef struct __attribute__((packed)) { * BluetoothActivity receives files over a custom BLE protocol and stores them on the SD card. * * The onCancel callback is called if the user presses back. + * onFileReceived is called when a file is successfully received with the path to the file. */ class BluetoothActivity final : public Activity { TaskHandle_t displayTaskHandle = nullptr; SemaphoreHandle_t renderingMutex = nullptr; bool updateRequired = false; const std::function onCancel; + const std::function onFileReceived; - static void taskTrampoline(void* param); + static void displayTaskTrampoline(void* param); [[noreturn]] void displayTaskLoop(); void render() const; + static void reportTaskTrampoline(void* param); + void report(); + void onConnected(bool isConnected); void onRequest(lfbt_message *msg, size_t msg_len); @@ -113,8 +118,9 @@ class BluetoothActivity final : public Activity { public: explicit BluetoothActivity(GfxRenderer& renderer, MappedInputManager& mappedInput, - const std::function& onCancel) - : Activity("Bluetooth", renderer, mappedInput), onCancel(onCancel), + const std::function& onCancel, + const std::function& onFileReceived) + : Activity("Bluetooth", renderer, mappedInput), onCancel(onCancel), onFileReceived(onFileReceived), serverCallbacks(this), requestCallbacks(this) {} void onEnter() override; void onExit() override; diff --git a/src/main.cpp b/src/main.cpp index b5db102c..d2b898d6 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -26,6 +26,7 @@ #include "activities/settings/SettingsActivity.h" #include "activities/util/FullScreenMessageActivity.h" #include "fontIds.h" +#include "util/StringUtils.h" #define SPI_FQ 40000000 // Display SPI pins (custom pins for XteinkX4, not hardware SPI defaults) @@ -229,7 +230,17 @@ void onGoToFileTransfer() { void onGoToBluetooth() { exitActivity(); - enterNewActivity(new BluetoothActivity(renderer, mappedInputManager, onGoHome)); + enterNewActivity(new BluetoothActivity( + renderer, + mappedInputManager, + onGoHome, + [](const std::string& filepath) { + Serial.printf("[%lu] [ ] File received over Bluetooth: %s\n", millis(), filepath.c_str()); + if (StringUtils::readableFileExtension(filepath)) { + onGoToReader(filepath, MyLibraryActivity::Tab::Recent); + } + } + )); } void onGoToSettings() { diff --git a/src/util/StringUtils.cpp b/src/util/StringUtils.cpp index 54ef363f..f533544c 100644 --- a/src/util/StringUtils.cpp +++ b/src/util/StringUtils.cpp @@ -61,6 +61,11 @@ bool checkFileExtension(const String& fileName, const char* extension) { return localFile.endsWith(localExtension); } +bool readableFileExtension(const std::string& fileName) { + return (StringUtils::checkFileExtension(fileName, ".epub") || StringUtils::checkFileExtension(fileName, ".xtch") || + StringUtils::checkFileExtension(fileName, ".xtc") || StringUtils::checkFileExtension(fileName, ".txt")); +} + std::pair splitFileName(const std::string& name) { size_t lastDot = name.find_last_of('.'); if (lastDot == std::string::npos) { diff --git a/src/util/StringUtils.h b/src/util/StringUtils.h index 7f36554a..323c1c69 100644 --- a/src/util/StringUtils.h +++ b/src/util/StringUtils.h @@ -19,6 +19,11 @@ std::string sanitizeFilename(const std::string& name, size_t maxLength = 100); bool checkFileExtension(const std::string& fileName, const char* extension); bool checkFileExtension(const String& fileName, const char* extension); +/** + * Check if the given filename ends with an extension we can open. + */ +bool readableFileExtension(const std::string& fileName); + /** * Split a filename into base name and extension. * If there is no extension, the second element of the pair will be an empty string. From f337d7934f9f90e927b48fd0951b8d1be82cad8c Mon Sep 17 00:00:00 2001 From: Aleksejs Popovs Date: Wed, 21 Jan 2026 21:32:42 -0500 Subject: [PATCH 6/8] appease cppcheck --- src/activities/bluetooth/BluetoothActivity.cpp | 13 +++++++------ src/activities/bluetooth/BluetoothActivity.h | 9 +++------ 2 files changed, 10 insertions(+), 12 deletions(-) diff --git a/src/activities/bluetooth/BluetoothActivity.cpp b/src/activities/bluetooth/BluetoothActivity.cpp index 83df6462..2e8a088d 100644 --- a/src/activities/bluetooth/BluetoothActivity.cpp +++ b/src/activities/bluetooth/BluetoothActivity.cpp @@ -54,10 +54,10 @@ void BluetoothActivity::onEnter() { Activity::onEnter(); NimBLEDevice::init(DEVICE_NAME); - pServer = NimBLEDevice::createServer(); + NimBLEServer *pServer = NimBLEDevice::createServer(); pServer->setCallbacks(&serverCallbacks, false); - pService = pServer->createService(SERVICE_UUID); - pRequestChar = pService->createCharacteristic( + NimBLEService *pService = pServer->createService(SERVICE_UUID); + NimBLECharacteristic *pRequestChar = pService->createCharacteristic( REQUEST_CHARACTERISTIC_UUID, NIMBLE_PROPERTY::WRITE | NIMBLE_PROPERTY::WRITE_NR ); @@ -116,7 +116,8 @@ void BluetoothActivity::intoState(State newState) { { // caller sets errorMessage file.close(); - if (pServer->getConnectedCount() > 0) { + NimBLEServer* pServer = NimBLEDevice::getServer(); + if (pServer != nullptr && pServer->getConnectedCount() > 0) { // TODO: send back a response over BLE? pServer->disconnect(pServer->getPeerInfo(0)); } @@ -299,7 +300,7 @@ void BluetoothActivity::onRequest(lfbt_message* msg, size_t msg_len) { filepath = OUTPUT_DIRECTORY "/" + filename; } - PROTOCOL_ASSERT(SdMan.openFileForWrite("BT", filepath, file), "Couldn't open file %s for writing", filepath); + PROTOCOL_ASSERT(SdMan.openFileForWrite("BT", filepath, file), "Couldn't open file %s for writing", filepath.c_str()); // TODO: would be neat to check if we have enough space, but SDCardManager doesn't seem to expose that info currently txnId = msg->txnId; @@ -326,7 +327,7 @@ void BluetoothActivity::onRequest(lfbt_message* msg, size_t msg_len) { msg_len - 8 - sizeof(lfbt_msg_client_chunk) ); PROTOCOL_ASSERT(state == STATE_OFFERED || state == STATE_RECEIVING, "Invalid state for client_chunk: %d", state); - PROTOCOL_ASSERT(msg->body.clientChunk.offset == receivedBytes, "Expected chunk %d, got %d", receivedBytes, msg->body.clientChunk.offset); + PROTOCOL_ASSERT(msg->body.clientChunk.offset == receivedBytes, "Expected chunk %zu, got %u", receivedBytes, msg->body.clientChunk.offset); size_t written = file.write((uint8_t*)msg->body.clientChunk.body, msg_len - 8 - sizeof(lfbt_msg_client_chunk)); PROTOCOL_ASSERT(written > 0, "Couldn't write to file"); diff --git a/src/activities/bluetooth/BluetoothActivity.h b/src/activities/bluetooth/BluetoothActivity.h index c30c3eaf..4f91f5b3 100644 --- a/src/activities/bluetooth/BluetoothActivity.h +++ b/src/activities/bluetooth/BluetoothActivity.h @@ -89,10 +89,7 @@ class BluetoothActivity final : public Activity { RequestCallbacks requestCallbacks; - NimBLEServer *pServer; - NimBLEService *pService; - NimBLECharacteristic *pRequestChar, *pResponseChar; - NimBLEAdvertising *pAdvertising; + NimBLECharacteristic *pResponseChar = nullptr; void startAdvertising(); void stopAdvertising(); @@ -111,8 +108,8 @@ class BluetoothActivity final : public Activity { FsFile file; size_t receivedBytes = 0; size_t totalBytes = 0; - char errorMessage[256]; - uint32_t txnId; + char errorMessage[256] = {}; + uint32_t txnId = 0; void intoState(State newState); From 25ef2e4e5d5f9dd6f42f8b198a0dd4777c866e3d Mon Sep 17 00:00:00 2001 From: Aleksejs Popovs Date: Wed, 21 Jan 2026 21:40:08 -0500 Subject: [PATCH 7/8] appease harder (sorry) --- src/activities/bluetooth/BluetoothActivity.cpp | 11 +++++++---- src/activities/bluetooth/BluetoothActivity.h | 2 +- 2 files changed, 8 insertions(+), 5 deletions(-) diff --git a/src/activities/bluetooth/BluetoothActivity.cpp b/src/activities/bluetooth/BluetoothActivity.cpp index 2e8a088d..8f387c84 100644 --- a/src/activities/bluetooth/BluetoothActivity.cpp +++ b/src/activities/bluetooth/BluetoothActivity.cpp @@ -260,7 +260,7 @@ void BluetoothActivity::onConnected(bool isConnected) { intoState(isConnected ? STATE_CONNECTED : STATE_WAITING); } -void BluetoothActivity::onRequest(lfbt_message* msg, size_t msg_len) { +void BluetoothActivity::onRequest(const lfbt_message* msg, size_t msg_len) { if (state == STATE_ERROR) { // ignore further messages in error state return; @@ -312,7 +312,7 @@ void BluetoothActivity::onRequest(lfbt_message* msg, size_t msg_len) { .txnId = txnId, .body = {.serverResponse = {.status = 0}} }; - pResponseChar->setValue((uint8_t*)&response, 8 + sizeof(lfbt_msg_server_response)); + pResponseChar->setValue(reinterpret_cast(&response), 8 + sizeof(lfbt_msg_server_response)); pResponseChar->indicate(); updateRequired = true; @@ -329,7 +329,10 @@ void BluetoothActivity::onRequest(lfbt_message* msg, size_t msg_len) { PROTOCOL_ASSERT(state == STATE_OFFERED || state == STATE_RECEIVING, "Invalid state for client_chunk: %d", state); PROTOCOL_ASSERT(msg->body.clientChunk.offset == receivedBytes, "Expected chunk %zu, got %u", receivedBytes, msg->body.clientChunk.offset); - size_t written = file.write((uint8_t*)msg->body.clientChunk.body, msg_len - 8 - sizeof(lfbt_msg_client_chunk)); + size_t written = file.write( + reinterpret_cast(msg->body.clientChunk.body), + msg_len - 8 - sizeof(lfbt_msg_client_chunk) + ); PROTOCOL_ASSERT(written > 0, "Couldn't write to file"); receivedBytes += msg_len - 8 - sizeof(lfbt_msg_client_chunk); if (receivedBytes >= totalBytes) { @@ -346,7 +349,7 @@ void BluetoothActivity::onRequest(lfbt_message* msg, size_t msg_len) { } void BluetoothActivity::RequestCallbacks::onWrite(NimBLECharacteristic* pCharacteristic, NimBLEConnInfo& connInfo) { - lfbt_message *msg = (lfbt_message*) pCharacteristic->getValue().data(); + const lfbt_message *msg = reinterpret_cast(pCharacteristic->getValue().data()); Serial.printf("[%lu] [BT] Received BLE message of type %u, txnId %x, length %d\n", millis(), msg->type, diff --git a/src/activities/bluetooth/BluetoothActivity.h b/src/activities/bluetooth/BluetoothActivity.h index 4f91f5b3..06e90653 100644 --- a/src/activities/bluetooth/BluetoothActivity.h +++ b/src/activities/bluetooth/BluetoothActivity.h @@ -62,7 +62,7 @@ class BluetoothActivity final : public Activity { void report(); void onConnected(bool isConnected); - void onRequest(lfbt_message *msg, size_t msg_len); + void onRequest(const lfbt_message *msg, size_t msg_len); class ServerCallbacks : public NimBLEServerCallbacks { friend class BluetoothActivity; From 8e38e1bd49e38f66f828c6aa81031d60336a65ec Mon Sep 17 00:00:00 2001 From: Aleksejs Popovs Date: Wed, 21 Jan 2026 21:44:31 -0500 Subject: [PATCH 8/8] bin/clang-format-fix --- .../bluetooth/BluetoothActivity.cpp | 135 ++++++++---------- src/activities/bluetooth/BluetoothActivity.h | 53 +++---- src/main.cpp | 15 +- 3 files changed, 88 insertions(+), 115 deletions(-) diff --git a/src/activities/bluetooth/BluetoothActivity.cpp b/src/activities/bluetooth/BluetoothActivity.cpp index 8f387c84..5cf3b70d 100644 --- a/src/activities/bluetooth/BluetoothActivity.cpp +++ b/src/activities/bluetooth/BluetoothActivity.cpp @@ -6,23 +6,22 @@ #include "fontIds.h" #include "util/StringUtils.h" -#define DEVICE_NAME "EPaper" -#define SERVICE_UUID "4ae29d01-499a-480a-8c41-a82192105125" -#define REQUEST_CHARACTERISTIC_UUID "a00e530d-b48b-48c8-aadb-d062a1b91792" +#define DEVICE_NAME "EPaper" +#define SERVICE_UUID "4ae29d01-499a-480a-8c41-a82192105125" +#define REQUEST_CHARACTERISTIC_UUID "a00e530d-b48b-48c8-aadb-d062a1b91792" #define RESPONSE_CHARACTERISTIC_UUID "0c656023-dee6-47c5-9afb-e601dfbdaa1d" #define OUTPUT_DIRECTORY "/bt" #define MAX_FILENAME_LENGTH 200 -#define PROTOCOL_ASSERT(cond, fmt, ...) \ - do { \ - if (!(cond)) \ - { \ - snprintf(errorMessage, sizeof(errorMessage), fmt, ##__VA_ARGS__); \ - intoState(STATE_ERROR); \ - return; \ - } \ - } while (0) +#define PROTOCOL_ASSERT(cond, fmt, ...) \ + do { \ + if (!(cond)) { \ + snprintf(errorMessage, sizeof(errorMessage), fmt, ##__VA_ARGS__); \ + intoState(STATE_ERROR); \ + return; \ + } \ + } while (0) void BluetoothActivity::displayTaskTrampoline(void* param) { auto* self = static_cast(param); @@ -42,33 +41,24 @@ void BluetoothActivity::report() { onFileReceived(OUTPUT_DIRECTORY "/" + filename); } -void BluetoothActivity::startAdvertising() { - NimBLEDevice::startAdvertising(); -} +void BluetoothActivity::startAdvertising() { NimBLEDevice::startAdvertising(); } -void BluetoothActivity::stopAdvertising() { - NimBLEDevice::stopAdvertising(); -} +void BluetoothActivity::stopAdvertising() { NimBLEDevice::stopAdvertising(); } void BluetoothActivity::onEnter() { Activity::onEnter(); NimBLEDevice::init(DEVICE_NAME); - NimBLEServer *pServer = NimBLEDevice::createServer(); + NimBLEServer* pServer = NimBLEDevice::createServer(); pServer->setCallbacks(&serverCallbacks, false); - NimBLEService *pService = pServer->createService(SERVICE_UUID); - NimBLECharacteristic *pRequestChar = pService->createCharacteristic( - REQUEST_CHARACTERISTIC_UUID, - NIMBLE_PROPERTY::WRITE | NIMBLE_PROPERTY::WRITE_NR - ); + NimBLEService* pService = pServer->createService(SERVICE_UUID); + NimBLECharacteristic* pRequestChar = + pService->createCharacteristic(REQUEST_CHARACTERISTIC_UUID, NIMBLE_PROPERTY::WRITE | NIMBLE_PROPERTY::WRITE_NR); pRequestChar->setCallbacks(&requestCallbacks); - pResponseChar = pService->createCharacteristic( - RESPONSE_CHARACTERISTIC_UUID, - NIMBLE_PROPERTY::INDICATE - ); + pResponseChar = pService->createCharacteristic(RESPONSE_CHARACTERISTIC_UUID, NIMBLE_PROPERTY::INDICATE); pService->start(); - NimBLEAdvertising *pAdvertising = NimBLEDevice::getAdvertising(); + NimBLEAdvertising* pAdvertising = NimBLEDevice::getAdvertising(); pAdvertising->setName(DEVICE_NAME); pAdvertising->addServiceUUID(pService->getUUID()); pAdvertising->enableScanResponse(true); @@ -106,14 +96,12 @@ void BluetoothActivity::intoState(State newState) { // we cannot call onFileReceived here directly because it might cause onExit to be called, // which calls NimBLEDevice::deinit, which cannot be called from inside a NimBLE callback. xTaskCreate(&BluetoothActivity::reportTaskTrampoline, "BluetoothReportTask", - 2048, // Stack size - this, // Parameters - 1, // Priority, - nullptr - ); + 2048, // Stack size + this, // Parameters + 1, // Priority, + nullptr); break; - case STATE_ERROR: - { + case STATE_ERROR: { // caller sets errorMessage file.close(); NimBLEServer* pServer = NimBLEDevice::getServer(); @@ -229,12 +217,8 @@ void BluetoothActivity::render() const { } // Draw help text at bottom - const auto labels = mappedInput.mapLabels( - "« Back", - (state == STATE_ERROR || state == STATE_DONE) ? "Restart" : "", - "", - "" - ); + const auto labels = + mappedInput.mapLabels("« Back", (state == STATE_ERROR || state == STATE_DONE) ? "Restart" : "", "", ""); renderer.drawButtonHints(UI_10_FONT_ID, labels.btn1, labels.btn2, labels.btn3, labels.btn4); renderer.displayBuffer(); @@ -266,23 +250,24 @@ void BluetoothActivity::onRequest(const lfbt_message* msg, size_t msg_len) { return; } - PROTOCOL_ASSERT((txnId == 0) || (txnId == msg->txnId), "Multiple transfers happening at once (%x != %x)", txnId, msg->txnId); + PROTOCOL_ASSERT((txnId == 0) || (txnId == msg->txnId), "Multiple transfers happening at once (%x != %x)", txnId, + msg->txnId); switch (msg->type) { - case 0: // client_offer + case 0: // client_offer { PROTOCOL_ASSERT(state == STATE_CONNECTED, "Invalid state for client_offer: %d", state); - PROTOCOL_ASSERT(msg->body.clientOffer.version == 1, "Unsupported protocol version: %u", msg->body.clientOffer.version); + PROTOCOL_ASSERT(msg->body.clientOffer.version == 1, "Unsupported protocol version: %u", + msg->body.clientOffer.version); totalBytes = msg->body.clientOffer.bodyLength; - - size_t filenameLength = msg_len - 8 - sizeof(lfbt_msg_client_offer); - std::string originalFilename = StringUtils::sanitizeFilename( - std::string(msg->body.clientOffer.name, filenameLength), - MAX_FILENAME_LENGTH - ); - PROTOCOL_ASSERT(SdMan.ensureDirectoryExists(OUTPUT_DIRECTORY), "Couldn't create output directory %s", OUTPUT_DIRECTORY); + size_t filenameLength = msg_len - 8 - sizeof(lfbt_msg_client_offer); + std::string originalFilename = + StringUtils::sanitizeFilename(std::string(msg->body.clientOffer.name, filenameLength), MAX_FILENAME_LENGTH); + + PROTOCOL_ASSERT(SdMan.ensureDirectoryExists(OUTPUT_DIRECTORY), "Couldn't create output directory %s", + OUTPUT_DIRECTORY); // generate unique filepath auto splitName = StringUtils::splitFileName(originalFilename); @@ -300,43 +285,39 @@ void BluetoothActivity::onRequest(const lfbt_message* msg, size_t msg_len) { filepath = OUTPUT_DIRECTORY "/" + filename; } - PROTOCOL_ASSERT(SdMan.openFileForWrite("BT", filepath, file), "Couldn't open file %s for writing", filepath.c_str()); - // TODO: would be neat to check if we have enough space, but SDCardManager doesn't seem to expose that info currently + PROTOCOL_ASSERT(SdMan.openFileForWrite("BT", filepath, file), "Couldn't open file %s for writing", + filepath.c_str()); + // TODO: would be neat to check if we have enough space, but SDCardManager doesn't seem to expose that info + // currently txnId = msg->txnId; intoState(STATE_OFFERED); - lfbt_message response = { - .type = 1, // server_response - .txnId = txnId, - .body = {.serverResponse = {.status = 0}} - }; + lfbt_message response = {.type = 1, // server_response + .txnId = txnId, + .body = {.serverResponse = {.status = 0}}}; pResponseChar->setValue(reinterpret_cast(&response), 8 + sizeof(lfbt_msg_server_response)); pResponseChar->indicate(); updateRequired = true; break; } - case 2: // client_chunk + case 2: // client_chunk { - Serial.printf( - "[%lu] [BT] Received client_chunk, offset %u, length %zu\n", - millis(), - msg->body.clientChunk.offset, - msg_len - 8 - sizeof(lfbt_msg_client_chunk) - ); + Serial.printf("[%lu] [BT] Received client_chunk, offset %u, length %zu\n", millis(), msg->body.clientChunk.offset, + msg_len - 8 - sizeof(lfbt_msg_client_chunk)); PROTOCOL_ASSERT(state == STATE_OFFERED || state == STATE_RECEIVING, "Invalid state for client_chunk: %d", state); - PROTOCOL_ASSERT(msg->body.clientChunk.offset == receivedBytes, "Expected chunk %zu, got %u", receivedBytes, msg->body.clientChunk.offset); + PROTOCOL_ASSERT(msg->body.clientChunk.offset == receivedBytes, "Expected chunk %zu, got %u", receivedBytes, + msg->body.clientChunk.offset); - size_t written = file.write( - reinterpret_cast(msg->body.clientChunk.body), - msg_len - 8 - sizeof(lfbt_msg_client_chunk) - ); + size_t written = file.write(reinterpret_cast(msg->body.clientChunk.body), + msg_len - 8 - sizeof(lfbt_msg_client_chunk)); PROTOCOL_ASSERT(written > 0, "Couldn't write to file"); receivedBytes += msg_len - 8 - sizeof(lfbt_msg_client_chunk); if (receivedBytes >= totalBytes) { - PROTOCOL_ASSERT(receivedBytes == totalBytes, "Got more bytes than expected: %zu > %zu", receivedBytes, totalBytes); + PROTOCOL_ASSERT(receivedBytes == totalBytes, "Got more bytes than expected: %zu > %zu", receivedBytes, + totalBytes); PROTOCOL_ASSERT(file.close(), "Couldn't finalize writing the file"); intoState(STATE_DONE); } else { @@ -349,12 +330,8 @@ void BluetoothActivity::onRequest(const lfbt_message* msg, size_t msg_len) { } void BluetoothActivity::RequestCallbacks::onWrite(NimBLECharacteristic* pCharacteristic, NimBLEConnInfo& connInfo) { - const lfbt_message *msg = reinterpret_cast(pCharacteristic->getValue().data()); - Serial.printf("[%lu] [BT] Received BLE message of type %u, txnId %x, length %d\n", - millis(), - msg->type, - msg->txnId, - pCharacteristic->getValue().length() - ); + const lfbt_message* msg = reinterpret_cast(pCharacteristic->getValue().data()); + Serial.printf("[%lu] [BT] Received BLE message of type %u, txnId %x, length %d\n", millis(), msg->type, msg->txnId, + pCharacteristic->getValue().length()); activity->onRequest(msg, pCharacteristic->getValue().length()); } diff --git a/src/activities/bluetooth/BluetoothActivity.h b/src/activities/bluetooth/BluetoothActivity.h index 06e90653..42913e3b 100644 --- a/src/activities/bluetooth/BluetoothActivity.h +++ b/src/activities/bluetooth/BluetoothActivity.h @@ -1,33 +1,31 @@ #pragma once +#include +#include +#include +#include #include #include #include -#include -#include -#include - #include -#include - #include "../Activity.h" -typedef struct __attribute__((packed)) { +typedef struct __attribute__((packed)) { uint32_t version; uint32_t bodyLength; uint32_t nameLength; char name[]; -} lfbt_msg_client_offer; // msg type 0 +} lfbt_msg_client_offer; // msg type 0 -typedef struct __attribute__((packed)) { +typedef struct __attribute__((packed)) { uint32_t status; -} lfbt_msg_server_response; // msg type 1 +} lfbt_msg_server_response; // msg type 1 -typedef struct __attribute__((packed)) { +typedef struct __attribute__((packed)) { uint32_t offset; char body[]; -} lfbt_msg_client_chunk; // msg type 2 +} lfbt_msg_client_chunk; // msg type 2 typedef union { lfbt_msg_client_offer clientOffer; @@ -43,7 +41,7 @@ typedef struct __attribute__((packed)) { /** * BluetoothActivity receives files over a custom BLE protocol and stores them on the SD card. - * + * * The onCancel callback is called if the user presses back. * onFileReceived is called when a file is successfully received with the path to the file. */ @@ -62,34 +60,34 @@ class BluetoothActivity final : public Activity { void report(); void onConnected(bool isConnected); - void onRequest(const lfbt_message *msg, size_t msg_len); + void onRequest(const lfbt_message* msg, size_t msg_len); class ServerCallbacks : public NimBLEServerCallbacks { friend class BluetoothActivity; - BluetoothActivity *activity; + BluetoothActivity* activity; void onConnect(NimBLEServer* pServer, NimBLEConnInfo& connInfo); void onDisconnect(NimBLEServer* pServer, NimBLEConnInfo& connInfo, int reason); - protected: - explicit ServerCallbacks(BluetoothActivity *activity) : activity(activity) {} + protected: + explicit ServerCallbacks(BluetoothActivity* activity) : activity(activity) {} }; ServerCallbacks serverCallbacks; class RequestCallbacks : public NimBLECharacteristicCallbacks { friend class BluetoothActivity; - BluetoothActivity *activity; + BluetoothActivity* activity; void onWrite(NimBLECharacteristic* pCharacteristic, NimBLEConnInfo& connInfo); - protected: - explicit RequestCallbacks(BluetoothActivity *activity) : activity(activity) {} + protected: + explicit RequestCallbacks(BluetoothActivity* activity) : activity(activity) {} }; RequestCallbacks requestCallbacks; - NimBLECharacteristic *pResponseChar = nullptr; + NimBLECharacteristic* pResponseChar = nullptr; void startAdvertising(); void stopAdvertising(); @@ -107,7 +105,7 @@ class BluetoothActivity final : public Activity { std::string filename; FsFile file; size_t receivedBytes = 0; - size_t totalBytes = 0; + size_t totalBytes = 0; char errorMessage[256] = {}; uint32_t txnId = 0; @@ -115,10 +113,13 @@ class BluetoothActivity final : public Activity { public: explicit BluetoothActivity(GfxRenderer& renderer, MappedInputManager& mappedInput, - const std::function& onCancel, - const std::function& onFileReceived) - : Activity("Bluetooth", renderer, mappedInput), onCancel(onCancel), onFileReceived(onFileReceived), - serverCallbacks(this), requestCallbacks(this) {} + const std::function& onCancel, + const std::function& onFileReceived) + : Activity("Bluetooth", renderer, mappedInput), + onCancel(onCancel), + onFileReceived(onFileReceived), + serverCallbacks(this), + requestCallbacks(this) {} void onEnter() override; void onExit() override; void loop() override; diff --git a/src/main.cpp b/src/main.cpp index d2b898d6..bdc0767f 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -230,17 +230,12 @@ void onGoToFileTransfer() { void onGoToBluetooth() { exitActivity(); - enterNewActivity(new BluetoothActivity( - renderer, - mappedInputManager, - onGoHome, - [](const std::string& filepath) { - Serial.printf("[%lu] [ ] File received over Bluetooth: %s\n", millis(), filepath.c_str()); - if (StringUtils::readableFileExtension(filepath)) { - onGoToReader(filepath, MyLibraryActivity::Tab::Recent); - } + enterNewActivity(new BluetoothActivity(renderer, mappedInputManager, onGoHome, [](const std::string& filepath) { + Serial.printf("[%lu] [ ] File received over Bluetooth: %s\n", millis(), filepath.c_str()); + if (StringUtils::readableFileExtension(filepath)) { + onGoToReader(filepath, MyLibraryActivity::Tab::Recent); } - )); + })); } void onGoToSettings() {