This commit is contained in:
John 2026-09-22 01:14:33 -05:00
commit 40abcd751c
16 changed files with 793 additions and 0 deletions

4
.gitignore vendored Normal file
View File

@ -0,0 +1,4 @@
.pio/
.vscode/
*.bin

198
README.md Normal file
View File

@ -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: <https://docs.platformio.org/en/latest/core/installation/methods/installer-script.html>.
## 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.

View File

@ -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"
}

8
examples/sample.txt Normal file
View File

@ -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.

20
include/Board.h Normal file
View File

@ -0,0 +1,20 @@
#pragma once
#include <Arduino.h>
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;
};

35
include/Config.h Normal file
View File

@ -0,0 +1,35 @@
#pragma once
#include <Arduino.h>
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

13
include/Library.h Normal file
View File

@ -0,0 +1,13 @@
#pragma once
#include <Arduino.h>
class Library {
public:
bool loadLastOrFirst(String& path, String& contents) const;
private:
bool loadFile(const String& path, String& contents) const;
bool firstBook(String& path) const;
};

29
include/Reader.h Normal file
View File

@ -0,0 +1,29 @@
#pragma once
#include <Arduino.h>
#include <vector>
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<Token> tokens_;
std::vector<uint32_t> paragraphStarts_;
uint32_t index_ = 0;
};

17
include/Settings.h Normal file
View File

@ -0,0 +1,17 @@
#pragma once
#include <Arduino.h>
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;
};

6
partitions.csv Normal file
View File

@ -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,
1 # Name Type SubType Offset Size Flags
2 nvs data nvs 0x9000 0x6000
3 otadata data ota 0xf000 0x2000
4 app0 app ota_0 0x10000 0x500000
5 spiffs data spiffs 0x510000 0xAE0000

16
platformio.ini Normal file
View File

@ -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

85
src/Board.cpp Normal file
View File

@ -0,0 +1,85 @@
#include "Board.h"
#include <Arduino_GFX_Library.h>
#include <SD.h>
#include <SPI.h>
#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); }

46
src/Library.cpp Normal file
View File

@ -0,0 +1,46 @@
#include "Library.h"
#include <SD.h>
#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<size_t>(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;
}

86
src/Reader.cpp Normal file
View File

@ -0,0 +1,86 @@
#include "Reader.h"
#include <ctype.h>
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<unsigned char>(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<uint16_t>(i - wordStart),
static_cast<uint32_t>(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;
}

30
src/Settings.cpp Normal file
View File

@ -0,0 +1,30 @@
#include "Settings.h"
#include <Preferences.h>
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();
}

164
src/main.cpp Normal file
View File

@ -0,0 +1,164 @@
#include <Arduino.h>
#include <WiFi.h>
#include <esp_bt.h>
#include <esp_sleep.h>
#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<gpio_num_t>(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);
}