MVP
This commit is contained in:
commit
40abcd751c
|
|
@ -0,0 +1,4 @@
|
||||||
|
.pio/
|
||||||
|
.vscode/
|
||||||
|
*.bin
|
||||||
|
|
||||||
|
|
@ -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.
|
||||||
|
|
@ -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"
|
||||||
|
}
|
||||||
|
|
||||||
|
|
@ -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.
|
||||||
|
|
@ -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;
|
||||||
|
};
|
||||||
|
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
|
@ -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;
|
||||||
|
};
|
||||||
|
|
||||||
|
|
@ -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;
|
||||||
|
};
|
||||||
|
|
||||||
|
|
@ -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;
|
||||||
|
};
|
||||||
|
|
||||||
|
|
@ -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,
|
||||||
|
|
||||||
|
|
|
@ -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
|
||||||
|
|
||||||
|
|
@ -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); }
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
@ -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();
|
||||||
|
}
|
||||||
|
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue