Skip to content

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). Use renderer.py --check rather 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 (multipart file=@...;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_fronts for telemetry stability, but the content is now the coach dashboard. The scheduler's run_epd47_fronts_push shells out to epd47_coach.py, not the historical epd47_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: pulse and the other deep modes read silicon-zack's live working memory at data/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.
  • mirror mode 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.
  • vision mode 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 / mirror prompts used to hardcode current-status.json and week-summary.json β€” both retired in May 2026 and 87 days stale on disk β€” as "the dashboard". The block is now built at render time from assess(), 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 memory pulse/deep modes read