From 26b2dc27fad8bafdd0561a244aa8009357ddfead Mon Sep 17 00:00:00 2001 From: Aleksejs Popovs Date: Mon, 19 Jan 2026 17:03:53 -0500 Subject: [PATCH] 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() {