Public release

This commit is contained in:
Dave Allie
2025-12-03 22:00:29 +11:00
commit 2ccdbeecc8
54 changed files with 33356 additions and 0 deletions

6
src/Battery.h Normal file
View File

@@ -0,0 +1,6 @@
#pragma once
#include <BatteryMonitor.h>
#define BAT_GPIO0 0 // Battery voltage
static BatteryMonitor battery(BAT_GPIO0);

43
src/Input.cpp Normal file
View File

@@ -0,0 +1,43 @@
#include "Input.h"
#include <esp32-hal-adc.h>
void setupInputPinModes() {
pinMode(BTN_GPIO1, INPUT);
pinMode(BTN_GPIO2, INPUT);
pinMode(BTN_GPIO3, INPUT_PULLUP); // Power button
}
// Get currently pressed button by reading ADC values (and digital for power
// button)
Button getPressedButton() {
// Check BTN_GPIO3 (Power button) - digital read
if (digitalRead(BTN_GPIO3) == LOW) return POWER;
// Check BTN_GPIO1 (4 buttons on resistor ladder)
const int btn1 = analogRead(BTN_GPIO1);
if (btn1 < BTN_RIGHT_VAL + BTN_THRESHOLD) return RIGHT;
if (btn1 < BTN_LEFT_VAL + BTN_THRESHOLD) return LEFT;
if (btn1 < BTN_CONFIRM_VAL + BTN_THRESHOLD) return CONFIRM;
if (btn1 < BTN_BACK_VAL + BTN_THRESHOLD) return BACK;
// Check BTN_GPIO2 (2 buttons on resistor ladder)
const int btn2 = analogRead(BTN_GPIO2);
if (btn2 < BTN_VOLUME_DOWN_VAL + BTN_THRESHOLD) return VOLUME_DOWN;
if (btn2 < BTN_VOLUME_UP_VAL + BTN_THRESHOLD) return VOLUME_UP;
return NONE;
}
Input getInput(const bool skipWait) {
const Button button = getPressedButton();
if (button == NONE) return {NONE, 0};
if (skipWait) {
return {button, 0};
}
const auto start = millis();
while (getPressedButton() == button) delay(50);
return {button, millis() - start};
}

28
src/Input.h Normal file
View File

@@ -0,0 +1,28 @@
#pragma once
// 4 buttons on ADC resistor ladder: Back, Confirm, Left, Right
#define BTN_GPIO1 1
// 2 buttons on ADC resistor ladder: Volume Up, Volume Down
#define BTN_GPIO2 2
// Power button (digital)
#define BTN_GPIO3 3
// 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_VOLUME_DOWN_VAL 3
#define BTN_VOLUME_UP_VAL 2305
enum Button { NONE = 0, RIGHT, LEFT, CONFIRM, BACK, VOLUME_UP, VOLUME_DOWN, POWER };
struct Input {
Button button;
unsigned long pressTime;
};
void setupInputPinModes();
Button getPressedButton();
Input getInput(bool skipWait = false);

182
src/main.cpp Normal file
View File

