4 Commits

2 changed files with 235 additions and 27 deletions

View File

@@ -0,0 +1,109 @@
# Mainline Renderer \+ ntfy Message Queue for ESP32
## Problem
mainline\.py does heavy work unsuitable for ESP32: 25\+ HTTPS/TLS RSS feeds, OTF font rasterization via Pillow, Google Translate API calls, and complex text layout\. Simultaneously, messages arriving on `ntfy.sh/klubhaus_terminal_mainline` need to interrupt the news ticker on the same device\.
## Architecture: Server \+ Thin Client
Split the system into two halves that are designed together\.
**Server \(mainline\.py `--serve` mode, runs on any always\-on machine\)**
* Reuses existing feed fetching, caching, content filtering, translation, and Pillow font rendering pipeline — no duplication\.
* Pre\-renders each headline into a 1\-bit bitmap strip \(the OTF→half\-block pipeline already produces this as an intermediate step in `_render_line()`\)\.
* Exposes a lightweight HTTP API the ESP32 polls\.
**ESP32 thin client \(Arduino sketch\)**
* Polls the mainline server for pre\-rendered headline bitmaps over plain HTTP \(no TLS needed if on the same LAN\)\.
* Polls `ntfy.sh/klubhaus_terminal_mainline` directly for messages, reusing the proven `NetManager::httpGet()` \+ JSON parsing pattern from DoorbellLogic \(`DoorbellLogic.cpp:155-192`\)\.
* Manages scrolling, gradient coloring, and glitch effects locally \(cheap per\-frame GPU work\)\.
* When an ntfy message arrives, the scroll is paused and the message takes over the display — same interrupt pattern as the doorbell's ALERT→DASHBOARD flow\.
## Server API \(mainline repo\)
New file: `serve.py` \(or `--serve` mode in mainline\.py\)\.
Endpoints:
* `GET /api/headlines` — returns JSON array of headline metadata: `[{"id": 0, "src": "Nature", "ts": "14:30", "width": 280, "height": 16, "bitmap": "<base64 1-bit packed>"}]`\. Bitmaps are 1\-bit\-per\-pixel, row\-major, packed 8px/byte\. The ESP32 applies gradient color locally\.
* `GET /api/config` — returns `{"count": 120, "version": "...", "mode": "news"}` so the ESP32 knows what it's getting\.
* `GET /api/health``{"ok": true, "last_fetch": "...", "headline_count": 120}`
The server renders at a configurable target width \(e\.g\. 800px for Board 3, 320px for Boards 1/2\) via a `--width` flag or query parameter\. Height is fixed per headline by the font size\.
The server refreshes feeds on a timer \(reusing `_SCROLL_DUR` cadence or a longer interval\), re\-renders, and serves the latest set\. The ESP32 polls `/api/headlines` periodically \(e\.g\. every 60s\) and swaps in the new set\.
## Render pipeline \(server side\)
The existing `_render_line()` in mainline\.py already does:
1. `ImageFont.truetype()``ImageDraw.text()` → grayscale `Image`
2. Resize to target height
3. Threshold to 1\-bit \(the `thr = 80` step\)
For the server, we stop at step 3 and pack the 1\-bit data into bytes instead of converting to half\-block Unicode\. This is the exact same pipeline, just with a different output format\. The `_big_wrap()` and `_lr_gradient()` logic stays on the server for layout; gradient *coloring* moves to the ESP32 \(it's just an index lookup per pixel column\)\.
## ESP32 client
### State machine
```warp-runnable-command
BOOT → SCROLL ⇄ MESSAGE
↘ OFF (inactivity timeout)
```
* **BOOT** — WiFi connect, initial headline fetch from server\.
* **SCROLL** — Vertical scroll through pre\-rendered headlines with local gradient \+ glitch\. Polls server for new headlines periodically\. Polls ntfy every 15s\.
* **MESSAGE** — ntfy message arrived\. Scroll paused, message displayed\. Auto\-dismiss after timeout or touch\-dismiss\. Returns to SCROLL\.
* **OFF** — Backlight off after inactivity \(polling continues in background\)\.
### ntfy integration
The ESP32 polls `https://ntfy.sh/klubhaus_terminal_mainline/json?since=20s&poll=1` on the same 15s interval as the doorbell polls its topics\. When a message event arrives:
1. Parse JSON: `{"event": "message", "title": "...", "message": "..."}`
2. Save current scroll position\.
3. Transition to MESSAGE state\.
4. Render message text using the display library's built\-in fonts \(messages are short, no custom font needed\)\.
5. After `MESSAGE_TIMEOUT_MS` \(e\.g\. 30s\) or touch, restore scroll position and resume\.
This is architecturally identical to `DoorbellLogic::onAlert()` → `dismissAlert()`, just with different content\. The ntfy polling runs independently of the server connection, so messages work even if the mainline server is offline \(the device just shows the last cached headlines\)\.
### Headline storage
* Board 3 \(8 MB PSRAM\): store all ~120 headline bitmaps in PSRAM\. At 800px × 16px × 1 bit = 1\.6 KB each → ~192 KB total\. Trivial\.
* Boards 1/2 \(PSRAM TBD\): at 320px × 16px = 640 bytes each → ~77 KB for 120 headlines\. Fits if PSRAM is present\. Without PSRAM, keep ~20 headlines in a ring buffer \(~13 KB\)\.
### Gradient coloring \(local\)
The 12\-step ANSI gradient in mainline\.py maps to 12 RGB565 values:
```warp-runnable-command
const uint16_t GRADIENT[] = {
0xFFFF, // white
0xC7FF, // pale cyan
0x07F9, // bright cyan
0x07C0, // bright lime
...
0x0120, // deep green
0x18C3, // near black
};
```
For each pixel column `x` in the bitmap, the ESP32 picks `GRADIENT[x * 12 / width]` if the bit is set, else background color\. This is a per\-pixel multiply \+ table lookup — fast\.
## File layout
### mainline repo
```warp-runnable-command
mainline.py (existing, unchanged)
serve.py (new — HTTP server, imports mainline rendering functions)
klubhaus-doorbell-hardware.md (existing)
```
`serve.py` imports the rendering functions from mainline\.py \(after refactoring them into importable form — they're currently top\-level but not wrapped in `if __name__`\)\.
### klubhaus\-doorbell repo \(or mainline repo under firmware/\)
```warp-runnable-command
boards/esp32-mainline/
├── esp32-mainline.ino Main sketch
├── board_config.h Display/pin config (copy from target board)
├── secrets.h WiFi creds + server URL
├── MainlineLogic.h/.cpp State machine (replaces DoorbellLogic)
├── HeadlineStore.h/.cpp Bitmap ring buffer in PSRAM
└── NtfyPoller.h/.cpp ntfy.sh polling (extracted from DoorbellLogic pattern)
```
The display driver is reused from the target board \(e\.g\. `DisplayDriverGFX` for Board 3\)\. `MainlineLogic` replaces `DoorbellLogic` as the state machine but follows the same patterns\.
## Branch strategy recommendation
The work spans two repos and has clear dependency ordering\.
### Phase 1 — Finish current branch \(mainline repo\)
**Branch:** `feat/arduino` \(current\)
**Content:** Hardware spec doc\. Already done\.
**Action:** Merge to main when ready\.
### Phase 2 — Server renderer \(mainline repo\)
**Branch:** `feat/renderer` \(branch from main after Phase 1 merges\)
**Content:**
* Refactor mainline\.py rendering functions to be importable \(extract from `__main__` guard\)
* `serve.py` — HTTP server with `/api/headlines`, `/api/config`, `/api/health`
* Bitmap packing utility \(1\-bit row\-major\)
**Why a separate branch:** This changes mainline\.py's structure \(refactoring for imports\) and adds a new entry point\. It's a self\-contained, testable unit — you can verify the API with `curl` before touching any Arduino code\.
### Phase 3 — ESP32 client \(klubhaus\-doorbell repo, or mainline repo\)
**Branch:** `feat/mainline-client` in whichever repo hosts it
**Content:**
* `MainlineLogic` state machine
* `HeadlineStore` bitmap buffer
* `NtfyPoller` for `klubhaus_terminal_mainline`
* Board\-specific sketch for the target board
**Depends on:** Phase 2 \(needs a running server to test against\)
**Repo decision:** If you have push access to klubhaus\-doorbell, it fits naturally as a new board target alongside the existing doorbell sketches — it reuses `NetManager`, `IDisplayDriver`, and the vendored display libraries\. If not, put it under `mainline/firmware/` and vendor the shared KlubhausCore library\.
### Merge order
1. `feat/arduino` → main \(hardware spec\)
2. `feat/renderer` → main \(server\)
3. `feat/mainline-client` → main in whichever repo \(ESP32 client\)
Each phase is independently testable and doesn't block the other until Phase 3 needs a running server\.

View File

@@ -139,25 +139,27 @@ Things that are plausibly relevant but entirely absent from the codebase:
## Questions for board owner ## Questions for board owner
I'm looking at porting [mainline.py](mainline.py) — a scrolling terminal news/poetry stream with OTF-font rendering, RSS feeds, ANSI gradients, and glitch effects — to run on one of these boards. To figure out the right approach I need a few things only you can answer: I'm looking at displaying [mainline.py](mainline.py) — a scrolling news/poetry consciousness stream — on one of these boards. The plan ("Mainline Renderer + ntfy Message Queue for ESP32") uses a **server + thin client** architecture: a Python server pre-renders headlines and serves them via HTTP; the ESP32 just displays pre-rendered bitmaps, applies gradient/glitch effects locally, and polls `ntfy.sh/klubhaus_terminal_mainline` for messages that interrupt the news scroll.
### 1. Which board should I target? To build this I need the following from you:
The three boards have very different constraints: ### 1. Which board?
With a renderer server doing the heavy lifting, all three boards are viable — but the experience differs:
| | Board 1 (2.8″) | Board 2 (4.0″) | Board 3 (4.3″) | | | Board 1 (2.8″) | Board 2 (4.0″) | Board 3 (4.3″) |
|---|---|---|---| |---|---|---|---|
| Resolution | 320 × 240 | 320 × 480 | 800 × 480 | | Resolution | 320 × 240 | 320 × 480 | 800 × 480 |
| Display bus | SPI (40 MHz) | SPI (40 MHz) | RGB parallel (14 MHz pclk) | | Headline buffer (120 items) | ~77 KB | ~77 KB | ~192 KB |
| Flash | 4 MB | 4 MB | 16 MB | | Firehose mode | no (too narrow) | no (SPI too slow) | yes |
| PSRAM | unknown | unknown | 8 MB | | Smooth scroll at 20 FPS | yes (partial updates) | tight (partial updates mandatory) | yes (framebuffer) |
| Full-screen redraw | ~60 ms+ | ~120 ms+ | near-instant (framebuffer) | | Flash for caching | 4 MB (tight) | 4 MB (tight) | 16 MB (9 MB FAT partition) |
Board 3 is the only one with enough RAM and display bandwidth for smooth scrolling with many headlines buffered. Boards 1 & 2 would need aggressive feature cuts. **Which board do you want this on?** Board 3 is the most capable. Boards 1 & 2 work but lose firehose mode and need careful partial-update rendering. **Which board do you want this on?**
### 2. PSRAM on your ESP32-32E boards ### 2. PSRAM on Boards 1 & 2
The build flags say `-DBOARD_HAS_PSRAM` but I can't tell the capacity. Can you check? Easiest way: The build flags say `-DBOARD_HAS_PSRAM` but I can't confirm the capacity. Can you check?
``` ```
// Add to setup() temporarily: // Add to setup() temporarily:
@@ -165,27 +167,124 @@ Serial.printf("PSRAM size: %d bytes\n", ESP.getPsramSize());
Serial.printf("Free PSRAM: %d bytes\n", ESP.getFreePsram()); Serial.printf("Free PSRAM: %d bytes\n", ESP.getFreePsram());
``` ```
If PSRAM is 0 on Boards 1 or 2, those boards can only hold a handful of headlines in 520 KB SRAM (WiFi + TLS eat most of it). If PSRAM is 0, the board can only buffer ~20 headlines in a ring buffer instead of the full ~120 set. (Board 3's 8 MB PSRAM is confirmed — this only matters if you pick Board 1 or 2.)
### 3. Feature priorities ### 3. Network & server hosting
mainline.py does a lot of things that don't map directly to an ESP32 + TFT. Which of these matter to you? The renderer server (`serve.py`) needs Python 3 + Pillow, internet access (for RSS feeds), and network access to the ESP32.
- **RSS headline scrolling** — the core experience. How many feeds? All ~25, or a curated subset? - **Where will the server run?** Raspberry Pi, NAS, always-on desktop, cloud VM?
- **OTF font rendering** — mainline uses Pillow to rasterize a custom `.otf` font into half-block characters. On ESP32 this would become either bitmap fonts or a pre-rendered glyph atlas baked into flash. Is the specific font important, or is the aesthetic (large, stylized text) what matters? - **Same LAN as the ESP32?** If yes, the ESP32 can use plain HTTP (no TLS overhead). If remote, we'd need HTTPS (~4050 KB RAM per connection).
- **Left-to-right color gradient** — the white-hot → green → black fade. Easy to replicate in RGB565 on the TFT. Keep? - **Server discovery:** Hardcoded IP in `secrets.h`, mDNS (`mainline.local`), or a DNS name?
- **Glitch / noise effects** — the ░▒▓█ and katakana rain. Keep? - **WiFi credentials:** Use the existing multi-network setup from the doorbell firmware, or a specific SSID?
- **Mic-reactive glitch intensity** — none of these boards have a microphone. Drop entirely, or substitute with something else (e.g. touch-reactive, or time-of-day reactive)?
- **Auto-translation** — mainline translates headlines for region-specific sources via Google Translate. This requires HTTPS calls that are expensive on ESP32 (~4050 KB RAM per TLS connection). Keep, pre-translate on a server, or drop?
- **Poetry mode** — fetches full Gutenberg texts. These are large (100+ KB each). Cache to SD card, trim to a small set, or drop?
- **Content filtering** — the sports/vapid regex filter. Trivial to keep.
- **Boot sequence animation** — the typewriter-style boot log. Keep?
### 4. Network environment ### 4. ESP32 client repo
- Will the board be on a WiFi network that can reach the public internet (RSS feeds, Google Translate, ntfy.sh)? The ESP32 sketch reuses `NetManager`, `IDisplayDriver`, and vendored display libraries from klubhaus-doorbell. Two options:
- Is there a preferred SSID / network, or should it use the existing multi-network setup from the doorbell firmware?
### 5. SD card availability - **klubhaus-doorbell repo** — natural fit as a new board target (`boards/esp32-mainline/`). Requires push access or a PR.
- **mainline repo** — under `firmware/esp32-mainline/` with a vendored copy of KlubhausCore. Self-contained but duplicates shared code.
All three boards have TF card slots but the doorbell firmware doesn't use them. A microSD card would be useful for caching fonts, pre-rendered glyph atlases, or translated headline buffers. **Is there an SD card in the board you'd want to target?** **Which repo should host the ESP32 client?**
### 5. Display features
With the renderer handling RSS fetching, translation, content filtering, and font rendering server-side, the remaining feature questions are about what the ESP32 displays locally:
- **Left-to-right color gradient** — the white-hot → green → black fade, applied per-pixel on the ESP32. Keep?
- **Glitch / noise effects** — random block characters and katakana rain between headlines. Keep?
- **Glitch reactivity** — mainline.py uses a microphone (none on these boards). Substitute with touch-reactive, time-of-day reactive, or just random?
- **Firehose mode** — dense data ticker at the bottom (only viable on Board 3). Want it?
- **Boot sequence animation** — typewriter-style status log during startup. Keep?
- **Poetry mode** — the server can serve Gutenberg text instead of RSS. Want both modes available, or just news?
### 6. ntfy message queue
The ESP32 polls `ntfy.sh/klubhaus_terminal_mainline` directly for messages that interrupt the news scroll.
- **Is `klubhaus_terminal_mainline` the right topic name?** Or match the doorbell convention (`ALERT_klubhaus_topic`, etc.)?
- **Who sends messages?** Just you (manual `curl`), a bot, other people?
- **Display duration:** How long before auto-dismiss? The doorbell uses 120s for alerts. 30s for terminal messages? Touch-to-dismiss regardless?
- **Priority levels?** ntfy supports 15. Should high-priority messages turn on the backlight if the display is OFF, or show in a different color?
- **Message history on boot?** Show recent messages from the topic, or only messages arriving while running? The doorbell uses `?since=20s`. You might want `?since=5m` for a message board feel.
### 7. Server-offline fallback
If the renderer server goes down, what should the ESP32 do?
- **A.** Show last cached headlines indefinitely (ntfy messages still work independently).
- **B.** Show a "no signal" screen after a timeout, keep polling.
- **C.** Fall back to ntfy messages + clock only.
### 8. Scroll and layout
- **Vertical scroll (like mainline.py)?** Or horizontal marquee?
- **Speed:** mainline.py uses ~3.75s per headline. Same pace, or different for ambient display?
- **Font height:** The server pre-renders at a configurable pixel height. On Board 3 (480px tall), 3248px headlines would match mainline.py's feel. On Boards 1/2, 1624px. What looks right to you?
### 9. Font and aesthetics
mainline.py uses `CSBishopDrawn-Italic.otf`. The server renders bitmaps with this font, so the ESP32 never needs the font file itself.
- **Can you provide this font to whoever hosts the server?** Or should it fall back to a freely available alternative?
- **Any license concern** with serving the rendered bitmaps (not the font file) over HTTP?
### 10. SD card
All three boards have TF card slots (unused by the doorbell firmware). An SD card would let the ESP32 cache headline bitmaps for instant boot and offline operation. **Is there an SD card in the board you'd pick?**
---
## Recent mainline.py changes and port implications
*Updated after merge of latest `main` — three new features affect the port calculus.*
### Local headline cache (`--refresh`)
mainline.py now caches fetched headlines to a JSON file (`.mainline_cache_{MODE}.json`) and skips network fetches on subsequent runs unless `--refresh` is passed. This is good news for an ESP32 port:
- **Faster boot.** The device could load cached headlines from flash/SD on power-up and start scrolling immediately, then refresh feeds in the background.
- **Offline operation.** If WiFi drops, the device can still display cached content.
- **Storage question.** A typical cache is ~200500 headlines × ~100 bytes ≈ 2050 KB of JSON. That fits comfortably in Board 3's 9 MB FAT partition or on SD. On Boards 1 & 2 (4 MB flash), it would need SPIFFS/LittleFS and would compete with app code for flash space.
**Recommendation:** If targeting Boards 1 or 2, confirm how much flash is left after the app partition. Board 3's `app3M_fat9M_16MB` partition scheme already has 9 MB of FAT storage — more than enough.
### Firehose mode (`--firehose`)
mainline.py now has a `--firehose` flag that adds a 12-row dense data zone at the bottom of the viewport, cycling rapidly (every frame) through raw headline text, glitch noise, status lines, and headline fragments. This is the most demanding new feature for an ESP32 port:
- **Frame rate.** The firehose zone redraws completely every frame at 20 FPS. On SPI displays (Boards 1 & 2), rewriting 12 rows × 320 or 480 pixels at 40 MHz SPI would consume a significant fraction of each 50 ms frame budget. On Board 3's RGB framebuffer, this is trivial.
- **Randomness.** Each firehose line calls `random.choice()` over the full item pool. On ESP32, `esp_random()` is hardware-backed and fast, but iterating the full pool each frame needs the pool in RAM.
- **Visual density.** At 320px wide (Boards 1 & 2), 12 rows of dense data may be illegible. At 800px (Board 3), it works.
**Recommendation:** Firehose mode is only practical on Board 3. On Boards 1 & 2, consider either dropping it or replacing it with a single-line ticker.
### Fixed 20 FPS frame timing
The scroll loop now uses `time.monotonic()` with a 50 ms frame budget (`_FRAME_DT = 0.05`) and a scroll accumulator instead of sleeping per scroll step. This is actually a better fit for ESP32 than the old approach — it maps cleanly to a `millis()`-based main loop:
```
// ESP32 equivalent pattern
void loop() {
uint32_t t0 = millis();
// ... render frame ...
uint32_t elapsed = millis() - t0;
if (elapsed < FRAME_DT_MS) delay(FRAME_DT_MS - elapsed);
}
```
**Estimated per-frame budgets:**
| | Board 1 (320×240) | Board 2 (320×480) | Board 3 (800×480) |
|---|---|---|---|
| Full-screen SPI flush | ~30 ms | ~60 ms | n/a (framebuffer) |
| Partial update (12 rows) | ~4 ms | ~4 ms | n/a |
| Remaining CPU budget (of 50 ms) | ~20 ms | ~0 ms (tight) | ~45 ms |
Board 2 at 20 FPS with a full-screen redraw each frame would have essentially zero headroom. Partial updates (dirty-rect tracking) would be mandatory.
### Port-relevant implications
- **Firehose mode** is only practical on Board 3 (see §5 in Questions). On Boards 1 & 2, consider a single-line ticker instead.
- **Headline caching** maps to the ESP32 storing bitmap data from the server to flash/SD for instant boot and offline fallback (see §10 in Questions).
- **20 FPS frame timing** maps cleanly to a `millis()`-based ESP32 main loop. Board 2 would have zero headroom without partial updates.