fix: resolve terminal display wobble and effect dimension stability
- Fix TerminalDisplay: add screen clear each frame (cursor home + erase down) - Fix CameraStage: use set_canvas_size instead of read-only viewport properties - Fix Glitch effect: preserve visible line lengths, remove cursor positioning - Fix Fade effect: return original line when fade=0 instead of empty string - Fix Noise effect: use input line length instead of terminal_width - Remove HUD effect from all presets (redundant with border FPS display) - Add regression tests for effect dimension stability - Add docs/ARCHITECTURE.md with Mermaid diagrams - Add mise tasks: diagram-ascii, diagram-validate, diagram-check - Move markdown docs to docs/ (ARCHITECTURE, Refactor, hardware specs) - Remove redundant requirements files (use pyproject.toml) - Add *.dot and *.png to .gitignore Closes #25
This commit is contained in:
156
docs/ARCHITECTURE.md
Normal file
156
docs/ARCHITECTURE.md
Normal file
@@ -0,0 +1,156 @@
|
||||
# Mainline Architecture Diagrams
|
||||
|
||||
> These diagrams use Mermaid. Render with: `npx @mermaid-js/mermaid-cli -i ARCHITECTURE.md` or view in GitHub/GitLab/Notion.
|
||||
|
||||
## Class Hierarchy (Mermaid)
|
||||
|
||||
```mermaid
|
||||
classDiagram
|
||||
class Stage {
|
||||
<<abstract>>
|
||||
+str name
|
||||
+set[str] capabilities
|
||||
+set[str] dependencies
|
||||
+process(data, ctx) Any
|
||||
}
|
||||
|
||||
Stage <|-- DataSourceStage
|
||||
Stage <|-- CameraStage
|
||||
Stage <|-- FontStage
|
||||
Stage <|-- ViewportFilterStage
|
||||
Stage <|-- EffectPluginStage
|
||||
Stage <|-- DisplayStage
|
||||
Stage <|-- SourceItemsToBufferStage
|
||||
Stage <|-- PassthroughStage
|
||||
Stage <|-- ImageToTextStage
|
||||
Stage <|-- CanvasStage
|
||||
|
||||
class EffectPlugin {
|
||||
<<abstract>>
|
||||
+str name
|
||||
+EffectConfig config
|
||||
+process(buf, ctx) list[str]
|
||||
+configure(config) None
|
||||
}
|
||||
|
||||
EffectPlugin <|-- NoiseEffect
|
||||
EffectPlugin <|-- FadeEffect
|
||||
EffectPlugin <|-- GlitchEffect
|
||||
EffectPlugin <|-- FirehoseEffect
|
||||
EffectPlugin <|-- CropEffect
|
||||
EffectPlugin <|-- TintEffect
|
||||
|
||||
class Display {
|
||||
<<protocol>>
|
||||
+int width
|
||||
+int height
|
||||
+init(width, height, reuse)
|
||||
+show(buffer, border)
|
||||
+clear() None
|
||||
+cleanup() None
|
||||
}
|
||||
|
||||
Display <|.. TerminalDisplay
|
||||
Display <|.. NullDisplay
|
||||
Display <|.. PygameDisplay
|
||||
Display <|.. WebSocketDisplay
|
||||
Display <|.. SixelDisplay
|
||||
|
||||
class Camera {
|
||||
+int viewport_width
|
||||
+int viewport_height
|
||||
+CameraMode mode
|
||||
+apply(buffer, width, height) list[str]
|
||||
}
|
||||
|
||||
class Pipeline {
|
||||
+dict[str, Stage] stages
|
||||
+PipelineContext context
|
||||
+execute(data) StageResult
|
||||
}
|
||||
|
||||
Pipeline --> Stage
|
||||
Stage --> Display
|
||||
```
|
||||
|
||||
## Data Flow (Mermaid)
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
DataSource[Data Source] --> DataSourceStage
|
||||
DataSourceStage --> FontStage
|
||||
FontStage --> CameraStage
|
||||
CameraStage --> EffectStages
|
||||
EffectStages --> DisplayStage
|
||||
DisplayStage --> TerminalDisplay
|
||||
DisplayStage --> BrowserWebSocket
|
||||
DisplayStage --> SixelDisplay
|
||||
DisplayStage --> NullDisplay
|
||||
```
|
||||
|
||||
## Effect Chain (Mermaid)
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
InputBuffer --> NoiseEffect
|
||||
NoiseEffect --> FadeEffect
|
||||
FadeEffect --> GlitchEffect
|
||||
GlitchEffect --> FirehoseEffect
|
||||
FirehoseEffect --> Output
|
||||
```
|
||||
|
||||
> **Note:** Each effect must preserve buffer dimensions (line count and visible width).
|
||||
|
||||
## Stage Capabilities
|
||||
|
||||
```mermaid
|
||||
flowchart TB
|
||||
subgraph "Capability Resolution"
|
||||
D[DataSource<br/>provides: source.*]
|
||||
C[Camera<br/>provides: render.output]
|
||||
E[Effects<br/>provides: render.effect]
|
||||
DIS[Display<br/>provides: display.output]
|
||||
end
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Legacy ASCII Diagrams
|
||||
|
||||
### Stage Inheritance
|
||||
```
|
||||
Stage(ABC)
|
||||
├── DataSourceStage
|
||||
├── CameraStage
|
||||
├── FontStage
|
||||
├── ViewportFilterStage
|
||||
├── EffectPluginStage
|
||||
├── DisplayStage
|
||||
├── SourceItemsToBufferStage
|
||||
├── PassthroughStage
|
||||
├── ImageToTextStage
|
||||
└── CanvasStage
|
||||
```
|
||||
|
||||
### Display Backends
|
||||
```
|
||||
Display(Protocol)
|
||||
├── TerminalDisplay
|
||||
├── NullDisplay
|
||||
├── PygameDisplay
|
||||
├── WebSocketDisplay
|
||||
├── SixelDisplay
|
||||
├── KittyDisplay
|
||||
└── MultiDisplay
|
||||
```
|
||||
|
||||
### Camera Modes
|
||||
```
|
||||
Camera
|
||||
├── FEED # Static view
|
||||
├── SCROLL # Horizontal scroll
|
||||
├── VERTICAL # Vertical scroll
|
||||
├── HORIZONTAL # Same as scroll
|
||||
├── OMNI # Omnidirectional
|
||||
├── FLOATING # Floating particles
|
||||
└── BOUNCE # Bouncing camera
|
||||
109
docs/Mainline Renderer + ntfy Message Queue for ESP32.md
Normal file
109
docs/Mainline Renderer + ntfy Message Queue for ESP32.md
Normal 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\.
|
||||
255
docs/Refactor mainline.md
Normal file
255
docs/Refactor mainline.md
Normal file
@@ -0,0 +1,255 @@
|
||||
#
|
||||
|
||||
Refactor mainline\.py into modular package
|
||||
|
||||
## Problem
|
||||
|
||||
`mainline.py` is a single 1085\-line file with ~10 interleaved concerns\. This prevents:
|
||||
|
||||
* Reusing the ntfy doorbell interrupt in other visualizers
|
||||
* Importing the render pipeline from `serve.py` \(future ESP32 HTTP server\)
|
||||
* Testing any concern in isolation
|
||||
* Porting individual layers to Rust independently
|
||||
|
||||
## Target structure
|
||||
|
||||
```warp-runnable-command
|
||||
mainline.py # thin entrypoint: venv bootstrap → engine.app.main()
|
||||
engine/
|
||||
__init__.py
|
||||
config.py # constants, CLI flags, glyph tables
|
||||
sources.py # FEEDS, POETRY_SOURCES, SOURCE_LANGS, _LOCATION_LANGS
|
||||
terminal.py # ANSI codes, tw/th, type_out, slow_print, boot_ln
|
||||
filter.py # HTML stripping, content filter (_SKIP_RE)
|
||||
translate.py # Google Translate wrapper + location→language detection
|
||||
render.py # OTF font loading, _render_line, _big_wrap, _lr_gradient, _make_block
|
||||
effects.py # noise, glitch_bar, _fade_line, _vis_trunc, _firehose_line, _next_headline
|
||||
fetch.py # RSS/Gutenberg fetching, cache load/save
|
||||
ntfy.py # NtfyPoller class — standalone, zero internal deps
|
||||
mic.py # MicMonitor class — standalone
|
||||
scroll.py # stream() frame loop + message rendering
|
||||
app.py # main(), TITLE art, boot sequence, signal handler
|
||||
```
|
||||
|
||||
The package is named `engine/` to avoid a naming conflict with the `mainline.py` entrypoint\.
|
||||
|
||||
## Module dependency graph
|
||||
|
||||
```warp-runnable-command
|
||||
config ← (nothing)
|
||||
sources ← (nothing)
|
||||
terminal ← (nothing)
|
||||
filter ← (nothing)
|
||||
translate ← sources
|
||||
render ← config, terminal, sources
|
||||
effects ← config, terminal, sources
|
||||
fetch ← config, sources, filter, terminal
|
||||
ntfy ← (nothing — stdlib only, fully standalone)
|
||||
mic ← (nothing — sounddevice only)
|
||||
scroll ← config, terminal, render, effects, ntfy, mic
|
||||
app ← everything above
|
||||
```
|
||||
|
||||
Critical property: **ntfy\.py and mic\.py have zero internal dependencies**, making ntfy reusable by any visualizer\.
|
||||
|
||||
## Module details
|
||||
|
||||
### mainline\.py \(entrypoint — slimmed down\)
|
||||
|
||||
Keeps only the venv bootstrap \(lines 10\-38\) which must run before any third\-party imports\. After bootstrap, delegates to `engine.app.main()`\.
|
||||
|
||||
### engine/config\.py
|
||||
|
||||
From current mainline\.py:
|
||||
|
||||
* `HEADLINE_LIMIT`, `FEED_TIMEOUT`, `MIC_THRESHOLD_DB` \(lines 55\-57\)
|
||||
* `MODE`, `FIREHOSE` CLI flag parsing \(lines 58\-59\)
|
||||
* `NTFY_TOPIC`, `NTFY_POLL_INTERVAL`, `MESSAGE_DISPLAY_SECS` \(lines 62\-64\)
|
||||
* `_FONT_PATH`, `_FONT_SZ`, `_RENDER_H` \(lines 147\-150\)
|
||||
* `_SCROLL_DUR`, `_FRAME_DT`, `FIREHOSE_H` \(lines 505\-507\)
|
||||
* `GLITCH`, `KATA` glyph tables \(lines 143\-144\)
|
||||
|
||||
### engine/sources\.py
|
||||
|
||||
Pure data, no logic:
|
||||
|
||||
* `FEEDS` dict \(lines 102\-140\)
|
||||
* `POETRY_SOURCES` dict \(lines 67\-80\)
|
||||
* `SOURCE_LANGS` dict \(lines 258\-266\)
|
||||
* `_LOCATION_LANGS` dict \(lines 269\-289\)
|
||||
* `_SCRIPT_FONTS` dict \(lines 153\-165\)
|
||||
* `_NO_UPPER` set \(line 167\)
|
||||
|
||||
### engine/terminal\.py
|
||||
|
||||
ANSI primitives and terminal I/O:
|
||||
|
||||
* All ANSI constants: `RST`, `BOLD`, `DIM`, `G_HI`, `G_MID`, `G_LO`, `G_DIM`, `W_COOL`, `W_DIM`, `W_GHOST`, `C_DIM`, `CLR`, `CURSOR_OFF`, `CURSOR_ON` \(lines 83\-99\)
|
||||
* `tw()`, `th()` \(lines 223\-234\)
|
||||
* `type_out()`, `slow_print()`, `boot_ln()` \(lines 355\-386\)
|
||||
|
||||
### engine/filter\.py
|
||||
|
||||
* `_Strip` HTML parser class \(lines 205\-214\)
|
||||
* `strip_tags()` \(lines 217\-220\)
|
||||
* `_SKIP_RE` compiled regex \(lines 322\-346\)
|
||||
* `_skip()` predicate \(lines 349\-351\)
|
||||
|
||||
### engine/translate\.py
|
||||
|
||||
* `_TRANSLATE_CACHE` \(line 291\)
|
||||
* `_detect_location_language()` \(lines 294\-300\) — imports `_LOCATION_LANGS` from sources
|
||||
* `_translate_headline()` \(lines 303\-319\)
|
||||
|
||||
### engine/render\.py
|
||||
|
||||
The OTF→terminal pipeline\. This is exactly what `serve.py` will import to produce 1\-bit bitmaps for the ESP32\.
|
||||
|
||||
* `_GRAD_COLS` gradient table \(lines 169\-182\)
|
||||
* `_font()`, `_font_for_lang()` with lazy\-load \+ cache \(lines 185\-202\)
|
||||
* `_render_line()` — OTF text → half\-block terminal rows \(lines 567\-605\)
|
||||
* `_big_wrap()` — word\-wrap \+ render \(lines 608\-636\)
|
||||
* `_lr_gradient()` — apply left→right color gradient \(lines 639\-656\)
|
||||
* `_make_block()` — composite: translate → render → colorize a headline \(lines 718\-756\)\. Imports from translate, sources\.
|
||||
|
||||
### engine/effects\.py
|
||||
|
||||
Visual effects applied during the frame loop:
|
||||
|
||||
* `noise()` \(lines 237\-245\)
|
||||
* `glitch_bar()` \(lines 248\-252\)
|
||||
* `_fade_line()` — probabilistic character dissolve \(lines 659\-680\)
|
||||
* `_vis_trunc()` — ANSI\-aware width truncation \(lines 683\-701\)
|
||||
* `_firehose_line()` \(lines 759\-801\) — imports config\.MODE, sources\.FEEDS/POETRY\_SOURCES
|
||||
* `_next_headline()` — pool management \(lines 704\-715\)
|
||||
|
||||
### engine/fetch\.py
|
||||
|
||||
* `fetch_feed()` \(lines 390\-396\)
|
||||
* `fetch_all()` \(lines 399\-426\) — imports filter\.\_skip, filter\.strip\_tags, terminal\.boot\_ln
|
||||
* `_fetch_gutenberg()` \(lines 429\-456\)
|
||||
* `fetch_poetry()` \(lines 459\-472\)
|
||||
* `_cache_path()`, `_load_cache()`, `_save_cache()` \(lines 476\-501\)
|
||||
|
||||
### engine/ntfy\.py — standalone, reusable
|
||||
|
||||
Refactored from the current globals \+ thread \(lines 531\-564\) and the message rendering section of `stream()` \(lines 845\-909\) into a class:
|
||||
|
||||
```python
|
||||
class NtfyPoller:
|
||||
def __init__(self, topic_url, poll_interval=15, display_secs=30):
|
||||
...
|
||||
def start(self):
|
||||
"""Start background polling thread."""
|
||||
def get_active_message(self):
|
||||
"""Return (title, body, timestamp) if a message is active and not expired, else None."""
|
||||
def dismiss(self):
|
||||
"""Manually dismiss current message."""
|
||||
```
|
||||
|
||||
Dependencies: `urllib.request`, `json`, `threading`, `time` — all stdlib\. No internal imports\.
|
||||
Other visualizers use it like:
|
||||
|
||||
```python
|
||||
from engine.ntfy import NtfyPoller
|
||||
poller = NtfyPoller("https://ntfy.sh/my_topic/json?since=20s&poll=1")
|
||||
poller.start()
|
||||
# in render loop:
|
||||
msg = poller.get_active_message()
|
||||
if msg:
|
||||
title, body, ts = msg
|
||||
render_my_message(title, body) # visualizer-specific
|
||||
```
|
||||
|
||||
### engine/mic\.py — standalone
|
||||
|
||||
Refactored from the current globals \(lines 508\-528\) into a class:
|
||||
|
||||
```python
|
||||
class MicMonitor:
|
||||
def __init__(self, threshold_db=50):
|
||||
...
|
||||
def start(self) -> bool:
|
||||
"""Start background mic stream. Returns False if unavailable."""
|
||||
def stop(self):
|
||||
...
|
||||
@property
|
||||
def db(self) -> float:
|
||||
"""Current RMS dB level."""
|
||||
@property
|
||||
def excess(self) -> float:
|
||||
"""dB above threshold (clamped to 0)."""
|
||||
```
|
||||
|
||||
Dependencies: `sounddevice`, `numpy` \(both optional — graceful fallback\)\.
|
||||
|
||||
### engine/scroll\.py
|
||||
|
||||
The `stream()` function \(lines 804\-990\)\. Receives its dependencies via arguments or imports:
|
||||
|
||||
* `stream(items, ntfy_poller, mic_monitor, config)` or similar
|
||||
* Message rendering \(lines 855\-909\) stays here since it's terminal\-display\-specific — a different visualizer would render messages differently
|
||||
|
||||
### engine/app\.py
|
||||
|
||||
The orchestrator:
|
||||
|
||||
* `TITLE` ASCII art \(lines 994\-1001\)
|
||||
* `main()` \(lines 1004\-1084\): CLI handling, signal setup, boot animation, fetch, wire up ntfy/mic/scroll
|
||||
|
||||
## Execution order
|
||||
|
||||
### Step 1: Create engine/ package skeleton
|
||||
|
||||
Create `engine/__init__.py` and all empty module files\.
|
||||
|
||||
### Step 2: Extract pure data modules \(zero\-dep\)
|
||||
|
||||
Move constants and data dicts into `config.py`, `sources.py`\. These have no logic dependencies\.
|
||||
|
||||
### Step 3: Extract terminal\.py
|
||||
|
||||
Move ANSI codes and terminal I/O helpers\. No internal deps\.
|
||||
|
||||
### Step 4: Extract filter\.py and translate\.py
|
||||
|
||||
Both are small, self\-contained\. translate imports from sources\.
|
||||
|
||||
### Step 5: Extract render\.py
|
||||
|
||||
Font loading \+ the OTF→half\-block pipeline\. Imports from config, terminal, sources\. This is the module `serve.py` will later import\.
|
||||
|
||||
### Step 6: Extract effects\.py
|
||||
|
||||
Visual effects\. Imports from config, terminal, sources\.
|
||||
|
||||
### Step 7: Extract fetch\.py
|
||||
|
||||
Feed/Gutenberg fetching \+ caching\. Imports from config, sources, filter, terminal\.
|
||||
|
||||
### Step 8: Extract ntfy\.py and mic\.py
|
||||
|
||||
Refactor globals\+threads into classes\. Zero internal deps\.
|
||||
|
||||
### Step 9: Extract scroll\.py
|
||||
|
||||
The frame loop\. Last to extract because it depends on everything above\.
|
||||
|
||||
### Step 10: Extract app\.py
|
||||
|
||||
The `main()` function, boot sequence, signal handler\. Wire up all modules\.
|
||||
|
||||
### Step 11: Slim down mainline\.py
|
||||
|
||||
Keep only venv bootstrap \+ `from engine.app import main; main()`\.
|
||||
|
||||
### Step 12: Verify
|
||||
|
||||
Run `python3 mainline.py`, `python3 mainline.py --poetry`, and `python3 mainline.py --firehose` to confirm identical behavior\. No behavioral changes in this refactor\.
|
||||
|
||||
## What this enables
|
||||
|
||||
* **serve\.py** \(future\): `from engine.render import _render_line, _big_wrap` \+ `from engine.fetch import fetch_all` — imports the pipeline directly
|
||||
* **Other visualizers**: `from engine.ntfy import NtfyPoller` — doorbell feature with no coupling to mainline's scroll engine
|
||||
* **Rust port**: Clear boundaries for what to port first \(ntfy client, render pipeline\) vs what stays in Python \(fetching, caching — the server side\)
|
||||
* **Testing**: Each module can be unit\-tested in isolation
|
||||
290
docs/klubhaus-doorbell-hardware.md
Normal file
290
docs/klubhaus-doorbell-hardware.md
Normal file
@@ -0,0 +1,290 @@
|
||||
# Klubhaus Doorbell — Hardware Spec Sheet
|
||||
|
||||
Derived from source code analysis of [klubhaus-doorbell](https://git.notsosm.art/david/klubhaus-doorbell.git) and manufacturer datasheets.
|
||||
|
||||
The project targets **three** ESP32-based development boards, each with an integrated TFT display and touch input. All three are all-in-one "ESP32 + screen" modules (not a bare Arduino with a separate breakout).
|
||||
|
||||
---
|
||||
|
||||
## Board 1 — ESP32-32E (2.8″ ILI9341)
|
||||
|
||||
| Attribute | Value |
|
||||
|---|---|
|
||||
| **Manufacturer** | Hosyond (likely) |
|
||||
| **Module** | ESP32-32E (ESP32-WROOM-32 family) |
|
||||
| **SoC** | ESP32-D0WD-V3, Xtensa dual-core 32-bit LX6 |
|
||||
| **Max clock** | 240 MHz |
|
||||
| **SRAM** | 520 KB |
|
||||
| **ROM** | 448 KB |
|
||||
| **Flash** | 4 MB (external QSPI) — per FQBN `FlashSize=4M` |
|
||||
| **PSRAM** | Flagged in build (`-DBOARD_HAS_PSRAM`) — likely present but unconfirmed capacity |
|
||||
| **WiFi** | 2.4 GHz 802.11 b/g/n |
|
||||
| **Bluetooth** | v4.2 BR/EDR + BLE |
|
||||
| **Display driver** | ILI9341 |
|
||||
| **Display size** | ~2.8″ (inferred from `.crushmemory` note "original 2.8″ ILI9341") |
|
||||
| **Resolution** | 320 × 240 (landscape rotation 1) |
|
||||
| **Display interface** | 4-line SPI |
|
||||
| **Color depth** | 65K (RGB565) |
|
||||
| **Touch** | XPT2046 resistive (SPI) |
|
||||
| **Backlight GPIO** | 22 |
|
||||
| **SPI pins** | MOSI 23, SCLK 18, CS 5, DC 27, RST 33 |
|
||||
| **Touch CS** | GPIO 14 |
|
||||
| **SPI clock** | 40 MHz (display), 20 MHz (read), 2.5 MHz (touch) |
|
||||
| **Serial baud** | 115200 |
|
||||
| **USB** | Type-C (programming / power) |
|
||||
| **Display library** | TFT_eSPI (vendored) |
|
||||
|
||||
---
|
||||
|
||||
## Board 2 — ESP32-32E-4″ (Hosyond 4.0″ ST7796)
|
||||
|
||||
| Attribute | Value |
|
||||
|---|---|
|
||||
| **Manufacturer** | Hosyond |
|
||||
| **Module** | ESP32-32E |
|
||||
| **SoC** | ESP32-D0WD-V3, Xtensa dual-core 32-bit LX6 |
|
||||
| **Max clock** | 240 MHz |
|
||||
| **SRAM** | 520 KB |
|
||||
| **ROM** | 448 KB |
|
||||
| **Flash** | 4 MB (external QSPI) — per FQBN `FlashSize=4M` |
|
||||
| **PSRAM** | Flagged in build (`-DBOARD_HAS_PSRAM`) — likely present but unconfirmed capacity |
|
||||
| **WiFi** | 2.4 GHz 802.11 b/g/n |
|
||||
| **Bluetooth** | v4.2 BR/EDR + BLE |
|
||||
| **Display driver** | ST7796S |
|
||||
| **Display size** | 4.0″ |
|
||||
| **Resolution** | 320 × 480 (landscape rotation 1) |
|
||||
| **Display interface** | 4-line SPI |
|
||||
| **Color depth** | 262K (RGB666) per manufacturer; firmware uses RGB565 |
|
||||
| **Touch** | XPT2046 resistive (SPI) |
|
||||
| **Backlight GPIO** | 27 (active HIGH) |
|
||||
| **SPI pins** | MISO 12, MOSI 13, SCLK 14, CS 15, DC 2, RST tied to EN |
|
||||
| **Touch CS / IRQ** | GPIO 33 / GPIO 36 |
|
||||
| **SPI clock** | 40 MHz (display), 20 MHz (read), 2.5 MHz (touch) |
|
||||
| **Serial baud** | 115200 |
|
||||
| **Physical size** | 60.88 × 111.11 × 5.65 mm |
|
||||
| **USB** | Type-C |
|
||||
| **Display library** | TFT_eSPI (vendored) |
|
||||
| **Reference** | [lcdwiki.com/4.0inch_ESP32-32E_Display](https://www.lcdwiki.com/4.0inch_ESP32-32E_Display) |
|
||||
|
||||
---
|
||||
|
||||
## Board 3 — Waveshare ESP32-S3-Touch-LCD-4.3
|
||||
|
||||
| Attribute | Value |
|
||||
|---|---|
|
||||
| **Manufacturer** | Waveshare |
|
||||
| **Module** | ESP32-S3-WROOM-1-N16R8 |
|
||||
| **SoC** | ESP32-S3, Xtensa dual-core 32-bit LX7 |
|
||||
| **Max clock** | 240 MHz |
|
||||
| **SRAM** | 512 KB |
|
||||
| **ROM** | 384 KB |
|
||||
| **PSRAM** | 8 MB (onboard) |
|
||||
| **Flash** | 16 MB — per FQBN `FlashSize=16M` |
|
||||
| **WiFi** | 2.4 GHz 802.11 b/g/n |
|
||||
| **Bluetooth** | v5.0 BLE |
|
||||
| **Antenna** | Onboard PCB antenna |
|
||||
| **Display driver** | RGB parallel (16-bit, 5-6-5 R/G/B channel split) |
|
||||
| **Display size** | 4.3″ |
|
||||
| **Resolution** | 800 × 480 |
|
||||
| **Color depth** | 65K |
|
||||
| **Touch** | GT911 capacitive, 5-point, I2C |
|
||||
| **Touch I2C** | SDA 8, SCL 9, INT 4, addr 0x14 (runtime) / 0x5D (defined in config) |
|
||||
| **I/O expander** | CH422G (I2C, shared bus with touch) — controls backlight, LCD reset, SD CS, etc. |
|
||||
| **Pixel clock** | 14 MHz (LovyanGFX Bus_RGB) |
|
||||
| **USB** | Type-C (UART via CH343P + native USB HW CDC on GPIO 19/20) |
|
||||
| **Peripheral interfaces** | CAN, RS485, I2C, UART, TF card slot (SPI via CH422G EXIO4), ADC sensor header |
|
||||
| **Partition scheme** | `app3M_fat9M_16MB` (3 MB app, 9 MB FAT) |
|
||||
| **Serial baud** | 115200 |
|
||||
| **Display library** | LovyanGFX (vendored) |
|
||||
| **Reference** | [waveshare.com/wiki/ESP32-S3-Touch-LCD-4.3](https://www.waveshare.com/wiki/ESP32-S3-Touch-LCD-4.3) |
|
||||
|
||||
### RGB bus pin map (Board 3)
|
||||
|
||||
| Signal | GPIOs |
|
||||
|---|---|
|
||||
| Red R0–R4 | 1, 2, 42, 41, 40 |
|
||||
| Green G0–G5 | 39, 0, 45, 48, 47, 21 |
|
||||
| Blue B0–B4 | 14, 38, 18, 17, 10 |
|
||||
| DE / VSYNC / HSYNC / PCLK | 5, 3, 46, 7 |
|
||||
|
||||
---
|
||||
|
||||
## Known unknowns
|
||||
|
||||
These are facts the code references or implies but does not pin down:
|
||||
|
||||
- **PSRAM size on Boards 1 & 2.** The build flag `-DBOARD_HAS_PSRAM` is set for both ESP32-32E targets, but the capacity (typically 4 MB or 8 MB on ESP32-WROOM-32 variants) is never stated in the code or config. Hosyond product pages list some models with PSRAM and some without.
|
||||
- **Exact screen panel size for Board 1.** The `.crushmemory` file calls it "original 2.8″ ILI9341," but the ILI9341 driver is also used on 2.4″ and 3.2″ panels. No board_config.h comment names the panel size explicitly.
|
||||
- **Board 1 manufacturer.** The code doesn't name Hosyond for the 2.8″ board the way it does for the 4″. It could be a generic ESP32-32E devkit from any number of vendors.
|
||||
- **Board 1 SPI MISO pin.** Not defined in `tft_user_setup.h` (only MOSI/SCLK/CS/DC/RST are set). This means SPI read-back from the display may not be wired or used.
|
||||
- **Serial port path.** All three `board-config.sh` files default to `/dev/ttyUSB0`, which is a Linux convention. The actual development machine appears to be macOS, so the real port (e.g. `/dev/cu.usbserial-*`) is likely overridden at runtime.
|
||||
- **GT911 I2C address discrepancy (Board 3).** `board_config.h` defines `GT911_ADDR 0x5D` but `LovyanPins.h` configures the touch at address `0x14`. Both are valid GT911 addresses; the runtime address depends on the INT pin state at boot. The code comment says "IMPORTANT: Address 0x14, not 0x5D!" suggesting 0x5D was tried and didn't work.
|
||||
- **CH422G expander pin mapping (Board 3).** `LovyanPins.h` defines symbolic names (`TP_RST=1`, `LCD_BL=2`, `LCD_RST=3`, `SD_CS=4`, `USB_SEL=5`) but these are CH422G *expander output indices*, not ESP32 GPIOs. The I2C init sequence that drives these pins lives in `DisplayDriverGFX.cpp`, which was not fully inspected.
|
||||
|
||||
## Unknown unknowns
|
||||
|
||||
Things that are plausibly relevant but entirely absent from the codebase:
|
||||
|
||||
- **Power supply specs.** No code references input voltage ranges, regulators, or battery charging circuits, though the Waveshare board has a PH2.0 LiPo header and the Hosyond boards support external lithium batteries with onboard charge management.
|
||||
- **Thermal limits / operating temperature range.** Not mentioned anywhere.
|
||||
- **Hardware revision / PCB version.** No version identifiers for any of the three physical boards.
|
||||
- **Antenna characteristics.** The Waveshare board uses an onboard PCB antenna; the Hosyond boards likely do as well. Gain, radiation pattern, and any shielding considerations are unaddressed.
|
||||
- **Display viewing angle / brightness / contrast.** The Hosyond 4″ is listed as TN type (narrower viewing angles); the Waveshare 4.3″ is likely IPS but not confirmed in code.
|
||||
- **ESD / EMC compliance.** No mention of certifications (FCC, CE, etc.).
|
||||
- **Deep sleep / low-power modes.** The firmware uses `millis()`-based timing and a display-off state, but never enters ESP32 deep sleep. Whether the hardware supports wake-on-touch or wake-on-WiFi is not explored.
|
||||
- **Audio hardware.** The Hosyond boards support external speakers per their datasheets, and the codebase has no audio code. The Waveshare board does not appear to have onboard audio.
|
||||
- **SD card.** The Waveshare board has a TF card slot (CS via CH422G EXIO4), and the Hosyond boards have TF card slots as well. The firmware does not use storage.
|
||||
|
||||
---
|
||||
|
||||
## Questions for board owner
|
||||
|
||||
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.
|
||||
|
||||
To build this I need the following from you:
|
||||
|
||||
### 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″) |
|
||||
|---|---|---|---|
|
||||
| Resolution | 320 × 240 | 320 × 480 | 800 × 480 |
|
||||
| Headline buffer (120 items) | ~77 KB | ~77 KB | ~192 KB |
|
||||
| Firehose mode | no (too narrow) | no (SPI too slow) | yes |
|
||||
| Smooth scroll at 20 FPS | yes (partial updates) | tight (partial updates mandatory) | yes (framebuffer) |
|
||||
| Flash for caching | 4 MB (tight) | 4 MB (tight) | 16 MB (9 MB FAT partition) |
|
||||
|
||||
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 Boards 1 & 2
|
||||
|
||||
The build flags say `-DBOARD_HAS_PSRAM` but I can't confirm the capacity. Can you check?
|
||||
|
||||
```
|
||||
// Add to setup() temporarily:
|
||||
Serial.printf("PSRAM size: %d bytes\n", ESP.getPsramSize());
|
||||
Serial.printf("Free PSRAM: %d bytes\n", ESP.getFreePsram());
|
||||
```
|
||||
|
||||
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. Network & server hosting
|
||||
|
||||
The renderer server (`serve.py`) needs Python 3 + Pillow, internet access (for RSS feeds), and network access to the ESP32.
|
||||
|
||||
- **Where will the server run?** Raspberry Pi, NAS, always-on desktop, cloud VM?
|
||||
- **Same LAN as the ESP32?** If yes, the ESP32 can use plain HTTP (no TLS overhead). If remote, we'd need HTTPS (~40–50 KB RAM per connection).
|
||||
- **Server discovery:** Hardcoded IP in `secrets.h`, mDNS (`mainline.local`), or a DNS name?
|
||||
- **WiFi credentials:** Use the existing multi-network setup from the doorbell firmware, or a specific SSID?
|
||||
|
||||
### 4. ESP32 client repo
|
||||
|
||||
The ESP32 sketch reuses `NetManager`, `IDisplayDriver`, and vendored display libraries from klubhaus-doorbell. Two options:
|
||||
|
||||
- **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.
|
||||
|
||||
**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 1–5. 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), 32–48px headlines would match mainline.py's feel. On Boards 1/2, 16–24px. 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 ~200–500 headlines × ~100 bytes ≈ 20–50 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.
|
||||
Reference in New Issue
Block a user