tds3p-rsvp-reader/src/main.cpp

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);
}