@@ -0,0 +1,182 @@
#include <Arduino.h>
#include <EpdRenderer.h>
#include <Epub.h>
#include <GxEPD2_BW.h>
#include <SD.h>
#include <SPI.h>
#include "Battery.h"
#include "Input.h"
#include "screens/EpubReaderScreen.h"
#include "screens/FullScreenMessageScreen.h"
#define SPI_FQ 40000000
// Display SPI pins (custom pins for XteinkX4, not hardware SPI defaults)
#define EPD_SCLK 8 // SPI Clock
#define EPD_MOSI 10 // SPI MOSI (Master Out Slave In)
#define EPD_CS 21 // Chip Select
#define EPD_DC 4 // Data/Command
#define EPD_RST 5 // Reset
#define EPD_BUSY 6 // Busy
#define UART0_RXD 20 // Used for USB connection detection
#define SD_SPI_CS 12
#define SD_SPI_MISO 7
GxEPD2_BW<GxEPD2_426_GDEQ0426T82, GxEPD2_426_GDEQ0426T82::HEIGHT> display(GxEPD2_426_GDEQ0426T82(EPD_CS, EPD_DC,
EPD_RST, EPD_BUSY));
auto renderer = new EpdRenderer(&display);
Screen* currentScreen;
// Power button timing
// Time required to confirm boot from sleep
constexpr unsigned long POWER_BUTTON_WAKEUP_MS = 1500;
// Time required to enter sleep mode
constexpr unsigned long POWER_BUTTON_SLEEP_MS = 1000;
Epub* loadEpub(const std::string& path) {
if (!SD.exists(path.c_str())) {
Serial.println("File does not exist");
return nullptr;
}
const auto epub = new Epub(path, "/.crosspoint");
if (epub->load()) {
return epub;
}
Serial.println("Failed to load epub");
free(epub);
return nullptr;
}
void enterNewScreen(Screen* screen) {
if (currentScreen) {
currentScreen->onExit();
delete currentScreen;
}
currentScreen = screen;
currentScreen->onEnter();
}
// Verify long press on wake-up from deep sleep
void verifyWakeupLongPress() {
const auto input = getInput();
if (input.button == POWER && input.pressTime > POWER_BUTTON_WAKEUP_MS) {
// Button released too early. Returning to sleep.
// IMPORTANT: Re-arm the wakeup trigger before sleeping again
esp_deep_sleep_enable_gpio_wakeup(1ULL << BTN_GPIO3, ESP_GPIO_WAKEUP_GPIO_LOW);
esp_deep_sleep_start();
}
}
// Enter deep sleep mode
void enterDeepSleep() {
enterNewScreen(new FullScreenMessageScreen(renderer, "Sleeping", true, false, true));
Serial.println("Power button released after a long press. Entering deep sleep.");
delay(2000); // Allow Serial buffer to empty and display to update
// Enable Wakeup on LOW (button press)
esp_deep_sleep_enable_gpio_wakeup(1ULL << BTN_GPIO3, ESP_GPIO_WAKEUP_GPIO_LOW);
display.hibernate();
// Enter Deep Sleep
esp_deep_sleep_start();
}
void setupSerial() {
Serial.begin(115200);
// Wait for serial monitor
const unsigned long start = millis();
while (!Serial && (millis() - start) < 3000) {
delay(10);
}
if (Serial) {
// delay for monitor to start reading
delay(1000);
}
}
void setup() {
setupInputPinModes();
// Check if boot was triggered by the Power Button (Deep Sleep Wakeup)
// If triggered by RST pin or Battery insertion, this will be false, allowing
// normal boot.
if (esp_sleep_get_wakeup_cause() == ESP_SLEEP_WAKEUP_GPIO) {
verifyWakeupLongPress();
}
setupSerial();
// Initialize pins
pinMode(BAT_GPIO0, INPUT);
// Initialize SPI with custom pins
SPI.begin(EPD_SCLK, SD_SPI_MISO, EPD_MOSI, EPD_CS);
// Initialize display
const SPISettings spi_settings(SPI_FQ, MSBFIRST, SPI_MODE0);
display.init(115200, true, 2, false, SPI, spi_settings);
display.setRotation(3); // 270 degrees
display.setTextColor(GxEPD_BLACK);
Serial.println("Display initialized");
enterNewScreen(new FullScreenMessageScreen(renderer, "Loading...", 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;
}
}
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"));
}
}
void loop() {
delay(50);
const Input input = getInput();
if (input.button == NONE) {
return;
}
if (input.button == POWER && input.pressTime > POWER_BUTTON_SLEEP_MS) {
enterDeepSleep();
// This should never be hit as `enterDeepSleep` calls esp_deep_sleep_start
delay(1000);
return;
}
if (currentScreen) {
currentScreen->handleInput(input);
}
}

View File

