540 lines
14 KiB
C++
540 lines
14 KiB
C++
#include <Arduino.h>
|
|
#include <TouchDrvCSTXXX.hpp>
|
|
#include <Wire.h>
|
|
#include <WiFi.h>
|
|
#include <esp_bt.h>
|
|
#include <esp_sleep.h>
|
|
#include <vector>
|
|
|
|
#include "Board.h"
|
|
#include "Config.h"
|
|
#include "Library.h"
|
|
#include "Reader.h"
|
|
#include "Settings.h"
|
|
|
|
namespace {
|
|
Board board;
|
|
Library library;
|
|
Reader reader;
|
|
Settings settings;
|
|
SavedState state;
|
|
bool playing = false;
|
|
bool displayIsOff = false;
|
|
bool displayIsDimmed = false;
|
|
uint32_t lastInteractionMs = 0;
|
|
uint32_t nextWordMs = 0;
|
|
TouchDrvCSTXXX touch;
|
|
bool touchAvailable = false;
|
|
bool touchPlayPauseRequested = false;
|
|
uint32_t lastTouchHomeMs = 0;
|
|
|
|
enum class Screen {
|
|
kReader,
|
|
kMenu,
|
|
kBookPicker,
|
|
kChapterPicker,
|
|
kBrightnessPicker,
|
|
kFontStylePicker,
|
|
kFontSizePicker,
|
|
};
|
|
|
|
enum class ReaderView {
|
|
kChapterTitle,
|
|
kWord,
|
|
kTheEnd,
|
|
};
|
|
|
|
Screen screen = Screen::kReader;
|
|
ReaderView readerView = ReaderView::kChapterTitle;
|
|
String pendingChapterPath;
|
|
uint8_t pendingBrightness = config::kNormalBrightness;
|
|
uint8_t pendingFontStyle = config::kDefaultFontStyle;
|
|
uint8_t pendingFontSize = config::kDefaultFontSize;
|
|
uint8_t menuSelection = 0;
|
|
|
|
struct Button {
|
|
explicit Button(uint8_t buttonPin) : pin(buttonPin) {}
|
|
|
|
uint8_t pin;
|
|
bool stable = HIGH;
|
|
bool candidate = HIGH;
|
|
uint32_t changedAt = 0;
|
|
};
|
|
|
|
Button menu{config::kButtonPlayPause};
|
|
Button previous{config::kButtonPrevious};
|
|
Button next{config::kButtonNext};
|
|
|
|
void disableRadios() {
|
|
WiFi.persistent(false);
|
|
WiFi.mode(WIFI_OFF);
|
|
if (esp_bt_controller_get_status() == ESP_BT_CONTROLLER_STATUS_ENABLED) {
|
|
esp_bt_controller_disable();
|
|
}
|
|
}
|
|
|
|
bool released(Button& button) {
|
|
const bool now = digitalRead(button.pin);
|
|
if (now != button.candidate) {
|
|
button.candidate = now;
|
|
button.changedAt = millis();
|
|
}
|
|
if (button.stable == button.candidate || millis() - button.changedAt < 25) return false;
|
|
const bool wasPressed = button.stable == LOW;
|
|
button.stable = button.candidate;
|
|
return wasPressed && button.stable == HIGH;
|
|
}
|
|
|
|
void persistState() {
|
|
state.wordIndex = reader.currentIndex();
|
|
settings.save(state);
|
|
}
|
|
|
|
void setReadingContext(uint8_t progressPercent) {
|
|
board.setReadingContext(library.bookName(state.bookPath),
|
|
library.chapterName(state.bookPath), progressPercent);
|
|
}
|
|
|
|
void showCurrentWord() {
|
|
setReadingContext(reader.progressPercent());
|
|
board.drawWord(reader.currentWord(), state.wpm, reader.currentStyleFlags());
|
|
}
|
|
|
|
void wakeDisplay();
|
|
void configureTouchLandscape();
|
|
|
|
void showReaderScreen() {
|
|
switch (readerView) {
|
|
case ReaderView::kChapterTitle:
|
|
setReadingContext(0);
|
|
board.drawChapterTitle(library.chapterName(state.bookPath), state.wpm);
|
|
break;
|
|
case ReaderView::kWord:
|
|
showCurrentWord();
|
|
break;
|
|
case ReaderView::kTheEnd:
|
|
setReadingContext(100);
|
|
board.drawTheEnd(state.wpm);
|
|
break;
|
|
}
|
|
}
|
|
|
|
bool loadChapter(const String& path) {
|
|
String contents;
|
|
std::vector<uint8_t> sidecar;
|
|
if (path.isEmpty() || !library.load(path, contents)) {
|
|
return false;
|
|
}
|
|
library.loadSidecar(path, sidecar);
|
|
if (!reader.load(contents, sidecar)) return false;
|
|
state.bookPath = path;
|
|
state.wordIndex = 0;
|
|
reader.restoreAtParagraphStart(0);
|
|
readerView = ReaderView::kChapterTitle;
|
|
playing = false;
|
|
return true;
|
|
}
|
|
|
|
void returnToReader() {
|
|
screen = Screen::kReader;
|
|
playing = false;
|
|
configureTouchLandscape();
|
|
wakeDisplay();
|
|
showReaderScreen();
|
|
}
|
|
|
|
void closeMenuWithoutSaving() {
|
|
// Brightness is the only picker that changes hardware before confirmation.
|
|
board.setPreferredBrightness(state.brightness);
|
|
returnToReader();
|
|
}
|
|
|
|
void drawPicker() {
|
|
switch (screen) {
|
|
case Screen::kBookPicker:
|
|
board.drawPicker("BOOK", library.bookName(pendingChapterPath));
|
|
break;
|
|
case Screen::kChapterPicker:
|
|
board.drawPicker("CHAPTER", library.chapterName(pendingChapterPath));
|
|
break;
|
|
case Screen::kBrightnessPicker:
|
|
board.drawPicker("BRIGHTNESS", String(pendingBrightness) + "/16");
|
|
break;
|
|
case Screen::kFontStylePicker:
|
|
board.drawFontStylePicker(pendingFontStyle, pendingFontSize);
|
|
break;
|
|
case Screen::kFontSizePicker:
|
|
board.drawFontSizePicker(pendingFontStyle, pendingFontSize);
|
|
break;
|
|
default:
|
|
break;
|
|
}
|
|
}
|
|
|
|
void openMenu() {
|
|
// Opening the menu stops playback, so it is a manual pause checkpoint.
|
|
if (playing) persistState();
|
|
playing = false;
|
|
menuSelection = 0;
|
|
screen = Screen::kMenu;
|
|
wakeDisplay();
|
|
board.drawMenu(menuSelection);
|
|
}
|
|
|
|
void openPicker(Screen picker) {
|
|
pendingChapterPath = state.bookPath;
|
|
pendingBrightness = state.brightness;
|
|
pendingFontStyle = state.fontStyle;
|
|
pendingFontSize = state.fontSize;
|
|
screen = picker;
|
|
configureTouchLandscape();
|
|
wakeDisplay();
|
|
if (screen == Screen::kBrightnessPicker) {
|
|
board.setBrightness(pendingBrightness);
|
|
}
|
|
drawPicker();
|
|
}
|
|
|
|
void changePicker(int direction) {
|
|
switch (screen) {
|
|
case Screen::kBookPicker:
|
|
library.selectBook(pendingChapterPath, direction, pendingChapterPath);
|
|
break;
|
|
case Screen::kChapterPicker:
|
|
library.selectChapter(pendingChapterPath, direction, pendingChapterPath);
|
|
break;
|
|
case Screen::kBrightnessPicker:
|
|
pendingBrightness = constrain(pendingBrightness + direction, 1, 16);
|
|
board.setBrightness(pendingBrightness);
|
|
break;
|
|
case Screen::kFontStylePicker:
|
|
pendingFontStyle = (pendingFontStyle + direction + 3) % 3;
|
|
break;
|
|
case Screen::kFontSizePicker:
|
|
pendingFontSize = constrain(
|
|
static_cast<int>(pendingFontSize) + direction * config::kFontSizeStep,
|
|
config::kMinimumFontSize, config::kMaximumFontSize);
|
|
break;
|
|
default:
|
|
return;
|
|
}
|
|
drawPicker();
|
|
}
|
|
|
|
void applyPicker() {
|
|
if (screen == Screen::kBrightnessPicker) {
|
|
state.brightness = pendingBrightness;
|
|
board.setPreferredBrightness(state.brightness);
|
|
} else if (screen == Screen::kFontStylePicker ||
|
|
screen == Screen::kFontSizePicker) {
|
|
state.fontStyle = pendingFontStyle;
|
|
state.fontSize = pendingFontSize;
|
|
board.setFont(state.fontStyle, state.fontSize);
|
|
} else {
|
|
loadChapter(pendingChapterPath);
|
|
}
|
|
persistState();
|
|
returnToReader();
|
|
}
|
|
|
|
void togglePlayback() {
|
|
wakeDisplay();
|
|
if (readerView == ReaderView::kTheEnd) return;
|
|
|
|
if (readerView == ReaderView::kChapterTitle) {
|
|
reader.restoreAtParagraphStart(state.wordIndex);
|
|
readerView = ReaderView::kWord;
|
|
playing = true;
|
|
showCurrentWord();
|
|
} else {
|
|
playing = !playing;
|
|
}
|
|
// Playing progress is intentionally not checkpointed. Resume is paragraph
|
|
// based, so writing at each pause avoids needless NVS wear.
|
|
if (!playing) persistState();
|
|
if (playing) nextWordMs = millis() + reader.delayMs(state.wpm);
|
|
}
|
|
|
|
void navigatePreviousPaused() {
|
|
switch (readerView) {
|
|
case ReaderView::kChapterTitle: {
|
|
String previousChapterPath;
|
|
if (library.previousChapter(state.bookPath, previousChapterPath) &&
|
|
loadChapter(previousChapterPath)) {
|
|
reader.lastParagraph();
|
|
readerView = ReaderView::kWord;
|
|
showReaderScreen();
|
|
}
|
|
break;
|
|
}
|
|
case ReaderView::kWord:
|
|
if (!reader.previousParagraph()) {
|
|
readerView = ReaderView::kChapterTitle;
|
|
}
|
|
showReaderScreen();
|
|
break;
|
|
case ReaderView::kTheEnd:
|
|
// state.bookPath remains the final chapter, so the ending card can be
|
|
// stepped back into its last paragraph without reloading the SD card.
|
|
if (reader.lastParagraph()) {
|
|
readerView = ReaderView::kWord;
|
|
showReaderScreen();
|
|
}
|
|
break;
|
|
}
|
|
}
|
|
|
|
void navigateNextPaused() {
|
|
switch (readerView) {
|
|
case ReaderView::kChapterTitle:
|
|
reader.restoreAtParagraphStart(state.wordIndex);
|
|
readerView = ReaderView::kWord;
|
|
showReaderScreen();
|
|
break;
|
|
case ReaderView::kWord: {
|
|
if (reader.nextParagraph()) {
|
|
showReaderScreen();
|
|
break;
|
|
}
|
|
|
|
String nextChapterPath;
|
|
if (library.nextChapter(state.bookPath, nextChapterPath) &&
|
|
loadChapter(nextChapterPath)) {
|
|
showReaderScreen();
|
|
} else {
|
|
readerView = ReaderView::kTheEnd;
|
|
showReaderScreen();
|
|
}
|
|
break;
|
|
}
|
|
case ReaderView::kTheEnd:
|
|
break;
|
|
}
|
|
}
|
|
|
|
void onTouchHome(void*) {
|
|
constexpr uint32_t kTouchDebounceMs = 200;
|
|
if (millis() - lastTouchHomeMs < kTouchDebounceMs) return;
|
|
lastTouchHomeMs = millis();
|
|
touchPlayPauseRequested = true;
|
|
}
|
|
|
|
void confirmMenuSelection() {
|
|
if (screen == Screen::kReader) {
|
|
togglePlayback();
|
|
return;
|
|
}
|
|
if (screen == Screen::kMenu) {
|
|
switch (menuSelection) {
|
|
case 0: openPicker(Screen::kBookPicker); break;
|
|
case 1: openPicker(Screen::kChapterPicker); break;
|
|
case 2: openPicker(Screen::kBrightnessPicker); break;
|
|
case 3: openPicker(Screen::kFontStylePicker); break;
|
|
case 4: openPicker(Screen::kFontSizePicker); break;
|
|
}
|
|
return;
|
|
}
|
|
applyPicker();
|
|
}
|
|
|
|
void beginTouch() {
|
|
touch.setPins(config::kTouchReset, config::kTouchInterrupt);
|
|
touchAvailable = touch.begin(Wire, CST226SE_SLAVE_ADDRESS,
|
|
config::kI2cSda, config::kI2cScl);
|
|
if (!touchAvailable) return;
|
|
|
|
configureTouchLandscape();
|
|
touch.setHomeButtonCallback(onTouchHome);
|
|
}
|
|
|
|
void configureTouchLandscape() {
|
|
if (!touchAvailable) return;
|
|
// Match LilyGO's coordinate configuration for the rotated 480 x 222 reader.
|
|
touch.setMaxCoordinates(480, 222);
|
|
touch.setSwapXY(true);
|
|
touch.setMirrorXY(false, true);
|
|
}
|
|
|
|
void handleTouch() {
|
|
if (!touchAvailable) return;
|
|
int16_t x;
|
|
int16_t y;
|
|
touch.getPoint(&x, &y); // Polling delivers home-button callbacks.
|
|
if (touchPlayPauseRequested) {
|
|
touchPlayPauseRequested = false;
|
|
confirmMenuSelection();
|
|
}
|
|
}
|
|
|
|
void wakeDisplay() {
|
|
if (displayIsOff || displayIsDimmed) {
|
|
board.displayOn();
|
|
displayIsOff = false;
|
|
displayIsDimmed = false;
|
|
}
|
|
lastInteractionMs = millis();
|
|
}
|
|
|
|
void enterDeepSleep() {
|
|
persistState();
|
|
board.displayOff();
|
|
// GPIO12 is RTC-capable on ESP32-S3. A press (active low) wakes the reader.
|
|
esp_sleep_enable_ext0_wakeup(static_cast<gpio_num_t>(config::kButtonPrevious), 0);
|
|
delay(50);
|
|
esp_deep_sleep_start();
|
|
}
|
|
|
|
void handleInput() {
|
|
if (released(menu)) {
|
|
if (screen == Screen::kReader) {
|
|
openMenu();
|
|
} else {
|
|
closeMenuWithoutSaving();
|
|
}
|
|
return;
|
|
}
|
|
|
|
if (released(previous)) {
|
|
wakeDisplay();
|
|
if (screen == Screen::kMenu) {
|
|
menuSelection = (menuSelection + 4) % 5;
|
|
board.drawMenu(menuSelection);
|
|
return;
|
|
}
|
|
if (screen != Screen::kReader) {
|
|
changePicker(-1);
|
|
return;
|
|
}
|
|
if (playing) {
|
|
state.wpm = state.wpm > config::kMinimumWpm + config::kWpmStep
|
|
? state.wpm - config::kWpmStep
|
|
: config::kMinimumWpm;
|
|
showCurrentWord();
|
|
} else {
|
|
navigatePreviousPaused();
|
|
}
|
|
}
|
|
if (released(next)) {
|
|
wakeDisplay();
|
|
if (screen == Screen::kMenu) {
|
|
menuSelection = (menuSelection + 1) % 5;
|
|
board.drawMenu(menuSelection);
|
|
return;
|
|
}
|
|
if (screen != Screen::kReader) {
|
|
changePicker(1);
|
|
return;
|
|
}
|
|
if (playing) {
|
|
state.wpm = state.wpm + config::kWpmStep < config::kMaximumWpm
|
|
? state.wpm + config::kWpmStep
|
|
: config::kMaximumWpm;
|
|
showCurrentWord();
|
|
} else {
|
|
navigateNextPaused();
|
|
}
|
|
}
|
|
}
|
|
} // namespace
|
|
|
|
void setup() {
|
|
disableRadios();
|
|
Serial.begin(115200);
|
|
|
|
pinMode(config::kButtonPlayPause, INPUT_PULLUP);
|
|
pinMode(config::kButtonPrevious, INPUT_PULLUP);
|
|
pinMode(config::kButtonNext, INPUT_PULLUP);
|
|
settings.begin();
|
|
state = settings.load();
|
|
bool sanitizedSettings = false;
|
|
if (state.wpm < config::kMinimumWpm || state.wpm > config::kMaximumWpm) {
|
|
state.wpm = config::kDefaultWpm;
|
|
sanitizedSettings = true;
|
|
}
|
|
if (state.brightness < 1 || state.brightness > 16) {
|
|
state.brightness = config::kNormalBrightness;
|
|
sanitizedSettings = true;
|
|
}
|
|
if (state.fontStyle > 2) {
|
|
state.fontStyle = config::kDefaultFontStyle;
|
|
sanitizedSettings = true;
|
|
}
|
|
if (state.fontSize < config::kMinimumFontSize ||
|
|
state.fontSize > config::kMaximumFontSize ||
|
|
state.fontSize % config::kFontSizeStep != 0) {
|
|
state.fontSize = config::kDefaultFontSize;
|
|
sanitizedSettings = true;
|
|
}
|
|
|
|
const bool sdAvailable = board.beginSd();
|
|
if (!board.begin()) {
|
|
while (true) delay(1000);
|
|
}
|
|
board.setPreferredBrightness(state.brightness);
|
|
board.setFont(state.fontStyle, state.fontSize);
|
|
beginTouch();
|
|
|
|
if (!sdAvailable) {
|
|
board.drawWord("SD CARD ERROR", state.wpm);
|
|
while (true) delay(1000);
|
|
}
|
|
|
|
String book;
|
|
std::vector<uint8_t> sidecar;
|
|
if (!library.loadLastOrFirst(state.bookPath, book)) {
|
|
board.drawWord("NO BOOKS", state.wpm);
|
|
while (true) delay(1000);
|
|
}
|
|
library.loadSidecar(state.bookPath, sidecar);
|
|
if (!reader.load(book, sidecar)) {
|
|
board.drawWord("NO BOOKS", state.wpm);
|
|
while (true) delay(1000);
|
|
}
|
|
const uint32_t savedWord = state.wordIndex;
|
|
reader.restoreAtParagraphStart(savedWord);
|
|
state.wordIndex = reader.currentIndex();
|
|
if (sanitizedSettings || state.wordIndex != savedWord) persistState();
|
|
readerView = ReaderView::kChapterTitle;
|
|
showReaderScreen();
|
|
lastInteractionMs = millis();
|
|
}
|
|
|
|
void loop() {
|
|
handleTouch();
|
|
handleInput();
|
|
|
|
if (playing && millis() >= nextWordMs) {
|
|
if (!reader.advance()) {
|
|
String nextChapterPath;
|
|
if (library.nextChapter(state.bookPath, nextChapterPath) &&
|
|
loadChapter(nextChapterPath)) {
|
|
showReaderScreen();
|
|
} else {
|
|
readerView = ReaderView::kTheEnd;
|
|
playing = false;
|
|
showReaderScreen();
|
|
}
|
|
// Reaching a chapter boundary pauses automatically; save that new
|
|
// chapter before waiting for the reader to continue.
|
|
persistState();
|
|
lastInteractionMs = millis();
|
|
} else {
|
|
showCurrentWord();
|
|
nextWordMs = millis() + reader.delayMs(state.wpm);
|
|
}
|
|
}
|
|
|
|
const uint32_t idleMs = millis() - lastInteractionMs;
|
|
if (!playing && idleMs >= config::kDeepSleepAfterMs) {
|
|
enterDeepSleep();
|
|
} else if (!playing && idleMs >= config::kDisplayOffAfterMs && !displayIsOff) {
|
|
board.displayOff();
|
|
displayIsOff = true;
|
|
} else if (!playing && idleMs >= config::kDimAfterMs && !displayIsOff &&
|
|
!displayIsDimmed) {
|
|
board.setBrightness(config::kDimBrightness);
|
|
displayIsDimmed = true;
|
|
}
|
|
delay(5);
|
|
}
|