17 Commits

Author SHA1 Message Date
e2467bff4d Merge branch 'main' into feat/arduino 2026-03-15 01:03:30 +00:00
5b4c6cbaac feat: introduce server-thin client architecture for mainline.py on ESP32 with ntfy integration and update hardware documentation to reflect this design. 2026-03-14 18:02:29 -07:00
270f119184 Merge pull request 'doc: Add a new section detailing recent mainline.py changes and their implications for hardware porting, including headline caching, firehose mode, and frame timing.' (#4) from feat/arduino into main
Reviewed-on: #4
2026-03-15 00:10:10 +00:00
ae81ba9b79 doc: Add a new section detailing recent mainline.py changes and their implications for hardware porting, including headline caching, firehose mode, and frame timing. 2026-03-14 17:09:41 -07:00
9979d955ed Merge pull request 'feat/stdout' (#3) from feat/stdout into main
Reviewed-on: #3
2026-03-15 00:05:29 +00:00
69081344d5 refactor: Implement fixed frame rate and precise timing for the scrolling animation loop. 2026-03-14 16:54:33 -07:00
8f95ee5df9 feat: Implement caching for fetched items to improve startup performance and ignore cache files. 2026-03-14 16:43:59 -07:00
b69515238c Increase firehose zone height and adjust scroll duration multiplier for firehose mode. 2026-03-14 16:40:48 -07:00
20ebe96ea6 refactor: remove FIREHOSE conditional from bot_f calculation. 2026-03-14 16:38:56 -07:00
ce81f94a9b feat: add a --firehose mode with a dynamic bottom display zone and include standard Python ignores in .gitignore. 2026-03-14 16:31:11 -07:00
d2bcf8df67 Merge pull request 'feat/arduino' (#2) from feat/arduino into main
Reviewed-on: #2
2026-03-14 23:06:01 +00:00
a3374efcfb Merge branch 'main' into feat/arduino 2026-03-14 23:05:53 +00:00
e4bf8e25b5 docs: Add questions for the board owner to clarify hardware and feature requirements for mainline.py porting. 2026-03-14 16:04:13 -07:00
5118eb8b1d feat: Add a detailed hardware specification document for the Klubhaus Doorbell ESP32 development boards. 2026-03-14 15:55:21 -07:00
ae8585e0f7 Merge pull request 'init' (#1) from init into main
Reviewed-on: #1
2026-03-14 22:42:26 +00:00
258105887a docs: Expand README with detailed sections on usage, configuration, implementation, feeds, and future ideas. 2026-03-14 15:40:17 -07:00
3f8f2cb879 feat: introduce mainline.py as a digital news/poetry consciousness stream with Matrix-aesthetic terminal rendering. 2026-03-14 15:36:19 -07:00
5 changed files with 1442 additions and 2 deletions

4
.gitignore vendored Normal file
View File

@@ -0,0 +1,4 @@
__pycache__/
*.pyc
.mainline_venv/
.mainline_cache_*.json

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

@@ -1,3 +1,80 @@
# Mainline # MAINLINE
A full-screen terminal news stream in a Matrix/THX-1138 aesthetic — scrolling live global headlines in large block type, with per-region translation and mic-reactive glitch effects. Supports a --poetry mode for public-domain literary passages. > *Digital consciousness stream. Matrix aesthetic · THX-1138 hue.*
A full-screen terminal news ticker that renders live global headlines in large OTF-font block characters with a white-hot → deep green gradient. Headlines auto-translate into the native script of their subject region. Ambient mic input warps the glitch rate in real time. A `--poetry` mode replaces the feed with public-domain literary passages.
---
## Run
```bash
python3 mainline.py # news stream
python3 mainline.py --poetry # literary consciousness mode
python3 mainline.py -p # same
```
First run bootstraps a local `.mainline_venv/` and installs deps (`feedparser`, `Pillow`, `sounddevice`, `numpy`). Subsequent runs start immediately.
---
## Config
At the top of `mainline.py`:
| Constant | Default | What it does |
|---|---|---|
| `HEADLINE_LIMIT` | `1000` | Total headlines per session |
| `MIC_THRESHOLD_DB` | `50` | dB floor above which glitches spike |
| `_FONT_PATH` | hardcoded path | Path to your OTF/TTF display font |
| `_FONT_SZ` | `60` | Font render size (affects block density) |
| `_RENDER_H` | `8` | Terminal rows per headline line |
**Font:** `_FONT_PATH` is hardcoded to a local path. Update it to point to whatever display font you want — anything with strong contrast and wide letterforms works well.
---
## How it works
- Feeds are fetched and filtered on startup (sports and vapid content stripped)
- Headlines are rasterized via Pillow into half-block characters (`▀▄█ `) at the configured font size
- A left-to-right ANSI gradient colors each character: white-hot leading edge trails off to near-black
- Subject-region detection runs a regex pass on each headline; matches trigger a Google Translate call and font swap to the appropriate script (CJK, Arabic, Devanagari, etc.) using macOS system fonts
- The mic stream runs in a background thread, feeding RMS dB into the glitch probability calculation each frame
- The viewport scrolls through a virtual canvas of pre-rendered blocks; fade zones at top and bottom dissolve characters probabilistically
---
## Feeds
~25 sources across four categories: **Science & Technology**, **Economics & Business**, **World & Politics**, **Culture & Ideas**. Add or swap in `FEEDS`.
**Poetry mode** pulls from Project Gutenberg: Whitman, Dickinson, Thoreau, Emerson.
---
## Ideas / Future
### Performance
- **Concurrent feed fetching** — startup currently blocks sequentially on ~25 HTTP requests; `concurrent.futures.ThreadPoolExecutor` would cut load time to the slowest single feed
- **Background refresh** — re-fetch feeds in a daemon thread so a long session stays current without restart
- **Translation pre-fetch** — run translate calls concurrently during the boot sequence rather than on first render
### Graphics
- **Matrix rain underlay** — katakana column rain rendered at low opacity beneath the scrolling blocks as a background layer
- **Animated gradient** — shift the white-hot leading edge left/right each frame for a pulse/comet effect
- **CRT simulation** — subtle dim scanlines every N rows, occasional brightness ripple across the full screen
- **Sixel / iTerm2 inline images** — bypass half-blocks entirely and stream actual bitmap frames for true resolution; would require a capable terminal
- **Parallax secondary column** — a second, dimmer, faster-scrolling stream of ambient text at reduced opacity on one side
### Cyberpunk Vibes
- **Keyword watch list** — highlight or strobe any headline matching tracked terms (names, topics, tickers)
- **Breaking interrupt** — full-screen flash + synthesized blip when a high-priority keyword hits
- **Live data overlay** — secondary ticker strip at screen edge: BTC price, ISS position, geomagnetic index
- **Theme switcher** — `--amber` (phosphor), `--ice` (electric cyan), `--red` (alert state) palette modes via CLI flag
- **Persona modes** — `--surveillance`, `--oracle`, `--underground` as feed presets with matching color themes and boot copy
- **Synthesized audio** — short static bursts tied to glitch events, independent of mic input
---
*macOS only (system font paths hardcoded). Python 3.9+.*

View 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 R0R4 | 1, 2, 42, 41, 40 |
| Green G0G5 | 39, 0, 45, 48, 47, 21 |
| Blue B0B4 | 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 (~4050 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 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.

960
mainline.py Executable file
View File

@@ -0,0 +1,960 @@
#!/usr/bin/env python3
"""
M A I N L I N E
Digital news consciousness stream.
Matrix aesthetic · THX-1138 hue.
"""
import subprocess, sys, os, pathlib
# ─── BOOTSTRAP VENV ───────────────────────────────────────
_VENV = pathlib.Path(__file__).resolve().parent / ".mainline_venv"
_MARKER = _VENV / ".installed_v3"
def _ensure_venv():
"""Create a local venv and install deps if needed."""
if _MARKER.exists():
return
import venv
print("\033[2;38;5;34m > first run — creating environment...\033[0m")
venv.create(str(_VENV), with_pip=True, clear=True)
pip = str(_VENV / "bin" / "pip")
subprocess.check_call(
[pip, "install", "feedparser", "Pillow", "-q"],
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
)
_MARKER.touch()
_ensure_venv()
# Install sounddevice on first run after v3
_MARKER_SD = _VENV / ".installed_sd"
if not _MARKER_SD.exists():
_pip = str(_VENV / "bin" / "pip")
subprocess.check_call([_pip, "install", "sounddevice", "numpy", "-q"],
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
_MARKER_SD.touch()
sys.path.insert(0, str(next((_VENV / "lib").glob("python*/site-packages"))))
import feedparser # noqa: E402
from PIL import Image, ImageDraw, ImageFont # noqa: E402
import random, time, re, signal, atexit, textwrap # noqa: E402
try:
import sounddevice as _sd
import numpy as _np
_HAS_MIC = True
except Exception:
_HAS_MIC = False
import urllib.request, urllib.parse, json # noqa: E402
from datetime import datetime
from html import unescape
from html.parser import HTMLParser
# ─── CONFIG ───────────────────────────────────────────────
HEADLINE_LIMIT = 1000
FEED_TIMEOUT = 10
MIC_THRESHOLD_DB = 50 # dB above which glitches intensify
MODE = 'poetry' if '--poetry' in sys.argv or '-p' in sys.argv else 'news'
FIREHOSE = '--firehose' in sys.argv
# Poetry/literature sources — public domain via Project Gutenberg
POETRY_SOURCES = {
"Whitman": "https://www.gutenberg.org/cache/epub/1322/pg1322.txt",
"Dickinson": "https://www.gutenberg.org/cache/epub/12242/pg12242.txt",
"Thoreau": "https://www.gutenberg.org/cache/epub/205/pg205.txt",
"Emerson": "https://www.gutenberg.org/cache/epub/2944/pg2944.txt",
"Whitman II": "https://www.gutenberg.org/cache/epub/8388/pg8388.txt",
}
# ─── ANSI ─────────────────────────────────────────────────
RST = "\033[0m"
BOLD = "\033[1m"
DIM = "\033[2m"
# Matrix greens
G_HI = "\033[38;5;46m"
G_MID = "\033[38;5;34m"
G_LO = "\033[38;5;22m"
G_DIM = "\033[2;38;5;34m"
# THX-1138 sterile tones
W_COOL = "\033[38;5;250m"
W_DIM = "\033[2;38;5;245m"
W_GHOST = "\033[2;38;5;238m"
C_DIM = "\033[2;38;5;37m"
# Terminal control
CLR = "\033[2J\033[H"
CURSOR_OFF = "\033[?25l"
CURSOR_ON = "\033[?25h"
# ─── FEEDS ────────────────────────────────────────────────
FEEDS = {
# Science & Technology
"Nature": "https://www.nature.com/nature.rss",
"Science Daily": "https://www.sciencedaily.com/rss/all.xml",
"Phys.org": "https://phys.org/rss-feed/",
"NASA": "https://www.nasa.gov/news-release/feed/",
"Ars Technica": "https://feeds.arstechnica.com/arstechnica/index",
"New Scientist": "https://www.newscientist.com/section/news/feed/",
"Quanta": "https://api.quantamagazine.org/feed/",
"BBC Science": "http://feeds.bbci.co.uk/news/science_and_environment/rss.xml",
"MIT Tech Review": "https://www.technologyreview.com/feed/",
# Economics & Business
"BBC Business": "http://feeds.bbci.co.uk/news/business/rss.xml",
"MarketWatch": "https://feeds.marketwatch.com/marketwatch/topstories/",
"Economist": "https://www.economist.com/finance-and-economics/rss.xml",
# World & Politics
"BBC World": "http://feeds.bbci.co.uk/news/world/rss.xml",
"NPR": "https://feeds.npr.org/1001/rss.xml",
"Al Jazeera": "https://www.aljazeera.com/xml/rss/all.xml",
"Guardian World": "https://www.theguardian.com/world/rss",
"DW": "https://rss.dw.com/rdf/rss-en-all",
"France24": "https://www.france24.com/en/rss",
"ABC Australia": "https://www.abc.net.au/news/feed/2942460/rss.xml",
"Japan Times": "https://www.japantimes.co.jp/feed/",
"The Hindu": "https://www.thehindu.com/news/national/feeder/default.rss",
"SCMP": "https://www.scmp.com/rss/91/feed",
"Der Spiegel": "https://www.spiegel.de/international/index.rss",
# Culture & Ideas
"Guardian Culture": "https://www.theguardian.com/culture/rss",
"Aeon": "https://aeon.co/feed.rss",
"Smithsonian": "https://www.smithsonianmag.com/rss/latest_articles/",
"The Marginalian": "https://www.themarginalian.org/feed/",
"Nautilus": "https://nautil.us/feed/",
"Wired": "https://www.wired.com/feed/rss",
"The Conversation": "https://theconversation.com/us/articles.atom",
"Longreads": "https://longreads.com/feed/",
"Literary Hub": "https://lithub.com/feed/",
"Atlas Obscura": "https://www.atlasobscura.com/feeds/latest",
}
# ─── GLYPHS ───────────────────────────────────────────────
GLITCH = "░▒▓█▌▐╌╍╎╏┃┆┇┊┋"
KATA = "ハミヒーウシナモニサワツオリアホテマケメエカキムユラセネスタヌヘ"
# ─── FONT RENDERING (OTF → terminal blocks) ─────────────
_FONT_PATH = "/Users/genejohnson/Documents/CS Bishop Drawn/CSBishopDrawn-Italic.otf"
_FONT_OBJ = None
_FONT_SZ = 60
_RENDER_H = 8 # terminal rows per rendered text line
# Non-Latin scripts → macOS system fonts
_SCRIPT_FONTS = {
'zh-cn': '/System/Library/Fonts/STHeiti Medium.ttc',
'ja': '/System/Library/Fonts/ヒラギノ角ゴシック W9.ttc',
'ko': '/System/Library/Fonts/AppleSDGothicNeo.ttc',
'ru': '/System/Library/Fonts/Supplemental/Arial.ttf',
'uk': '/System/Library/Fonts/Supplemental/Arial.ttf',
'el': '/System/Library/Fonts/Supplemental/Arial.ttf',
'he': '/System/Library/Fonts/Supplemental/Arial.ttf',
'ar': '/System/Library/Fonts/GeezaPro.ttc',
'fa': '/System/Library/Fonts/GeezaPro.ttc',
'hi': '/System/Library/Fonts/Kohinoor.ttc',
'th': '/System/Library/Fonts/ThonburiUI.ttc',
}
_FONT_CACHE = {}
_NO_UPPER = {'zh-cn', 'ja', 'ko', 'ar', 'fa', 'hi', 'th', 'he'}
# Left → right gradient: white-hot leading edge fades to near-black
_GRAD_COLS = [
"\033[1;38;5;231m", # white
"\033[1;38;5;195m", # pale cyan-white
"\033[38;5;123m", # bright cyan
"\033[38;5;118m", # bright lime
"\033[38;5;82m", # lime
"\033[38;5;46m", # bright green
"\033[38;5;40m", # green
"\033[38;5;34m", # medium green
"\033[38;5;28m", # dark green
"\033[38;5;22m", # deep green
"\033[2;38;5;22m", # dim deep green
"\033[2;38;5;235m", # near black
]
def _font():
"""Lazy-load the OTF font."""
global _FONT_OBJ
if _FONT_OBJ is None:
_FONT_OBJ = ImageFont.truetype(_FONT_PATH, _FONT_SZ)
return _FONT_OBJ
def _font_for_lang(lang=None):
"""Get appropriate font for a language."""
if lang is None or lang not in _SCRIPT_FONTS:
return _font()
if lang not in _FONT_CACHE:
try:
_FONT_CACHE[lang] = ImageFont.truetype(_SCRIPT_FONTS[lang], _FONT_SZ)
except Exception:
_FONT_CACHE[lang] = _font()
return _FONT_CACHE[lang]
# ─── HELPERS ──────────────────────────────────────────────
class _Strip(HTMLParser):
def __init__(self):
super().__init__()
self._t = []
def handle_data(self, d):
self._t.append(d)
def text(self):
return "".join(self._t).strip()
def strip_tags(html):
s = _Strip()
s.feed(unescape(html or ""))
return s.text()
def tw():
try:
return os.get_terminal_size().columns
except Exception:
return 80
def th():
try:
return os.get_terminal_size().lines
except Exception:
return 24
def noise(w):
d = random.choice([0.15, 0.25, 0.35, 0.12]) # was [0.08, 0.12, 0.2, 0.05], now much denser
return "".join(
f"{random.choice([G_LO, G_DIM, C_DIM, W_GHOST])}"
f"{random.choice(GLITCH + KATA)}{RST}"
if random.random() < d
else " "
for _ in range(w)
)
def glitch_bar(w):
c = random.choice(["", "", "", ""])
n = random.randint(3, w // 2)
o = random.randint(0, w - n)
return " " * o + f"{G_LO}{DIM}" + c * n + RST
# ─── SOURCE → LANGUAGE MAPPING ──────────────────────────
# Headlines from these outlets render in their cultural home language
# regardless of content, reflecting the true distribution of sources.
SOURCE_LANGS = {
"Der Spiegel": "de",
"DW": "de",
"France24": "fr",
"Japan Times": "ja",
"The Hindu": "hi",
"SCMP": "zh-cn",
"Al Jazeera": "ar",
}
# ─── LOCATION → LANGUAGE ─────────────────────────────────
_LOCATION_LANGS = {
r'\b(?:china|chinese|beijing|shanghai|hong kong|xi jinping)\b': 'zh-cn',
r'\b(?:japan|japanese|tokyo|osaka|kishida)\b': 'ja',
r'\b(?:korea|korean|seoul|pyongyang)\b': 'ko',
r'\b(?:russia|russian|moscow|kremlin|putin)\b': 'ru',
r'\b(?:saudi|dubai|qatar|egypt|cairo|arabic)\b': 'ar',
r'\b(?:india|indian|delhi|mumbai|modi)\b': 'hi',
r'\b(?:germany|german|berlin|munich|scholz)\b': 'de',
r'\b(?:france|french|paris|lyon|macron)\b': 'fr',
r'\b(?:spain|spanish|madrid)\b': 'es',
r'\b(?:italy|italian|rome|milan|meloni)\b': 'it',
r'\b(?:portugal|portuguese|lisbon)\b': 'pt',
r'\b(?:brazil|brazilian|são paulo|lula)\b': 'pt',
r'\b(?:greece|greek|athens)\b': 'el',
r'\b(?:turkey|turkish|istanbul|ankara|erdogan)\b': 'tr',
r'\b(?:iran|iranian|tehran)\b': 'fa',
r'\b(?:thailand|thai|bangkok)\b': 'th',
r'\b(?:vietnam|vietnamese|hanoi)\b': 'vi',
r'\b(?:ukraine|ukrainian|kyiv|kiev|zelensky)\b': 'uk',
r'\b(?:israel|israeli|jerusalem|tel aviv|netanyahu)\b': 'he',
}
_TRANSLATE_CACHE = {}
def _detect_location_language(title):
"""Detect if headline mentions a location, return target language."""
title_lower = title.lower()
for pattern, lang in _LOCATION_LANGS.items():
if re.search(pattern, title_lower):
return lang
return None
def _translate_headline(title, target_lang):
"""Translate headline via Google Translate API (zero dependencies)."""
key = (title, target_lang)
if key in _TRANSLATE_CACHE:
return _TRANSLATE_CACHE[key]
try:
q = urllib.parse.quote(title)
url = ("https://translate.googleapis.com/translate_a/single"
f"?client=gtx&sl=en&tl={target_lang}&dt=t&q={q}")
req = urllib.request.Request(url, headers={"User-Agent": "mainline/0.1"})
resp = urllib.request.urlopen(req, timeout=5)
data = json.loads(resp.read())
result = "".join(p[0] for p in data[0] if p[0]) or title
except Exception:
result = title
_TRANSLATE_CACHE[key] = result
return result
# ─── CONTENT FILTER ───────────────────────────────────────
_SKIP_RE = re.compile(
r'\b(?:'
# ── sports ──
r'football|soccer|basketball|baseball|softball|tennis|golf|cricket|rugby|'
r'hockey|lacrosse|volleyball|badminton|'
r'nba|nfl|nhl|mlb|mls|fifa|uefa|'
r'premier league|champions league|la liga|serie a|bundesliga|'
r'world cup|super bowl|world series|stanley cup|'
r'playoff|playoffs|touchdown|goalkeeper|striker|quarterback|'
r'slam dunk|home run|grand slam|offside|halftime|'
r'batting|wicket|innings|'
r'formula 1|nascar|motogp|'
r'boxing|ufc|mma|'
r'marathon|tour de france|'
r'transfer window|draft pick|relegation|'
# ── vapid / insipid ──
r'kardashian|jenner|reality tv|reality show|'
r'influencer|viral video|tiktok|instagram|'
r'best dressed|worst dressed|red carpet|'
r'horoscope|zodiac|gossip|bikini|selfie|'
r'you won.t believe|what happened next|'
r'celebrity couple|celebrity feud|baby bump'
r')\b',
re.IGNORECASE
)
def _skip(title):
"""Return True if headline is sports, vapid, or insipid."""
return bool(_SKIP_RE.search(title))
# ─── DISPLAY ──────────────────────────────────────────────
def type_out(text, color=G_HI):
i = 0
while i < len(text):
if random.random() < 0.3:
b = random.randint(2, 5)
sys.stdout.write(f"{color}{text[i:i+b]}{RST}")
i += b
else:
sys.stdout.write(f"{color}{text[i]}{RST}")
i += 1
sys.stdout.flush()
time.sleep(random.uniform(0.004, 0.018))
def slow_print(text, color=G_DIM, delay=0.015):
for ch in text:
sys.stdout.write(f"{color}{ch}{RST}")
sys.stdout.flush()
time.sleep(delay)
def boot_ln(label, status, ok=True):
dots = max(3, min(30, tw() - len(label) - len(status) - 8))
sys.stdout.write(f" {G_DIM}>{RST} {W_DIM}{label} ")
sys.stdout.flush()
for _ in range(dots):
sys.stdout.write(f"{G_LO}.")
sys.stdout.flush()
time.sleep(random.uniform(0.006, 0.025))
c = G_MID if ok else "\033[2;38;5;196m"
print(f" {c}{status}{RST}")
time.sleep(random.uniform(0.02, 0.1))
# ─── FETCH ────────────────────────────────────────────────
def fetch_feed(url):
try:
req = urllib.request.Request(url, headers={"User-Agent": "mainline/0.1"})
resp = urllib.request.urlopen(req, timeout=FEED_TIMEOUT)
return feedparser.parse(resp.read())
except Exception:
return None
def fetch_all():
items = []
linked = failed = 0
for src, url in FEEDS.items():
feed = fetch_feed(url)
if feed is None or (feed.bozo and not feed.entries):
boot_ln(src, "DARK", False)
failed += 1
continue
n = 0
for e in feed.entries:
t = strip_tags(e.get("title", ""))
if not t or _skip(t):
continue
pub = e.get("published_parsed") or e.get("updated_parsed")
try:
ts = datetime(*pub[:6]).strftime("%H:%M") if pub else "——:——"
except Exception:
ts = "——:——"
items.append((t, src, ts))
n += 1
if n:
boot_ln(src, f"LINKED [{n}]", True)
linked += 1
else:
boot_ln(src, "EMPTY", False)
failed += 1
return items, linked, failed
def _fetch_gutenberg(url, label):
"""Download and parse stanzas/passages from a Project Gutenberg text."""
try:
req = urllib.request.Request(url, headers={"User-Agent": "mainline/0.1"})
resp = urllib.request.urlopen(req, timeout=15)
text = resp.read().decode('utf-8', errors='replace').replace('\r\n', '\n').replace('\r', '\n')
# Strip PG boilerplate
m = re.search(r'\*\*\*\s*START OF[^\n]*\n', text)
if m:
text = text[m.end():]
m = re.search(r'\*\*\*\s*END OF', text)
if m:
text = text[:m.start()]
# Split on blank lines into stanzas/passages
blocks = re.split(r'\n{2,}', text.strip())
items = []
for blk in blocks:
blk = ' '.join(blk.split()) # flatten to one line
if len(blk) < 20 or len(blk) > 280:
continue
if blk.isupper(): # skip all-caps headers
continue
if re.match(r'^[IVXLCDM]+\.?\s*$', blk): # roman numerals
continue
items.append((blk, label, ''))
return items
except Exception:
return []
def fetch_poetry():
"""Fetch all poetry/literature sources."""
items = []
linked = failed = 0
for label, url in POETRY_SOURCES.items():
stanzas = _fetch_gutenberg(url, label)
if stanzas:
boot_ln(label, f"LOADED [{len(stanzas)}]", True)
items.extend(stanzas)
linked += 1
else:
boot_ln(label, "DARK", False)
failed += 1
return items, linked, failed
# ─── CACHE ────────────────────────────────────────────────
_CACHE_DIR = pathlib.Path(__file__).resolve().parent
def _cache_path():
return _CACHE_DIR / f".mainline_cache_{MODE}.json"
def _load_cache():
"""Load cached items from disk if available."""
p = _cache_path()
if not p.exists():
return None
try:
data = json.loads(p.read_text())
items = [tuple(i) for i in data["items"]]
return items if items else None
except Exception:
return None
def _save_cache(items):
"""Save fetched items to disk for fast subsequent runs."""
try:
_cache_path().write_text(json.dumps({"items": items}))
except Exception:
pass
# ─── STREAM ───────────────────────────────────────────────
_SCROLL_DUR = 3.75 # seconds per headline
_FRAME_DT = 0.05 # 50ms base frame rate (20 FPS)
FIREHOSE_H = 12 # firehose zone height (terminal rows)
_mic_db = -99.0 # current mic level, written by background thread
_mic_stream = None
def _start_mic():
"""Start background mic monitoring; silently skipped if unavailable."""
global _mic_db, _mic_stream
if not _HAS_MIC:
return
def _cb(indata, frames, t, status):
global _mic_db
rms = float(_np.sqrt(_np.mean(indata ** 2)))
_mic_db = 20 * _np.log10(rms) if rms > 0 else -99.0
try:
_mic_stream = _sd.InputStream(
callback=_cb, channels=1, samplerate=44100, blocksize=2048)
_mic_stream.start()
atexit.register(lambda: _mic_stream.stop() if _mic_stream else None)
return True
except Exception:
return False
def _render_line(text, font=None):
"""Render a line of text as terminal rows using OTF font + half-blocks."""
if font is None:
font = _font()
bbox = font.getbbox(text)
if not bbox or bbox[2] <= bbox[0]:
return [""]
pad = 4
img_w = bbox[2] - bbox[0] + pad * 2
img_h = bbox[3] - bbox[1] + pad * 2
img = Image.new('L', (img_w, img_h), 0)
draw = ImageDraw.Draw(img)
draw.text((-bbox[0] + pad, -bbox[1] + pad), text, fill=255, font=font)
pix_h = _RENDER_H * 2
scale = pix_h / max(img_h, 1)
new_w = max(1, int(img_w * scale))
img = img.resize((new_w, pix_h), Image.Resampling.LANCZOS)
data = img.tobytes()
thr = 80
rows = []
for y in range(0, pix_h, 2):
row = []
for x in range(new_w):
top = data[y * new_w + x] > thr
bot = data[(y + 1) * new_w + x] > thr if y + 1 < pix_h else False
if top and bot:
row.append("")
elif top:
row.append("")
elif bot:
row.append("")
else:
row.append(" ")
rows.append("".join(row))
while rows and not rows[-1].strip():
rows.pop()
while rows and not rows[0].strip():
rows.pop(0)
return rows if rows else [""]
def _big_wrap(text, max_w, font=None):
"""Word-wrap text and render with OTF font."""
if font is None:
font = _font()
words = text.split()
lines, cur = [], ""
for word in words:
test = f"{cur} {word}".strip() if cur else word
bbox = font.getbbox(test)
if bbox:
img_h = bbox[3] - bbox[1] + 8
pix_h = _RENDER_H * 2
scale = pix_h / max(img_h, 1)
term_w = int((bbox[2] - bbox[0] + 8) * scale)
else:
term_w = 0
if term_w > max_w - 4 and cur:
lines.append(cur)
cur = word
else:
cur = test
if cur:
lines.append(cur)
out = []
for i, ln in enumerate(lines):
out.extend(_render_line(ln, font))
if i < len(lines) - 1:
out.append("")
return out
def _lr_gradient(rows):
"""Color each non-space block character with a left-to-right gradient."""
n = len(_GRAD_COLS)
max_x = max((len(r.rstrip()) for r in rows if r.strip()), default=1)
out = []
for row in rows:
if not row.strip():
out.append(row)
continue
buf = []
for x, ch in enumerate(row):
if ch == ' ':
buf.append(' ')
else:
idx = min(round(x / max(max_x - 1, 1) * (n - 1)), n - 1)
buf.append(f"{_GRAD_COLS[idx]}{ch}\033[0m")
out.append("".join(buf))
return out
def _fade_line(s, fade):
"""Dissolve a rendered line by probabilistically dropping characters."""
if fade >= 1.0:
return s
if fade <= 0.0:
return ''
result = []
i = 0
while i < len(s):
if s[i] == '\033' and i + 1 < len(s) and s[i + 1] == '[':
j = i + 2
while j < len(s) and not s[j].isalpha():
j += 1
result.append(s[i:j + 1])
i = j + 1
elif s[i] == ' ':
result.append(' ')
i += 1
else:
result.append(s[i] if random.random() < fade else ' ')
i += 1
return ''.join(result)
def _vis_trunc(s, w):
"""Truncate string to visual width w, skipping ANSI escape codes."""
result = []
vw = 0
i = 0
while i < len(s):
if vw >= w:
break
if s[i] == '\033' and i + 1 < len(s) and s[i + 1] == '[':
j = i + 2
while j < len(s) and not s[j].isalpha():
j += 1
result.append(s[i:j + 1])
i = j + 1
else:
result.append(s[i])
vw += 1
i += 1
return ''.join(result)
def _next_headline(pool, items, seen):
"""Pull the next unique headline from pool, refilling as needed."""
while True:
if not pool:
pool.extend(items)
random.shuffle(pool)
seen.clear()
title, src, ts = pool.pop()
sig = title.lower().strip()
if sig not in seen:
seen.add(sig)
return title, src, ts
def _make_block(title, src, ts, w):
"""Render a headline into a content block with color."""
target_lang = (SOURCE_LANGS.get(src) or _detect_location_language(title)) if MODE == 'news' else None
lang_font = _font_for_lang(target_lang)
if target_lang:
title = _translate_headline(title, target_lang)
# Don't uppercase scripts that have no case (CJK, Arabic, etc.)
if target_lang and target_lang in _NO_UPPER:
title_up = re.sub(r"\s+", " ", title)
else:
title_up = re.sub(r"\s+", " ", title.upper())
for old, new in [("\u2019","'"), ("\u2018","'"), ("\u201c",'"'),
("\u201d",'"'), ("\u2013","-"), ("\u2014","-")]:
title_up = title_up.replace(old, new)
big_rows = _big_wrap(title_up, w - 4, lang_font)
big_rows = _lr_gradient(big_rows)
hc = random.choice([
"\033[38;5;46m", # matrix green
"\033[38;5;34m", # dark green
"\033[38;5;82m", # lime
"\033[38;5;48m", # sea green
"\033[38;5;37m", # teal
"\033[38;5;44m", # cyan
"\033[38;5;87m", # sky
"\033[38;5;117m", # ice blue
"\033[38;5;250m", # cool white
"\033[38;5;156m", # pale green
"\033[38;5;120m", # mint
"\033[38;5;80m", # dark cyan
"\033[38;5;108m", # grey-green
"\033[38;5;115m", # sage
"\033[1;38;5;46m", # bold green
"\033[1;38;5;250m",# bold white
])
content = [" " + r for r in big_rows]
content.append("")
meta = f"\u2591 {src} \u00b7 {ts}"
content.append(" " * max(2, w - len(meta) - 2) + meta)
return content, hc, len(content) - 1 # (rows, color, meta_row_index)
def _firehose_line(items, w):
"""Generate one line of rapidly cycling firehose content."""
r = random.random()
if r < 0.35:
# Raw headline text
title, src, ts = random.choice(items)
text = title[:w - 1]
color = random.choice([G_LO, G_DIM, W_GHOST, C_DIM])
return f"{color}{text}{RST}"
elif r < 0.55:
# Dense glitch noise
d = random.choice([0.45, 0.55, 0.65, 0.75])
return "".join(
f"{random.choice([G_LO, G_DIM, C_DIM, W_GHOST])}"
f"{random.choice(GLITCH + KATA)}{RST}"
if random.random() < d else " "
for _ in range(w)
)
elif r < 0.78:
# Status / program output
sources = FEEDS if MODE == 'news' else POETRY_SOURCES
src = random.choice(list(sources.keys()))
msgs = [
f" SIGNAL :: {src} :: {datetime.now().strftime('%H:%M:%S.%f')[:-3]}",
f" ░░ FEED ACTIVE :: {src}",
f" >> DECODE 0x{random.randint(0x1000, 0xFFFF):04X} :: {src[:24]}",
f" ▒▒ ACQUIRE :: {random.choice(['TCP', 'UDP', 'RSS', 'ATOM', 'XML'])} :: {src}",
f" {''.join(random.choice(KATA) for _ in range(3))} STRM "
f"{random.randint(0, 255):02X}:{random.randint(0, 255):02X}",
]
text = random.choice(msgs)[:w - 1]
color = random.choice([G_LO, G_DIM, W_GHOST])
return f"{color}{text}{RST}"
else:
# Headline fragment with glitch prefix
title, _, _ = random.choice(items)
start = random.randint(0, max(0, len(title) - 20))
frag = title[start:start + random.randint(10, 35)]
pad = random.randint(0, max(0, w - len(frag) - 8))
gp = ''.join(random.choice(GLITCH) for _ in range(random.randint(1, 3)))
text = (' ' * pad + gp + ' ' + frag)[:w - 1]
color = random.choice([G_LO, C_DIM, W_GHOST])
return f"{color}{text}{RST}"
def stream(items):
random.shuffle(items)
pool = list(items)
seen = set()
queued = 0
time.sleep(0.5)
sys.stdout.write(CLR)
sys.stdout.flush()
w, h = tw(), th()
fh = FIREHOSE_H if FIREHOSE else 0
sh = h - fh # scroll zone height
GAP = 3 # blank rows between headlines
scroll_interval = _SCROLL_DUR / (sh + 15) * 2
# active blocks: (content_rows, color, canvas_y, meta_idx)
active = []
cam = 0 # viewport top in virtual canvas coords
next_y = sh # canvas-y where next block starts (off-screen bottom)
noise_cache = {}
scroll_accum = 0.0
def _noise_at(cy):
if cy not in noise_cache:
noise_cache[cy] = noise(w) if random.random() < 0.15 else None
return noise_cache[cy]
while queued < HEADLINE_LIMIT or active:
t0 = time.monotonic()
w, h = tw(), th()
fh = FIREHOSE_H if FIREHOSE else 0
sh = h - fh
# Advance scroll on schedule
scroll_accum += _FRAME_DT
while scroll_accum >= scroll_interval:
scroll_accum -= scroll_interval
cam += 1
# Enqueue new headlines when room at the bottom
while next_y < cam + sh + 10 and queued < HEADLINE_LIMIT:
t, src, ts = _next_headline(pool, items, seen)
content, hc, midx = _make_block(t, src, ts, w)
active.append((content, hc, next_y, midx))
next_y += len(content) + GAP
queued += 1
# Prune off-screen blocks and stale noise
active = [(c, hc, by, mi) for c, hc, by, mi in active
if by + len(c) > cam]
for k in list(noise_cache):
if k < cam:
del noise_cache[k]
# Draw scroll zone
top_zone = max(1, int(sh * 0.25))
bot_zone = max(1, int(sh * 0.10))
buf = []
for r in range(sh):
cy = cam + r
top_f = min(1.0, r / top_zone)
bot_f = min(1.0, (sh - 1 - r) / bot_zone)
row_fade = min(top_f, bot_f)
drawn = False
for content, hc, by, midx in active:
cr = cy - by
if 0 <= cr < len(content):
ln = _vis_trunc(content[cr], w)
if row_fade < 1.0:
ln = _fade_line(ln, row_fade)
if cr == midx:
buf.append(f"\033[{r+1};1H{W_COOL}{ln}{RST}\033[K")
elif ln.strip():
buf.append(f"\033[{r+1};1H{hc}{ln}{RST}\033[K")
else:
buf.append(f"\033[{r+1};1H\033[K")
drawn = True
break
if not drawn:
n = _noise_at(cy)
if row_fade < 1.0 and n:
n = _fade_line(n, row_fade)
if n:
buf.append(f"\033[{r+1};1H{n}")
else:
buf.append(f"\033[{r+1};1H\033[K")
# Draw firehose zone
if FIREHOSE and fh > 0:
for fr in range(fh):
fline = _firehose_line(items, w)
buf.append(f"\033[{sh + fr + 1};1H{fline}\033[K")
# Glitch — base rate + mic-reactive spikes (scroll zone only)
mic_excess = max(0.0, _mic_db - MIC_THRESHOLD_DB)
glitch_prob = 0.32 + min(0.9, mic_excess * 0.16)
n_hits = 4 + int(mic_excess / 2)
g_limit = sh if FIREHOSE else len(buf)
if random.random() < glitch_prob and g_limit > 0:
for _ in range(min(n_hits, g_limit)):
gi = random.randint(0, g_limit - 1)
buf[gi] = f"\033[{gi+1};1H{glitch_bar(w)}"
sys.stdout.buffer.write("".join(buf).encode())
sys.stdout.flush()
# Precise frame timing
elapsed = time.monotonic() - t0
time.sleep(max(0, _FRAME_DT - elapsed))
sys.stdout.write(CLR)
sys.stdout.flush()
# ─── MAIN ─────────────────────────────────────────────────
TITLE = [
" ███╗ ███╗ █████╗ ██╗███╗ ██╗██╗ ██╗███╗ ██╗███████╗",
" ████╗ ████║██╔══██╗██║████╗ ██║██║ ██║████╗ ██║██╔════╝",
" ██╔████╔██║███████║██║██╔██╗ ██║██║ ██║██╔██╗ ██║█████╗ ",
" ██║╚██╔╝██║██╔══██║██║██║╚██╗██║██║ ██║██║╚██╗██║██╔══╝ ",
" ██║ ╚═╝ ██║██║ ██║██║██║ ╚████║███████╗██║██║ ╚████║███████╗",
" ╚═╝ ╚═╝╚═╝ ╚═╝╚═╝╚═╝ ╚═══╝╚══════╝╚═╝╚═╝ ╚═══╝╚══════╝",
]
def main():
atexit.register(lambda: print(CURSOR_ON, end="", flush=True))
def handle_sigint(*_):
print(f"\n\n {G_DIM}> SIGNAL LOST{RST}")
print(f" {W_GHOST}> connection terminated{RST}\n")
sys.exit(0)
signal.signal(signal.SIGINT, handle_sigint)
w = tw()
print(CLR, end="")
print(CURSOR_OFF, end="")
print()
time.sleep(0.4)
for ln in TITLE:
print(f"{G_HI}{ln}{RST}")
time.sleep(0.07)
print()
_subtitle = "literary consciousness stream" if MODE == 'poetry' else "digital consciousness stream"
print(f" {W_DIM}v0.1 · {_subtitle}{RST}")
print(f" {W_GHOST}{'' * (w - 4)}{RST}")
print()
time.sleep(0.4)
cached = _load_cache() if '--refresh' not in sys.argv else None
if cached:
items = cached
boot_ln("Cache", f"LOADED [{len(items)} SIGNALS]", True)
elif MODE == 'poetry':
slow_print(" > INITIALIZING LITERARY CORPUS...\n")
time.sleep(0.2)
print()
items, linked, failed = fetch_poetry()
print()
print(f" {G_DIM}>{RST} {G_MID}{linked} TEXTS LOADED{RST} {W_GHOST}· {failed} DARK{RST}")
print(f" {G_DIM}>{RST} {G_MID}{len(items)} STANZAS ACQUIRED{RST}")
_save_cache(items)
else:
slow_print(" > INITIALIZING FEED ARRAY...\n")
time.sleep(0.2)
print()
items, linked, failed = fetch_all()
print()
print(f" {G_DIM}>{RST} {G_MID}{linked} SOURCES LINKED{RST} {W_GHOST}· {failed} DARK{RST}")
print(f" {G_DIM}>{RST} {G_MID}{len(items)} SIGNALS ACQUIRED{RST}")
_save_cache(items)
if not items:
print(f"\n {W_DIM}> NO SIGNAL — check network{RST}")
sys.exit(1)
print()
mic_ok = _start_mic()
if _HAS_MIC:
boot_ln("Microphone", "ACTIVE" if mic_ok else "OFFLINE · check System Settings → Privacy → Microphone", mic_ok)
if FIREHOSE:
boot_ln("Firehose", "ENGAGED", True)
time.sleep(0.4)
slow_print(" > STREAMING...\n")
time.sleep(0.2)
print(f" {W_GHOST}{'' * (w - 4)}{RST}")
print()
time.sleep(0.4)
stream(items)
print()
print(f" {W_GHOST}{'' * (tw() - 4)}{RST}")
print(f" {G_DIM}> {HEADLINE_LIMIT} SIGNALS PROCESSED{RST}")
print(f" {W_GHOST}> end of stream{RST}")
print()
if __name__ == "__main__":
main()