@@ -0,0 +1,209 @@
#include "EpubReaderScreen.h"
#include <EpdRenderer.h>
#include <SD.h>
#include "Battery.h"
constexpr unsigned long SKIP_CHAPTER_MS = 700;
void EpubReaderScreen::taskTrampoline(void* param) {
auto* self = static_cast<EpubReaderScreen*>(param);
self->displayTaskLoop();
}
void EpubReaderScreen::onEnter() {
sectionMutex = xSemaphoreCreateMutex();
epub->setupCacheDir();
// TODO: Move this to a state object
if (SD.exists((epub->getCachePath() + "/progress.bin").c_str())) {
File f = SD.open((epub->getCachePath() + "/progress.bin").c_str());
uint8_t data[4];
f.read(data, 4);
currentSpineIndex = data[0] + (data[1] << 8);
nextPageNumber = data[2] + (data[3] << 8);
Serial.printf("Loaded cache: %d, %d\n", currentSpineIndex, nextPageNumber);
f.close();
}
// Trigger first update
updateRequired = true;
xTaskCreate(&EpubReaderScreen::taskTrampoline, "EpubReaderScreenTask",
8192, // Stack size
this, // Parameters
1, // Priority
&displayTaskHandle // Task handle
);
}
void EpubReaderScreen::onExit() {
vTaskDelete(displayTaskHandle);
xSemaphoreTake(sectionMutex, portMAX_DELAY);
vSemaphoreDelete(sectionMutex);
sectionMutex = nullptr;
}
void EpubReaderScreen::handleInput(const Input input) {
if (input.button == VOLUME_UP || input.button == VOLUME_DOWN) {
const bool skipChapter = input.pressTime > SKIP_CHAPTER_MS;
// No current section, attempt to rerender the book
if (!section) {
updateRequired = true;
return;
}
if (input.button == VOLUME_UP && skipChapter) {
nextPageNumber = 0;
currentSpineIndex--;
delete section;
section = nullptr;
} else if (input.button == VOLUME_DOWN && skipChapter) {
nextPageNumber = 0;
currentSpineIndex++;
delete section;
section = nullptr;
} else if (input.button == VOLUME_UP) {
if (section->currentPage > 0) {
section->currentPage--;
} else {
xSemaphoreTake(sectionMutex, portMAX_DELAY);
nextPageNumber = UINT16_MAX;
currentSpineIndex--;
delete section;
section = nullptr;
xSemaphoreGive(sectionMutex);
}
} else if (input.button == VOLUME_DOWN) {
if (section->currentPage < section->pageCount - 1) {
section->currentPage++;
} else {
xSemaphoreTake(sectionMutex, portMAX_DELAY);
nextPageNumber = 0;
currentSpineIndex++;
delete section;
section = nullptr;
xSemaphoreGive(sectionMutex);
}
}
updateRequired = true;
}
}
void EpubReaderScreen::displayTaskLoop() {
while (true) {
if (updateRequired) {
updateRequired = false;
renderPage();
}
vTaskDelay(10 / portTICK_PERIOD_MS);
}
}
void EpubReaderScreen::renderPage() {
if (!epub) {
return;
}
if (currentSpineIndex >= epub->getSpineItemsCount() || currentSpineIndex < 0) {
currentSpineIndex = 0;
}
xSemaphoreTake(sectionMutex, portMAX_DELAY);
if (!section) {
const auto filepath = epub->getSpineItem(currentSpineIndex);
Serial.printf("Loading file: %s, index: %d\n", filepath.c_str(), currentSpineIndex);
section = new Section(epub, currentSpineIndex, renderer);
if (!section->hasCache()) {
Serial.println("Cache not found, building...");
section->setupCacheDir();
if (!section->persistPageDataToSD()) {
Serial.println("Failed to persist page data to SD");
free(section);
section = nullptr;
xSemaphoreGive(sectionMutex);
return;
}
} else {
Serial.println("Cache found, skipping build...");
}
if (nextPageNumber == UINT16_MAX) {
section->currentPage = section->pageCount - 1;
} else {
section->currentPage = nextPageNumber;
}
}
renderer->clearScreen();
section->renderPage();
renderStatusBar();
renderer->flushDisplay();
File f = SD.open((epub->getCachePath() + "/progress.bin").c_str(), FILE_WRITE);
uint8_t data[4];
data[0] = currentSpineIndex & 0xFF;
data[1] = (currentSpineIndex >> 8) & 0xFF;
data[2] = section->currentPage & 0xFF;
data[3] = (section->currentPage >> 8) & 0xFF;
f.write(data, 4);
f.close();
xSemaphoreGive(sectionMutex);
}
void EpubReaderScreen::renderStatusBar() const {
const auto pageWidth = renderer->getPageWidth();
std::string progress = std::to_string(currentPage + 1) + " / " + std::to_string(section->pageCount);
const auto progressTextWidth = renderer->getSmallTextWidth(progress.c_str());
renderer->drawSmallText(pageWidth - progressTextWidth, 765, progress.c_str());
const uint16_t percentage = battery.readPercentage();
auto percentageText = std::to_string(percentage) + "%";
const auto percentageTextWidth = renderer->getSmallTextWidth(percentageText.c_str());
renderer->drawSmallText(20, 765, percentageText.c_str());
// 1 column on left, 2 columns on right, 5 columns of battery body
constexpr int batteryWidth = 15;
constexpr int batteryHeight = 10;
const int x = 0;
const int y = 772;
// Top line
renderer->drawLine(x, y, x + batteryWidth - 4, y, 1);
// Bottom line
renderer->drawLine(x, y + batteryHeight - 1, x + batteryWidth - 4, y + batteryHeight - 1, 1);
// Left line
renderer->drawLine(x, y, x, y + batteryHeight - 1, 1);
// Battery end
renderer->drawLine(x + batteryWidth - 4, y, x + batteryWidth - 4, y + batteryHeight - 1, 1);
renderer->drawLine(x + batteryWidth - 3, y + 2, x + batteryWidth - 3, y + batteryHeight - 3, 1);
renderer->drawLine(x + batteryWidth - 2, y + 2, x + batteryWidth - 2, y + batteryHeight - 3, 1);
renderer->drawLine(x + batteryWidth - 1, y + 2, x + batteryWidth - 1, y + batteryHeight - 3, 1);
// The +1 is to round up, so that we always fill at least one pixel
int filledWidth = percentage * (batteryWidth - 5) / 100 + 1;
if (filledWidth > batteryWidth - 5) {
filledWidth = batteryWidth - 5; // Ensure we don't overflow
}
renderer->fillRect(x + 1, y + 1, filledWidth, batteryHeight - 2, 1);
// Page width minus existing content with 30px padding on each side
const int leftMargin = 20 + percentageTextWidth + 30;
const int rightMargin = progressTextWidth + 30;
const int availableTextWidth = pageWidth - leftMargin - rightMargin;
const auto tocItem = epub->getTocItem(epub->getTocIndexForSpineIndex(currentSpineIndex));
auto title = tocItem.title;
int titleWidth = renderer->getSmallTextWidth(title.c_str());
while (titleWidth > availableTextWidth) {
title = title.substr(0, title.length() - 8) + "...";
titleWidth = renderer->getSmallTextWidth(title.c_str());
}
renderer->drawSmallText(leftMargin + (availableTextWidth - titleWidth) / 2, 765, title.c_str());
}

