8 Commits
0.1.0 ... 0.2.0

Author SHA1 Message Date
Dave Allie
2f9f86b3dd Update README.md features 2025-12-04 00:14:47 +11:00
Dave Allie
47eb1157ef Support left and right buttons in reader and file picker 2025-12-04 00:11:51 +11:00
Dave Allie
aee239a931 Adjust input button thresholds to support more devices 2025-12-04 00:11:51 +11:00
Dave Allie
1ee8b728f9 Add file selection screen 2025-12-04 00:11:51 +11:00
Dave Allie
2c80aca7b5 Use correct current page on reader screen
Fixes the page counter not updating
2025-12-03 22:34:16 +11:00
Dave Allie
7704772ebe Handle nested navpoint elements in nxc TOC 2025-12-03 22:30:50 +11:00
Dave Allie
4186c7da9e Remove debug lines 2025-12-03 22:30:13 +11:00
Dave Allie
802c9d0a30 Add web flashing instructions 2025-12-03 22:21:11 +11:00
13 changed files with 256 additions and 64 deletions

View File

@@ -31,7 +31,9 @@ This project is **not affiliated with Xteink**; it's built as a community projec
- [x] EPUB parsing and rendering
- [x] Saved reading position
- [ ] File explorer with file picker
- Currently CrossPoint will just open the first EPUB it finds at the root of the SD card
- [x] Basic EPUB picker from root directory
- [ ] Support nested folders
- [ ] EPUB picker with cover art
- [ ] Image support within EPUB
- [ ] Configurable font, layout, and display options
- [ ] WiFi connectivity
@@ -50,10 +52,22 @@ This project is **not affiliated with Xteink**; it's built as a community projec
#### Command line
Connect your Xteink X4 to your computer via USB-C and run the following command.
```sh
pio run --target upload
```
#### Web
1. Connect your Xteink X4 to your computer via USB-C
2. Download the `firmware.bin` file from the latest release via the [releases page](https://github.com/daveallie/crosspoint-reader/releases)
3. Go to https://xteink.dve.al/ and flash the firmware file using the "OTA fast flash controls" section
4. Press the reset button on the Xteink X4 to restart the device
To revert back to the official firmware, you can flash the latest official firmware from https://xteink.dve.al/, or swap
back to the other partition using the "Swap boot partition" button here https://xteink.dve.al/debug.
## Internals
CrossPoint Reader is pretty aggressive about caching data down to the SD card to minimise RAM usage. The ESP32-C3 only

View File

@@ -57,10 +57,10 @@ void EpdRenderer::drawText(const int x, const int y, const char* text, const boo
getFontRenderer(bold, italic)->renderString(text, &xpos, &ypos, color > 0 ? GxEPD_BLACK : GxEPD_WHITE);
}
void EpdRenderer::drawSmallText(const int x, const int y, const char* text) const {
void EpdRenderer::drawSmallText(const int x, const int y, const char* text, const uint16_t color) const {
int ypos = y + smallFont->font->data->advanceY + marginTop;
int xpos = x + marginLeft;
smallFont->renderString(text, &xpos, &ypos, GxEPD_BLACK);
smallFont->renderString(text, &xpos, &ypos, color > 0 ? GxEPD_BLACK : GxEPD_WHITE);
}
void EpdRenderer::drawTextBox(const int x, const int y, const std::string& text, const int width, const int height,

View File

@@ -26,7 +26,7 @@ class EpdRenderer {
int getTextWidth(const char* text, bool bold = false, bool italic = false) const;
int getSmallTextWidth(const char* text) const;
void drawText(int x, int y, const char* text, bool bold = false, bool italic = false, uint16_t color = 1) const;
void drawSmallText(int x, int y, const char* text) const;
void drawSmallText(int x, int y, const char* text, uint16_t color = 1) const;
void drawTextBox(int x, int y, const std::string& text, int width, int height, bool bold = false,
bool italic = false) const;
void drawLine(int x1, int y1, int x2, int y2, uint16_t color) const;

View File

@@ -3,7 +3,6 @@
#include <HardwareSerial.h>
#include <SD.h>
#include <ZipFile.h>
#include <tinyxml2.h>
#include <map>
@@ -162,14 +161,14 @@ bool Epub::parseContentOpf(ZipFile& zip, std::string& content_opf_file) {
return true;
}
bool Epub::parseTocNcxFile(ZipFile& zip) {
bool Epub::parseTocNcxFile(const ZipFile& zip) {
// the ncx file should have been specified in the content.opf file
if (tocNcxItem.empty()) {
Serial.println("No ncx file specified");
return false;
}
auto ncxData = zip.readTextFileToMemory(tocNcxItem.c_str());
const auto ncxData = zip.readTextFileToMemory(tocNcxItem.c_str());
if (!ncxData) {
Serial.printf("Could not find %s\n", tocNcxItem.c_str());
return false;
@@ -177,7 +176,7 @@ bool Epub::parseTocNcxFile(ZipFile& zip) {
// Parse the Toc contents
tinyxml2::XMLDocument doc;
auto result = doc.Parse(ncxData);
const auto result = doc.Parse(ncxData);
free(ncxData);
if (result != tinyxml2::XML_SUCCESS) {
@@ -185,27 +184,30 @@ bool Epub::parseTocNcxFile(ZipFile& zip) {
return false;
}
auto ncx = doc.FirstChildElement("ncx");
const auto ncx = doc.FirstChildElement("ncx");
if (!ncx) {
Serial.println("Could not find first child ncx in toc");
return false;
}
auto navMap = ncx->FirstChildElement("navMap");
const auto navMap = ncx->FirstChildElement("navMap");
if (!navMap) {
Serial.println("Could not find navMap child in ncx");
return false;
}
auto navPoint = navMap->FirstChildElement("navPoint");
recursivelyParseNavMap(navMap->FirstChildElement("navPoint"));
return true;
}
void Epub::recursivelyParseNavMap(tinyxml2::XMLElement* element) {
// Fills toc map
while (navPoint) {
std::string navTitle = navPoint->FirstChildElement("navLabel")->FirstChildElement("text")->FirstChild()->Value();
auto content = navPoint->FirstChildElement("content");
while (element) {
std::string navTitle = element->FirstChildElement("navLabel")->FirstChildElement("text")->FirstChild()->Value();
const auto content = element->FirstChildElement("content");
std::string href = contentBasePath + content->Attribute("src");
// split the href on the # to get the href and the anchor
size_t pos = href.find('#');
const size_t pos = href.find('#');
std::string anchor;
if (pos != std::string::npos) {
@@ -214,10 +216,13 @@ bool Epub::parseTocNcxFile(ZipFile& zip) {
}
toc.emplace_back(navTitle, href, anchor, 0);
navPoint = navPoint->NextSiblingElement("navPoint");
}
return true;
tinyxml2::XMLElement* nestedNavPoint = element->FirstChildElement("navPoint");
if (nestedNavPoint) {
recursivelyParseNavMap(nestedNavPoint);
}
element = element->NextSiblingElement("navPoint");
}
}
// load in the meta data for the epub file
@@ -369,9 +374,7 @@ int Epub::getSpineIndexForTocIndex(const int tocIndex) const {
int Epub::getTocIndexForSpineIndex(const int spineIndex) const {
// the toc entry should have an href that matches the spine item
// so we can find the toc index by looking for the href
Serial.printf("Looking for %s\n", spine[spineIndex].second.c_str());
for (int i = 0; i < toc.size(); i++) {
Serial.printf("Looking at %s\n", toc[i].href.c_str());
if (toc[i].href == spine[spineIndex].second) {
return i;
}

View File

@@ -1,5 +1,6 @@
#pragma once
#include <HardwareSerial.h>
#include <tinyxml2.h>
#include <string>
#include <unordered_map>
@@ -38,36 +39,29 @@ class Epub {
// find the path for the content.opf file
static bool findContentOpfFile(const ZipFile& zip, std::string& contentOpfFile);
bool parseContentOpf(ZipFile& zip, std::string& content_opf_file);
bool parseTocNcxFile(ZipFile& zip);
bool parseTocNcxFile(const ZipFile& zip);
void recursivelyParseNavMap(tinyxml2::XMLElement* element);
public:
explicit Epub(std::string filepath, const std::string& cacheDir) : filepath(std::move(filepath)) {
// create a cache key based on the filepath
cachePath = cacheDir + "/epub_" + std::to_string(std::hash<std::string>{}(this->filepath));
}
~Epub() = default;
std::string& getBasePath() { return contentBasePath; }
bool load();
void clearCache() const;
void setupCacheDir() const;
const std::string& getCachePath() const;
const std::string& getPath() const;
const std::string& getTitle() const;
const std::string& getCoverImageItem() const;
uint8_t* getItemContents(const std::string& itemHref, size_t* size = nullptr) const;
char* getTextItemContents(const std::string& itemHref, size_t* size = nullptr) const;
std::string& getSpineItem(int spineIndex);
int getSpineItemsCount() const;
EpubTocEntry& getTocItem(int tocTndex);
int getTocItemsCount() const;
// work out the section index for a toc index
int getSpineIndexForTocIndex(int tocIndex) const;
int getTocIndexForSpineIndex(int spineIndex) const;
};

46
src/CrossPointState.cpp Normal file
View File

@@ -0,0 +1,46 @@
#include "CrossPointState.h"
#include <HardwareSerial.h>
#include <SD.h>
#include <Serialization.h>
#include <fstream>
constexpr uint8_t STATE_VERSION = 1;
constexpr char STATE_FILE[] = "/sd/.crosspoint/state.bin";
void CrossPointState::serialize(std::ostream& os) const {
serialization::writePod(os, STATE_VERSION);
serialization::writeString(os, openEpubPath);
}
CrossPointState* CrossPointState::deserialize(std::istream& is) {
const auto state = new CrossPointState();
uint8_t version;
serialization::readPod(is, version);
if (version != STATE_VERSION) {
Serial.printf("CrossPointState: Unknown version %u\n", version);
return state;
}
serialization::readString(is, state->openEpubPath);
return state;
}
void CrossPointState::saveToFile() const {
std::ofstream outputFile(STATE_FILE);
serialize(outputFile);
outputFile.close();
}
CrossPointState* CrossPointState::loadFromFile() {
if (!SD.exists(&STATE_FILE[3])) {
return new CrossPointState();
}
std::ifstream inputFile(STATE_FILE);
CrossPointState* state = deserialize(inputFile);
inputFile.close();
return state;
}

14
src/CrossPointState.h Normal file
View File

@@ -0,0 +1,14 @@
#pragma once
#include <iosfwd>
#include <string>
class CrossPointState {
void serialize(std::ostream& os) const;
static CrossPointState* deserialize(std::istream& is);
public:
std::string openEpubPath;
~CrossPointState() = default;
void saveToFile() const;
static CrossPointState* loadFromFile();
};

View File

@@ -10,9 +10,9 @@
// Button ADC thresholds
#define BTN_THRESHOLD 100 // Threshold tolerance
#define BTN_RIGHT_VAL 3
#define BTN_LEFT_VAL 1470
#define BTN_CONFIRM_VAL 2655
#define BTN_BACK_VAL 3470
#define BTN_LEFT_VAL 1500
#define BTN_CONFIRM_VAL 2700
#define BTN_BACK_VAL 3550
#define BTN_VOLUME_DOWN_VAL 3
#define BTN_VOLUME_UP_VAL 2305

View File

@@ -6,8 +6,10 @@
#include <SPI.h>
#include "Battery.h"
#include "CrossPointState.h"
#include "Input.h"
#include "screens/EpubReaderScreen.h"
#include "screens/FileSelectionScreen.h"
#include "screens/FullScreenMessageScreen.h"
#define SPI_FQ 40000000
@@ -28,6 +30,7 @@ GxEPD2_BW<GxEPD2_426_GDEQ0426T82, GxEPD2_426_GDEQ0426T82::HEIGHT> display(GxEPD2
EPD_RST, EPD_BUSY));
auto renderer = new EpdRenderer(&display);
Screen* currentScreen;
CrossPointState* appState;
// Power button timing
// Time required to confirm boot from sleep
@@ -102,6 +105,21 @@ void setupSerial() {
}
}
void onGoHome();
void onSelectEpubFile(const std::string& path) {
enterNewScreen(new FullScreenMessageScreen(renderer, "Loading..."));
Epub* epub = loadEpub(path);
if (epub) {
appState->openEpubPath = path;
appState->saveToFile();
enterNewScreen(new EpubReaderScreen(renderer, epub, onGoHome));
} else {
enterNewScreen(new FullScreenMessageScreen(renderer, "Failed to load epub"));
}
}
void onGoHome() { enterNewScreen(new FileSelectionScreen(renderer, onSelectEpubFile)); }
void setup() {
setupInputPinModes();
@@ -127,37 +145,21 @@ void setup() {
display.setTextColor(GxEPD_BLACK);
Serial.println("Display initialized");
enterNewScreen(new FullScreenMessageScreen(renderer, "Loading...", true));
enterNewScreen(new FullScreenMessageScreen(renderer, "Booting...", true));
// SD Card Initialization
SD.begin(SD_SPI_CS, SPI, SPI_FQ);
// TODO: Add a file selection screen, for now just load the first file
File root = SD.open("/");
String filename;
while (true) {
filename = root.getNextFileName();
if (!filename) {
break;
}
if (filename.substring(filename.length() - 5) == ".epub") {
Serial.printf("Found epub: %s\n", filename.c_str());
break;
appState = CrossPointState::loadFromFile();
if (!appState->openEpubPath.empty()) {
Epub* epub = loadEpub(appState->openEpubPath);
if (epub) {
enterNewScreen(new EpubReaderScreen(renderer, epub, onGoHome));
return;
}
}
if (!filename) {
enterNewScreen(new FullScreenMessageScreen(renderer, "Could not find epub"));
return;
}
Epub* epub = loadEpub(std::string(filename.c_str()));
if (epub) {
enterNewScreen(new EpubReaderScreen(renderer, epub));
} else {
enterNewScreen(new FullScreenMessageScreen(renderer, "Failed to load epub"));
}
enterNewScreen(new FileSelectionScreen(renderer, onSelectEpubFile));
}
void loop() {

View File

@@ -41,13 +41,14 @@ void EpubReaderScreen::onEnter() {
void EpubReaderScreen::onExit() {
vTaskDelete(displayTaskHandle);
displayTaskHandle = nullptr;
xSemaphoreTake(sectionMutex, portMAX_DELAY);
vSemaphoreDelete(sectionMutex);
sectionMutex = nullptr;
}
void EpubReaderScreen::handleInput(const Input input) {
if (input.button == VOLUME_UP || input.button == VOLUME_DOWN) {
if (input.button == VOLUME_UP || input.button == VOLUME_DOWN || input.button == LEFT || input.button == RIGHT) {
const bool skipChapter = input.pressTime > SKIP_CHAPTER_MS;
// No current section, attempt to rerender the book
@@ -56,17 +57,17 @@ void EpubReaderScreen::handleInput(const Input input) {
return;
}
if (input.button == VOLUME_UP && skipChapter) {
if ((input.button == VOLUME_UP || input.button == LEFT) && skipChapter) {
nextPageNumber = 0;
currentSpineIndex--;
delete section;
section = nullptr;
} else if (input.button == VOLUME_DOWN && skipChapter) {
} else if ((input.button == VOLUME_DOWN || input.button == RIGHT) && skipChapter) {
nextPageNumber = 0;
currentSpineIndex++;
delete section;
section = nullptr;
} else if (input.button == VOLUME_UP) {
} else if (input.button == VOLUME_UP || input.button == LEFT) {
if (section->currentPage > 0) {
section->currentPage--;
} else {
@@ -77,7 +78,7 @@ void EpubReaderScreen::handleInput(const Input input) {
section = nullptr;
xSemaphoreGive(sectionMutex);
}
} else if (input.button == VOLUME_DOWN) {
} else if (input.button == VOLUME_DOWN || input.button == RIGHT) {
if (section->currentPage < section->pageCount - 1) {
section->currentPage++;
} else {
@@ -91,6 +92,8 @@ void EpubReaderScreen::handleInput(const Input input) {
}
updateRequired = true;
} else if (input.button == BACK) {
onGoHome();
}
}
@@ -159,7 +162,7 @@ void EpubReaderScreen::renderPage() {
void EpubReaderScreen::renderStatusBar() const {
const auto pageWidth = renderer->getPageWidth();
std::string progress = std::to_string(currentPage + 1) + " / " + std::to_string(section->pageCount);
std::string progress = std::to_string(section->currentPage + 1) + " / " + std::to_string(section->pageCount);
const auto progressTextWidth = renderer->getSmallTextWidth(progress.c_str());
renderer->drawSmallText(pageWidth - progressTextWidth, 765, progress.c_str());

View File

@@ -14,8 +14,8 @@ class EpubReaderScreen final : public Screen {
SemaphoreHandle_t sectionMutex = nullptr;
int currentSpineIndex = 0;
int nextPageNumber = 0;
int currentPage = 0;
bool updateRequired = false;
const std::function<void()> onGoHome;
static void taskTrampoline(void* param);
[[noreturn]] void displayTaskLoop();
@@ -23,7 +23,8 @@ class EpubReaderScreen final : public Screen {
void renderStatusBar() const;
public:
explicit EpubReaderScreen(EpdRenderer* renderer, Epub* epub) : Screen(renderer), epub(epub) {}
explicit EpubReaderScreen(EpdRenderer* renderer, Epub* epub, const std::function<void()>& onGoHome)
: Screen(renderer), epub(epub), onGoHome(onGoHome) {}
~EpubReaderScreen() override { free(section); }
void onEnter() override;
void onExit() override;

View File

@@ -0,0 +1,87 @@
#include "FileSelectionScreen.h"
#include <EpdRenderer.h>
#include <SD.h>
void FileSelectionScreen::taskTrampoline(void* param) {
auto* self = static_cast<FileSelectionScreen*>(param);
self->displayTaskLoop();
}
void FileSelectionScreen::onEnter() {
files.clear();
auto root = SD.open("/");
File file;
while ((file = root.openNextFile())) {
if (file.isDirectory()) {
file.close();
continue;
}
auto filename = std::string(file.name());
if (filename.substr(filename.length() - 5) != ".epub" || filename[0] == '.') {
file.close();
continue;
}
files.emplace_back(filename);
file.close();
}
root.close();
// Trigger first update
updateRequired = true;
xTaskCreate(&FileSelectionScreen::taskTrampoline, "FileSelectionScreenTask",
1024, // Stack size
this, // Parameters
1, // Priority
&displayTaskHandle // Task handle
);
}
void FileSelectionScreen::onExit() {
vTaskDelete(displayTaskHandle);
displayTaskHandle = nullptr;
}
void FileSelectionScreen::handleInput(const Input input) {
if (input.button == VOLUME_DOWN || input.button == RIGHT) {
selectorIndex = (selectorIndex + 1) % files.size();
updateRequired = true;
} else if (input.button == VOLUME_UP || input.button == LEFT) {
selectorIndex = (selectorIndex + files.size() - 1) % files.size();
updateRequired = true;
} else if (input.button == CONFIRM) {
Serial.printf("Selected file: %s\n", files[selectorIndex].c_str());
onSelect("/" + files[selectorIndex]);
}
}
void FileSelectionScreen::displayTaskLoop() {
while (true) {
if (updateRequired) {
updateRequired = false;
render();
}
vTaskDelay(10 / portTICK_PERIOD_MS);
}
}
void FileSelectionScreen::render() const {
renderer->clearScreen();
const auto pageWidth = renderer->getPageWidth();
const auto titleWidth = renderer->getTextWidth("CrossPoint Reader", true);
renderer->drawText((pageWidth - titleWidth) / 2, 0, "CrossPoint Reader", true);
// Draw selection
renderer->fillRect(0, 50 + selectorIndex * 20 + 2, pageWidth - 1, 20, 1);
for (size_t i = 0; i < files.size(); i++) {
const auto file = files[i];
renderer->drawSmallText(50, 50 + i * 20, file.c_str(), i == selectorIndex ? 0 : 1);
}
renderer->flushDisplay();
}

View File

@@ -0,0 +1,28 @@
#pragma once
#include <freertos/FreeRTOS.h>
#include <freertos/task.h>
#include <functional>
#include <string>
#include <vector>
#include "Screen.h"
class FileSelectionScreen final : public Screen {
TaskHandle_t displayTaskHandle = nullptr;
std::vector<std::string> files;
int selectorIndex = 0;
bool updateRequired = false;
const std::function<void(const std::string&)> onSelect;
static void taskTrampoline(void* param);
[[noreturn]] void displayTaskLoop();
void render() const;
public:
explicit FileSelectionScreen(EpdRenderer* renderer, const std::function<void(const std::string&)>& onSelect)
: Screen(renderer), onSelect(onSelect) {}
void onEnter() override;
void onExit() override;
void handleInput(Input input) override;
};