mirror of
https://github.com/daveallie/crosspoint-reader.git
synced 2026-02-05 07:07:38 +03:00
Renames Calibre Browser to a more generic OPDS Browser and adds basic auth
This commit is contained in:
parent
12666636a7
commit
8114899bef
BIN
calibre-plugin/crosspoint_reader/plugin/Archive.zip
Normal file
BIN
calibre-plugin/crosspoint_reader/plugin/Archive.zip
Normal file
Binary file not shown.
@ -98,18 +98,30 @@ class WebSocketClient:
|
||||
self.sock.sendall(header + masked)
|
||||
|
||||
def read_text(self):
|
||||
opcode, payload = self._read_frame()
|
||||
if opcode == 0x8:
|
||||
code = None
|
||||
reason = ''
|
||||
if len(payload) >= 2:
|
||||
code = struct.unpack('!H', payload[:2])[0]
|
||||
reason = payload[2:].decode('utf-8', 'ignore')
|
||||
self._log('Server closed connection', code, reason)
|
||||
raise WebSocketError('Connection closed')
|
||||
if opcode != 0x1:
|
||||
return ''
|
||||
return payload.decode('utf-8', 'ignore')
|
||||
deadline = time.time() + self.timeout
|
||||
while True:
|
||||
if time.time() > deadline:
|
||||
raise WebSocketError('Timed out waiting for text frame')
|
||||
opcode, payload = self._read_frame()
|
||||
if opcode == 0x8:
|
||||
code = None
|
||||
reason = ''
|
||||
if len(payload) >= 2:
|
||||
code = struct.unpack('!H', payload[:2])[0]
|
||||
reason = payload[2:].decode('utf-8', 'ignore')
|
||||
self._log('Server closed connection', code, reason)
|
||||
raise WebSocketError('Connection closed')
|
||||
if opcode == 0x9:
|
||||
# Ping -> respond with Pong
|
||||
self._send_frame(0xA, payload)
|
||||
continue
|
||||
if opcode == 0xA:
|
||||
# Pong -> ignore
|
||||
continue
|
||||
if opcode != 0x1:
|
||||
self._log('Ignoring non-text opcode', opcode, len(payload))
|
||||
continue
|
||||
return payload.decode('utf-8', 'ignore')
|
||||
|
||||
def _read_frame(self):
|
||||
if self.sock is None:
|
||||
@ -251,6 +263,8 @@ def upload_file(host, port, upload_path, filename, filepath, chunk_size=16384, d
|
||||
|
||||
msg = client.read_text()
|
||||
client._log('Received', msg)
|
||||
if not msg:
|
||||
raise WebSocketError('Unexpected response: <empty>')
|
||||
if msg.startswith('ERROR'):
|
||||
raise WebSocketError(msg)
|
||||
if msg != 'READY':
|
||||
|
||||
@ -14,7 +14,7 @@ CrossPointSettings CrossPointSettings::instance;
|
||||
namespace {
|
||||
constexpr uint8_t SETTINGS_FILE_VERSION = 1;
|
||||
// Increment this when adding new persisted settings fields
|
||||
constexpr uint8_t SETTINGS_COUNT = 18;
|
||||
constexpr uint8_t SETTINGS_COUNT = 20;
|
||||
constexpr char SETTINGS_FILE[] = "/.crosspoint/settings.bin";
|
||||
} // namespace
|
||||
|
||||
@ -48,6 +48,9 @@ bool CrossPointSettings::saveToFile() const {
|
||||
serialization::writePod(outputFile, textAntiAliasing);
|
||||
serialization::writePod(outputFile, hideBatteryPercentage);
|
||||
serialization::writePod(outputFile, longPressChapterSkip);
|
||||
// New fields added at end for backward compatibility
|
||||
serialization::writeString(outputFile, std::string(opdsUsername));
|
||||
serialization::writeString(outputFile, std::string(opdsPassword));
|
||||
outputFile.close();
|
||||
|
||||
Serial.printf("[%lu] [CPS] Settings saved to file\n", millis());
|
||||
@ -110,12 +113,28 @@ bool CrossPointSettings::loadFromFile() {
|
||||
strncpy(opdsServerUrl, urlStr.c_str(), sizeof(opdsServerUrl) - 1);
|
||||
opdsServerUrl[sizeof(opdsServerUrl) - 1] = '\0';
|
||||
}
|
||||
if (++settingsRead >= fileSettingsCount) break;
|
||||
serialization::readPod(inputFile, textAntiAliasing);
|
||||
if (++settingsRead >= fileSettingsCount) break;
|
||||
serialization::readPod(inputFile, hideBatteryPercentage);
|
||||
if (++settingsRead >= fileSettingsCount) break;
|
||||
serialization::readPod(inputFile, longPressChapterSkip);
|
||||
if (++settingsRead >= fileSettingsCount) break;
|
||||
// New fields added at end for backward compatibility
|
||||
{
|
||||
std::string usernameStr;
|
||||
serialization::readString(inputFile, usernameStr);
|
||||
strncpy(opdsUsername, usernameStr.c_str(), sizeof(opdsUsername) - 1);
|
||||
opdsUsername[sizeof(opdsUsername) - 1] = '\0';
|
||||
}
|
||||
if (++settingsRead >= fileSettingsCount) break;
|
||||
{
|
||||
std::string passwordStr;
|
||||
serialization::readString(inputFile, passwordStr);
|
||||
strncpy(opdsPassword, passwordStr.c_str(), sizeof(opdsPassword) - 1);
|
||||
opdsPassword[sizeof(opdsPassword) - 1] = '\0';
|
||||
}
|
||||
if (++settingsRead >= fileSettingsCount) break;
|
||||
} while (false);
|
||||
|
||||
inputFile.close();
|
||||
|
||||
@ -88,6 +88,8 @@ class CrossPointSettings {
|
||||
uint8_t screenMargin = 5;
|
||||
// OPDS browser settings
|
||||
char opdsServerUrl[128] = "";
|
||||
char opdsUsername[64] = "";
|
||||
char opdsPassword[64] = "";
|
||||
// Hide battery percentage
|
||||
uint8_t hideBatteryPercentage = HIDE_NEVER;
|
||||
// Long-press chapter skip on side buttons
|
||||
|
||||
@ -16,7 +16,6 @@
|
||||
namespace {
|
||||
constexpr int PAGE_ITEMS = 23;
|
||||
constexpr int SKIP_PAGE_MS = 700;
|
||||
constexpr char OPDS_ROOT_PATH[] = "opds"; // No leading slash - relative to server URL
|
||||
} // namespace
|
||||
|
||||
void OpdsBookBrowserActivity::taskTrampoline(void* param) {
|
||||
@ -31,7 +30,7 @@ void OpdsBookBrowserActivity::onEnter() {
|
||||
state = BrowserState::CHECK_WIFI;
|
||||
entries.clear();
|
||||
navigationHistory.clear();
|
||||
currentPath = OPDS_ROOT_PATH;
|
||||
currentPath = ""; // Root path - user provides full URL in settings
|
||||
selectorIndex = 0;
|
||||
errorMessage.clear();
|
||||
statusMessage = "Checking WiFi...";
|
||||
@ -170,7 +169,7 @@ void OpdsBookBrowserActivity::render() const {
|
||||
const auto pageWidth = renderer.getScreenWidth();
|
||||
const auto pageHeight = renderer.getScreenHeight();
|
||||
|
||||
renderer.drawCenteredText(UI_12_FONT_ID, 15, "Calibre Library", true, EpdFontFamily::BOLD);
|
||||
renderer.drawCenteredText(UI_12_FONT_ID, 15, "OPDS Browser", true, EpdFontFamily::BOLD);
|
||||
|
||||
if (state == BrowserState::CHECK_WIFI) {
|
||||
renderer.drawCenteredText(UI_10_FONT_ID, pageHeight / 2, statusMessage.c_str());
|
||||
|
||||
@ -498,8 +498,8 @@ void HomeActivity::render() {
|
||||
// Build menu items dynamically
|
||||
std::vector<const char*> menuItems = {"Browse Files", "File Transfer", "Settings"};
|
||||
if (hasOpdsUrl) {
|
||||
// Insert Calibre Library after Browse Files
|
||||
menuItems.insert(menuItems.begin() + 1, "Calibre Library");
|
||||
// Insert OPDS Browser after Browse Files
|
||||
menuItems.insert(menuItems.begin() + 1, "OPDS Browser");
|
||||
}
|
||||
|
||||
const int menuTileWidth = pageWidth - 2 * margin;
|
||||
|
||||
272
src/activities/network/CalibreConnectActivity.cpp
Normal file
272
src/activities/network/CalibreConnectActivity.cpp
Normal file
@ -0,0 +1,272 @@
|
||||
#include "CalibreConnectActivity.h"
|
||||
|
||||
#include <ESPmDNS.h>
|
||||
#include <GfxRenderer.h>
|
||||
#include <WiFi.h>
|
||||
#include <esp_task_wdt.h>
|
||||
|
||||
#include "MappedInputManager.h"
|
||||
#include "ScreenComponents.h"
|
||||
#include "WifiSelectionActivity.h"
|
||||
#include "fontIds.h"
|
||||
|
||||
namespace {
|
||||
constexpr const char* HOSTNAME = "crosspoint";
|
||||
} // namespace
|
||||
|
||||
void CalibreConnectActivity::taskTrampoline(void* param) {
|
||||
auto* self = static_cast<CalibreConnectActivity*>(param);
|
||||
self->displayTaskLoop();
|
||||
}
|
||||
|
||||
void CalibreConnectActivity::onEnter() {
|
||||
ActivityWithSubactivity::onEnter();
|
||||
|
||||
renderingMutex = xSemaphoreCreateMutex();
|
||||
updateRequired = true;
|
||||
state = CalibreConnectState::WIFI_SELECTION;
|
||||
connectedIP.clear();
|
||||
connectedSSID.clear();
|
||||
lastHandleClientTime = 0;
|
||||
lastProgressReceived = 0;
|
||||
lastProgressTotal = 0;
|
||||
currentUploadName.clear();
|
||||
lastCompleteName.clear();
|
||||
lastCompleteAt = 0;
|
||||
|
||||
xTaskCreate(&CalibreConnectActivity::taskTrampoline, "CalibreConnectTask",
|
||||
2048, // Stack size
|
||||
this, // Parameters
|
||||
1, // Priority
|
||||
&displayTaskHandle // Task handle
|
||||
);
|
||||
|
||||
if (WiFi.status() != WL_CONNECTED) {
|
||||
enterNewActivity(new WifiSelectionActivity(
|
||||
renderer, mappedInput, [this](const bool connected) { onWifiSelectionComplete(connected); }));
|
||||
} else {
|
||||
connectedIP = WiFi.localIP().toString().c_str();
|
||||
connectedSSID = WiFi.SSID().c_str();
|
||||
startWebServer();
|
||||
}
|
||||
}
|
||||
|
||||
void CalibreConnectActivity::onExit() {
|
||||
ActivityWithSubactivity::onExit();
|
||||
|
||||
stopWebServer();
|
||||
MDNS.end();
|
||||
|
||||
delay(50);
|
||||
WiFi.disconnect(false);
|
||||
delay(30);
|
||||
WiFi.mode(WIFI_OFF);
|
||||
delay(30);
|
||||
|
||||
xSemaphoreTake(renderingMutex, portMAX_DELAY);
|
||||
if (displayTaskHandle) {
|
||||
vTaskDelete(displayTaskHandle);
|
||||
displayTaskHandle = nullptr;
|
||||
}
|
||||
vSemaphoreDelete(renderingMutex);
|
||||
renderingMutex = nullptr;
|
||||
}
|
||||
|
||||
void CalibreConnectActivity::onWifiSelectionComplete(const bool connected) {
|
||||
if (!connected) {
|
||||
exitActivity();
|
||||
onComplete();
|
||||
return;
|
||||
}
|
||||
|
||||
if (subActivity) {
|
||||
connectedIP = static_cast<WifiSelectionActivity*>(subActivity.get())->getConnectedIP();
|
||||
} else {
|
||||
connectedIP = WiFi.localIP().toString().c_str();
|
||||
}
|
||||
connectedSSID = WiFi.SSID().c_str();
|
||||
exitActivity();
|
||||
startWebServer();
|
||||
}
|
||||
|
||||
void CalibreConnectActivity::startWebServer() {
|
||||
state = CalibreConnectState::SERVER_STARTING;
|
||||
updateRequired = true;
|
||||
|
||||
if (MDNS.begin(HOSTNAME)) {
|
||||
// mDNS is optional for the Calibre plugin but still helpful for users.
|
||||
Serial.printf("[%lu] [CAL] mDNS started: http://%s.local/\n", millis(), HOSTNAME);
|
||||
}
|
||||
|
||||
webServer.reset(new CrossPointWebServer());
|
||||
webServer->begin();
|
||||
|
||||
if (webServer->isRunning()) {
|
||||
state = CalibreConnectState::SERVER_RUNNING;
|
||||
updateRequired = true;
|
||||
} else {
|
||||
state = CalibreConnectState::ERROR;
|
||||
updateRequired = true;
|
||||
}
|
||||
}
|
||||
|
||||
void CalibreConnectActivity::stopWebServer() {
|
||||
if (webServer) {
|
||||
webServer->stop();
|
||||
webServer.reset();
|
||||
}
|
||||
}
|
||||
|
||||
void CalibreConnectActivity::loop() {
|
||||
if (subActivity) {
|
||||
subActivity->loop();
|
||||
return;
|
||||
}
|
||||
|
||||
if (mappedInput.wasPressed(MappedInputManager::Button::Back)) {
|
||||
onComplete();
|
||||
return;
|
||||
}
|
||||
|
||||
if (webServer && webServer->isRunning()) {
|
||||
const unsigned long timeSinceLastHandleClient = millis() - lastHandleClientTime;
|
||||
if (lastHandleClientTime > 0 && timeSinceLastHandleClient > 100) {
|
||||
Serial.printf("[%lu] [CAL] WARNING: %lu ms gap since last handleClient\n", millis(), timeSinceLastHandleClient);
|
||||
}
|
||||
|
||||
esp_task_wdt_reset();
|
||||
constexpr int MAX_ITERATIONS = 500;
|
||||
for (int i = 0; i < MAX_ITERATIONS && webServer->isRunning(); i++) {
|
||||
webServer->handleClient();
|
||||
if ((i & 0x1F) == 0x1F) {
|
||||
esp_task_wdt_reset();
|
||||
}
|
||||
if ((i & 0x3F) == 0x3F) {
|
||||
yield();
|
||||
if (mappedInput.wasPressed(MappedInputManager::Button::Back)) {
|
||||
onComplete();
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
lastHandleClientTime = millis();
|
||||
|
||||
const auto status = webServer->getWsUploadStatus();
|
||||
bool changed = false;
|
||||
if (status.inProgress) {
|
||||
if (status.received != lastProgressReceived || status.total != lastProgressTotal ||
|
||||
status.filename != currentUploadName) {
|
||||
lastProgressReceived = status.received;
|
||||
lastProgressTotal = status.total;
|
||||
currentUploadName = status.filename;
|
||||
changed = true;
|
||||
}
|
||||
} else if (lastProgressReceived != 0 || lastProgressTotal != 0) {
|
||||
lastProgressReceived = 0;
|
||||
lastProgressTotal = 0;
|
||||
currentUploadName.clear();
|
||||
changed = true;
|
||||
}
|
||||
if (status.lastCompleteAt != 0 && status.lastCompleteAt != lastCompleteAt) {
|
||||
lastCompleteAt = status.lastCompleteAt;
|
||||
lastCompleteName = status.lastCompleteName;
|
||||
changed = true;
|
||||
}
|
||||
if (lastCompleteAt > 0 && (millis() - lastCompleteAt) >= 6000) {
|
||||
lastCompleteAt = 0;
|
||||
lastCompleteName.clear();
|
||||
changed = true;
|
||||
}
|
||||
if (changed) {
|
||||
updateRequired = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void CalibreConnectActivity::displayTaskLoop() {
|
||||
while (true) {
|
||||
if (updateRequired) {
|
||||
updateRequired = false;
|
||||
xSemaphoreTake(renderingMutex, portMAX_DELAY);
|
||||
render();
|
||||
xSemaphoreGive(renderingMutex);
|
||||
}
|
||||
vTaskDelay(10 / portTICK_PERIOD_MS);
|
||||
}
|
||||
}
|
||||
|
||||
void CalibreConnectActivity::render() const {
|
||||
if (state == CalibreConnectState::SERVER_RUNNING) {
|
||||
renderer.clearScreen();
|
||||
renderServerRunning();
|
||||
renderer.displayBuffer();
|
||||
return;
|
||||
}
|
||||
|
||||
renderer.clearScreen();
|
||||
const auto pageHeight = renderer.getScreenHeight();
|
||||
if (state == CalibreConnectState::SERVER_STARTING) {
|
||||
renderer.drawCenteredText(UI_12_FONT_ID, pageHeight / 2 - 20, "Starting Calibre...", true, EpdFontFamily::BOLD);
|
||||
} else if (state == CalibreConnectState::ERROR) {
|
||||
renderer.drawCenteredText(UI_12_FONT_ID, pageHeight / 2 - 20, "Calibre setup failed", true, EpdFontFamily::BOLD);
|
||||
}
|
||||
renderer.displayBuffer();
|
||||
}
|
||||
|
||||
void CalibreConnectActivity::renderServerRunning() const {
|
||||
constexpr int LINE_SPACING = 24;
|
||||
constexpr int TOP_PADDING = 18;
|
||||
renderer.drawCenteredText(UI_12_FONT_ID, 15, "Connect to Calibre", true, EpdFontFamily::BOLD);
|
||||
|
||||
int y = 60 + TOP_PADDING;
|
||||
std::string ssidInfo = "Network: " + connectedSSID;
|
||||
if (ssidInfo.length() > 28) {
|
||||
ssidInfo.replace(25, ssidInfo.length() - 25, "...");
|
||||
}
|
||||
renderer.drawCenteredText(UI_10_FONT_ID, y, ssidInfo.c_str());
|
||||
renderer.drawCenteredText(UI_10_FONT_ID, y + LINE_SPACING, ("IP: " + connectedIP).c_str());
|
||||
|
||||
y += LINE_SPACING * 2;
|
||||
renderer.drawCenteredText(SMALL_FONT_ID, y, "Install the CrossPoint Reader");
|
||||
renderer.drawCenteredText(SMALL_FONT_ID, y + LINE_SPACING, "device plugin in Calibre.");
|
||||
|
||||
y += LINE_SPACING * 2;
|
||||
renderer.drawCenteredText(SMALL_FONT_ID, y, "Make sure your computer is");
|
||||
renderer.drawCenteredText(SMALL_FONT_ID, y + LINE_SPACING, "on the same WiFi network.");
|
||||
|
||||
y += LINE_SPACING * 2;
|
||||
renderer.drawCenteredText(SMALL_FONT_ID, y, "Then in Calibre, click");
|
||||
renderer.drawCenteredText(SMALL_FONT_ID, y + LINE_SPACING, "\"Send to device\".");
|
||||
|
||||
y += LINE_SPACING * 2;
|
||||
renderer.drawCenteredText(SMALL_FONT_ID, y, "Leave this screen open while sending.");
|
||||
|
||||
y += LINE_SPACING * 2;
|
||||
if (lastProgressTotal > 0 && lastProgressReceived <= lastProgressTotal) {
|
||||
std::string label = "Receiving";
|
||||
if (!currentUploadName.empty()) {
|
||||
label += ": " + currentUploadName;
|
||||
if (label.length() > 34) {
|
||||
label.replace(31, label.length() - 31, "...");
|
||||
}
|
||||
}
|
||||
renderer.drawCenteredText(SMALL_FONT_ID, y, label.c_str());
|
||||
constexpr int barWidth = 300;
|
||||
constexpr int barHeight = 16;
|
||||
constexpr int barX = (480 - barWidth) / 2;
|
||||
ScreenComponents::drawProgressBar(renderer, barX, y + 28, barWidth, barHeight, lastProgressReceived,
|
||||
lastProgressTotal);
|
||||
y += 46;
|
||||
}
|
||||
|
||||
if (lastCompleteAt > 0 && (millis() - lastCompleteAt) < 6000) {
|
||||
std::string msg = "Received: " + lastCompleteName;
|
||||
if (msg.length() > 36) {
|
||||
msg.replace(33, msg.length() - 33, "...");
|
||||
}
|
||||
renderer.drawCenteredText(SMALL_FONT_ID, y, msg.c_str());
|
||||
}
|
||||
|
||||
const auto labels = mappedInput.mapLabels("« Exit", "", "", "");
|
||||
renderer.drawButtonHints(UI_10_FONT_ID, labels.btn1, labels.btn2, labels.btn3, labels.btn4);
|
||||
}
|
||||
54
src/activities/network/CalibreConnectActivity.h
Normal file
54
src/activities/network/CalibreConnectActivity.h
Normal file
@ -0,0 +1,54 @@
|
||||
#pragma once
|
||||
#include <freertos/FreeRTOS.h>
|
||||
#include <freertos/semphr.h>
|
||||
#include <freertos/task.h>
|
||||
|
||||
#include <functional>
|
||||
#include <memory>
|
||||
#include <string>
|
||||
|
||||
#include "activities/ActivityWithSubactivity.h"
|
||||
#include "network/CrossPointWebServer.h"
|
||||
|
||||
enum class CalibreConnectState { WIFI_SELECTION, SERVER_STARTING, SERVER_RUNNING, ERROR };
|
||||
|
||||
/**
|
||||
* CalibreConnectActivity starts the file transfer server in STA mode,
|
||||
* but renders Calibre-specific instructions instead of the web transfer UI.
|
||||
*/
|
||||
class CalibreConnectActivity final : public ActivityWithSubactivity {
|
||||
TaskHandle_t displayTaskHandle = nullptr;
|
||||
SemaphoreHandle_t renderingMutex = nullptr;
|
||||
bool updateRequired = false;
|
||||
CalibreConnectState state = CalibreConnectState::WIFI_SELECTION;
|
||||
const std::function<void()> onComplete;
|
||||
|
||||
std::unique_ptr<CrossPointWebServer> webServer;
|
||||
std::string connectedIP;
|
||||
std::string connectedSSID;
|
||||
unsigned long lastHandleClientTime = 0;
|
||||
size_t lastProgressReceived = 0;
|
||||
size_t lastProgressTotal = 0;
|
||||
std::string currentUploadName;
|
||||
std::string lastCompleteName;
|
||||
unsigned long lastCompleteAt = 0;
|
||||
|
||||
static void taskTrampoline(void* param);
|
||||
[[noreturn]] void displayTaskLoop();
|
||||
void render() const;
|
||||
void renderServerRunning() const;
|
||||
|
||||
void onWifiSelectionComplete(bool connected);
|
||||
void startWebServer();
|
||||
void stopWebServer();
|
||||
|
||||
public:
|
||||
explicit CalibreConnectActivity(GfxRenderer& renderer, MappedInputManager& mappedInput,
|
||||
const std::function<void()>& onComplete)
|
||||
: ActivityWithSubactivity("CalibreConnect", renderer, mappedInput), onComplete(onComplete) {}
|
||||
void onEnter() override;
|
||||
void onExit() override;
|
||||
void loop() override;
|
||||
bool skipLoopDelay() override { return webServer && webServer->isRunning(); }
|
||||
bool preventAutoSleep() override { return webServer && webServer->isRunning(); }
|
||||
};
|
||||
@ -1,856 +0,0 @@
|
||||
#include "CalibreWirelessActivity.h"
|
||||
|
||||
#include <GfxRenderer.h>
|
||||
#include <HardwareSerial.h>
|
||||
#include <SDCardManager.h>
|
||||
#include <WiFi.h>
|
||||
|
||||
#include <cstring>
|
||||
|
||||
#include "MappedInputManager.h"
|
||||
#include "ScreenComponents.h"
|
||||
#include "fontIds.h"
|
||||
#include "util/StringUtils.h"
|
||||
|
||||
namespace {
|
||||
constexpr uint16_t UDP_PORTS[] = {54982, 48123, 39001, 44044, 59678};
|
||||
constexpr uint16_t LOCAL_UDP_PORT = 8134;
|
||||
} // namespace
|
||||
|
||||
void CalibreWirelessActivity::displayTaskTrampoline(void* param) {
|
||||
static_cast<CalibreWirelessActivity*>(param)->displayTaskLoop();
|
||||
}
|
||||
|
||||
void CalibreWirelessActivity::networkTaskTrampoline(void* param) {
|
||||
static_cast<CalibreWirelessActivity*>(param)->networkTaskLoop();
|
||||
}
|
||||
|
||||
void CalibreWirelessActivity::onEnter() {
|
||||
Activity::onEnter();
|
||||
|
||||
renderingMutex = xSemaphoreCreateMutex();
|
||||
stateMutex = xSemaphoreCreateMutex();
|
||||
|
||||
state = WirelessState::DISCOVERING;
|
||||
statusMessage = "Discovering Calibre...";
|
||||
errorMessage.clear();
|
||||
calibreHostname.clear();
|
||||
calibreHost.clear();
|
||||
calibrePort = 0;
|
||||
calibreAltPort = 0;
|
||||
currentFilename.clear();
|
||||
currentFileSize = 0;
|
||||
bytesReceived = 0;
|
||||
inBinaryMode = false;
|
||||
recvBuffer.clear();
|
||||
inSkipMode = false;
|
||||
skipBytesRemaining = 0;
|
||||
skipOpcode = -1;
|
||||
skipExtractedLpath.clear();
|
||||
skipExtractedLength = 0;
|
||||
shouldExit = false;
|
||||
|
||||
updateRequired = true;
|
||||
|
||||
udp.begin(LOCAL_UDP_PORT);
|
||||
|
||||
xTaskCreate(&CalibreWirelessActivity::displayTaskTrampoline, "CalDisplayTask", 2048, this, 1, &displayTaskHandle);
|
||||
xTaskCreate(&CalibreWirelessActivity::networkTaskTrampoline, "CalNetworkTask", 12288, this, 2, &networkTaskHandle);
|
||||
}
|
||||
|
||||
void CalibreWirelessActivity::onExit() {
|
||||
Activity::onExit();
|
||||
|
||||
shouldExit = true;
|
||||
vTaskDelay(50 / portTICK_PERIOD_MS);
|
||||
|
||||
if (tcpClient.connected()) {
|
||||
tcpClient.stop();
|
||||
}
|
||||
udp.stop();
|
||||
|
||||
vTaskDelay(250 / portTICK_PERIOD_MS);
|
||||
|
||||
networkTaskHandle = nullptr;
|
||||
displayTaskHandle = nullptr;
|
||||
|
||||
WiFi.mode(WIFI_OFF);
|
||||
|
||||
if (currentFile) {
|
||||
currentFile.close();
|
||||
}
|
||||
|
||||
recvBuffer.clear();
|
||||
recvBuffer.shrink_to_fit();
|
||||
skipExtractedLpath.clear();
|
||||
skipExtractedLpath.shrink_to_fit();
|
||||
|
||||
if (renderingMutex) {
|
||||
vSemaphoreDelete(renderingMutex);
|
||||
renderingMutex = nullptr;
|
||||
}
|
||||
|
||||
if (stateMutex) {
|
||||
vSemaphoreDelete(stateMutex);
|
||||
stateMutex = nullptr;
|
||||
}
|
||||
}
|
||||
|
||||
void CalibreWirelessActivity::loop() {
|
||||
if (mappedInput.wasPressed(MappedInputManager::Button::Back)) {
|
||||
onCompleteCallback();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
void CalibreWirelessActivity::displayTaskLoop() {
|
||||
while (!shouldExit) {
|
||||
if (updateRequired) {
|
||||
updateRequired = false;
|
||||
xSemaphoreTake(renderingMutex, portMAX_DELAY);
|
||||
if (!shouldExit) {
|
||||
render();
|
||||
}
|
||||
xSemaphoreGive(renderingMutex);
|
||||
}
|
||||
vTaskDelay(50 / portTICK_PERIOD_MS);
|
||||
}
|
||||
vTaskDelete(nullptr);
|
||||
}
|
||||
|
||||
void CalibreWirelessActivity::networkTaskLoop() {
|
||||
while (!shouldExit) {
|
||||
xSemaphoreTake(stateMutex, portMAX_DELAY);
|
||||
const auto currentState = state;
|
||||
xSemaphoreGive(stateMutex);
|
||||
|
||||
if (shouldExit) break;
|
||||
|
||||
switch (currentState) {
|
||||
case WirelessState::DISCOVERING:
|
||||
listenForDiscovery();
|
||||
break;
|
||||
case WirelessState::CONNECTING:
|
||||
case WirelessState::WAITING:
|
||||
case WirelessState::RECEIVING:
|
||||
handleTcpClient();
|
||||
break;
|
||||
case WirelessState::COMPLETE:
|
||||
case WirelessState::DISCONNECTED:
|
||||
case WirelessState::ERROR:
|
||||
vTaskDelay(100 / portTICK_PERIOD_MS);
|
||||
break;
|
||||
}
|
||||
|
||||
vTaskDelay(10 / portTICK_PERIOD_MS);
|
||||
}
|
||||
vTaskDelete(nullptr);
|
||||
}
|
||||
|
||||
void CalibreWirelessActivity::listenForDiscovery() {
|
||||
for (const uint16_t port : UDP_PORTS) {
|
||||
udp.beginPacket("255.255.255.255", port);
|
||||
udp.write(reinterpret_cast<const uint8_t*>("hello"), 5);
|
||||
udp.endPacket();
|
||||
}
|
||||
|
||||
vTaskDelay(500 / portTICK_PERIOD_MS);
|
||||
|
||||
const int packetSize = udp.parsePacket();
|
||||
if (packetSize > 0) {
|
||||
char buffer[256];
|
||||
const int len = udp.read(buffer, sizeof(buffer) - 1);
|
||||
if (len > 0) {
|
||||
buffer[len] = '\0';
|
||||
std::string response(buffer);
|
||||
|
||||
size_t onPos = response.find("(on ");
|
||||
size_t closePos = response.find(')');
|
||||
size_t semiPos = response.find(';');
|
||||
size_t commaPos = response.find(',', semiPos);
|
||||
|
||||
if (semiPos != std::string::npos) {
|
||||
std::string portStr;
|
||||
if (commaPos != std::string::npos && commaPos > semiPos) {
|
||||
portStr = response.substr(semiPos + 1, commaPos - semiPos - 1);
|
||||
uint16_t altPort = 0;
|
||||
for (size_t i = commaPos + 1; i < response.size(); i++) {
|
||||
char c = response[i];
|
||||
if (c >= '0' && c <= '9') {
|
||||
altPort = altPort * 10 + (c - '0');
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
calibreAltPort = altPort;
|
||||
} else {
|
||||
portStr = response.substr(semiPos + 1);
|
||||
}
|
||||
|
||||
uint16_t mainPort = 0;
|
||||
for (char c : portStr) {
|
||||
if (c >= '0' && c <= '9') {
|
||||
mainPort = mainPort * 10 + (c - '0');
|
||||
} else if (c != ' ' && c != '\t') {
|
||||
break;
|
||||
}
|
||||
}
|
||||
calibrePort = mainPort;
|
||||
|
||||
if (onPos != std::string::npos && closePos != std::string::npos && closePos > onPos + 4) {
|
||||
calibreHostname = response.substr(onPos + 4, closePos - onPos - 4);
|
||||
}
|
||||
}
|
||||
|
||||
calibreHost = udp.remoteIP().toString().c_str();
|
||||
if (calibreHostname.empty()) {
|
||||
calibreHostname = calibreHost;
|
||||
}
|
||||
|
||||
if (calibrePort > 0) {
|
||||
setState(WirelessState::CONNECTING);
|
||||
setStatus("Connecting to " + calibreHostname + "...");
|
||||
|
||||
vTaskDelay(100 / portTICK_PERIOD_MS);
|
||||
|
||||
Serial.printf("[%lu] [CAL] Connecting to %s:%d\n", millis(), calibreHost.c_str(), calibrePort);
|
||||
if (tcpClient.connect(calibreHost.c_str(), calibrePort, 5000)) {
|
||||
Serial.printf("[%lu] [CAL] Connected!\n", millis());
|
||||
setState(WirelessState::WAITING);
|
||||
setStatus("Connected to " + calibreHostname + "\nWaiting for commands...");
|
||||
} else if (calibreAltPort > 0 && tcpClient.connect(calibreHost.c_str(), calibreAltPort, 5000)) {
|
||||
Serial.printf("[%lu] [CAL] Connected on alt port!\n", millis());
|
||||
setState(WirelessState::WAITING);
|
||||
setStatus("Connected to " + calibreHostname + "\nWaiting for commands...");
|
||||
} else {
|
||||
Serial.printf("[%lu] [CAL] Connection failed\n", millis());
|
||||
setState(WirelessState::DISCOVERING);
|
||||
setStatus("Discovering Calibre...\n(Connection failed, retrying)");
|
||||
calibrePort = 0;
|
||||
calibreAltPort = 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void CalibreWirelessActivity::handleTcpClient() {
|
||||
// In binary mode, keep reading even if connection closed - data may still be buffered
|
||||
if (inBinaryMode) {
|
||||
// Check if there's still data to read, even if connection is closing
|
||||
if (tcpClient.available() > 0 || tcpClient.connected()) {
|
||||
receiveBinaryData();
|
||||
return;
|
||||
}
|
||||
// Connection closed and no more data - check if transfer was complete
|
||||
if (binaryBytesRemaining > 0) {
|
||||
Serial.printf("[%lu] [CAL] Connection lost with %zu bytes remaining\n", millis(), binaryBytesRemaining);
|
||||
currentFile.close();
|
||||
inBinaryMode = false;
|
||||
setError("Transfer incomplete - connection lost");
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (!tcpClient.connected()) {
|
||||
setState(WirelessState::DISCONNECTED);
|
||||
setStatus("Calibre disconnected");
|
||||
return;
|
||||
}
|
||||
|
||||
std::string message;
|
||||
if (readJsonMessage(message)) {
|
||||
size_t start = message.find('[');
|
||||
if (start != std::string::npos) {
|
||||
start++;
|
||||
size_t end = message.find(',', start);
|
||||
if (end != std::string::npos) {
|
||||
int opcodeInt = 0;
|
||||
for (size_t i = start; i < end; i++) {
|
||||
char c = message[i];
|
||||
if (c >= '0' && c <= '9') {
|
||||
opcodeInt = opcodeInt * 10 + (c - '0');
|
||||
} else if (c != ' ' && c != '\t') {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (opcodeInt >= 0 && opcodeInt <= OpCode::ERROR) {
|
||||
auto opcode = static_cast<OpCode>(opcodeInt);
|
||||
size_t dataStart = end + 1;
|
||||
size_t dataEnd = message.rfind(']');
|
||||
std::string data;
|
||||
if (dataEnd != std::string::npos && dataEnd > dataStart) {
|
||||
data = message.substr(dataStart, dataEnd - dataStart);
|
||||
}
|
||||
handleCommand(opcode, data);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
bool CalibreWirelessActivity::readJsonMessage(std::string& message) {
|
||||
constexpr size_t MAX_BUFFERED_MSG_SIZE = 32768;
|
||||
|
||||
// Handle skip mode for large messages
|
||||
if (inSkipMode) {
|
||||
while (skipBytesRemaining > 0 && tcpClient.available() > 0) {
|
||||
uint8_t discardBuf[1024];
|
||||
size_t toRead = std::min({static_cast<size_t>(tcpClient.available()), sizeof(discardBuf), skipBytesRemaining});
|
||||
int bytesRead = tcpClient.read(discardBuf, toRead);
|
||||
if (bytesRead > 0) {
|
||||
skipBytesRemaining -= bytesRead;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (skipBytesRemaining == 0) {
|
||||
inSkipMode = false;
|
||||
if (skipOpcode == OpCode::SEND_BOOK && !skipExtractedLpath.empty() && skipExtractedLength > 0) {
|
||||
message = "[" + std::to_string(skipOpcode) + ",{\"lpath\":\"" + skipExtractedLpath +
|
||||
"\",\"length\":" + std::to_string(skipExtractedLength) + "}]";
|
||||
skipOpcode = -1;
|
||||
skipExtractedLpath.clear();
|
||||
skipExtractedLength = 0;
|
||||
return true;
|
||||
}
|
||||
if (skipOpcode >= 0) {
|
||||
message = "[" + std::to_string(skipOpcode) + ",{}]";
|
||||
skipOpcode = -1;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// Read available data into buffer
|
||||
int available = tcpClient.available();
|
||||
if (available > 0) {
|
||||
size_t maxBuffer = MAX_BUFFERED_MSG_SIZE + 20;
|
||||
if (recvBuffer.size() < maxBuffer) {
|
||||
char buf[1024];
|
||||
size_t spaceLeft = maxBuffer - recvBuffer.size();
|
||||
while (available > 0 && spaceLeft > 0) {
|
||||
int toRead = std::min({available, static_cast<int>(sizeof(buf)), static_cast<int>(spaceLeft)});
|
||||
int bytesRead = tcpClient.read(reinterpret_cast<uint8_t*>(buf), toRead);
|
||||
if (bytesRead > 0) {
|
||||
recvBuffer.append(buf, bytesRead);
|
||||
available -= bytesRead;
|
||||
spaceLeft -= bytesRead;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (recvBuffer.empty()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
size_t bracketPos = recvBuffer.find('[');
|
||||
if (bracketPos == std::string::npos) {
|
||||
if (recvBuffer.size() > 1000) {
|
||||
recvBuffer.clear();
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
size_t msgLen = 0;
|
||||
bool validPrefix = false;
|
||||
|
||||
if (bracketPos > 0 && bracketPos <= 12) {
|
||||
bool allDigits = true;
|
||||
size_t parsedLen = 0;
|
||||
for (size_t i = 0; i < bracketPos; i++) {
|
||||
char c = recvBuffer[i];
|
||||
if (c >= '0' && c <= '9') {
|
||||
parsedLen = parsedLen * 10 + (c - '0');
|
||||
} else {
|
||||
allDigits = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (allDigits) {
|
||||
msgLen = parsedLen;
|
||||
validPrefix = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (!validPrefix) {
|
||||
if (bracketPos > 0) {
|
||||
recvBuffer = recvBuffer.substr(bracketPos);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
if (msgLen > 10000000) {
|
||||
recvBuffer.clear();
|
||||
return false;
|
||||
}
|
||||
|
||||
// Handle large messages
|
||||
if (msgLen > MAX_BUFFERED_MSG_SIZE) {
|
||||
Serial.printf("[%lu] [CAL] Large message (%zu bytes), streaming\n", millis(), msgLen);
|
||||
|
||||
int opcodeInt = -1;
|
||||
size_t opcodeStart = bracketPos + 1;
|
||||
size_t commaPos = recvBuffer.find(',', opcodeStart);
|
||||
if (commaPos != std::string::npos) {
|
||||
opcodeInt = 0;
|
||||
for (size_t i = opcodeStart; i < commaPos; i++) {
|
||||
char c = recvBuffer[i];
|
||||
if (c >= '0' && c <= '9') {
|
||||
opcodeInt = opcodeInt * 10 + (c - '0');
|
||||
} else if (c != ' ' && c != '\t') {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
skipOpcode = opcodeInt;
|
||||
skipExtractedLpath.clear();
|
||||
skipExtractedLength = 0;
|
||||
|
||||
if (opcodeInt == OpCode::SEND_BOOK) {
|
||||
size_t lpathPos = recvBuffer.find("\"lpath\"");
|
||||
if (lpathPos != std::string::npos) {
|
||||
size_t colonPos = recvBuffer.find(':', lpathPos + 7);
|
||||
if (colonPos != std::string::npos) {
|
||||
size_t quoteStart = recvBuffer.find('"', colonPos + 1);
|
||||
if (quoteStart != std::string::npos) {
|
||||
size_t quoteEnd = recvBuffer.find('"', quoteStart + 1);
|
||||
if (quoteEnd != std::string::npos) {
|
||||
skipExtractedLpath = recvBuffer.substr(quoteStart + 1, quoteEnd - quoteStart - 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
int depth = 0;
|
||||
const char* lengthKey = "\"length\"";
|
||||
for (size_t i = bracketPos; i < recvBuffer.size() && i < bracketPos + 2000; i++) {
|
||||
char c = recvBuffer[i];
|
||||
if (c == '{' || c == '[')
|
||||
depth++;
|
||||
else if (c == '}' || c == ']')
|
||||
depth--;
|
||||
else if (depth == 2 && c == '"' && i + 8 <= recvBuffer.size()) {
|
||||
bool match = true;
|
||||
for (size_t j = 0; j < 8 && match; j++) {
|
||||
if (recvBuffer[i + j] != lengthKey[j]) match = false;
|
||||
}
|
||||
if (match) {
|
||||
size_t numStart = i + 8;
|
||||
while (numStart < recvBuffer.size() && (recvBuffer[numStart] == ':' || recvBuffer[numStart] == ' ')) {
|
||||
numStart++;
|
||||
}
|
||||
while (numStart < recvBuffer.size() && recvBuffer[numStart] >= '0' && recvBuffer[numStart] <= '9') {
|
||||
skipExtractedLength = skipExtractedLength * 10 + (recvBuffer[numStart] - '0');
|
||||
numStart++;
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
size_t totalMsgBytes = bracketPos + msgLen;
|
||||
if (recvBuffer.size() >= totalMsgBytes) {
|
||||
recvBuffer = recvBuffer.substr(totalMsgBytes);
|
||||
if (skipOpcode == OpCode::SEND_BOOK && !skipExtractedLpath.empty() && skipExtractedLength > 0) {
|
||||
message = "[" + std::to_string(skipOpcode) + ",{\"lpath\":\"" + skipExtractedLpath +
|
||||
"\",\"length\":" + std::to_string(skipExtractedLength) + "}]";
|
||||
skipOpcode = -1;
|
||||
skipExtractedLpath.clear();
|
||||
skipExtractedLength = 0;
|
||||
return true;
|
||||
}
|
||||
if (skipOpcode >= 0) {
|
||||
message = "[" + std::to_string(skipOpcode) + ",{}]";
|
||||
skipOpcode = -1;
|
||||
return true;
|
||||
}
|
||||
} else {
|
||||
skipBytesRemaining = totalMsgBytes - recvBuffer.size();
|
||||
recvBuffer.clear();
|
||||
inSkipMode = true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
size_t totalNeeded = bracketPos + msgLen;
|
||||
if (recvBuffer.size() < totalNeeded) {
|
||||
return false;
|
||||
}
|
||||
|
||||
message = recvBuffer.substr(bracketPos, msgLen);
|
||||
recvBuffer = recvBuffer.size() > totalNeeded ? recvBuffer.substr(totalNeeded) : "";
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
void CalibreWirelessActivity::sendJsonResponse(const OpCode opcode, const std::string& data) {
|
||||
std::string json = "[" + std::to_string(opcode) + "," + data + "]";
|
||||
std::string msg = std::to_string(json.length()) + json;
|
||||
tcpClient.write(reinterpret_cast<const uint8_t*>(msg.c_str()), msg.length());
|
||||
tcpClient.flush();
|
||||
}
|
||||
|
||||
void CalibreWirelessActivity::handleCommand(const OpCode opcode, const std::string& data) {
|
||||
Serial.printf("[%lu] [CAL] Command: %d, data size: %zu\n", millis(), opcode, data.size());
|
||||
|
||||
switch (opcode) {
|
||||
case OpCode::GET_INITIALIZATION_INFO:
|
||||
handleGetInitializationInfo(data);
|
||||
break;
|
||||
case OpCode::GET_DEVICE_INFORMATION:
|
||||
handleGetDeviceInformation();
|
||||
break;
|
||||
case OpCode::FREE_SPACE:
|
||||
case OpCode::TOTAL_SPACE:
|
||||
handleFreeSpace();
|
||||
break;
|
||||
case OpCode::GET_BOOK_COUNT:
|
||||
handleGetBookCount();
|
||||
break;
|
||||
case OpCode::SEND_BOOK:
|
||||
handleSendBook(data);
|
||||
break;
|
||||
case OpCode::SEND_BOOK_METADATA:
|
||||
handleSendBookMetadata(data);
|
||||
break;
|
||||
case OpCode::DISPLAY_MESSAGE:
|
||||
handleDisplayMessage(data);
|
||||
break;
|
||||
case OpCode::NOOP:
|
||||
handleNoop(data);
|
||||
break;
|
||||
case OpCode::SET_CALIBRE_DEVICE_INFO:
|
||||
case OpCode::SET_CALIBRE_DEVICE_NAME:
|
||||
case OpCode::SET_LIBRARY_INFO:
|
||||
case OpCode::SEND_BOOKLISTS:
|
||||
sendJsonResponse(OpCode::OK, "{}");
|
||||
break;
|
||||
default:
|
||||
sendJsonResponse(OpCode::OK, "{}");
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
void CalibreWirelessActivity::receiveBinaryData() {
|
||||
// KOReader-style: read all available data, write only what we need to file,
|
||||
// put excess (next JSON message) back into buffer.
|
||||
|
||||
int available = tcpClient.available();
|
||||
if (available <= 0) {
|
||||
// Wait longer for data - TCP buffers may not be immediately available
|
||||
// especially near end of transfer when connection is closing
|
||||
vTaskDelay(10 / portTICK_PERIOD_MS);
|
||||
return;
|
||||
}
|
||||
|
||||
uint8_t buffer[4096];
|
||||
int bytesRead = tcpClient.read(buffer, std::min(sizeof(buffer), static_cast<size_t>(available)));
|
||||
|
||||
if (bytesRead <= 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Write only what we need (like KOReader's data:sub(1, to_write_bytes))
|
||||
size_t toWrite = std::min(static_cast<size_t>(bytesRead), binaryBytesRemaining);
|
||||
|
||||
if (toWrite > 0) {
|
||||
currentFile.write(buffer, toWrite);
|
||||
bytesReceived += toWrite;
|
||||
binaryBytesRemaining -= toWrite;
|
||||
updateRequired = true;
|
||||
}
|
||||
|
||||
// If we read more than needed, it's the next JSON message (like KOReader's buffer handling)
|
||||
if (static_cast<size_t>(bytesRead) > toWrite) {
|
||||
size_t excess = bytesRead - toWrite;
|
||||
recvBuffer.assign(reinterpret_cast<char*>(buffer + toWrite), excess);
|
||||
Serial.printf("[%lu] [CAL] Binary done, %zu excess bytes -> buffer\n", millis(), excess);
|
||||
}
|
||||
|
||||
// Progress logging
|
||||
static unsigned long lastLog = 0;
|
||||
unsigned long now = millis();
|
||||
if (now - lastLog > 500) {
|
||||
Serial.printf("[%lu] [CAL] Binary: %zu/%zu (%.1f%%)\n", now, bytesReceived, currentFileSize,
|
||||
currentFileSize > 0 ? (100.0 * bytesReceived / currentFileSize) : 0.0);
|
||||
lastLog = now;
|
||||
}
|
||||
|
||||
// Check completion
|
||||
if (binaryBytesRemaining == 0) {
|
||||
currentFile.flush();
|
||||
currentFile.close();
|
||||
inBinaryMode = false;
|
||||
|
||||
Serial.printf("[%lu] [CAL] File complete: %zu bytes\n", millis(), bytesReceived);
|
||||
setState(WirelessState::WAITING);
|
||||
setStatus("Received: " + currentFilename + "\nWaiting for more...");
|
||||
}
|
||||
}
|
||||
|
||||
void CalibreWirelessActivity::handleGetInitializationInfo(const std::string& data) {
|
||||
setState(WirelessState::WAITING);
|
||||
setStatus("Connected to " + calibreHostname +
|
||||
"\nWaiting for transfer...\n\nIf transfer fails, enable\n'Ignore free space' in Calibre's\nSmartDevice "
|
||||
"plugin settings.");
|
||||
|
||||
std::string response = "{";
|
||||
response += "\"appName\":\"CrossPoint\",";
|
||||
response += "\"acceptedExtensions\":[\"epub\"],";
|
||||
response += "\"cacheUsesLpaths\":true,";
|
||||
response += "\"canAcceptLibraryInfo\":true,";
|
||||
response += "\"canDeleteMultipleBooks\":true,";
|
||||
response += "\"canReceiveBookBinary\":true,";
|
||||
response += "\"canSendOkToSendbook\":true,";
|
||||
response += "\"canStreamBooks\":true,";
|
||||
response += "\"canStreamMetadata\":true,";
|
||||
response += "\"canUseCachedMetadata\":true,";
|
||||
response += "\"ccVersionNumber\":212,";
|
||||
response += "\"coverHeight\":0,";
|
||||
response += "\"deviceKind\":\"CrossPoint\",";
|
||||
response += "\"deviceName\":\"CrossPoint\",";
|
||||
response += "\"extensionPathLengths\":{\"epub\":37},";
|
||||
response += "\"maxBookContentPacketLen\":1024,";
|
||||
response += "\"passwordHash\":\"\",";
|
||||
response += "\"useUuidFileNames\":false,";
|
||||
response += "\"versionOK\":true";
|
||||
response += "}";
|
||||
|
||||
sendJsonResponse(OpCode::OK, response);
|
||||
}
|
||||
|
||||
void CalibreWirelessActivity::handleGetDeviceInformation() {
|
||||
std::string response = "{";
|
||||
response += "\"device_info\":{";
|
||||
response += "\"device_store_uuid\":\"" + getDeviceUuid() + "\",";
|
||||
response += "\"device_name\":\"CrossPoint Reader\",";
|
||||
response += "\"device_version\":\"" CROSSPOINT_VERSION "\"";
|
||||
response += "},";
|
||||
response += "\"version\":1,";
|
||||
response += "\"device_version\":\"" CROSSPOINT_VERSION "\"";
|
||||
response += "}";
|
||||
|
||||
sendJsonResponse(OpCode::OK, response);
|
||||
}
|
||||
|
||||
void CalibreWirelessActivity::handleFreeSpace() {
|
||||
sendJsonResponse(OpCode::OK, "{\"free_space_on_device\":10737418240}");
|
||||
}
|
||||
|
||||
void CalibreWirelessActivity::handleGetBookCount() {
|
||||
sendJsonResponse(OpCode::OK, "{\"count\":0,\"willStream\":true,\"willScan\":false}");
|
||||
}
|
||||
|
||||
void CalibreWirelessActivity::handleSendBook(const std::string& data) {
|
||||
Serial.printf("[%lu] [CAL] SEND_BOOK (first 500): %.500s\n", millis(), data.c_str());
|
||||
|
||||
// Extract lpath
|
||||
std::string lpath;
|
||||
size_t lpathPos = data.find("\"lpath\"");
|
||||
if (lpathPos != std::string::npos) {
|
||||
size_t colonPos = data.find(':', lpathPos + 7);
|
||||
if (colonPos != std::string::npos) {
|
||||
size_t quoteStart = data.find('"', colonPos + 1);
|
||||
if (quoteStart != std::string::npos) {
|
||||
size_t quoteEnd = data.find('"', quoteStart + 1);
|
||||
if (quoteEnd != std::string::npos) {
|
||||
lpath = data.substr(quoteStart + 1, quoteEnd - quoteStart - 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Extract top-level length
|
||||
size_t length = 0;
|
||||
int depth = 0;
|
||||
const char* lengthKey = "\"length\"";
|
||||
|
||||
for (size_t i = 0; i < data.size(); i++) {
|
||||
char c = data[i];
|
||||
if (c == '{' || c == '[')
|
||||
depth++;
|
||||
else if (c == '}' || c == ']')
|
||||
depth--;
|
||||
else if (depth == 1 && c == '"' && i + 8 <= data.size()) {
|
||||
bool match = true;
|
||||
for (size_t j = 0; j < 8 && match; j++) {
|
||||
if (data[i + j] != lengthKey[j]) match = false;
|
||||
}
|
||||
if (match) {
|
||||
size_t colonPos = i + 8;
|
||||
while (colonPos < data.size() && data[colonPos] != ':') colonPos++;
|
||||
if (colonPos < data.size()) {
|
||||
size_t numStart = colonPos + 1;
|
||||
while (numStart < data.size() && (data[numStart] == ' ' || data[numStart] == '\t')) numStart++;
|
||||
while (numStart < data.size() && data[numStart] >= '0' && data[numStart] <= '9') {
|
||||
length = length * 10 + (data[numStart] - '0');
|
||||
numStart++;
|
||||
}
|
||||
if (length > 0) {
|
||||
Serial.printf("[%lu] [CAL] Extracted length=%zu\n", millis(), length);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (lpath.empty() || length == 0) {
|
||||
sendJsonResponse(OpCode::ERROR, "{\"message\":\"Invalid book data\"}");
|
||||
return;
|
||||
}
|
||||
|
||||
std::string filename = lpath;
|
||||
size_t lastSlash = filename.rfind('/');
|
||||
if (lastSlash != std::string::npos) {
|
||||
filename = filename.substr(lastSlash + 1);
|
||||
}
|
||||
|
||||
currentFilename = "/" + StringUtils::sanitizeFilename(filename);
|
||||
if (!StringUtils::checkFileExtension(currentFilename, ".epub")) {
|
||||
currentFilename += ".epub";
|
||||
}
|
||||
currentFileSize = length;
|
||||
bytesReceived = 0;
|
||||
binaryBytesRemaining = length;
|
||||
|
||||
Serial.printf("[%lu] [CAL] File: %s, size: %zu, buffer: %zu\n", millis(), currentFilename.c_str(), length,
|
||||
recvBuffer.size());
|
||||
|
||||
setState(WirelessState::RECEIVING);
|
||||
setStatus("Receiving: " + filename);
|
||||
|
||||
if (!SdMan.openFileForWrite("CAL", currentFilename.c_str(), currentFile)) {
|
||||
setError("Failed to create file");
|
||||
sendJsonResponse(OpCode::ERROR, "{\"message\":\"Failed to create file\"}");
|
||||
return;
|
||||
}
|
||||
|
||||
// Send OK - Calibre will start sending binary
|
||||
sendJsonResponse(OpCode::OK, "{}");
|
||||
|
||||
// Switch to binary mode
|
||||
inBinaryMode = true;
|
||||
|
||||
// Process any data already in buffer (like KOReader)
|
||||
if (!recvBuffer.empty()) {
|
||||
size_t toWrite = std::min(recvBuffer.size(), binaryBytesRemaining);
|
||||
Serial.printf("[%lu] [CAL] Writing %zu from buffer\n", millis(), toWrite);
|
||||
currentFile.write(reinterpret_cast<const uint8_t*>(recvBuffer.data()), toWrite);
|
||||
bytesReceived += toWrite;
|
||||
binaryBytesRemaining -= toWrite;
|
||||
|
||||
if (recvBuffer.size() > toWrite) {
|
||||
recvBuffer = recvBuffer.substr(toWrite);
|
||||
} else {
|
||||
recvBuffer.clear();
|
||||
}
|
||||
updateRequired = true;
|
||||
|
||||
if (binaryBytesRemaining == 0) {
|
||||
currentFile.flush();
|
||||
currentFile.close();
|
||||
inBinaryMode = false;
|
||||
Serial.printf("[%lu] [CAL] File complete: %zu bytes\n", millis(), bytesReceived);
|
||||
setState(WirelessState::WAITING);
|
||||
setStatus("Received: " + currentFilename + "\nWaiting for more...");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void CalibreWirelessActivity::handleSendBookMetadata(const std::string& data) {
|
||||
Serial.printf("[%lu] [CAL] SEND_BOOK_METADATA\n", millis());
|
||||
sendJsonResponse(OpCode::OK, "{}");
|
||||
}
|
||||
|
||||
void CalibreWirelessActivity::handleDisplayMessage(const std::string& data) {
|
||||
if (data.find("\"messageKind\":1") != std::string::npos) {
|
||||
setError("Password required");
|
||||
}
|
||||
sendJsonResponse(OpCode::OK, "{}");
|
||||
}
|
||||
|
||||
void CalibreWirelessActivity::handleNoop(const std::string& data) {
|
||||
if (data.find("\"ejecting\":true") != std::string::npos) {
|
||||
setState(WirelessState::DISCONNECTED);
|
||||
setStatus("Calibre disconnected");
|
||||
}
|
||||
sendJsonResponse(OpCode::NOOP, "{}");
|
||||
}
|
||||
|
||||
void CalibreWirelessActivity::render() const {
|
||||
renderer.clearScreen();
|
||||
|
||||
const auto pageWidth = renderer.getScreenWidth();
|
||||
const auto pageHeight = renderer.getScreenHeight();
|
||||
|
||||
renderer.drawCenteredText(UI_12_FONT_ID, 30, "Calibre Wireless", true, EpdFontFamily::BOLD);
|
||||
|
||||
const std::string ipAddr = WiFi.localIP().toString().c_str();
|
||||
renderer.drawCenteredText(UI_10_FONT_ID, 60, ("IP: " + ipAddr).c_str());
|
||||
|
||||
int statusY = pageHeight / 2 - 40;
|
||||
std::string status = statusMessage;
|
||||
size_t pos = 0;
|
||||
while ((pos = status.find('\n')) != std::string::npos) {
|
||||
renderer.drawCenteredText(UI_10_FONT_ID, statusY, status.substr(0, pos).c_str());
|
||||
statusY += 25;
|
||||
status = status.substr(pos + 1);
|
||||
}
|
||||
if (!status.empty()) {
|
||||
renderer.drawCenteredText(UI_10_FONT_ID, statusY, status.c_str());
|
||||
statusY += 25;
|
||||
}
|
||||
|
||||
if (state == WirelessState::RECEIVING && currentFileSize > 0) {
|
||||
const int barWidth = pageWidth - 100;
|
||||
constexpr int barHeight = 20;
|
||||
constexpr int barX = 50;
|
||||
const int barY = statusY + 20;
|
||||
ScreenComponents::drawProgressBar(renderer, barX, barY, barWidth, barHeight, bytesReceived, currentFileSize);
|
||||
}
|
||||
|
||||
if (!errorMessage.empty()) {
|
||||
renderer.drawCenteredText(UI_10_FONT_ID, pageHeight - 120, errorMessage.c_str());
|
||||
}
|
||||
|
||||
const auto labels = mappedInput.mapLabels("Back", "", "", "");
|
||||
renderer.drawButtonHints(UI_10_FONT_ID, labels.btn1, labels.btn2, labels.btn3, labels.btn4);
|
||||
|
||||
renderer.displayBuffer();
|
||||
}
|
||||
|
||||
std::string CalibreWirelessActivity::getDeviceUuid() const {
|
||||
uint8_t mac[6];
|
||||
WiFi.macAddress(mac);
|
||||
char uuid[37];
|
||||
snprintf(uuid, sizeof(uuid), "%02x%02x%02x%02x-%02x%02x-4000-8000-%02x%02x%02x%02x%02x%02x", mac[0], mac[1], mac[2],
|
||||
mac[3], mac[4], mac[5], mac[0], mac[1], mac[2], mac[3], mac[4], mac[5]);
|
||||
return std::string(uuid);
|
||||
}
|
||||
|
||||
void CalibreWirelessActivity::setState(WirelessState newState) {
|
||||
xSemaphoreTake(stateMutex, portMAX_DELAY);
|
||||
state = newState;
|
||||
xSemaphoreGive(stateMutex);
|
||||
updateRequired = true;
|
||||
}
|
||||
|
||||
void CalibreWirelessActivity::setStatus(const std::string& message) {
|
||||
statusMessage = message;
|
||||
updateRequired = true;
|
||||
}
|
||||
|
||||
void CalibreWirelessActivity::setError(const std::string& message) {
|
||||
errorMessage = message;
|
||||
setState(WirelessState::ERROR);
|
||||
}
|
||||
@ -1,117 +0,0 @@
|
||||
#pragma once
|
||||
#include <SDCardManager.h>
|
||||
#include <WiFiClient.h>
|
||||
#include <WiFiUdp.h>
|
||||
#include <freertos/FreeRTOS.h>
|
||||
#include <freertos/semphr.h>
|
||||
#include <freertos/task.h>
|
||||
|
||||
#include <functional>
|
||||
#include <string>
|
||||
|
||||
#include "activities/Activity.h"
|
||||
|
||||
/**
|
||||
* CalibreWirelessActivity implements Calibre's "wireless device" protocol.
|
||||
* This allows Calibre desktop to send books directly to the device over WiFi.
|
||||
*
|
||||
* Protocol specification sourced from Calibre's smart device driver:
|
||||
* https://github.com/kovidgoyal/calibre/blob/master/src/calibre/devices/smart_device_app/driver.py
|
||||
*/
|
||||
class CalibreWirelessActivity final : public Activity {
|
||||
enum class WirelessState { DISCOVERING, CONNECTING, WAITING, RECEIVING, COMPLETE, DISCONNECTED, ERROR };
|
||||
|
||||
enum OpCode : uint8_t {
|
||||
OK = 0,
|
||||
SET_CALIBRE_DEVICE_INFO = 1,
|
||||
SET_CALIBRE_DEVICE_NAME = 2,
|
||||
GET_DEVICE_INFORMATION = 3,
|
||||
TOTAL_SPACE = 4,
|
||||
FREE_SPACE = 5,
|
||||
GET_BOOK_COUNT = 6,
|
||||
SEND_BOOKLISTS = 7,
|
||||
SEND_BOOK = 8,
|
||||
GET_INITIALIZATION_INFO = 9,
|
||||
BOOK_DONE = 11,
|
||||
NOOP = 12,
|
||||
DELETE_BOOK = 13,
|
||||
GET_BOOK_FILE_SEGMENT = 14,
|
||||
GET_BOOK_METADATA = 15,
|
||||
SEND_BOOK_METADATA = 16,
|
||||
DISPLAY_MESSAGE = 17,
|
||||
CALIBRE_BUSY = 18,
|
||||
SET_LIBRARY_INFO = 19,
|
||||
ERROR = 20,
|
||||
};
|
||||
|
||||
TaskHandle_t displayTaskHandle = nullptr;
|
||||
TaskHandle_t networkTaskHandle = nullptr;
|
||||
SemaphoreHandle_t renderingMutex = nullptr;
|
||||
SemaphoreHandle_t stateMutex = nullptr;
|
||||
bool updateRequired = false;
|
||||
volatile bool shouldExit = false;
|
||||
|
||||
WirelessState state = WirelessState::DISCOVERING;
|
||||
const std::function<void()> onCompleteCallback;
|
||||
|
||||
WiFiUDP udp;
|
||||
WiFiClient tcpClient;
|
||||
std::string calibreHost;
|
||||
uint16_t calibrePort = 0;
|
||||
uint16_t calibreAltPort = 0;
|
||||
std::string calibreHostname;
|
||||
|
||||
std::string currentFilename;
|
||||
size_t currentFileSize = 0;
|
||||
size_t bytesReceived = 0;
|
||||
std::string statusMessage;
|
||||
std::string errorMessage;
|
||||
|
||||
bool inBinaryMode = false;
|
||||
size_t binaryBytesRemaining = 0;
|
||||
FsFile currentFile;
|
||||
std::string recvBuffer;
|
||||
|
||||
bool inSkipMode = false;
|
||||
size_t skipBytesRemaining = 0;
|
||||
int skipOpcode = -1;
|
||||
std::string skipExtractedLpath;
|
||||
size_t skipExtractedLength = 0;
|
||||
|
||||
static void displayTaskTrampoline(void* param);
|
||||
static void networkTaskTrampoline(void* param);
|
||||
void displayTaskLoop();
|
||||
void networkTaskLoop();
|
||||
void render() const;
|
||||
|
||||
void listenForDiscovery();
|
||||
void handleTcpClient();
|
||||
bool readJsonMessage(std::string& message);
|
||||
void sendJsonResponse(OpCode opcode, const std::string& data);
|
||||
void handleCommand(OpCode opcode, const std::string& data);
|
||||
void receiveBinaryData();
|
||||
|
||||
void handleGetInitializationInfo(const std::string& data);
|
||||
void handleGetDeviceInformation();
|
||||
void handleFreeSpace();
|
||||
void handleGetBookCount();
|
||||
void handleSendBook(const std::string& data);
|
||||
void handleSendBookMetadata(const std::string& data);
|
||||
void handleDisplayMessage(const std::string& data);
|
||||
void handleNoop(const std::string& data);
|
||||
|
||||
std::string getDeviceUuid() const;
|
||||
void setState(WirelessState newState);
|
||||
void setStatus(const std::string& message);
|
||||
void setError(const std::string& message);
|
||||
|
||||
public:
|
||||
explicit CalibreWirelessActivity(GfxRenderer& renderer, MappedInputManager& mappedInput,
|
||||
const std::function<void()>& onComplete)
|
||||
: Activity("CalibreWireless", renderer, mappedInput), onCompleteCallback(onComplete) {}
|
||||
void onEnter() override;
|
||||
void onExit() override;
|
||||
void loop() override;
|
||||
bool preventAutoSleep() override { return true; }
|
||||
bool skipLoopDelay() override { return true; }
|
||||
};
|
||||
@ -12,6 +12,7 @@
|
||||
#include "MappedInputManager.h"
|
||||
#include "NetworkModeSelectionActivity.h"
|
||||
#include "WifiSelectionActivity.h"
|
||||
#include "activities/network/CalibreConnectActivity.h"
|
||||
#include "fontIds.h"
|
||||
|
||||
namespace {
|
||||
@ -125,8 +126,13 @@ void CrossPointWebServerActivity::onExit() {
|
||||
}
|
||||
|
||||
void CrossPointWebServerActivity::onNetworkModeSelected(const NetworkMode mode) {
|
||||
Serial.printf("[%lu] [WEBACT] Network mode selected: %s\n", millis(),
|
||||
mode == NetworkMode::JOIN_NETWORK ? "Join Network" : "Create Hotspot");
|
||||
const char* modeName = "Join Network";
|
||||
if (mode == NetworkMode::CONNECT_CALIBRE) {
|
||||
modeName = "Connect to Calibre";
|
||||
} else if (mode == NetworkMode::CREATE_HOTSPOT) {
|
||||
modeName = "Create Hotspot";
|
||||
}
|
||||
Serial.printf("[%lu] [WEBACT] Network mode selected: %s\n", millis(), modeName);
|
||||
|
||||
networkMode = mode;
|
||||
isApMode = (mode == NetworkMode::CREATE_HOTSPOT);
|
||||
@ -134,6 +140,18 @@ void CrossPointWebServerActivity::onNetworkModeSelected(const NetworkMode mode)
|
||||
// Exit mode selection subactivity
|
||||
exitActivity();
|
||||
|
||||
if (mode == NetworkMode::CONNECT_CALIBRE) {
|
||||
exitActivity();
|
||||
enterNewActivity(new CalibreConnectActivity(renderer, mappedInput, [this] {
|
||||
exitActivity();
|
||||
state = WebServerActivityState::MODE_SELECTION;
|
||||
enterNewActivity(new NetworkModeSelectionActivity(
|
||||
renderer, mappedInput, [this](const NetworkMode nextMode) { onNetworkModeSelected(nextMode); },
|
||||
[this]() { onGoBack(); }));
|
||||
}));
|
||||
return;
|
||||
}
|
||||
|
||||
if (mode == NetworkMode::JOIN_NETWORK) {
|
||||
// STA mode - launch WiFi selection
|
||||
Serial.printf("[%lu] [WEBACT] Turning on WiFi (STA mode)...\n", millis());
|
||||
@ -179,6 +197,7 @@ void CrossPointWebServerActivity::onWifiSelectionComplete(const bool connected)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
void CrossPointWebServerActivity::startAccessPoint() {
|
||||
Serial.printf("[%lu] [WEBACT] Starting Access Point mode...\n", millis());
|
||||
Serial.printf("[%lu] [WEBACT] [MEM] Free heap before AP start: %d bytes\n", millis(), ESP.getFreeHeap());
|
||||
|
||||
@ -23,7 +23,7 @@ enum class WebServerActivityState {
|
||||
/**
|
||||
* CrossPointWebServerActivity is the entry point for file transfer functionality.
|
||||
* It:
|
||||
* - First presents a choice between "Join a Network" (STA) and "Create Hotspot" (AP)
|
||||
* - First presents a choice between "Join a Network" (STA), "Connect to Calibre", and "Create Hotspot" (AP)
|
||||
* - For STA mode: Launches WifiSelectionActivity to connect to an existing network
|
||||
* - For AP mode: Creates an Access Point that clients can connect to
|
||||
* - Starts the CrossPointWebServer when connected
|
||||
|
||||
@ -6,10 +6,13 @@
|
||||
#include "fontIds.h"
|
||||
|
||||
namespace {
|
||||
constexpr int MENU_ITEM_COUNT = 2;
|
||||
const char* MENU_ITEMS[MENU_ITEM_COUNT] = {"Join a Network", "Create Hotspot"};
|
||||
const char* MENU_DESCRIPTIONS[MENU_ITEM_COUNT] = {"Connect to an existing WiFi network",
|
||||
"Create a WiFi network others can join"};
|
||||
constexpr int MENU_ITEM_COUNT = 3;
|
||||
const char* MENU_ITEMS[MENU_ITEM_COUNT] = {"Join a Network", "Connect to Calibre", "Create Hotspot"};
|
||||
const char* MENU_DESCRIPTIONS[MENU_ITEM_COUNT] = {
|
||||
"Connect to an existing WiFi network",
|
||||
"Use Calibre wireless device transfers",
|
||||
"Create a WiFi network others can join",
|
||||
};
|
||||
} // namespace
|
||||
|
||||
void NetworkModeSelectionActivity::taskTrampoline(void* param) {
|
||||
@ -58,7 +61,12 @@ void NetworkModeSelectionActivity::loop() {
|
||||
|
||||
// Handle confirm button - select current option
|
||||
if (mappedInput.wasPressed(MappedInputManager::Button::Confirm)) {
|
||||
const NetworkMode mode = (selectedIndex == 0) ? NetworkMode::JOIN_NETWORK : NetworkMode::CREATE_HOTSPOT;
|
||||
NetworkMode mode = NetworkMode::JOIN_NETWORK;
|
||||
if (selectedIndex == 1) {
|
||||
mode = NetworkMode::CONNECT_CALIBRE;
|
||||
} else if (selectedIndex == 2) {
|
||||
mode = NetworkMode::CREATE_HOTSPOT;
|
||||
}
|
||||
onModeSelected(mode);
|
||||
return;
|
||||
}
|
||||
|
||||
@ -8,11 +8,12 @@
|
||||
#include "../Activity.h"
|
||||
|
||||
// Enum for network mode selection
|
||||
enum class NetworkMode { JOIN_NETWORK, CREATE_HOTSPOT };
|
||||
enum class NetworkMode { JOIN_NETWORK, CONNECT_CALIBRE, CREATE_HOTSPOT };
|
||||
|
||||
/**
|
||||
* NetworkModeSelectionActivity presents the user with a choice:
|
||||
* - "Join a Network" - Connect to an existing WiFi network (STA mode)
|
||||
* - "Connect to Calibre" - Use Calibre wireless device transfers
|
||||
* - "Create Hotspot" - Create an Access Point that others can connect to (AP mode)
|
||||
*
|
||||
* The onModeSelected callback is called with the user's choice.
|
||||
|
||||
@ -1,20 +1,16 @@
|
||||
#include "CalibreSettingsActivity.h"
|
||||
|
||||
#include <GfxRenderer.h>
|
||||
#include <WiFi.h>
|
||||
|
||||
#include <cstring>
|
||||
|
||||
#include "CrossPointSettings.h"
|
||||
#include "MappedInputManager.h"
|
||||
#include "activities/network/CalibreWirelessActivity.h"
|
||||
#include "activities/network/WifiSelectionActivity.h"
|
||||
#include "activities/util/KeyboardEntryActivity.h"
|
||||
#include "fontIds.h"
|
||||
|
||||
namespace {
|
||||
constexpr int MENU_ITEMS = 2;
|
||||
const char* menuNames[MENU_ITEMS] = {"Calibre Web URL", "Connect as Wireless Device"};
|
||||
constexpr int MENU_ITEMS = 3;
|
||||
const char* menuNames[MENU_ITEMS] = {"OPDS Server URL", "Username", "Password"};
|
||||
} // namespace
|
||||
|
||||
void CalibreSettingsActivity::taskTrampoline(void* param) {
|
||||
@ -80,10 +76,10 @@ void CalibreSettingsActivity::handleSelection() {
|
||||
xSemaphoreTake(renderingMutex, portMAX_DELAY);
|
||||
|
||||
if (selectedIndex == 0) {
|
||||
// Calibre Web URL
|
||||
// OPDS Server URL
|
||||
exitActivity();
|
||||
enterNewActivity(new KeyboardEntryActivity(
|
||||
renderer, mappedInput, "Calibre Web URL", SETTINGS.opdsServerUrl, 10,
|
||||
renderer, mappedInput, "OPDS Server URL", SETTINGS.opdsServerUrl, 10,
|
||||
127, // maxLength
|
||||
false, // not password
|
||||
[this](const std::string& url) {
|
||||
@ -98,26 +94,41 @@ void CalibreSettingsActivity::handleSelection() {
|
||||
updateRequired = true;
|
||||
}));
|
||||
} else if (selectedIndex == 1) {
|
||||
// Wireless Device - launch the activity (handles WiFi connection internally)
|
||||
// Username
|
||||
exitActivity();
|
||||
if (WiFi.status() != WL_CONNECTED) {
|
||||
enterNewActivity(new WifiSelectionActivity(renderer, mappedInput, [this](bool connected) {
|
||||
exitActivity();
|
||||
if (connected) {
|
||||
enterNewActivity(new CalibreWirelessActivity(renderer, mappedInput, [this] {
|
||||
exitActivity();
|
||||
updateRequired = true;
|
||||
}));
|
||||
} else {
|
||||
enterNewActivity(new KeyboardEntryActivity(
|
||||
renderer, mappedInput, "Username", SETTINGS.opdsUsername, 10,
|
||||
63, // maxLength
|
||||
false, // not password
|
||||
[this](const std::string& username) {
|
||||
strncpy(SETTINGS.opdsUsername, username.c_str(), sizeof(SETTINGS.opdsUsername) - 1);
|
||||
SETTINGS.opdsUsername[sizeof(SETTINGS.opdsUsername) - 1] = '\0';
|
||||
SETTINGS.saveToFile();
|
||||
exitActivity();
|
||||
updateRequired = true;
|
||||
}
|
||||
}));
|
||||
} else {
|
||||
enterNewActivity(new CalibreWirelessActivity(renderer, mappedInput, [this] {
|
||||
exitActivity();
|
||||
updateRequired = true;
|
||||
}));
|
||||
}
|
||||
},
|
||||
[this]() {
|
||||
exitActivity();
|
||||
updateRequired = true;
|
||||
}));
|
||||
} else if (selectedIndex == 2) {
|
||||
// Password
|
||||
exitActivity();
|
||||
enterNewActivity(new KeyboardEntryActivity(
|
||||
renderer, mappedInput, "Password", SETTINGS.opdsPassword, 10,
|
||||
63, // maxLength
|
||||
true, // password mode
|
||||
[this](const std::string& password) {
|
||||
strncpy(SETTINGS.opdsPassword, password.c_str(), sizeof(SETTINGS.opdsPassword) - 1);
|
||||
SETTINGS.opdsPassword[sizeof(SETTINGS.opdsPassword) - 1] = '\0';
|
||||
SETTINGS.saveToFile();
|
||||
exitActivity();
|
||||
updateRequired = true;
|
||||
},
|
||||
[this]() {
|
||||
exitActivity();
|
||||
updateRequired = true;
|
||||
}));
|
||||
}
|
||||
|
||||
xSemaphoreGive(renderingMutex);
|
||||
@ -141,24 +152,32 @@ void CalibreSettingsActivity::render() {
|
||||
const auto pageWidth = renderer.getScreenWidth();
|
||||
|
||||
// Draw header
|
||||
renderer.drawCenteredText(UI_12_FONT_ID, 15, "Calibre", true, EpdFontFamily::BOLD);
|
||||
renderer.drawCenteredText(UI_12_FONT_ID, 15, "OPDS Browser", true, EpdFontFamily::BOLD);
|
||||
|
||||
// Draw info text about Calibre
|
||||
renderer.drawCenteredText(UI_10_FONT_ID, 40, "For Calibre, add /opds to your URL");
|
||||
|
||||
// Draw selection highlight
|
||||
renderer.fillRect(0, 60 + selectedIndex * 30 - 2, pageWidth - 1, 30);
|
||||
renderer.fillRect(0, 70 + selectedIndex * 30 - 2, pageWidth - 1, 30);
|
||||
|
||||
// Draw menu items
|
||||
for (int i = 0; i < MENU_ITEMS; i++) {
|
||||
const int settingY = 60 + i * 30;
|
||||
const int settingY = 70 + i * 30;
|
||||
const bool isSelected = (i == selectedIndex);
|
||||
|
||||
renderer.drawText(UI_10_FONT_ID, 20, settingY, menuNames[i], !isSelected);
|
||||
|
||||
// Draw status for URL setting
|
||||
// Draw status for each setting
|
||||
const char* status = "[Not Set]";
|
||||
if (i == 0) {
|
||||
const char* status = (strlen(SETTINGS.opdsServerUrl) > 0) ? "[Set]" : "[Not Set]";
|
||||
const auto width = renderer.getTextWidth(UI_10_FONT_ID, status);
|
||||
renderer.drawText(UI_10_FONT_ID, pageWidth - 20 - width, settingY, status, !isSelected);
|
||||
status = (strlen(SETTINGS.opdsServerUrl) > 0) ? "[Set]" : "[Not Set]";
|
||||
} else if (i == 1) {
|
||||
status = (strlen(SETTINGS.opdsUsername) > 0) ? "[Set]" : "[Not Set]";
|
||||
} else if (i == 2) {
|
||||
status = (strlen(SETTINGS.opdsPassword) > 0) ? "[Set]" : "[Not Set]";
|
||||
}
|
||||
const auto width = renderer.getTextWidth(UI_10_FONT_ID, status);
|
||||
renderer.drawText(UI_10_FONT_ID, pageWidth - 20 - width, settingY, status, !isSelected);
|
||||
}
|
||||
|
||||
// Draw button hints
|
||||
|
||||
@ -8,8 +8,8 @@
|
||||
#include "activities/ActivityWithSubactivity.h"
|
||||
|
||||
/**
|
||||
* Submenu for Calibre settings.
|
||||
* Shows Calibre Web URL and Calibre Wireless Device options.
|
||||
* Submenu for OPDS Browser settings.
|
||||
* Shows OPDS Server URL and HTTP authentication options.
|
||||
*/
|
||||
class CalibreSettingsActivity final : public ActivityWithSubactivity {
|
||||
public:
|
||||
|
||||
@ -41,7 +41,7 @@ const SettingInfo settingsList[settingsCount] = {
|
||||
{"1 min", "5 min", "10 min", "15 min", "30 min"}),
|
||||
SettingInfo::Enum("Refresh Frequency", &CrossPointSettings::refreshFrequency,
|
||||
{"1 page", "5 pages", "10 pages", "15 pages", "30 pages"}),
|
||||
SettingInfo::Action("Calibre Settings"),
|
||||
SettingInfo::Action("OPDS Browser"),
|
||||
SettingInfo::Action("Check for updates")};
|
||||
} // namespace
|
||||
|
||||
@ -139,7 +139,7 @@ void SettingsActivity::toggleCurrentSetting() {
|
||||
SETTINGS.*(setting.valuePtr) = currentValue + setting.valueRange.step;
|
||||
}
|
||||
} else if (setting.type == SettingType::ACTION) {
|
||||
if (strcmp(setting.name, "Calibre Settings") == 0) {
|
||||
if (strcmp(setting.name, "OPDS Browser") == 0) {
|
||||
xSemaphoreTake(renderingMutex, portMAX_DELAY);
|
||||
exitActivity();
|
||||
enterNewActivity(new CalibreSettingsActivity(renderer, mappedInput, [this] {
|
||||
|
||||
@ -30,6 +30,9 @@ size_t wsUploadSize = 0;
|
||||
size_t wsUploadReceived = 0;
|
||||
unsigned long wsUploadStartTime = 0;
|
||||
bool wsUploadInProgress = false;
|
||||
String wsLastCompleteName;
|
||||
size_t wsLastCompleteSize = 0;
|
||||
unsigned long wsLastCompleteAt = 0;
|
||||
} // namespace
|
||||
|
||||
// File listing page template - now using generated headers:
|
||||
@ -223,6 +226,18 @@ void CrossPointWebServer::handleClient() {
|
||||
}
|
||||
}
|
||||
|
||||
CrossPointWebServer::WsUploadStatus CrossPointWebServer::getWsUploadStatus() const {
|
||||
WsUploadStatus status;
|
||||
status.inProgress = wsUploadInProgress;
|
||||
status.received = wsUploadReceived;
|
||||
status.total = wsUploadSize;
|
||||
status.filename = wsUploadFileName.c_str();
|
||||
status.lastCompleteName = wsLastCompleteName.c_str();
|
||||
status.lastCompleteSize = wsLastCompleteSize;
|
||||
status.lastCompleteAt = wsLastCompleteAt;
|
||||
return status;
|
||||
}
|
||||
|
||||
void CrossPointWebServer::handleRoot() const {
|
||||
server->send(200, "text/html", HomePageHtml);
|
||||
Serial.printf("[%lu] [WEB] Served root page\n", millis());
|
||||
@ -813,6 +828,10 @@ void CrossPointWebServer::onWebSocketEvent(uint8_t num, WStype_t type, uint8_t*
|
||||
wsUploadFile.close();
|
||||
wsUploadInProgress = false;
|
||||
|
||||
wsLastCompleteName = wsUploadFileName;
|
||||
wsLastCompleteSize = wsUploadSize;
|
||||
wsLastCompleteAt = millis();
|
||||
|
||||
unsigned long elapsed = millis() - wsUploadStartTime;
|
||||
float kbps = (elapsed > 0) ? (wsUploadSize / 1024.0) / (elapsed / 1000.0) : 0;
|
||||
|
||||
|
||||
@ -4,6 +4,8 @@
|
||||
#include <WebSocketsServer.h>
|
||||
#include <WiFiUdp.h>
|
||||
|
||||
#include <memory>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
// Structure to hold file information
|
||||
@ -16,6 +18,16 @@ struct FileInfo {
|
||||
|
||||
class CrossPointWebServer {
|
||||
public:
|
||||
struct WsUploadStatus {
|
||||
bool inProgress = false;
|
||||
size_t received = 0;
|
||||
size_t total = 0;
|
||||
std::string filename;
|
||||
std::string lastCompleteName;
|
||||
size_t lastCompleteSize = 0;
|
||||
unsigned long lastCompleteAt = 0;
|
||||
};
|
||||
|
||||
CrossPointWebServer();
|
||||
~CrossPointWebServer();
|
||||
|
||||
@ -31,6 +43,8 @@ class CrossPointWebServer {
|
||||
// Check if server is running
|
||||
bool isRunning() const { return running; }
|
||||
|
||||
WsUploadStatus getWsUploadStatus() const;
|
||||
|
||||
// Get the port number
|
||||
uint16_t getPort() const { return port; }
|
||||
|
||||
|
||||
@ -5,8 +5,10 @@
|
||||
#include <WiFiClient.h>
|
||||
#include <WiFiClientSecure.h>
|
||||
|
||||
#include <cstring>
|
||||
#include <memory>
|
||||
|
||||
#include "CrossPointSettings.h"
|
||||
#include "util/UrlUtils.h"
|
||||
|
||||
bool HttpDownloader::fetchUrl(const std::string& url, std::string& outContent) {
|
||||
@ -27,6 +29,11 @@ bool HttpDownloader::fetchUrl(const std::string& url, std::string& outContent) {
|
||||
http.setFollowRedirects(HTTPC_STRICT_FOLLOW_REDIRECTS);
|
||||
http.addHeader("User-Agent", "CrossPoint-ESP32-" CROSSPOINT_VERSION);
|
||||
|
||||
// Add Basic HTTP auth if credentials are configured
|
||||
if (strlen(SETTINGS.opdsUsername) > 0 && strlen(SETTINGS.opdsPassword) > 0) {
|
||||
http.setAuthorization(SETTINGS.opdsUsername, SETTINGS.opdsPassword);
|
||||
}
|
||||
|
||||
const int httpCode = http.GET();
|
||||
if (httpCode != HTTP_CODE_OK) {
|
||||
Serial.printf("[%lu] [HTTP] Fetch failed: %d\n", millis(), httpCode);
|
||||
@ -61,6 +68,11 @@ HttpDownloader::DownloadError HttpDownloader::downloadToFile(const std::string&
|
||||
http.setFollowRedirects(HTTPC_STRICT_FOLLOW_REDIRECTS);
|
||||
http.addHeader("User-Agent", "CrossPoint-ESP32-" CROSSPOINT_VERSION);
|
||||
|
||||
// Add Basic HTTP auth if credentials are configured
|
||||
if (strlen(SETTINGS.opdsUsername) > 0 && strlen(SETTINGS.opdsPassword) > 0) {
|
||||
http.setAuthorization(SETTINGS.opdsUsername, SETTINGS.opdsPassword);
|
||||
}
|
||||
|
||||
const int httpCode = http.GET();
|
||||
if (httpCode != HTTP_CODE_OK) {
|
||||
Serial.printf("[%lu] [HTTP] Download failed: %d\n", millis(), httpCode);
|
||||
|
||||
Loading…
Reference in New Issue
Block a user