Almanac is e-reader firmware for the ESP32-C3-based Xteink X4 and X3. It renders EPUBs well on a very constrained device, and it shows you what is flying overhead. Since 1.1.0 it will also keep you occupied for ten minutes while you wait — one turn-based puzzle, held to a deliberately narrow bar.
The name is the honest description. A nautical or aeronautical almanac is a book of tables you carry to navigate by — part reference, part sky. That is what this firmware is.
Almanac is a hard fork of CrossPoint Reader by Dave Allie and the CrossPoint contributors, used under the MIT licence (see LICENSE). Essentially all of the reader engine is their work, and it is excellent.
The fork exists because Almanac adds things CrossPoint's scope deliberately excludes — a network-backed flight tracker, and its own theming and identity. It takes no further merges from upstream and sends nothing back. If you want the reader without the aviation parts, use CrossPoint — it is the better-maintained, more widely tested project, and Almanac is one person's build.
Almanac's own feature. Press it and it fetches aircraft near a configured home location from OpenSky Network's free anonymous API, then shows them three ways:
- List — distance-sorted, with callsign, bearing and altitude.
- Radar — a plan view with range rings, heading-oriented aircraft markers, and a readout for the selected aircraft.
- Detail — altitude, speed, heading, vertical rate, distance, origin country, and an on-demand aircraft-type and registration lookup via adsbdb.
The home location is set in Flight Tracker settings, either by typing a 5-digit US zip code — geocoded once via Zippopotam.us's free keyless API — or by entering latitude and longitude directly.
Fetches are user-initiated only — there is no background polling — and memory use is bounded regardless of how busy the airspace is, capped at the 20 closest aircraft. Both matter on a device with ~380 KB of RAM and a battery.
Inherited from CrossPoint and unchanged:
- Reader engine: EPUB 2/3 with embedded styles, images, hyphenation, kerning, chapter navigation, footnotes, bookmarks, dictionary lookups (StarDict), go-to-percent, auto page turn, orientation control, focus reading, and KOReader progress sync.
- Formats:
.epub,.xtc/.xtch,.txt,.bmp. - Custom fonts from the SD card.
- Library: folder browser, recent books, long-press delete, cache management.
- Wireless: file-transfer web UI, EPUB optimiser, web settings, WebDAV, AP and STA modes with QR helpers, Calibre wireless, OPDS browser, OTA updates.
- Localisation: 31 UI languages, with RTL support.
One, and it has a frame around it. 2048 sits on the Home screen: four directions, one screen update per move, best score kept across games.
SCOPE.md used to rule games out entirely. Rather than quietly cross that line,
1.1.0 moved it: the Mission gained a third pillar, and a
Diversions section set the bar a diversion has to clear —
all four, not a majority:
- Turn-based. One user action, one screen update. Anything assuming a frame rate is out; this panel refreshes in 770–1720 ms.
- Playable on the buttons we have. Seven physical, four directional. This is what rules out text adventures, despite their being a natural fit for an e-reader.
- No network. Ever. Not even optional.
- No steady-state RAM. State measured in bytes, no heap allocation, nothing retained while you are reading.
2048 qualifies on all four: the board is 16 cells stored as exponents, and the feature allocates no heap at all. The game is written to the SD card once, on exit, behind a dirty flag — never per move, because erase cycles are finite and page turns already spend them.
Emulators are permanently out, and SCOPE.md records why so the argument
does not have to be had twice: every working ESP32 port of an NES- or Game
Boy-class emulator needs PSRAM, which the C3 does not have, at a frame rate this
panel cannot produce.
scripts/generate_dashboard_epub.py fetches weather, headlines, sun and moon
times, and aircraft overhead, renders them into a dated EPUB, and uploads it to
the device over the File Transfer screen you already use.
python3 -m venv .venv && .venv/bin/pip install -r requirements.txt
.venv/bin/python scripts/generate_dashboard_epub.pyNo firmware code exists for this, which is the entire design. SCOPE.md
rules out RSS and background connectivity because of what they cost the device
in RAM, flash and battery; this spends none of them. The radio comes up only for
the File Transfer session you start by hand, and the device's side of it is
opening an EPUB, which it already knew how to do.
Every section fails independently — an unreachable source renders as "unavailable" with a reason, and the page still builds. Setup, daily use and the config reference are in docs/mini-dashboard.md.
Almanac can use Tesserae to show a self-hosted, server-rendered dashboard whenever the reader enters sleep. Xteink X3 and X4 panels support monochrome and four-level grayscale frames.
- Almanac — the theme, an instrument panel. Solid black header and footer bars bookend the screen, the list sits in a single frame with hairline separators, and the selected row gets two-level emphasis: a filled bar plus a heavier stroke hugging it, separated by a 1px gap so the two levels stay distinct on a 1-bit panel. The battery reads as white text in the header bar rather than the usual pictogram, because the shared battery-outline helper only draws black ink. It is the firmware's only look — the inherited Classic, Lyra, Lyra Extended and RoundedRaff themes (and the theme picker) have been removed.
- Its own mark — a compass rose in an instrument bezel — plus boot splash, sleep screen and version identity.
Devices that had another theme saved boot into Almanac after upgrading; the stored choice is ignored and dropped on the next settings save.
Version 1.1.0 — Almanac's own numbering, restarted at 1.0.0 rather than continuing CrossPoint's. Built and flashed on real X4 hardware.
1.1.0 adds 2048 as a Home tile and shows the running firmware version in
Settings. It is the release that opens SCOPE.md's third mission pillar,
"Pass the time", behind a four-part bar for what counts as a diversion. It also
adds a host-side tool that builds a daily weather/news/sky/flights EPUB and
uploads it over File Transfer, without adding any firmware feature to do it.
Devices on 1.0.0 must be flashed over USB once: that version's update check compared uninitialized values and cannot be relied on to find anything. From 1.0.1 onward, Settings → Check for Update works normally.
Verified by CI on every change: the default and sticky build environments
(the two target MCU families — see Build environments);
the host unit-test suite; clang-format (pinned to version 21); and cppcheck,
which fails the build on a single finding of any severity. The gh_release,
gh_release_rc and slim environments differ from default only in logging
level, so CI does not build them per change.
Verified on device: boots to Home, reads settings and caches from the SD card, no off-panel draw errors, no panics, ~162 KB free heap at idle against a ~380 KB total.
Not yet verified on device: the theme across all screens in both orientations, the flight tracker end to end, and reading progress surviving an upgrade. This is one person's firmware on one device — treat it accordingly.
USER_GUIDE.md is the guide for the device itself — button layout, every screen, reading controls, WiFi transfer, Calibre, OPDS, KOReader sync and the settings reference. Start there once it is flashed.
Prebuilt binaries are on the
releases page. Each release
carries the gh_release build for the X4/X3: firmware.bin, plus
bootloader.bin and partitions.bin for a from-scratch flash, and
firmware.elf/firmware.map for symbolicating crash traces.
To update an existing Almanac install, flash firmware.bin at offset
0x10000. For a device coming from stock or CrossPoint, flash all three
binaries with esptool.py:
esptool.py --chip esp32c3 write_flash 0x0 bootloader.bin 0x8000 partitions.bin 0x10000 firmware.binThere is no sticky binary in the releases — the Seeed Sticky is a different
MCU family and has to be built from source (below). Its update check knows
this and always reports no update, so a Sticky is never offered the X4's
firmware; keep it current by reflashing from source.
To go back to CrossPoint or to Xteink's official firmware, use the flash tools at https://crosspointreader.com/#flash-tools.
Note on OTA: the in-firmware update check points at this repository's releases. It must never point at CrossPoint's — it parses the release's
tag_nameas a semantic version and offers anything numerically higher, so every upstream release would read as an available update and installing it would flash CrossPoint over Almanac.
- pioarduino, or VS Code + the pioarduino plugin
- Python 3.8+
clang-format21 (the version CI pins; newer versions format differently)- A USB-C cable that carries data
git clone --recursive https://github.com/jclima/almanacIf you cloned without --recursive — or you are working in a git worktree,
which does not populate submodules — the build will fail with
PackageException: Can not create a symbolic link for freeink-sdk/.... Fix it with:
git submodule update --init --recursivepio run --target uploadpython3 scripts/debugging_monitor.py./bin/clang-format-fix && pio check -e default && pio run -e defaultcmake -S test -B build/test && cmake --build build/test && ctest --test-dir build/test --output-on-failure -jpython3 scripts/generate_logo.py --preview| Env | Purpose |
|---|---|
default |
Development — debug logging, version stamped with branch and SHA |
gh_release |
Production — info-level logging |
gh_release_rc |
Release candidate |
slim |
No serial logging |
sticky |
Seeed Sticky (ESP32-S3, 800×480) — different MCU family |
simulator |
Desktop build (macOS + SDL2), no device — pio run -e simulator -t run_simulator |
Tags are almanac-v<version> — the bare numbers 0.4.0 through 1.5.0 are
already taken by CrossPoint's inherited tags. Cutting one is a button, not a
manual ritual: the Release Train workflow computes the next version,
bumps platformio.ini and the README's version line, drafts release notes,
commits, tags, and pushes. release.yml then builds and publishes exactly as
before.
One-time setup: the workflow fails at its very first step — before touching anything — unless the repository secret
RELEASE_TRAIN_TOKENis set, to a fine-grained PAT scoped to this repo with Contents: Read and write. Reason: GitHub suppresses workflow triggers for events made with the defaultGITHUB_TOKEN, so a tag pushed with it would never startrelease.yml— the tag would exist, nothing would publish, and the failure would look like success.
To run it: Actions tab → Release Train → Run workflow. Leave the
branch on develop — Gate 0 refuses any other ref unless allow_any_ref is
also ticked, since without it a PR branch can otherwise clear the other two
gates on commits that never merged — and pick patch/minor/major. If
you're not sure the moment is right, tick dry_run first — it runs all
three gates below and prints the plan (previous tag, next tag, commit count,
and whether it will keep your hand-written release notes or draft one)
without writing or pushing anything.
Three gates run before anything is touched, and each names itself in its error:
- Gate 0 — refuses unless the dispatched ref is
develop(Release Train must be dispatched against develop).ci.ymlalso runs onpull_request, so without this gate a PR branch with green CI could clear Gate A on commits that never merged intodevelop, and Gate B would pass trivially too against that diverged branch. The escape hatch,allow_any_ref, exists for the rare case where releasing from somewhere else is genuinely intended — leave it off by default. - Gate A — refuses unless every check run on the exact commit being
released completed successfully. Two messages, two different fixes:
No check runs found for <sha>means CI simply hasn't started yet — wait for it, then re-run.Not every check on <sha> completed successfullycovers both a check still running (wait, then re-run) and a check that actually failed — waiting never resolves the failed case; land a fix instead, and release that commit once its own CI is green. - Gate B — refuses if nothing changed since the last tag (
Nothing changed since <tag>— no override for this one; there's simply nothing to release yet), or if every changed path is underdocs/or*.md(Only docs changed since <tag>). The docs-only case alone has an escape hatch,allow_docs_only, but leave it off by default: a docs-only release still publishes a real firmware binary and offers it as an OTA update to every device in the field, identical to the one already installed. Only tick it when that's genuinely the intent, e.g. correcting release notes that already shipped.
Three more checks run just before anything is written, each naming the
problem: the target tag already existing, platformio.ini's version
disagreeing with the last tag, and README.md missing its **Version X.Y.Z** marker. Resolve whatever it names and re-run.
The platformio.ini-disagrees-with-the-last-tag case is usually a release
that was rolled back, not a manual edit. Recovery is the same as for a tag
whose release never published: delete the tag, delete the GitHub Release if
one was published, and revert the release: X.Y.Z commit on develop, then
re-run. Do not just delete the tag — the revert is what brings
platformio.ini back into agreement with the last tag, and it also removes
the commit's drafted docs/release-notes/almanac-vX.Y.Z.md, which is what
lets a corrected draft be generated on retry.
Release notes are generated from the commit log only when
docs/release-notes/almanac-vX.Y.Z.md doesn't already exist. To ship your
own prose instead of the generated draft, write that file by hand before
running the workflow — see
almanac-v1.1.0.md for the bar the
hand-written ones set; the generated fallback is much plainer.
The workflow only bumps the **Version X.Y.Z** marker itself — the rest of
that sentence, and every paragraph after it, still describe the previous
release until a human edits them.
Almanac caches aggressively to the SD card to keep RAM free; the ESP32-C3 has only ~380 KB usable and no PSRAM. Most design decisions follow from that.
Caches, reading progress and bookmarks live in /.crosspoint/ on the SD
card. That directory keeps its original name deliberately: cache identity is a
hash of each book's path beneath it, so renaming it would silently orphan every
book's progress and bookmarks.
See docs/ for the file formats, activity manager, i18n and webserver documentation, docs/mini-dashboard.md for the host-side weather/news/sky/flights page generator, and CLAUDE.md for the engineering constraints any change has to respect.
SCOPE.md is the one to read before proposing a feature: it records what this fork deliberately will and will not do, and the test a new feature has to pass. Almanac is a personal build with a narrow remit, and that document is why some obvious-looking additions are declined.
MIT — see LICENSE. Copyright (c) 2025 Dave Allie for the original CrossPoint Reader work, which this project is built on.