#include #include #include #include #include #include #include #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 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(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(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 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); }