View File

@@ -0,0 +1,31 @@
#pragma once
#include <Epub.h>
#include <Epub/Section.h>
#include <freertos/FreeRTOS.h>
#include <freertos/semphr.h>
#include <freertos/task.h>
#include "Screen.h"
class EpubReaderScreen final : public Screen {
Epub* epub;
Section* section = nullptr;
TaskHandle_t displayTaskHandle = nullptr;
SemaphoreHandle_t sectionMutex = nullptr;
int currentSpineIndex = 0;
int nextPageNumber = 0;
int currentPage = 0;
bool updateRequired = false;
static void taskTrampoline(void* param);
[[noreturn]] void displayTaskLoop();
void renderPage();
void renderStatusBar() const;
public:
explicit EpubReaderScreen(EpdRenderer* renderer, Epub* epub) : Screen(renderer), epub(epub) {}
~EpubReaderScreen() override { free(section); }
void onEnter() override;
void onExit() override;
void handleInput(Input input) override;
};

View File

@@ -0,0 +1,14 @@
#include "FullScreenMessageScreen.h"
#include <EpdRenderer.h>
void FullScreenMessageScreen::onEnter() {
const auto width = renderer->getTextWidth(text.c_str(), bold, italic);
const auto height = renderer->getLineHeight();
const auto left = (renderer->getPageWidth() - width) / 2;
const auto top = (renderer->getPageHeight() - height) / 2;
renderer->clearScreen(invert);
renderer->drawText(left, top, text.c_str(), bold, italic, invert ? 0 : 1);
renderer->flushDisplay();
}

View File

@@ -0,0 +1,18 @@
#pragma once
#include <string>
#include <utility>
#include "Screen.h"
class FullScreenMessageScreen final : public Screen {
std::string text;
bool bold;
bool italic;
bool invert;
public:
explicit FullScreenMessageScreen(EpdRenderer* renderer, std::string text, const bool bold = false,
const bool italic = false, const bool invert = false)
: Screen(renderer), text(std::move(text)), bold(bold), italic(italic), invert(invert) {}
void onEnter() override;
};

16
src/screens/Screen.h Normal file
View File

@@ -0,0 +1,16 @@
#pragma once
#include "Input.h"
class EpdRenderer;
class Screen {
protected:
EpdRenderer* renderer;
public:
explicit Screen(EpdRenderer* renderer) : renderer(renderer) {}
virtual ~Screen() = default;
virtual void onEnter() {}
virtual void onExit() {}
virtual void handleInput(Input input) {}
};