diff --git a/platformio.ini b/platformio.ini index e8574470..81d0d325 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 new file mode 100644 index 00000000..5cf3b70d --- /dev/null +++ b/src/activities/bluetooth/BluetoothActivity.cpp @@ -0,0 +1,337 @@ +#include "BluetoothActivity.h" + +#include + +#include "MappedInputManager.h" +#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 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) + +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(); } + +void BluetoothActivity::stopAdvertising() { NimBLEDevice::stopAdvertising(); } + +void BluetoothActivity::onEnter() { + Activity::onEnter(); + + NimBLEDevice::init(DEVICE_NAME); + 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); + pRequestChar->setCallbacks(&requestCallbacks); + pResponseChar = pService->createCharacteristic(RESPONSE_CHARACTERISTIC_UUID, NIMBLE_PROPERTY::INDICATE); + pService->start(); + + NimBLEAdvertising* pAdvertising = NimBLEDevice::getAdvertising(); + pAdvertising->setName(DEVICE_NAME); + pAdvertising->addServiceUUID(pService->getUUID()); + pAdvertising->enableScanResponse(true); + + renderingMutex = xSemaphoreCreateMutex(); + + state = STATE_INITIALIZING; + intoState(STATE_WAITING); + + xTaskCreate(&BluetoothActivity::displayTaskTrampoline, "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_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 + file.close(); + NimBLEServer* pServer = NimBLEDevice::getServer(); + if (pServer != nullptr && pServer->getConnectedCount() > 0) { + // TODO: send back a response over BLE? + pServer->disconnect(pServer->getPeerInfo(0)); + } + break; + } + } + + state = newState; + updateRequired = true; +} + +void BluetoothActivity::onExit() { + Activity::onExit(); + + file.close(); + + stopAdvertising(); + + NimBLEDevice::deinit(true); + + // 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 || state == STATE_DONE) { + 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.c_str()); + } 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 || state == STATE_DONE) ? "Restart" : "", "", ""); + renderer.drawButtonHints(UI_10_FONT_ID, labels.btn1, labels.btn2, labels.btn3, labels.btn4); + + renderer.displayBuffer(); +} + +void BluetoothActivity::ServerCallbacks::onConnect(NimBLEServer* pServer, NimBLEConnInfo& connInfo) { + Serial.printf("[%lu] [BT] connected\n", millis()); + activity->onConnected(true); +} + +void BluetoothActivity::ServerCallbacks::onDisconnect(NimBLEServer* pServer, NimBLEConnInfo& connInfo, int reason) { + Serial.printf("[%lu] [BT] disconnected\n", millis()); + activity->onConnected(false); +} + +void BluetoothActivity::onConnected(bool isConnected) { + 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; + } + + intoState(isConnected ? STATE_CONNECTED : STATE_WAITING); +} + +void BluetoothActivity::onRequest(const 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 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); + 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.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}}}; + pResponseChar->setValue(reinterpret_cast(&response), 8 + sizeof(lfbt_msg_server_response)); + pResponseChar->indicate(); + + updateRequired = true; + break; + } + 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)); + 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(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(file.close(), "Couldn't finalize writing the file"); + intoState(STATE_DONE); + } else { + intoState(STATE_RECEIVING); + } + updateRequired = true; + break; + } + } +} + +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()); + activity->onRequest(msg, pCharacteristic->getValue().length()); +} diff --git a/src/activities/bluetooth/BluetoothActivity.h b/src/activities/bluetooth/BluetoothActivity.h new file mode 100644 index 00000000..42913e3b --- /dev/null +++ b/src/activities/bluetooth/BluetoothActivity.h @@ -0,0 +1,126 @@ +#pragma once +#include +#include +#include +#include +#include +#include +#include + +#include + +#include "../Activity.h" + +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. + * 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 displayTaskTrampoline(void* param); + [[noreturn]] void displayTaskLoop(); + void render() const; + + static void reportTaskTrampoline(void* param); + void report(); + + void onConnected(bool isConnected); + void onRequest(const lfbt_message* msg, size_t msg_len); + + class ServerCallbacks : public NimBLEServerCallbacks { + friend class BluetoothActivity; + BluetoothActivity* activity; + + void onConnect(NimBLEServer* pServer, NimBLEConnInfo& connInfo); + void onDisconnect(NimBLEServer* pServer, NimBLEConnInfo& connInfo, int reason); + + protected: + explicit ServerCallbacks(BluetoothActivity* activity) : activity(activity) {} + }; + + ServerCallbacks serverCallbacks; + + class RequestCallbacks : public NimBLECharacteristicCallbacks { + friend class BluetoothActivity; + BluetoothActivity* activity; + + void onWrite(NimBLECharacteristic* pCharacteristic, NimBLEConnInfo& connInfo); + + protected: + explicit RequestCallbacks(BluetoothActivity* activity) : activity(activity) {} + }; + + RequestCallbacks requestCallbacks; + + NimBLECharacteristic* pResponseChar = nullptr; + 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; + std::string filename; + FsFile file; + size_t receivedBytes = 0; + size_t totalBytes = 0; + char errorMessage[256] = {}; + uint32_t txnId = 0; + + void intoState(State newState); + + 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) {} + void onEnter() override; + void onExit() override; + void loop() override; +}; diff --git a/src/activities/home/HomeActivity.cpp b/src/activities/home/HomeActivity.cpp index 58b29505..66d4fd92 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; @@ -175,6 +175,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) { @@ -185,6 +186,8 @@ void HomeActivity::loop() { onOpdsBrowserOpen(); } else if (selectorIndex == fileTransferIdx) { onFileTransferOpen(); + } else if (selectorIndex == bluetoothIdx) { + onBluetoothOpen(); } else if (selectorIndex == settingsIdx) { onSettingsOpen(); } @@ -503,7 +506,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 OPDS Browser after My Library menuItems.insert(menuItems.begin() + 1, "OPDS Browser"); 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 89c4e13c..c552c19c 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" @@ -25,6 +26,7 @@ #include "activities/settings/SettingsActivity.h" #include "activities/util/FullScreenMessageActivity.h" #include "fontIds.h" +#include "util/StringUtils.h" HalDisplay display; HalGPIO gpio; @@ -216,6 +218,16 @@ void onGoToFileTransfer() { enterNewActivity(new CrossPointWebServerActivity(renderer, mappedInputManager, onGoHome)); } +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); + } + })); +} + void onGoToSettings() { exitActivity(); enterNewActivity(new SettingsActivity(renderer, mappedInputManager, onGoHome)); @@ -239,7 +251,7 @@ void onGoToBrowser() { void onGoHome() { exitActivity(); enterNewActivity(new HomeActivity(renderer, mappedInputManager, onContinueReading, onGoToMyLibrary, onGoToSettings, - onGoToFileTransfer, onGoToBrowser)); + onGoToFileTransfer, onGoToBluetooth, onGoToBrowser)); } void setupDisplayAndFonts() { diff --git a/src/util/StringUtils.cpp b/src/util/StringUtils.cpp index 2426b687..f533544c 100644 --- a/src/util/StringUtils.cpp +++ b/src/util/StringUtils.cpp @@ -61,6 +61,19 @@ 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) { + 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..323c1c69 100644 --- a/src/util/StringUtils.h +++ b/src/util/StringUtils.h @@ -19,6 +19,17 @@ 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. + */ +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);