commit 40abcd751c469d9f963b1d0a4780eeee646a51a9 Author: John Date: Tue Sep 22 01:14:33 2026 -0500 MVP diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..fbdea2e --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +.pio/ +.vscode/ +*.bin + diff --git a/README.md b/README.md new file mode 100644 index 0000000..8f83f75 --- /dev/null +++ b/README.md @@ -0,0 +1,198 @@ +# T-Display S3 Pro RSVP Reader + +An offline-first RSVP reader for the LilyGO T-Display S3 Pro. It renders one +white word on a black, landscape display and never starts Wi-Fi or Bluetooth. + +## What you need + +- A LilyGO T-Display S3 Pro and a **USB-C data cable**. A charge-only cable + cannot upload firmware. +- A computer with internet access for the first build, Python 3, and a terminal. +- A microSD card formatted as FAT32. The reader uses this as its book library. +- This repository, cloned or downloaded to your computer. + +The first build downloads the ESP32 toolchain and display library. The reader +does not need network access after it has been flashed. + +## 1. Install PlatformIO Core + +PlatformIO Core is the command-line build and upload tool used by this project. +Install it in your normal user account; do not use `sudo`. + +### macOS or Linux + +Confirm Python 3 is available: + +```bash +python3 --version +``` + +Then use PlatformIO's installer: + +```bash +curl -fsSL -o get-platformio.py https://raw.githubusercontent.com/platformio/platformio-core-installer/master/get-platformio.py +python3 get-platformio.py +``` + +The installer puts `pio` at `~/.platformio/penv/bin/pio`. Add that directory to +your shell `PATH`, or use the full path in every command below. For the current +shell only: + +```bash +export PATH="$HOME/.platformio/penv/bin:$PATH" +pio --version +``` + +On Linux, if the serial device appears but uploads fail with a permission error, +add your user to the serial-device group and then sign out and back in: + +```bash +sudo usermod -a -G dialout "$USER" +``` + +### Windows + +Install Python 3, then follow PlatformIO's official installer instructions. +After installation, open a new PowerShell window and confirm: + +```powershell +pio --version +``` + +PlatformIO's installer documentation is the authoritative reference for all +operating systems: . + +## 2. Prepare a book card + +1. Format the microSD card as **FAT32**. +2. Create a directory named `books` at the card's root. +3. Copy UTF-8 plain-text files ending in `.txt` into it. Copy + [`examples/sample.txt`](examples/sample.txt) for the first test. + +The resulting card must look like this: + +```text +microSD root/ +└── books/ + ├── sample.txt + └── another-book.txt +``` + +Only files directly in `/books` are discovered in this MVP. A book may be up to +1.5 MiB. The included display font is best for printable ASCII; accented or +non-Latin scripts need a future Unicode-font upgrade. + +Eject the card safely and insert it in the board before powering the board. + +## 3. Build the firmware + +Open a terminal in this project directory: + +```bash +cd /path/to/tds3p-rsvp-reader +pio run +``` + +On the first run, PlatformIO downloads the pinned ESP32 platform, compiler, and +Arduino_GFX dependency. A successful build ends with `SUCCESS` and creates the +firmware under `.pio/build/rsvp_reader/`. + +The project targets the T-Display S3 Pro's 16 MB flash and 8 MB OPI PSRAM. It +defaults to the v1.1 16-step backlight driver. If your board is the original +v1.0 hardware, add this line under `build_flags` in `platformio.ini` before +building: + +```ini + -DTDS3P_V1_0 +``` + +## 4. Connect and identify the board + +1. Connect the board directly to the computer with the USB-C data cable. +2. Give the operating system a few seconds to enumerate it. +3. List serial ports: + +```bash +pio device list +``` + +Typical ports are `/dev/ttyACM0` or `/dev/ttyUSB0` on Linux, `/dev/cu.usbmodem*` +on macOS, and `COM3` on Windows. Note the port belonging to the board. + +## 5. Upload and start the reader + +Let PlatformIO select the port automatically: + +```bash +pio run -t upload +``` + +If it selects the wrong port or does not find one, supply it explicitly: + +```bash +pio run -t upload --upload-port /dev/ttyACM0 +``` + +Replace `/dev/ttyACM0` with the port from the previous step. On Windows, for +example, use `--upload-port COM3`. + +After a successful upload, the display shows the first word of a book and is +**paused**. If it shows `NO BOOKS`, check that the card is inserted, FAT32, and +has at least one `/books/*.txt` file. + +If the board is not detected or an upload repeatedly fails, force download mode: + +1. Hold **BOOT** (the lower-left button, GPIO0). +2. While holding BOOT, press and release **RESET** (upper-left). +3. Release BOOT. +4. Run the upload command again. + +## 6. Read + +| Control | Paused | Playing | +| --- | --- | --- | +| BOOT / GPIO0 | Play or pause | Play or pause | +| Upper-right / GPIO12 | Previous paragraph | Lower WPM | +| Lower-right / GPIO16 | Next paragraph | Higher WPM | + +Do not hold BOOT while pressing RESET during normal use; that combination is +the manual download mode used for recovery above. + +The default is 300 WPM. While paused, the backlight dims after 20 seconds, +switches off after one minute, and the ESP32 deep-sleeps after ten minutes. +GPIO12 wakes it. + +## Resume and power behavior + +When the reader advances, pauses, or sleeps, it saves the exact word index and +current WPM in the ESP32's nonvolatile storage. On the next boot or wake it +opens the same book, moves to the first word of the paragraph containing that +saved index, renders it, and remains paused. This is intentional: it gives you +paragraph context before reading resumes. + +The physical power switch only disconnects the battery. USB power is sufficient +for development and flashing. The reader intentionally has no Wi-Fi, Bluetooth, +cloud account, or synchronization feature. + +## Troubleshooting + +- **No serial port:** use another USB-C cable; many included cables only charge. +- **Upload cannot connect:** use the BOOT + RESET sequence in step 5, then retry. +- **Permission denied on Linux:** complete the `dialout` group step and log out + and back in. +- **`NO BOOKS`:** reformat the card FAT32, create `/books`, and copy a `.txt` + file directly into that folder. Confirm the card is fully seated. +- **Black display after boot:** press GPIO12 to wake it if it has slept; otherwise + reflash and check whether the v1.0 backlight build flag is needed. +- **Build fails before compilation:** rerun `pio run` with internet access so + PlatformIO can download the required packages. + +## Limits of the first MVP + +- It reads up to 1.5 MiB per book and discovers `.txt` files directly in + `/books` (no nested folders). +- The built-in display font is best for printable ASCII text. UTF-8 text files + are accepted, but accented/non-Latin characters need a Unicode font addition + before they will render correctly. +- The v1.1 backlight driver is the default. Define `TDS3P_V1_0` in + `build_flags` for an original v1.0 board. diff --git a/board/t-display-s3-pro.json b/board/t-display-s3-pro.json new file mode 100644 index 0000000..5f41972 --- /dev/null +++ b/board/t-display-s3-pro.json @@ -0,0 +1,36 @@ +{ + "build": { + "arduino": { + "ldscript": "esp32s3_out.ld", + "memory_type": "qio_opi", + "partitions": "default_16MB.csv" + }, + "core": "esp32", + "extra_flags": [ + "-DARDUINO_ESP32S3_DEV", + "-DARDUINO_USB_MODE=1", + "-DARDUINO_USB_CDC_ON_BOOT=1", + "-DARDUINO_RUNNING_CORE=1", + "-DARDUINO_EVENT_RUNNING_CORE=1", + "-DBOARD_HAS_PSRAM" + ], + "f_cpu": "240000000L", + "f_flash": "80000000L", + "flash_mode": "qio", + "mcu": "esp32s3", + "variant": "esp32s3" + }, + "connectivity": ["wifi", "bluetooth"], + "frameworks": ["arduino", "espidf"], + "name": "LilyGo T-Display-S3-Pro", + "upload": { + "flash_size": "16MB", + "maximum_ram_size": 327680, + "maximum_size": 16777216, + "require_upload_port": true, + "speed": 921600 + }, + "url": "https://www.lilygo.cc/products/t-display-s3-pro", + "vendor": "LilyGo" +} + diff --git a/examples/sample.txt b/examples/sample.txt new file mode 100644 index 0000000..028a07e --- /dev/null +++ b/examples/sample.txt @@ -0,0 +1,8 @@ +Welcome to your RSVP reader. + +Copy plain-text books to the books directory on a FAT32 microSD card. The +reader opens a book at the beginning of the paragraph that contains your last +saved word, then waits for you to press play. + +Reading one word at a time means that pace is controlled by the device. Pause +whenever you want. The exact word remains saved for the next restart. diff --git a/include/Board.h b/include/Board.h new file mode 100644 index 0000000..2c0e158 --- /dev/null +++ b/include/Board.h @@ -0,0 +1,20 @@ +#pragma once + +#include + +class Arduino_GFX; + +class Board { + public: + bool begin(); + bool beginSd(); + void drawWord(const String& word); + void setBrightness(uint8_t level); + void displayOff(); + void displayOn(); + + private: + Arduino_GFX* display_ = nullptr; + uint8_t brightness_ = 0; +}; + diff --git a/include/Config.h b/include/Config.h new file mode 100644 index 0000000..576a2e2 --- /dev/null +++ b/include/Config.h @@ -0,0 +1,35 @@ +#pragma once + +#include + +namespace config { + +// Pins from LilyGO's T-Display-S3-Pro utilities.h. +constexpr uint8_t kSpiMiso = 8; +constexpr uint8_t kSpiMosi = 17; +constexpr uint8_t kSpiSck = 18; +constexpr uint8_t kTftCs = 39; +constexpr uint8_t kTftDc = 9; +constexpr uint8_t kTftReset = 47; +constexpr uint8_t kBacklight = 48; +constexpr uint8_t kSdCs = 14; +constexpr uint8_t kButtonPlayPause = 0; // BOOT; never hold while pressing RESET. +constexpr uint8_t kButtonPrevious = 12; +constexpr uint8_t kButtonNext = 16; + +constexpr char kBooksDirectory[] = "/books"; +constexpr size_t kMaxBookBytes = 1500 * 1024; + +constexpr uint16_t kDefaultWpm = 300; +constexpr uint16_t kMinimumWpm = 100; +constexpr uint16_t kMaximumWpm = 800; +constexpr uint16_t kWpmStep = 25; + +constexpr uint32_t kDimAfterMs = 20UL * 1000UL; +constexpr uint32_t kDisplayOffAfterMs = 60UL * 1000UL; +constexpr uint32_t kDeepSleepAfterMs = 10UL * 60UL * 1000UL; +constexpr uint8_t kNormalBrightness = 5; // 1–16 on v1.1 boards. +constexpr uint8_t kDimBrightness = 1; + +} // namespace config + diff --git a/include/Library.h b/include/Library.h new file mode 100644 index 0000000..bb1ff01 --- /dev/null +++ b/include/Library.h @@ -0,0 +1,13 @@ +#pragma once + +#include + +class Library { + public: + bool loadLastOrFirst(String& path, String& contents) const; + + private: + bool loadFile(const String& path, String& contents) const; + bool firstBook(String& path) const; +}; + diff --git a/include/Reader.h b/include/Reader.h new file mode 100644 index 0000000..3cd520f --- /dev/null +++ b/include/Reader.h @@ -0,0 +1,29 @@ +#pragma once + +#include +#include + +struct Token { + uint32_t offset; + uint16_t length; + uint32_t paragraph; +}; + +class Reader { + public: + bool load(const String& book); + void restoreAtParagraphStart(uint32_t savedWord); + const String currentWord() const; + bool advance(); + bool previousParagraph(); + bool nextParagraph(); + uint32_t currentIndex() const; + uint32_t delayMs(uint16_t wpm) const; + + private: + String book_; + std::vector tokens_; + std::vector paragraphStarts_; + uint32_t index_ = 0; +}; + diff --git a/include/Settings.h b/include/Settings.h new file mode 100644 index 0000000..5944f63 --- /dev/null +++ b/include/Settings.h @@ -0,0 +1,17 @@ +#pragma once + +#include + +struct SavedState { + String bookPath; + uint32_t wordIndex = 0; + uint16_t wpm = 300; +}; + +class Settings { + public: + void begin(); + SavedState load() const; + void save(const SavedState& state) const; +}; + diff --git a/partitions.csv b/partitions.csv new file mode 100644 index 0000000..91a820d --- /dev/null +++ b/partitions.csv @@ -0,0 +1,6 @@ +# Name, Type, SubType, Offset, Size, Flags +nvs, data, nvs, 0x9000, 0x6000, +otadata, data, ota, 0xf000, 0x2000, +app0, app, ota_0, 0x10000, 0x500000, +spiffs, data, spiffs, 0x510000, 0xAE0000, + diff --git a/platformio.ini b/platformio.ini new file mode 100644 index 0000000..d430c84 --- /dev/null +++ b/platformio.ini @@ -0,0 +1,16 @@ +[platformio] +default_envs = rsvp_reader +boards_dir = board + +[env:rsvp_reader] +platform = espressif32@6.3.0 +board = t-display-s3-pro +framework = arduino +monitor_speed = 115200 +board_build.partitions = partitions.csv +build_flags = + -DARDUINO_USB_CDC_ON_BOOT=1 + -DDISABLE_ALL_LIBRARY_WARNINGS +lib_deps = + moononournation/GFX Library for Arduino @ 1.3.7 + diff --git a/src/Board.cpp b/src/Board.cpp new file mode 100644 index 0000000..1fa84cb --- /dev/null +++ b/src/Board.cpp @@ -0,0 +1,85 @@ +#include "Board.h" + +#include +#include +#include + +#include "Config.h" + +namespace { +Arduino_DataBus* bus = new Arduino_ESP32SPI( + config::kTftDc, config::kTftCs, config::kSpiSck, config::kSpiMosi, + config::kSpiMiso); +// The panel is natively 222x480. Rotation 1 gives the reader a 480x222 canvas. +Arduino_GFX* makeDisplay() { + return new Arduino_ST7796(bus, config::kTftReset, 1, true, 222, 480, 49, 0); +} +} // namespace + +bool Board::begin() { + display_ = makeDisplay(); + if (!display_->begin()) { + return false; + } + display_->fillScreen(BLACK); + pinMode(config::kBacklight, OUTPUT); + setBrightness(config::kNormalBrightness); + return true; +} + +bool Board::beginSd() { + SPI.begin(config::kSpiSck, config::kSpiMiso, config::kSpiMosi); + return SD.begin(config::kSdCs, SPI); +} + +void Board::drawWord(const String& word) { + if (display_ == nullptr) return; + + display_->fillScreen(BLACK); + const size_t glyphs = word.length() == 0 ? 1 : word.length(); + uint16_t calculatedScale = 480 / (glyphs * 6); // MVP font supports printable ASCII text. + if (calculatedScale < 2) calculatedScale = 2; + if (calculatedScale > 12) calculatedScale = 12; + const uint8_t scale = calculatedScale; + const int16_t width = glyphs * 6 * scale; + const int16_t height = 8 * scale; + int16_t x = (display_->width() - width) / 2; + if (x < 0) x = 0; + display_->setTextColor(WHITE); + display_->setTextSize(scale); + display_->setCursor(x, (display_->height() - height) / 2); + display_->print(word); +} + +void Board::setBrightness(uint8_t level) { + level = constrain(level, 0, 16); +#ifdef TDS3P_V1_0 + ledcSetup(1, 1000, 8); + ledcAttachPin(config::kBacklight, 1); + ledcWrite(1, map(level, 0, 16, 0, 255)); +#else + // v1.1 uses a 16-step constant-current driver, not PWM. + if (level == 0) { + digitalWrite(config::kBacklight, LOW); + delay(3); + brightness_ = 0; + return; + } + if (brightness_ == 0) { + digitalWrite(config::kBacklight, HIGH); + brightness_ = 16; + delayMicroseconds(30); + } + const uint8_t from = 16 - brightness_; + const uint8_t to = 16 - level; + const uint8_t pulses = (16 + to - from) % 16; + for (uint8_t i = 0; i < pulses; ++i) { + digitalWrite(config::kBacklight, LOW); + digitalWrite(config::kBacklight, HIGH); + } + brightness_ = level; +#endif +} + +void Board::displayOff() { setBrightness(0); } +void Board::displayOn() { setBrightness(config::kNormalBrightness); } diff --git a/src/Library.cpp b/src/Library.cpp new file mode 100644 index 0000000..aa801b0 --- /dev/null +++ b/src/Library.cpp @@ -0,0 +1,46 @@ +#include "Library.h" + +#include + +#include "Config.h" +#include "Settings.h" + +bool Library::loadLastOrFirst(String& path, String& contents) const { + Settings settings; + settings.begin(); + const SavedState state = settings.load(); + if (!state.bookPath.isEmpty() && loadFile(state.bookPath, contents)) { + path = state.bookPath; + return true; + } + return firstBook(path) && loadFile(path, contents); +} + +bool Library::loadFile(const String& path, String& contents) const { + File file = SD.open(path, FILE_READ); + if (!file || file.isDirectory() || file.size() == 0 || + static_cast(file.size()) > config::kMaxBookBytes) { + return false; + } + contents = file.readString(); + file.close(); + return !contents.isEmpty(); +} + +bool Library::firstBook(String& path) const { + File directory = SD.open(config::kBooksDirectory); + if (!directory || !directory.isDirectory()) return false; + for (File entry = directory.openNextFile(); entry; + entry = directory.openNextFile()) { + const String name = entry.name(); + const bool isText = !entry.isDirectory() && name.endsWith(".txt"); + entry.close(); + if (isText) { + path = name.startsWith("/") ? name : String(config::kBooksDirectory) + "/" + name; + directory.close(); + return true; + } + } + directory.close(); + return false; +} diff --git a/src/Reader.cpp b/src/Reader.cpp new file mode 100644 index 0000000..f5d52a2 --- /dev/null +++ b/src/Reader.cpp @@ -0,0 +1,86 @@ +#include "Reader.h" + +#include + +bool Reader::load(const String& book) { + book_ = book; + tokens_.clear(); + paragraphStarts_.clear(); + index_ = 0; + + bool inWord = false; + uint32_t wordStart = 0; + uint16_t lineBreaks = 0; + for (uint32_t i = 0; i <= book_.length(); ++i) { + const char c = i < book_.length() ? book_[i] : ' '; + const bool whitespace = isspace(static_cast(c)); + if (!whitespace && !inWord) { + const bool paragraphBreak = lineBreaks >= 2 || paragraphStarts_.empty(); + wordStart = i; + inWord = true; + if (paragraphBreak) { + paragraphStarts_.push_back(tokens_.size()); + } + lineBreaks = 0; + } + if (whitespace && inWord) { + tokens_.push_back({wordStart, static_cast(i - wordStart), + static_cast(paragraphStarts_.size() - 1)}); + inWord = false; + } + if (c == '\n') { + ++lineBreaks; + } + } + return !tokens_.empty(); +} + +void Reader::restoreAtParagraphStart(uint32_t savedWord) { + if (tokens_.empty()) return; + index_ = savedWord < tokens_.size() ? savedWord : tokens_.size() - 1; + index_ = paragraphStarts_[tokens_[index_].paragraph]; +} + +const String Reader::currentWord() const { + if (tokens_.empty()) return String(); + const Token& token = tokens_[index_]; + return book_.substring(token.offset, token.offset + token.length); +} + +bool Reader::advance() { + if (index_ + 1 >= tokens_.size()) return false; + ++index_; + return true; +} + +bool Reader::previousParagraph() { + if (tokens_.empty()) return false; + const uint32_t paragraph = tokens_[index_].paragraph; + if (paragraph == 0) { + index_ = 0; + return false; + } + index_ = paragraphStarts_[paragraph - 1]; + return true; +} + +bool Reader::nextParagraph() { + if (tokens_.empty()) return false; + const uint32_t paragraph = tokens_[index_].paragraph; + if (paragraph + 1 >= paragraphStarts_.size()) return false; + index_ = paragraphStarts_[paragraph + 1]; + return true; +} + +uint32_t Reader::currentIndex() const { return index_; } + +uint32_t Reader::delayMs(uint16_t wpm) const { + if (tokens_.empty()) return 1000; + const String word = currentWord(); + uint32_t delay = 60000UL / wpm; + const char end = word[word.length() - 1]; + if (end == '.' || end == '!' || end == '?') delay *= 3; + else if (end == ',' || end == ';' || end == ':') delay *= 2; + else if (word.length() >= 11) delay = delay * 3 / 2; + return delay; +} diff --git a/src/Settings.cpp b/src/Settings.cpp new file mode 100644 index 0000000..53a8966 --- /dev/null +++ b/src/Settings.cpp @@ -0,0 +1,30 @@ +#include "Settings.h" + +#include + +namespace { +constexpr char kNamespace[] = "rsvp"; +} + +void Settings::begin() {} + +SavedState Settings::load() const { + Preferences preferences; + preferences.begin(kNamespace, true); + SavedState state; + state.bookPath = preferences.getString("book", ""); + state.wordIndex = preferences.getUInt("word", 0); + state.wpm = preferences.getUShort("wpm", 300); + preferences.end(); + return state; +} + +void Settings::save(const SavedState& state) const { + Preferences preferences; + preferences.begin(kNamespace, false); + preferences.putString("book", state.bookPath); + preferences.putUInt("word", state.wordIndex); + preferences.putUShort("wpm", state.wpm); + preferences.end(); +} + diff --git a/src/main.cpp b/src/main.cpp new file mode 100644 index 0000000..fd1b60a --- /dev/null +++ b/src/main.cpp @@ -0,0 +1,164 @@ +#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; +uint32_t lastInteractionMs = 0; +uint32_t nextWordMs = 0; + +struct Button { + uint8_t pin; + bool stable = HIGH; + bool candidate = HIGH; + uint32_t changedAt = 0; +}; + +Button playPause{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 persist() { + state.wordIndex = reader.currentIndex(); + settings.save(state); +} + +void showCurrentWord() { board.drawWord(reader.currentWord()); } + +void wakeDisplay() { + if (displayIsOff) { + board.displayOn(); + displayIsOff = false; + } + lastInteractionMs = millis(); +} + +void enterDeepSleep() { + persist(); + 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(playPause)) { + wakeDisplay(); + playing = !playing; + persist(); + nextWordMs = millis(); + } + if (released(previous)) { + wakeDisplay(); + if (playing) { + state.wpm = state.wpm > config::kMinimumWpm + config::kWpmStep + ? state.wpm - config::kWpmStep + : config::kMinimumWpm; + } else { + reader.previousParagraph(); + showCurrentWord(); + } + persist(); + } + if (released(next)) { + wakeDisplay(); + if (playing) { + state.wpm = state.wpm + config::kWpmStep < config::kMaximumWpm + ? state.wpm + config::kWpmStep + : config::kMaximumWpm; + } else { + reader.nextParagraph(); + showCurrentWord(); + } + persist(); + } +} +} // 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(); + if (state.wpm < config::kMinimumWpm || state.wpm > config::kMaximumWpm) { + state.wpm = config::kDefaultWpm; + } + + if (!board.begin() || !board.beginSd()) { + while (true) delay(1000); + } + + String book; + if (!library.loadLastOrFirst(state.bookPath, book) || !reader.load(book)) { + board.drawWord("NO BOOKS"); + while (true) delay(1000); + } + reader.restoreAtParagraphStart(state.wordIndex); + showCurrentWord(); + lastInteractionMs = millis(); +} + +void loop() { + handleInput(); + + if (playing && millis() >= nextWordMs) { + if (!reader.advance()) { + playing = false; + persist(); + lastInteractionMs = millis(); + } else { + showCurrentWord(); + persist(); + 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) { + board.setBrightness(config::kDimBrightness); + } + delay(5); +}