E-ink Display
Vita drives multiple physical e-ink/e-paper displays as always-on ambient dashboards. Each device shows a different slice of the data without requiring any interaction. There are three displays: a 6-color PhotoPainter desk dashboard, an EPD47 creative canvas (silicon-zack's expressive surface), and a second EPD47 running a training-plan coach dashboard.
Devices
PhotoPainter Dashboard (800Γ480, 6-color)
A Raspberry Pi Zero running custom firmware (PhotoPainter) drives an 800Γ480 e-ink panel in 6 colors (black, white, red, green, blue, yellow). Shows daily/weekly objectives, training progress, and sleep scores. Tufte-inspired design: high data-ink ratio, every pixel meaningful.
- Display: 800Γ480 resolution, 6-color palette
- Image POST:
<eink-host>:8080/display(raw PNG body) - Health check:
<eink-host>:8080/healthβ HTTP 200 - Refresh time: ~12 seconds (full e-ink flash)
- Mounted orientation: Portrait (480Γ800 canvas), rotated 90Β° CCW to land as 800Γ480 on the panel
- Schedule: Every 30 minutes, 6 amβ10 pm PT
Note: The PhotoPainter health endpoint (
/health) differs from the EPD47 one (/pingβPONG). Userenderer.py --checkrather than assuming a shared protocol.
LILYGO T5 4.7" Creative Canvas (EPD47)
An ESP32-S3-based 960Γ540 grayscale e-paper display running custom firmware. Silicon-zack's expressive surface β rotates through a weighted-random gallery of content: live working-memory signals, LLM-synthesized insights, generative art, haiku, and identity observations.
- Display: LILYGO T5 4.7" E-Paper V2.3, 960Γ540, 16 grayscale levels
- Chip: ESP32-S3-WROOM-1 (240 MHz, 8 MB PSRAM, 16 MB flash)
- Image POST:
<epd47-host>/display(multipartfile=@...;type=image/jpeg) - Health check:
<epd47-host>/pingβ"PONG" - Schedule: Every 30 minutes, 5 amβ10 pm PT
- Content modes: 11 weighted modes (see Canvas Content Modes)
LILYGO T5 4.7" Coach Dashboard (EPD47 #2)
A second EPD47 device (device id epd47-fronts) showing an at-a-glance training-plan dashboard β current training phase, week, a race countdown, large weekly plan rows, and a key metric. Rendered by scripts/eink/epd47_coach.py as a high-contrast 960Γ540 grayscale layout optimized for readability at 3β6 ft, pushed via the same HTTP driver as the canvas.
- Schedule: Every 30 minutes, 7 amβ10 pm PT
- Data: training-plan files under
data/coaching/(phase map, prescriptions, PMC/weight)
Note: The device hostname and scheduler loop/job name remain
epd47_frontsfor telemetry stability, but the content is now the coach dashboard. The scheduler'srun_epd47_fronts_pushshells out toepd47_coach.py, not the historicalepd47_fronts.py(which is preserved but no longer scheduled).
Device Resolution (registry + fallback)
None of the renderers hardcode a single device address. Each looks the device up in the device registry (data/devices/registry.json) via scripts/devices/registry.py, then falls back to a known address if the device hasn't checked in recently.
| Renderer | Registry device id | Fallback |
|---|---|---|
renderer.py (PhotoPainter) |
photopainter |
hardcoded 192.168.x:8080 (get_device_url β DeviceOfflineError β fallback) |
epd47_canvas.py (canvas) |
epd47 |
VITA_EPD47_FALLBACK_BASE_URL env (default http://vita-epd47.local) |
epd47_coach.py (coach) |
epd47-fronts |
VITA_EPD47_FRONTS_FALLBACK_BASE_URL env |
get_device_url(device_id, endpoint) raises DeviceOfflineError if the device's last check-in is stale; get_device_url_with_fallback(device_id, base_url, endpoint) swallows that and returns the fallback. Devices populate the registry through a firmware check-in callback, so the registry tracks the current LAN IP without a hardcoded address.
PhotoPainter Layout
+----------------------------------+
| DAY NAME date time TΒ° | Header (40px)
+----------------------------------+
| ββββ PRIMARY OBJECTIVE ββββββββ | Hero Zone (yellow banner)
+----------------------------------+
| - Secondary objective 1 | Secondary (2 items max)
| - Secondary objective 2 |
+----------------------------------+
| THIS WEEK | Weekly (blue heading)
| - Weekly obj 1 (in progress) |
| - Weekly obj 2 |
+----------------------------------+
| TRAINING S M T W T F S | Training Grid (green)
| skate . β . . β . . | Dots per component
| zone 2 β . β . . β . | Green=target met
| intervals . . . β . . . | Red=zero, Black=partial
| strength . β . . . β . |
+----------------------------------+
| SLEEP ββ ββ ββ ββ ββ ββ ββ | 7-day bar chart
| S M T W T F S | Color: red/blue/green
| avg: 82 |
+----------------------------------+
| β Sleep gate active (X.Xh avg) | Nudge Bar (conditional)
+----------------------------------+
The nudge bar is conditional: it only renders when the 7-day sleep average is below the gate threshold (avg_hours < 6.5). See Scheduling for the sleep gate mechanism this surfaces.
Canvas Content Modes
Each refresh, epd47_canvas.py picks one mode by weighted-random selection (pick_mode). Modes are declared as (name, render_fn, weight) tuples in MODES; higher weight = more frequent. Lightweight modes render locally with no API call; "deep" modes call an LLM (or image model) and cost roughly $0.005β0.05 per call.
| Mode | Weight | Kind | What it shows |
|---|---|---|---|
pulse |
5 | deep | Working memory (data/executive/reflection.md) distilled to one sharp signal |
oracle |
5 | deep | Agentic exploration of vita data β an insight |
observation |
3 | deep | An identity observation β agent reflection |
vision |
3 | deep | Context-aware AI art + caption |
mirror |
2 | deep | Stated-vs-actual contradictions surfaced from data |
data |
2 | local | Big-number cards from live data |
imagen |
2 | deep | Curated AI art generation |
generative |
1 | local | Algorithmic patterns |
quote |
1 | local | A quote |
haiku |
1 | local | A status haiku |
meta |
1 | local | Self-referential / system-state note |
Note:
pulseand the other deep modes read silicon-zack's live working memory atdata/executive/reflection.md. That file can contain personal/health/relationship context β the rendered output is for a private desk display, not published. Force--mode quote(or another local mode) when you want a deterministic, no-API, no-personal-data render.
Freshness Guarantees
Every figure on these displays is an audit assertion. bio-zack scans the
wall passively, as a barnacle detector β so a wrong number here costs more than
a blank card. On 2026-04-29 a single scan turned up three false readings at
once (a 0% that meant "no aggregator exists", a date-night gap measured
against a retired goal, and a stale homeschooling status).
Since 2026-07-26 the canvas renders through the render-side freshness contract. Concretely:
- The sleep card carries its own provenance.
_load_sleep_last_night()used to fall back from today's garmin file to yesterday's silently, so a two-day-old number reached the wall labelled "last night". It now returns a verdict alongside the data, and the card subtitle tells the truth:
| Verdict | Value | Subtitle |
|---|---|---|
| fresh | 8.3h |
last night |
| stale | 8.3h |
2026-07-25 Β· 1d old |
| suspect | β |
suspect β duration 3.2h below floor |
| missing | card omitted entirely |
- A suspect night never renders as a number. The 2026-07-04 watch-fell-off reading (5.05h recorded against ~7.5h actually slept) was perfectly fresh and completely wrong; validity rules catch that class independently of age.
mirrormode gets the strictest gate on the display. It accuses bio-zack of a contradiction between stated values and actual behavior, so it will only make that accusation from a source that can back it β an untrustworthy reading produces no contradiction rather than a wrong one.visionmode accepts stale data deliberately. Art direction is the one place a stale-but-plausible number is harmless: it tints a picture, it doesn't assert a fact. It falls back to neutral only when the source can't be believed at all.- The exploring agent's source list is generated from live verdicts. The
oracle/observation/mirrorprompts used to hardcodecurrent-status.jsonandweek-summary.jsonβ both retired in May 2026 and 87 days stale on disk β as "the dashboard". The block is now built at render time fromassess(), and untrustworthy views are listed with their verdict and an explicit instruction not to cite their numbers as current.
Data Sources
PhotoPainter dashboard
| Source | Path | What it provides |
|---|---|---|
| Daily objectives | data/objectives/daily/YYYY-MM-DD.json |
Primary + secondary objectives |
| Weekly objectives | data/objectives/weekly/YYYY-WNN.json |
This week's goals |
| Garmin | data/garmin/YYYY-MM-DD.json |
Sleep scores and duration |
| Training occurrences | data/scheduling/occurrences-YYYY-MM.jsonl |
Weekly training activity log |
| Training goals | goals/active/weekly-training-structure.json |
Component targets |
Coach dashboard
| Source | Path | What it provides |
|---|---|---|
| Phase map | data/coaching/grodeo-2026-plan.md |
Training phase / week structure |
| Prescriptions | data/coaching/prescriptions.jsonl |
Per-day prescribed vs completed |
| PMC | data/coaching/pmc.json |
Weight + as-of date |
| Working memory | data/coaching/working-memory.md |
Weight fallback |
Color Semantics (PhotoPainter)
| Color | Meaning |
|---|---|
| Red | Bad / warning / zero progress |
| Green | Good / complete / target met |
| Blue | Neutral / informational |
| Yellow | Primary objective highlight |
| Black | Partial progress / text |
The EPD47 displays are 16-level grayscale, so they encode the same semantics through contrast and weight rather than hue.
Usage
PhotoPainter Dashboard
# Push to display
uv run python scripts/eink/renderer.py
# Preview without pushing (saves PNG; default dashboard_preview.png)
uv run python scripts/eink/renderer.py --preview -o preview.png
# Render without the nudge bar
uv run python scripts/eink/renderer.py --no-nudge
# Health check
uv run python scripts/eink/renderer.py --check
EPD47 Creative Canvas
# Push random (weighted) creative content
uv run python scripts/eink/epd47_canvas.py --push
# Preview without pushing
uv run python scripts/eink/epd47_canvas.py --preview -o /tmp/preview.png
# Force a specific mode (choices come from MODES)
uv run python scripts/eink/epd47_canvas.py --mode oracle --push
uv run python scripts/eink/epd47_canvas.py --mode pulse --push
uv run python scripts/eink/epd47_canvas.py --mode quote --push
# Health check (curl GET /ping β PONG)
uv run python scripts/eink/epd47_canvas.py --check
# Manual trigger via scheduler API (port 33800)
curl -X POST http://localhost:33800/api/v1/scheduler/trigger/epd47_canvas
EPD47 Coach Dashboard
# Preview (save PNG only)
uv run python scripts/eink/epd47_coach.py --preview
# Render and push
uv run python scripts/eink/epd47_coach.py
Schedule
All three displays are driven by loops in api/src/scheduler.py and refresh every 30 minutes during waking hours. The scheduler is part of the Vita API service (./run api, port 33800); there are no separate cron jobs. Each loop checks a PT time window before rendering and shells out to the matching renderer (via api/src/services/scheduler_peripherals.py). Each job is registered in KNOWN_TASKS and can be fired manually through POST /api/v1/scheduler/trigger/<task>.
| Display | Loop | Task name | Hours (PT) | Interval |
|---|---|---|---|---|
| PhotoPainter | _eink_refresh_loop |
eink_refresh |
6 amβ10 pm | 30 min |
| EPD47 canvas | _epd47_canvas_loop |
epd47_canvas |
5 amβ10 pm | 30 min |
| EPD47 coach | _epd47_fronts_loop |
epd47_fronts |
7 amβ10 pm | 30 min |
File Locations
| Path | Contents |
|---|---|
scripts/eink/renderer.py |
PhotoPainter dashboard renderer (PIL build, rotate, POST) |
scripts/eink/epd47_canvas.py |
EPD47 creative canvas (MODES, pick_mode, render + push) |
scripts/eink/epd47_coach.py |
EPD47 coach training dashboard (the actively-scheduled coach renderer) |
scripts/eink/epd47_fronts.py |
Historical "live fronts" renderer β preserved, no longer scheduled |
scripts/eink/epd47_dashboard.py |
Shared EPD47 layout utilities |
scripts/eink/epd47.py |
Low-level EPD47 push helper |
scripts/devices/registry.py |
Device id β URL resolution with staleness fallback |
data/devices/registry.json |
Registered devices (id, last-seen IP) |
api/src/scheduler.py |
Loop definitions (_eink_refresh_loop, _epd47_canvas_loop, _epd47_fronts_loop) |
api/src/services/scheduler_peripherals.py |
Push wrappers the loops shell out to |
hardware/photopainter/ |
PhotoPainter Pi check-in client + service unit |
hardware/epd47/src/main.cpp |
EPD47 canvas firmware (ESP32-S3) |
hardware/epd47-fronts/src/main.cpp |
EPD47 coach device firmware |
.claude/skills/eink-display/SKILL.md |
PhotoPainter skill definition |
.claude/skills/epd47-canvas/SKILL.md |
EPD47 canvas skill definition |
Where to go next
- Scheduling β the sleep gate the PhotoPainter nudge bar surfaces
- Objectives β the daily/weekly objective data the dashboard reads
- Peripherals β other physical devices (todo console, stream dock)
- Executive Loop β produces
reflection.md, the working memorypulse/deep modes read