- C++ 74.4%
- C 24.2%
- HTML 0.8%
- Python 0.5%
- CMake 0.1%
|
|
||
|---|---|---|
| .github/workflows | ||
| .vscode | ||
| archive | ||
| examples | ||
| install | ||
| Pala_One_2_1 | ||
| scripts | ||
| test | ||
| .gitignore | ||
| platformio.ini | ||
| README.md | ||
pala-one-firmware
Pala One — A tiny E-Ink reader project by Paul Lagier
The goal of the project was to create a simple, distraction-free reading device that feels minimal, portable and easy to build while still looking and behaving more like a real product than a typical DIY electronics project.
This repository contains the firmware source code for the project.
Additional files such as:
- STL files
- STEP files
- assembly guides
- printable files
- project downloads
are available separately via Ko-fi:
https://ko-fi.com/s/e14ed892ea
Install (no toolchain needed)
The easiest way to flash a board is via the web installer. Plug your Heltec Wireless Paper into a desktop computer running Chrome, Edge, or Opera, then open the installer page and pick a channel:
- Stable (
/stable/) — latest tagged release (vX.Y.Z). Use this unless you have a reason not to. - Development (
/dev/) — latest build fromdev; new features, may break.
Each channel page lists both display revisions (V1.1 / V1.2) and both languages (English / Spanish-LA) — four install buttons total. Pick the one that matches your board + language and click Install. The installer keeps existing reading progress, bookmarks, and uploaded books across re-flashes.
Board Versions
There are currently two supported Heltec Wireless Paper versions:
Heltec V1.1Heltec V1.2
The board version is usually printed on the back of the PCB.
Pick your board's revision in the build step below — either by uncommenting the matching #define at the top of Pala_One_2_1/Pala_One_2_1.ino (Arduino IDE), or by selecting the matching env (PlatformIO).
Wi-Fi provisioning (Improv)
Besides the SoftAP captive portal, the firmware supports Improv Serial Wi-Fi provisioning (improv-wifi.com) over the USB-CDC port, using the jnthas/Improv-WiFi-Library. When the board is plugged into a computer, a browser can hand it Wi-Fi credentials directly — the web installer does this right after flashing and then redirects to connected.html.
Saved credentials let the device join your network in Station mode the next time it enters the web UI / upload mode; if none are saved (or the join fails) it falls back to the open SoftAP at 192.168.4.1.
OTA firmware updates
Once the device has Wi-Fi credentials stored (see Wi-Fi provisioning), firmware updates can be installed wirelessly — no USB cable, no computer required.
How to update
- Navigate to Firmware Update at the bottom of the library menu.
- The device connects to your home network automatically.
- Select a channel with 1× press:
[x] Stable— latest tagged release.[ ] Dev— latest development build; may contain new features or instabilities.
- Navigate to [ Check for update ] with 1× and confirm with 2×. The device probes the update server, fetches the manifest, and compares the remote version against the installed one.
- If an update is available, [ Install update ] appears. Navigate to it with 1× and confirm with 2×. The binary streams directly into the idle OTA partition (~5–30 s depending on your network).
- When flashing is complete, press 2× to reboot into the new firmware.
Requirements
- Wi-Fi credentials must be provisioned first (see below). If none are stored the screen shows "No Wi-Fi credentials — setup via web installer".
- The device must be able to reach
paullagier.github.ioover HTTPS. A local network without internet access will be reported as "Cannot reach update server".
Language
The firmware ships with two built-in languages, selected at build time:
LANG_EN— English (default)LANG_ES_LA— Spanish (Latin America)
One language per binary. PlatformIO users pick a leaf env that already encodes both the board and the language (wireless-paper-v1_2-en, wireless-paper-v1_2-es, wireless-paper-v1_1-en, wireless-paper-v1_1-es). Arduino IDE users uncomment one of LANG_EN / LANG_ES_LA near the top of Pala_One_2_1/Pala_One_2_1.ino, alongside the board #define. If nothing is set, the firmware compiles with LANG_EN and a #pragma message warning.
Strings live in Pala_One_2_1/src/lang/ — en.h is the canonical key set; es_la.h mirrors it. Adding a new language is additive: clone one of the headers, add the include arm in src/lang/lang.h, and add two leaf envs in platformio.ini (one per board). See src/lang/lang.h for the authoring rules (key set, placeholders, JS-confirm quoting constraint).
Glyph coverage: Latin Extended (á é í ó ú ñ Ñ ¿ ¡ ü Ü) is provided by u8g2_font_helv*_te for body, bold, app-large and toast roles. The small bitmap fonts used for the battery percentage and page-number indicator stay on ASCII-only _tf tables — they only render digits / %, and any translation routed through them would render missing-glyph boxes. Web responses declare charset=utf-8.
Web UI theme
The browser-side configuration UI ships with a light palette and a dark palette and a per-page toggle button in the header. The toggle's choice is stored in the browser's localStorage (palaTheme), so each device that connects to the captive portal remembers its own preference — there is no server-side persistence.
Out of the box, a first visit defaults to light. To change the firmware default (e.g. so a freshly connected device lands in dark mode), pick one of WEB_THEME_LIGHT / WEB_THEME_DARK at build time, mirroring the language flow:
- Arduino IDE — uncomment one of
WEB_THEME_LIGHT/WEB_THEME_DARKnear the top ofPala_One_2_1/Pala_One_2_1.ino(beneath the language block). - PlatformIO — add
-D WEB_THEME_DARKto your env'sbuild_flagsif you want dark as the default; otherwise leave it alone.
The build-time default only affects the first visit from a given browser — once the toggle is used, the localStorage choice wins from then on.
Device lock
The device can be locked to stop accidental input (page turns, menu, navigation) while it rests in a bag or pocket. The locked state is persisted to NVS (cfg_locked), so a device that fully powers down comes back locked.
Locking is a remappable button action. In the web UI under Settings → Buttons you can bind each of the three hold gestures — long press, very-long press, click-hold — to None, Bookmark, Lock device, or Menu. Out-of-box defaults:
- Long press → Bookmark
- Very-long press (≥ 2 s) → Lock device
- Click-hold → Menu
So by default you lock with a very-long press. Unlocking is intentionally permissive: any long, very-long, or click-hold press unlocks the device and shows an "Unlocked" toast — after a deep-sleep wake the firmware can't reconstruct a specific chord, so it accepts any hold gesture rather than risk locking you out. While locked, the sleep screen shows a small padlock badge in the top-right corner.
Apps
Pala One supports user-installable apps — self-contained position-independent C binaries that run on top of the firmware and have access to the display, button, RTC, and a per-app key-value store. Apps are uploaded over Wi-Fi through the same web UI used for books, and they appear under the Apps entry of the library menu. No firmware rebuild is needed to install one.
See examples/GETTING_STARTED.md for the full app-author guide — binary format, the PalaAPI (v3), required compiler flags, and upload steps.
Building an app
You need:
- The
xtensa-esp32s3-elf-gcccross-compiler, which ships with the Arduino ESP32 board package. On Linux it is found under~/.arduino15/packages/esp32/tools/esp-x32/<version>/bin/; theMakefilelocates it automatically. python3(for the post-build step that patches the entry point offset into the binary).
An app is a single C file that includes pala_app.h and pala_api.h from the firmware source and exports a void app_main(const PalaAPI* api) entry point. The header struct at the start of the binary carries the display name and API version check.
See examples/click_counter/ for a complete working example with a Makefile.
cd examples/click_counter
make
# produces click_counter.bin
Uploading an app
- Select Upload from the library menu on the device.
- Connect to the
PALA-XXXXXXWiFi network (password:palaread). - Open
http://192.168.4.1in a browser. - Use the Upload app (.bin) card to upload your
.binfile. - Triple-click to exit upload mode — the app will appear in the Apps menu immediately.
Contributing
If you improve the firmware, add features or fix bugs, feel free to open a pull request. Please clearly mention:
- which board version(s) you tested on (V1.1, V1.2, or both)
- what was changed
- how it was tested
Building the firmware
The same sources build under either toolchain.
PlatformIO (recommended)
- Install PlatformIO Core (CLI) or the PlatformIO IDE extension for VS Code.
- From the repo root:
pio run -e wireless-paper-v1_2-en -t upload # V1.2 panel, English pio run -e wireless-paper-v1_2-es -t upload # V1.2 panel, Spanish-LA pio run -e wireless-paper-v1_1-en -t upload # V1.1 panel, English pio run -e wireless-paper-v1_1-es -t upload # V1.1 panel, Spanish-LA - Serial monitor:
pio device monitor
Both envs share libraries and partition table via platformio.ini. The PIO build also runs scripts/build_info.py to inject:
FW_VERSIONfromgit describe --tags --always --dirty(e.g.v2.1,v2.1-3-gabc1234,…-dirty)BUILD_GIT_HASHfrom the current short SHA
Arduino IDE 2 (outdated)
- Install the esp32 by Espressif Systems board package (Boards Manager) and select the Heltec WiFi LoRa 32 V3 board.
- Install these libraries via Library Manager (or by URL):
heltec-eink-modules(todd-herbert fork)- Adafruit GFX Library (Adafruit)
- U8g2_for_Adafruit_GFX (olikraus)
Improv-WiFi-Library(jnthas) — serial Wi-Fi provisioning; PlatformIO installs it automatically, Arduino IDE users add it by URL
- Open
Pala_One_2_1/Pala_One_2_1.ino. Uncomment exactly one ofBOARD_V1_1/BOARD_V1_2at the top. - Tools → Partition Scheme → Custom (the sketch ships its own
partitions.csv). - Verify / Upload.
Arduino IDE / host-test builds skip the script and fall back to "dev" and "unknown" respectively — those toolchains are for developer iteration; releases go through the PIO + tagged-CI flow where the real values get injected.
Web Installer site (channels & CI)
The web installer is published to the gh-pages branch by .github/workflows/deploy-installer.yml. Two channels live side-by-side and never overwrite each other:
| Trigger | Channel | URL path | DEBUG_BUILD |
Manifest version |
|---|---|---|---|---|
push to dev |
dev |
/dev/ |
1 (git hash visible on device) |
dev-<sha> |
tag v* |
stable |
/stable/ |
0 (clean UI) |
vX.Y.Z |
workflow_dispatch |
choose | matches | depends on channel | dev-<sha> / manual-<sha> |
Merges to main do not auto-publish. Tagging is the explicit release event, so /stable/ never carries an arbitrary mid-release snapshot and the install dialog always shows a clean version name. To cut a release: merge to main, then git tag vX.Y.Z && git push origin vX.Y.Z.
Every run rebuilds the channel it owns and publishes only the corresponding gh-pages/<channel>/ subdirectory (the workflow uses keep_files: true, so the other channel's directory is preserved). A small landing page at the gh-pages root links to both — it is republished on every run with identical content, so the cross-write is safe. The Improv post-provisioning connected.html also lives at the gh-pages root (its content is channel-agnostic and the URL is baked into the firmware).
Source HTML/manifests live in install/ on the normal branches. The gh-pages branch is fully generated; do not edit it by hand.
Local development
To iterate on the installer page (HTML, Improv Serial provisioning flow, manifest tweaks) without CI:
- Build all four leaf envs at least once so the firmware bins exist:
pio run -e wireless-paper-v1_1-en pio run -e wireless-paper-v1_1-es pio run -e wireless-paper-v1_2-en pio run -e wireless-paper-v1_2-es - Assemble the bundle. Two layouts are supported:
python scripts/assemble_site.py # flat layout in site/ python scripts/assemble_site.py --channel dev # site/index.html + site/dev/ - Serve it. Web Serial works on
localhostwithout HTTPS:python -m http.server 8000 --directory site - Open http://localhost:8000 in Chrome, Edge, or Opera. With
--channel, the landing page is served; without, the installer is served directly.
Optional flags: --version <string> to label the manifest, --out <dir> to write somewhere other than site/. The channel-aware layout produced locally is bit-identical to what the workflow uploads to gh-pages.
Codebase layout
Pala_One_2_1/
├── Pala_One_2_1.ino # Sketch entry: board selection + setup()/loop()
├── pala_api.h # Public app API (firmware ↔ app ABI)
├── pala_app.h # PalaAppHeader + version constants
├── partitions.csv # ESP32 partition table
└── src/ # Firmware modules
├── config.h # Compile-time constants
├── state.{h,cpp} # Globals (display, WiFi server, prefs)
├── pure/ # Pure C++, no Arduino headers — host-testable
├── hal/ # Hardware adapters (display, battery, input)
├── storage/ # KV store + on-disk persistence
├── ui/ # Screens, fonts, sleep, toasts, widgets
│ └── screens/ # One file per screen
└── web/ # Captive-portal HTTP server (route groups)
docs/ # Architecture notes + refactor journal
scripts/ # PlatformIO pre-build helpers
test/ # Host-side CMake unit tests for pure/ + storage/
examples/ # Sample apps (click_counter, palagotchi)
install/ # ESP Web Tools installer page (deployed to GitHub Pages by CI)
archive/ # Past firmware revisions, kept for reference
The intent of the layering is pure → storage → hal → ui: pure modules never include Arduino.h, so they compile into the host test build verbatim. Storage adds an on-disk persistence shim behind KeyValueStore, hal isolates the hardware, and the UI sits on top.
Include style
Firmware sources include each other by the full path from the sketch root, e.g. #include "src/hal/display.h", not the shorter #include "hal/display.h".
This is to stay compatible with Arduino IDE. The IDE recursively compiles files inside the src/ subfolder of a sketch, but does not add src/ to the compiler's include path — only the sketch folder root is on it. So #include "config.h" from a file at sketch root fails (the file actually lives at src/config.h), whereas #include "src/config.h" resolves correctly. PlatformIO is happy either way; we picked the Arduino-IDE-compatible form so the same #include lines work in both build systems with no extra -I flags.
Host-side tests
Pure modules and KV-backed storage have host-side unit tests under test/. They build with CMake and run on your laptop — no board required.
See test/README.md for prerequisites (CMake + a C++17 compiler) and per-platform setup / run instructions for Windows, Linux, and macOS.
App API
Apps communicate with the firmware through the PalaAPI function pointer table passed to app_main. The current API version is v3 (PALA_API_VERSION 3 in pala_app.h).
Display
| Function | Description |
|---|---|
clearScreen() |
Clear the display buffer and prepare a new frame |
drawHeader(title) |
Draw the standard section header bar |
drawTextAt(x, y, text, bold) |
Draw text at a pixel position |
drawCenteredLarge(text) |
Draw text centred on screen in a large font |
refreshDisplay() |
Push the frame buffer to the e-ink panel |
Input
| Function | Description |
|---|---|
waitForEvent() |
Block until a button gesture; returns PALA_CLICK / PALA_DOUBLE / PALA_TRIPLE / PALA_LONG |
pollEvent() |
Non-blocking variant; returns 0 if no event is ready |
buttonPressed() |
Returns 1 if the button is currently held, 0 otherwise |
pendingPresses() |
Count of individual short press-release events since last call; bypasses multi-click grouping |
Timing
| Function | Description |
|---|---|
millisNow() |
Current uptime in milliseconds |
delayMs(ms) |
Yield for ms milliseconds |
rtcSeconds() |
Monotonic seconds counter that survives deep sleep; use for cross-session timing |
Storage
| Function | Description |
|---|---|
storageRead(key, buf, maxlen) |
Read from /apps/{key}.dat; returns bytes read, -1 on error |
storageWrite(key, buf, len) |
Write to /apps/{key}.dat; returns bytes written, -1 on error |
Utilities
| Function | Description |
|---|---|
snprintf_wrap(buf, len, fmt, ...) |
Standard snprintf |
Return from app_main to exit back to the Apps menu. Apps decide their own exit gesture — the firmware does not impose one.
Constraints:
- Apps must be compiled
-fPIC -mlongcalls(position-independent). - Apps must not use static mutable variables — the loader does not patch
.datarelocations. - Maximum binary size: 48 KB.
- The
api_versionfield inPalaAppHeadermust matchPALA_API_VERSIONexactly — the firmware rejects mismatched binaries.
Features
- TXT book support
- Adjustable font size and line spacing
- Font family choice (Helvetica / OpenDyslexic)
- Bionic reading mode
- Reading progress saving
- Bookmarks (on-device and over the web UI)
- In-book text search (web UI)
- Todo list
- Device lock (see Device lock)
- Remappable button gestures
- Custom screensaver image
- Adjustable idle sleep timeout
- Wi-Fi provisioning (Improv) + captive-portal web UI
- OTA firmware updates over Wi-Fi (see OTA firmware updates)
- User-installable apps (see Apps)
- Deep sleep mode
- USB-C charging
- Lightweight portable design
- Open-source firmware
Community & Modifications
Community improvements, forks and firmware modifications are welcome. If you build your own version or improve the project, feel free to share it with the community.
License & Copyright
The firmware in this repository is provided for personal and educational use.
Please do not:
- reupload paid project files
- redistribute complete download packages
- resell the project files
- commercially redistribute modified versions of paid assets
The design, branding, documentation and paid project assets remain copyright © Paul Lagier.
Created by Paul Lagier