Guidance for coding agents working in this repository. Claude Code reads it
through CLAUDE.md; Codex reads it directly.
- GitHub: github.com/wr/featherframe
- Linear project: Featherframe (id: cb3f0377-9778-49b2-a57f-e9f39cfb3de5), Personal team (W)
- Branch prefix: wells/
- PR mode: ready
Read docs/STYLE.md before writing anything a person reads: webapp copy,
the frame's screens, the README, the wiki, the marketing page, commits, PRs,
Linear, reports. Use its lexicon. Write every user-facing string yourself,
verbatim, in any subagent brief. server/scripts/check_copy.py checks the
mechanical rules (make test runs it on the webapp; pass --wiki DIR before
pushing the wiki).
Link owners to help through featherframe.app/help/<topic>
(site/src/help.json), never to the wiki directly.
Featherframe shows the birds a BirdNET station hears as illustrations from Audubon's and Gould's books, on an e-paper frame. A detector (BirdNET-Pi, BirdNET-Go, or a BirdWeather station) names a species; the server finds its plate, composes a sheet, and finishes it for every frame that shows it; each frame fetches its own bytes.
server/: Python/Starlette. Owns all logic and all image processing. Runs self-hosted (on the BirdNET device as a systemd service in its own venv, or in Docker on a NAS) and, unchanged, as Featherframe Cloud's per-household Container.firmware/: ESP32-S3 (PlatformIO). A deliberately simple client that fetches a pre-packed framebuffer and pushes it to the panel.hosted/: Featherframe Cloud on Cloudflare: a Worker, a Durable Object per household (its "front door"), the server in a Container, D1 and R2.site/: featherframe.app, the marketing page and the/help/<topic>links.flasher/: the USB web flasher, published to GitHub Pages on a release.
Two kits run the firmware: the EE03 (Seeed XIAO ePaper Kit, 10.3", 1404×1872, 16-level gray, IT8951) and the EE02 (13.3" E Ink Spectra 6, 1200×1600, six inks, ~15 s full refresh, no partial refresh). A TRMNL, a Kobo or Kindle, and a tablet's browser can be frames too. The wiki (github.com/wr/featherframe/wiki) has the hardware spec, wiring, battery numbers and the guide to porting another panel.
Kits sold as Featherframe ship on USB power, without a battery. Battery power (the Power setting's Battery, deep sleep, the low-battery hold) is supported for makers who fit a cell themselves: keep it working, but never describe a sold kit as battery-powered (site, setup, kit copy).
Read the doc for a subsystem before changing it, and update it in the same PR when you change what it describes.
docs/architecture.md: the server's model in depth: panels, the tick, frames, pictures and outputs, push, viewers, the drawn store, adding frames, firmware updates, sources, the webapp.docs/rendering.md: the render pipeline, the caption, the collage, AI generation and the spend gate, the plate library, the colour panel, dithering, the framebuffer format.docs/folios.md: the plate crosswalks and how each book's scans are cut;docs/gould-*/hold each Gould folio's working record,docs/plate-sources.mdthe survey of sources.docs/hosted.md: Featherframe Cloud.docs/firmware.md: the firmware client on both kits.docs/STYLE.md: how Featherframe writes.
server/featherframe/:service.py(the hub),frames.py,pictures.py,viewers.py,drawn.py,push.py,app.py(routes),config.py,panels.py,spend.py,switching.py,keys.py,hosted.py,birdnet.py(BirdNET-Pi's database),sources/(BirdWeather, pushed detections),render/(compose, collage, genart, finish, framebuffer, spectra),fonts/,art/.server/templates/,server/static/: the webapp and the/viewpage.server/scripts/:fetch_plates.py,folios/(one crosswalk per book),legends.yaml,export_dataset.py,check_copy.py.server/tests/: pytest.tests/fixtures/*-cases.jsonhold the cases a Python rule and its port (TypeScript inhosted/, JavaScript in the page) must both pass.firmware/src/:main.cpp, Improv, the status LED, the setup portal, the baked screens.firmware/tools/: the screen bake, release assets, portal font and preview, kit registration.hosted/src/: the Worker and the Durable Objects.hosted/migrations/: D1 schema.hosted/Dockerfile: the Container image..github/workflows/:ci.yml(tests),release.yml(av*tag).
All Make targets run from the repo root; they drive server/.venv.
make venv # create server venv + install requirements
make plates # download the folios' plates (scripts/fetch_plates.py)
make plates-all # cache every Havell plate (~2.9 GB, idempotent, retries)
make preview # END-TO-END render of a fake Cardinal -> test_output/*.png + *.fff
make preview-all # one PNG per curated species
make preview-collage # a daily collage
make preview-fallback # the typographic (no-plate) fallback
make preview-ee02 # the Cardinal for the EE02 colour panel (six-ink dither)
make preview-views # the Cardinal as viewers get it (TRMNL X, Kobo, Kindle, TRMNL OG, tablet)
make serve # run the server on :8181 (advertises mDNS; see Rules)
make test # pytest
make site-test # featherframe.app build, unit and Playwright testsmake preview is the headline verification path: the whole art pipeline
with no hardware and no detections. Run it after any render change and look
at test_output/.
More granular:
cd server
./.venv/bin/python -m pytest tests/test_names.py -q # one test file
./.venv/bin/python -m pytest -k cursor -q # by keyword
./.venv/bin/python -m featherframe.preview --species "Blue Jay" # any species
./.venv/bin/python -m featherframe.preview --dither stucki # bench override, never persisted
./.venv/bin/python scripts/fetch_plates.py --dry-run # resolve plates, no download
FEATHERFRAME_NO_MDNS=1 ./.venv/bin/python -m featherframe --port 8181 # run the server directly
./.venv/bin/python -m featherframe --clear-password # turn off the webapp's password
# Firmware
cd firmware && pio run -e xiao_ee03 -t upload && pio device monitor # build/flash the EE03 + serial (115200)
cd firmware && pio run -e ee02 # the EE02 colour build
cd firmware && pio run -e ee02_bench # Spectra 6 refresh-speed bench (serial-driven; test_bench_ee02/)
cd firmware && pio run -e release # the binary an EE03 kit ships with (release_ee02: the EE02)
cd firmware && pio run -e generic_bench # a panel the server has never heard of; bakes its own screens
python3 firmware/tools/portal_preview.py # the setup portal's pages (ff_portal.h) on :8199, no kit needed
# Featherframe Cloud
cd hosted && npm test # vitest, Workers poolDeploy:
- Self-hosted on a BirdNET device:
cd server && ./install.sh(venv, plates, systemd unit). - On a NAS:
docker-compose.ymlat the root runsghcr.io/wr/featherframe(server/Dockerfile): host networking for mDNS,/dataa volume, plates from the shared library (FEATHERFRAME_PLATE_LIBRARY, set in the Dockerfile),TZset because quiet hours run on local time. - A release: push a
v*tag.release.ymlpublishes the firmware images andfirmware-manifest.json, the Pages flasher, and the server image. - Featherframe Cloud:
cd hosted && npx wrangler d1 migrations apply featherframe --remotewhen there is a new migration, thennpx wrangler deployfrom an up-to-datemain. Docker must be running (wrangler builds the Container image); retry if the registry push drops. A new image serves only once the rollout ends and the Container restarts. - featherframe.app:
make site-deploy.
docs/architecture.md has each of these in depth.
Data flow. Detector → the server's ingest cursor → a composed sheet per
picture → a finished output per frame (packed framebuffer or PNG, with an
ETag) → GET /api/frame (If-None-Match) → the panel.
service.py is the hub. A background thread runs tick(), which is two
passes: _tick_pictures() decides what each picture is of and composes at
most one sheet per picture; _tick_frames() finishes each picture into one
output per frame that shows it. Web handlers only read bytes; nothing is
rendered in a request. The default is to do nothing: few panel refreshes.
Quiet hours are asked of the service (quiet_window / in_quiet_hours),
never of Config alone: Sunset → Sunrise at the source's location.
Frame, household, picture, output. A frame is any screen the server
draws for (a kit, a TRMNL, an e-reader, a tablet page): one registry,
frames.py, no primary frame, each row asking, on or ignored, with what
the device reported kept apart from what the owner set. Every frame of every
kind is approved by the owner on the webapp. The household is Config:
everything shared by every frame, and the whole /settings form.
frames.frame_config(row, household) is the one place a frame's effective
config comes from; frames.capabilities(row) decides what can be asked of a
frame from its transport and its report, never from a list of models. There
are exactly two pictures, plates and collage (pictures.py), each
composed once as a sheet whatever the panels are; picture_for(shows, now)
is the one place that answers which picture a frame gets. An output is one
frame's picture fitted, matted, dithered and packed for that frame, redrawn
only when its picture or settings change.
Push. A kit on USB holds a WebSocket to /api/frame/push and is told
when its next /api/frame answer would change; it answers with its usual GET,
so /api/frame stays the one contract. Battery frames never open one.
Viewers (TRMNL, e-readers, the /view page) are frames fed over plain
HTTP. They redraw the kept sheet at their own size and depth
(GET /api/view.png, TRMNL's /api/setup and /api/display) and never touch
a kit's output.
The drawn store (drawn.py, data/frames/drawn/) keeps everything drawn
once, named by what it was drawn from, and reuses it by name.
Rendering (render/, docs/rendering.md): pipeline.py runs
compose → finish (dither) → framebuffer pack. Art comes from a provider chain
(the plate library or the scans, then AI generation) and falls back to a
typographic plate. Every paid AI call goes through the spend gate
(spend.py).
Featherframe Cloud (hosted/, docs/hosted.md) runs this same Python
server in a per-household Container behind a Durable Object front door that
answers frames, keeps detections, and wakes the server only for news. The
only TypeScript copies of server rules are the spend gate's decide and the
front door's switch rule choose, each held to its Python side's fixtures.
Cloud is free to owners and is built to carry at least 500 households at the
least cost: weigh every hosted change at that scale (container time, Durable
Object requests and duration, R2 operations, D1 rows, Worker requests;
hosted/src/usage.ts prices each), prefer the design that starts the server
less often, keeps it up for less time and makes fewer requests, and measure
with the account's GraphQL Analytics before calling a change cheap.
Do not break one side of these without the other.
- Never a wrong bird. No match or no plate means the provider returns
Noneandcompose.render_fallbackdraws the typographic plate. Composite plates are shown whole, never cropped. The fuzzy name resolver innames.pyis build-time only (fetch_plates); live matching uses the curated index exactly. - BirdNET's database is read-only.
birdnet.pyopens it?mode=ro, never writes or locks it, and soft-fails to a safe default so a missing or odd database keeps the current picture. The fixture schema intests/_fixtures.pyis verbatim from the Nachtzuster fork. - Nothing renders in a request. Handlers read bytes the tick drew.
- The framebuffer format (FFF) is a contract with the firmware.
server/featherframe/render/framebuffer.pyanddisplayFrame()infirmware/src/main.cppchange together. Gray: 16-byte header, 4bpp, high nibble = left pixel, 0 = black, 15 = white, native landscape 1872×1404, rotation 90 or 270 only. Colour (FLAG_INKS): nibbles are ink codes, native portrait 1200×1600, rotation 0 or 180. Never send a frame a wrong-size image. - Bump the drawing versions when pixels move.
compose.SHEET_VERSIONfor a gray single sheet (layout, type, fonts, the bough),compose.COLOR_VERSIONfor the colour path,pipeline.FINISH_VERSIONfor finishing (fit, mat, dither, pack, views), andLOBBY_DRAWINGinhosted/src/pairing.tswhen Cloud's pairing screen changes. Kept drawings are reused by name;tests/test_drawn_versions.pyfails when pixels move without a bump (record withFF_RECORD_DRAWN=1). - Every paid AI call goes through the spend gate.
Gate.purchase(...)wraps the one vendor call; paid model methods raisespend.Unguardedoutside a purchase. The user's API key is never shown unmasked. - Ported rules move together.
spend.decideandspend.tsare held totests/fixtures/spend-cases.json;switching.pyandswitch.tstoswitch-cases.json;test_switch_e2e.py'sFrontDoormirrors the front door'sdecideAloneandtakeState. Change both sides and the fixtures. - A dev server never advertises mDNS. A frame whose own server is
unreachable adopts any server advertising its panel. Run every dev, bench or
preview server on a LAN with frames on it with
FEATHERFRAME_NO_MDNS=1(put it in each.claude/launch.jsonentry, which is untracked).make serveadvertises, because owners run the server that way. The tests turn it off themselves. - What an owner never tuned is a constant, not a setting. The poll
interval, confidence floor, alarm thresholds and corroboration numbers are
named constants at the top of
service.py. Think twice before turning one back into a field. - The render stays single-threaded and memory-frugal (target: Pi Zero 2W,
512 MB). Gray dithers with blue noise; Stucki is a slow per-pixel loop there,
never the gray default. The only dither override is
pipeline.DITHER_OVERRIDE. - Server code runs on Python 3.9 (BirdNET-Pi on Bullseye). CI tests 3.9 and 3.12.
- Folios reorder, never filter. A species is asked of the Region's folios
first, then the rest in published order.
plate: nonehands a species to the next folio, never to a guess. Pin a plate only once its engraved caption reads right on the scan.test_crosswalk.pyguards the tricky numbers. - The AI's style references are Havell plates only
(
genart._havell_species), because its prompts name the Havell edition. Image prompts state principles, never examples (docs/STYLE.md). - Hosted: nothing orders files by time. A Container start writes every file fresh.
- The wordmark is the plate title's script everywhere. After any change to
the script face or its theme sizes, run the favicon, screen bake and portal
font tools (
docs/rendering.md). A change to baked text means re-baking the screens for every panel. - Paths are env-overridable:
FEATHERFRAME_DATA_DIR,FEATHERFRAME_PLATES_DIR,FEATHERFRAME_DB,FEATHERFRAME_PORT(paths.py). - Never commit runtime state (
server/data/), downloaded plates (server/plates/), secrets or API keys, or a third-party file without a licence that allows it.
Code, the folio crosswalks (server/scripts/folios/) and the legend
transcriptions (server/scripts/legends.yaml) are Apache-2.0 (LICENSE,
NOTICE). The crosswalks are also published CC0 as
github.com/wr/historical-bird-plates. Bundled fonts are SIL OFL 1.1. Audubon's and
Gould's plates are public domain and are not in the git tree; THIRD_PARTY.md lists everything that belongs to
someone else and its terms. Add any new third-party file there.