Imported from estcarisimo/paperwhite-weather (
AGENTS.md). Install upstream withnpx skills add estcarisimo/paperwhite-weather. Copyright stays with the author.
AGENTS.md — Paperwhite Weather
Instructions for AI coding agents (Claude Code, Codex, Cursor, ...) working in this
repository. CLAUDE.md includes this file. Humans: see CONTRIBUTING.md.
Language policy: American English is mandatory for this file, all agent instructions,
all function, method, variable, and file names, and all documentation and commit messages
(initialize, analyze, color, behavior).
What this project is
Paperwhite Weather turns a used Kindle into a quiet, always-on e-ink home display showing
time, current weather, a multi-day forecast, and sun/twilight times, with interchangeable
skins. The design is client-server: a small Python service (this package) fetches weather,
normalizes it, renders a PNG at the Kindle's native resolution, and serves it on the LAN;
the jailbroken Kindle only downloads the PNG, writes it to the e-ink panel, and sleeps.
docs/ARCHITECTURE.md records the decision and its alternatives.
The target device is a Kindle Paperwhite 3 (7th generation, 2015) on firmware
5.16.2.1.1; framebuffer 1072x1448, 16 gray levels. docs/DEVICE.md lists what has been
verified on the actual device and what is still an assumption.
The distribution is paperwhite-weather, the import package paperwhite_weather, the
command paperwhite. It is not published to PyPI or any other package index, by
decision (2026-09-20); it is installed from GitHub. Never write
pip install paperwhite-weather and never add a PyPI badge. Never write the maintainer's
email address anywhere: contact paths are GitHub-only (private vulnerability reporting,
issues, the @estcarisimo handle).
Layout
src/paperwhite_weather/
config.py Pydantic settings: Location, Units, Display, Settings; load_settings(path)
models.py WeatherSnapshot (daily, hourly, sun, moon_phase), CurrentConditions,
DailyForecast, HourlyForecast, SunTimes, Condition
units.py celsius_to_fahrenheit, kmh_to_mph, kmh_to_ms
fonts.py load_font(weight, size): "regular"/"medium"/"bold" (Inter), "display"
(Oswald Medium, condensed numerals), "serif"/"serif-bold" (DejaVu Serif),
"serif-display" (Playfair Display Bold, mastheads); all bundled in
assets/fonts/
icons.py draw_icon(draw, condition, box, night): monochrome vector icons, one per
Condition, moon variants for clear and partly cloudy at night;
draw_drop(draw, box, level): a drop filled to a fraction; draw_wind,
draw_thermometer, draw_sun, draw_moon_phase(draw, box, phase): metric
glyphs; Glyph helper
sun.py compute_sun_times(location, day) -> SunTimes via astral (civil twilight);
compute_moon_phase(day), moon_illumination, moon_phase_name
providers/ base.py (WeatherProvider protocol), mock.py (fixture data),
open_meteo.py (live: build_query, parse_forecast, WMO_CONDITIONS,
OpenMeteoError), registry in __init__.py: get_provider(name),
available_providers()
skins/ base.py (Skin protocol, format helpers, CONDITION_LABELS), sun_arc.py
(draw_sun_arc: the day's arc over a horizon line, sun marked),
temperature_bars.py (draw_temperature_bars: days as low-high bars on one
axis, today marked), common.py (Canvas: scaled px(), text(), rule(), icon(),
sun_arc(), temperature_bars(), band_chart(), day_columns(), metrics_strip(),
number(), footer(), metrics()),
band_chart.py (draw_band_chart: the week's highs and lows as two curves
with the band between), timeline_chart.py (draw_timeline: hours as a
temperature curve, rain bars, wind, night shading; hours_from_midnight),
minimal.py, newspaper.py, weather_station.py, big_clock.py, forecast.py,
graphic.py, timeline.py;
registry in __init__.py: get_skin(name), available_skins()
render.py render_dashboard(snapshot, settings, now) -> "L" image at native size;
render_offline(settings, last_attempt_at, message); quantize_grayscale(image, levels)
service.py DashboardService (refresh(), frame(orientation), health(), skin,
set_skin(), next_skin(); the runtime skin persisted in state_dir),
DashboardServer/DashboardHandler (stdlib http.server: /health,
/dashboard*.png, /skins page, POST /skin, POST /skin/<name|next>),
serve_forever()
cli.py Typer app: `paperwhite render|gallery|serve|skins|providers|version`
tests/ pytest; conftest.py has the example-config and fixed-snapshot fixtures;
fixtures/ holds a recorded Open-Meteo response (metric, Chicago);
goldens/ holds one PNG per skin and orientation (see its README)
deploy/ systemd user unit and Avahi service file for the Raspberry Pi
kindle/ paperwhite.sh (client, verified on the device), config.example,
extensions/paperwhite (KUAL), install.sh (draft); ShellCheck in CI
hardware/frame/ frame.py: parametric 3D-printable landscape frame (FrameSpec dataclass,
trimesh + manifold3d CSG, PEP 723 inline deps, run with `uv run`);
stl/ exported parts (regenerate with `frame.py build` after a change);
img/ render, preview, drawing (regenerate with `frame.py render|build`);
README.md is the print and assembly guide
docs/ index.md (site landing page), ARCHITECTURE.md, DEVICE.md, DEPLOY.md,
ROADMAP.md, REPOSITORY_STATE.md; img/ (skin screenshots, committed)
mkdocs.yml MkDocs (Material) site over docs/; built --strict on every PR and
deployed to GitHub Pages from main by .github/workflows/docs.yml;
https://estcarisimo.github.io/paperwhite-weather/
config.example.yaml Example configuration; real config.yaml is git-ignored
Commands
uv sync # environment (Python 3.10–3.13 supported)
uv run pre-commit install # once per clone
uv run ruff check src/ tests/
uv run ruff format src/ tests/ # CI checks with --check
uv run mypy src/paperwhite_weather # blocking in CI (disallow_untyped_defs)
uv run pytest --cov=paperwhite_weather # CI enforces --cov-fail-under=85
uv sync --group docs && uv run mkdocs build --strict # the docs site; `uv run mkdocs serve` to preview
uv run paperwhite render --config config.example.yaml --output /tmp/dashboard.png
uv run paperwhite render -c config.example.yaml -o /tmp/d.png --now 2026-09-18T21:45:00+00:00
uv run paperwhite gallery -c config.example.yaml -o tests/goldens --now 2026-09-18T21:45:00+00:00 # regenerate goldens on purpose
uv run paperwhite serve -c config.example.yaml --host 127.0.0.1 --port 18765 # then GET /health
uv build # sdist + wheel via uv_build
uv run hardware/frame/frame.py build --preview hardware/frame/img/frame-preview.png --drawing hardware/frame/img/frame-drawing.png # regenerate the 3D-print parts
uv run hardware/frame/frame.py render -o hardware/frame/img/frame-render.png # picture of the finished frame (software renderer, no OpenGL)
Conventions
- Python 3.10+:
X | None,list[str], notyping.Optional/List. Ruff targetpy310. - Line length 100. Ruff is the only linter/formatter (no black/isort/flake8).
pathlib.Pathfor paths;loggingwith a module-level logger, neverprint, outsidecli.py(typer.echothere).- NumPy-style docstrings on public API. Type hints on every function.
- All settings live in Pydantic models in
config.pywithextra="forbid"; new options go there, thenconfig.example.yaml, then the CLI, then docs. - Every
datetimeis timezone-aware.WeatherSnapshot.fetched_atis UTC and validated as such. Convert tolocation.tzinfoonly when formatting. - Skins receive the canvas size and must return exactly that size in mode
"L";render.pyrotates landscape canvases and quantizes to 16 gray levels. Skins never load fonts from the host; usefonts.load_font. New skins build onskins/common.py(Canvas), branch onc.landscapefor the two layouts, keep the outer 1.5 % border white, cope with every optional field beingNoneand with a one-day forecast, and register inskins/__init__.py. Then regenerate the goldens and add the README row. - Golden frames in
tests/goldens/change only on purpose (paperwhite gallerywith the fixed--now), with the visual change described in the PR. The test tolerates 0.1 % of pixels differing by more than one gray step, no more. - Icons are drawn, not loaded:
icons.pymaps everyConditionto a drawer working in a unit square;tests/test_icons.pychecks each stays inside its box at three sizes. The sun arc (skins/sun_arc.py) and the temperature bars (skins/temperature_bars.py) are the same idea for the sun times and the forecast: one graphic in a box that stays inside it and degrades in narrow boxes (tests/test_sun_arc.py,tests/test_temperature_bars.py). - Providers raise on any failure; no partial snapshots, no silent fallbacks. Caching the
last good snapshot is
service.py's job, not the providers'. - Tests never call the real Open-Meteo API: parsing is tested on the recorded fixture and
the HTTP layer on a local stub server. CI's smoke test uses
--provider mock. To refresh the fixture, run the URL intests/fixtures/README.mdand commit the new JSON with the date in the filename; update the pinned values intests/test_open_meteo.py. - Sun times for live providers are computed locally with
astral(Apache-2.0) viasun.compute_sun_times, never taken from the API;MockProviderkeeps its fixed fixture times.tests/test_sun.pypins them to a US Naval Observatory table (mathmarker). - The service renders on request (clock = request time) and memoizes per minute; it never
stores rendered files on disk. The only thing it writes is the runtime skin choice, one
line in
PAPERWHITE_STATE_DIR(the unit'sStateDirectory). HTTP is stdlibhttp.server; do not add a web framework for a handful of routes, and no scripts on the/skinspage. - The server's hostname is never a constant or a default: clients configure it,
/healthreports it, and docs write<server>. The maintainer's Pi (smokingpi) appears only where a verified result is quoted. - On the maintainer's Pi the service is a user-level systemd unit (
docs/DEPLOY.md), configured throughPAPERWHITE_*environment variables. After merging a change that affects it:git pull && uv sync && systemctl --user restart paperwhite-weather.service. - Tests: plain functions, fixtures,
parametrize. Markersmath/behaviouras defined inpyproject.toml;--strict-markersis on.tests/test_<module>.pymirrorssrc/. - Keep
CHANGELOG.mdcurrent: a bullet under[Unreleased]for every user-visible change. Version lives only inpyproject.toml(andCITATION.cffat release time). - Do not edit
uv.lockby hand; runuv lock/uv add. CI usesuv sync --locked. - Never commit a real location, credentials, or rendered PNGs (
.gitignorecoversconfig.yaml,.env, and*.png; reference screenshots go indocs/img/deliberately).config.example.yamland.env.exampleare the committed templates; a new setting is added to the matching template in the same PR. - Documented commands are run before they are written down. Kindle-side commands that
have not been run on the device are labeled draft in
docs/DEVICE.md. - Kindle scripts are POSIX
shfor BusyBoxash(no bashisms;shellcheck -s shin CI). Test on the device over SSH (root@<kindle-ip>, key auth; find the IP by scanning port 22). A synthetic tap isevemu-event /dev/input/event1 --type EV_KEY --code BTN_TOUCH --value 1 --sync(then--value 0);fbgrab file.pngcaptures the panel. Remember thatstop frameworkmakes the Kindle's own controls unreachable untilstop.
Things that are easy to get wrong
- The Kindle's framebuffer is portrait (1072x1448). Landscape is a rendering choice:
compose on 1448x1072, then
render.pyrotates 90° counterclockwise. If the device shows it upside down, fix the rotation direction inrender.py, not in the skins. - Display width/height are configuration, not constants, so other Kindles can be supported later; only the defaults are Paperwhite 3.
- Inter and Oswald are bundled as static instances of the Google Fonts variable fonts
(made with fontTools) under the SIL Open Font License 1.1 (
LICENSE-Inter.txt,LICENSE-Oswald.txt). Playfair Display is bundled unmodified as the variable font (LICENSE-Playfair.txt): its license reserves the family name for unmodified files, sofonts.pysets the weight axis at load time instead of instancing it. DejaVu Serif is under the Bitstream Vera license (LICENSE-DejaVu.txt), all insrc/paperwhite_weather/assets/fonts/. Adding another typeface needs a license check and the license file next to it. MockProviderbuilds "today" from the location's local date, not the UTC date.- Open-Meteo returns local-time strings without an offset (
2026-09-18T06:33); the dailytimevalues are used as dates, the hourly ones are made aware with the location'stzinfoin_parse_hourly, and sun times come fromastral, so no naive datetime ever reaches the model. Hours whose temperature isnullare dropped. WMO codes not inWMO_CONDITIONSmap toCondition.UNKNOWN(shown as a dash), never raise. render_dashboardverifies the skin's output size and raises; do not catch that.- The existing Kindle dashboard projects listed in
README.mdare prior art to study, not code to copy. Any reuse is an explicit decision recorded in the PR after checking the license.
Pull request workflow (required)
main is protected by the protect-main ruleset: no direct pushes, PR required, the
lint, test (...) and build checks required, review threads must be resolved.
Repository policy: every PR gets an independent code review from a fresh session, and
no PR is merged until CI is green and that review returns APPROVE on the final
commit. "Fresh" means a reviewer with no context from the session that wrote the
change: a new AI agent session started for the review alone (a Claude Sonnet subagent
today), or a human. The brief the reviewer follows is .github/REVIEW.md; give it the
PR number and nothing else.
The loop:
- Branch from
main(feat/…,fix/…,docs/…,chore/…), commit, push, open the PR withgh pr create. Fill the PR template checklist honestly. - Wait for CI:
gh pr checks <n> --watch. If anything is red, read the log (gh run view <run-id> --log-failed), fix locally, push, wait again. - Start a fresh reviewer session with
.github/REVIEW.mdand the PR number. Wait for its verdict, then post the verdict in full as a PR comment (gh pr comment <n> --body-file <verdict.md>, prefixed with the round number and the commit reviewed). The verdict lives on the PR, not in a session transcript. - For each finding either fix it (commit + push) or rebut it with evidence in a PR comment. Reviewers do produce false positives. Pre-existing bugs outside the PR's scope go to a GitHub issue, linked from the PR.
- After any push, start another fresh reviewer (never reuse the previous session).
It reads the earlier rounds (
gh pr view <n> --comments) and reports any finding that was neither fixed nor validly rebutted. Repeat 2–5 until CI is green and the latest push hasVERDICT: APPROVE. - Only then merge:
gh pr merge <n> --squash. Never merge red, never merge without anAPPROVEon the final commit. Record the number of review rounds in the session log. - Do not use the admin bypass. If a human explicitly asks for it in an emergency, note it in the PR description.
Never push directly to main, never force-push a shared branch, never disable or weaken
a CI check to get green. One PR at a time when PRs touch CHANGELOG.md.
Session continuity
The roadmap and session log live in the maintainer's Notion page "Paperwhite Weather".
At the start of a session, read the latest log entry there (or ask for it); at the end,
record what was done, what was verified, what is pending, and the next concrete action,
with branch/commit/PR references. docs/ROADMAP.md mirrors the sprint plan and is
updated by PR when the plan changes.