A Pixlet app for the Tidbyt that displays current PM2.5 / PM10 / O3 plus today/tomorrow forecast for a configurable Front Range location. Primary source: BoulderAIR LUR (Longmont Union Reservoir — a fixed monitor regardless of the configured point). Secondary: AirNow forecast (CDPHE-issued) for the configured lat/lon. Includes a smoke indicator (3-hr PM2.5 rolling mean).
See design-notes.md for the full design rationale and source comparison. Licensed under MIT — © 2026 Brian W Bush.
-
Enter the dev shell. This flake provides
pixlet,yq-go,jq,python3, andcurl. The dev shell changed — re-enter it after pulling these files so the new tools are on$PATH:nix develop -
Create config.yaml from the template and fill in your keys:
cp config-example.yaml config.yaml ${EDITOR:-vi} config.yamlYou need: an AirNow API key (free), a Tidbyt API token (from
pixlet author your account), and your device ID (pixlet devices).Installation ID gotcha: Tidbyt's API requires the installation ID to be alphanumeric only (no hyphens, underscores, or other punctuation). The example uses
longmontaq— anything matching[A-Za-z0-9]+is fine.deploy.shvalidates this before pushing. -
Sanity-check upstream sources before deploying:
./scripts/check.shThis verifies the LUR JSONP feed responds, prints today's readings, computes the 3-hr smoke check, and confirms your AirNow key works.
-
Preview locally in a browser at http://localhost:8080:
./scripts/preview.sh -
One-shot push to the device (test that everything works end-to-end):
./scripts/deploy.sh -
For continuous push (a daemon that re-renders every 10 min so the AQI on the device stays current), build and run a podman container. Two ways:
Ad-hoc with
podman run(matches the older flow):./scripts/build-container.sh ./scripts/run-container.sh # foreground; Ctrl-C to stop ./scripts/run-container.sh --detach # background with --restart=always ./scripts/run-container.sh --once # one-shot push for smoke-testing the imageDeclarative with
podman kube(recommended for long-running):./scripts/build-container.sh podman kube play --replace longmontaq.yaml # start (or restart with new image) podman logs -f longmontaq-aq # follow the loop log podman kube down longmontaq.yaml # stopThe container bakes your
config.yamlin at build time (no volume mounts needed); rebuilding it picks up edits. See "Container" below for the details.
main.star |
The Pixlet app (Starlark). Layout A — single composite frame. |
flake.nix |
Nix dev shell + pixlet derivation + container image. |
config-example.yaml |
Template. Copy to config.yaml (gitignored). |
scripts/check.sh |
Pre-deploy sanity check of upstream sources. |
scripts/preview.sh |
pixlet serve for browser preview. |
scripts/render.sh |
Render one frame to out.webp. |
scripts/deploy.sh |
Render and pixlet push once. |
scripts/build-container.sh |
Build the OCI image with credentials baked in; load into podman. |
scripts/run-container.sh |
Run the push-daemon container with podman run (ad-hoc). |
longmontaq.yaml |
Podman kube spec for the push-daemon pod (declarative; recommended). |
design-notes.md |
Design rationale, source comparison, open questions. |
The container is a single-image push daemon: it boots, reads /app/config.yaml, then loops pixlet render → pixlet push → sleep $PUSH_INTERVAL_S forever. Default cadence is 10 minutes; override with PUSH_INTERVAL_S=300 ./scripts/run-container.sh.
What's in it:
pixlet(the same v0.34 binary the dev shell uses).bash,coreutils,yq-go,cacert,tzdatafrom nixpkgs./app/main.star,/app/config.yaml,/app/loop.sh— copied in at build time.- Env:
TZ=America/Denver,ZONEINFOpointed at tzdata,SSL_CERT_FILEpointed at the Mozilla CA bundle.
How credentials get in: build-container.sh exports LONGMONT_CONFIG_YAML=$(cat config.yaml) and passes --impure to nix build. The flake reads that env var via builtins.getEnv and pkgs.writeText's it into the image. Side effects:
- The image rebuilds whenever
config.yamlchanges (Nix sees a new derivation hash). - The credentials end up in the Nix store. On a multi-user system that's a small concern (
/nix/storeis world-readable). On a single-user dev box it's fine. - No bind mounts needed at run time — the container is self-contained.
How to stop a detached daemon: podman rm -f longmontaq (for run-container.sh) or podman kube down longmontaq.yaml (for the kube flow).
Autostart on boot:
- For the kube flow, the simplest path is rootless:
systemctl --user enable --now podman-restart.servicethenpodman kube play --replace longmontaq.yamlonce. The restart service brings the pod back up on reboot. - For tighter systemd integration, convert
longmontaq.yamlto a Quadlet under~/.config/containers/systemd/longmontaq.kube(or/etc/containers/systemd/for rootful) andsystemctl --user daemon-reload && systemctl --user enable --now longmontaq-pod.service.
When the 3-hour rolling mean PM2.5 at LUR exceeds 25 µg/m³, a small pixel-art flame appears in the top-right corner of the big tile, and the big tile is forced to display PM2.5 even if another pollutant happens to have a higher AQI. The threshold is hard-coded in main.star; edit SMOKE_PM25_UGM3 and SMOKE_WINDOW_S to tune.
┌────────────┐ PM2.5 33
│ [🔥] │ PM10 51
│ │ O3 47
│ 51 │
│ │ Su▮ Mo▮
└────────────┘
- No header row — the full 32 px height is used for data.
- Left tile (28×32): big AQI number for the dominant pollutant, centered. Background is the EPA category color (green→yellow→orange→red→purple→maroon). When an alert is active (smoke or CDPHE Action Day), a small badge appears in the top-right corner; otherwise the number is centered with no surrounding chrome.
- Right column (34×32): three per-pollutant rows — pollutant name (white) on the left, AQI number (color-coded by category) on the right. The forecast row at the bottom shows two-letter weekday names (
Su/Mo/Tu/…) with a small colored square for each day's AirNow forecast category — today on the left, tomorrow on the right.
- No time/timestamp. Standard practice for compact AQ displays. The container pushes every 10 min so the data is implicitly fresh; if the daemon dies, the per-pollutant numbers will look implausibly static — usable backstop without burning a row on a clock.
- No category words in the right column. Color encodes the category — green = Good, yellow = Moderate, orange = USG, etc. The "Gd"/"Mod" text was redundant and made the rows feel cluttered. Accessibility note: this layout is harder for color-blind users since color is the only category cue. Reasonable for a personal app; would need a redundant signal for broader audiences.
- No pollutant label in the big tile. The right column already shows each pollutant's AQI; the big number is one of them (the dominant), and matching is fast.
- Forecast as weekday letters + colored dots. Today is whatever weekday it is (
Sufor Sunday, etc.); tomorrow is the next day. Square color = AirNow forecast category for that day. - 3-digit AQI handling: ≤2 digits at
10x20font, 3 digits at6x13fallback. "100" at10x20would be 30 px wide in a 28 px tile; the smaller font keeps it readable without clipping.
http.get(ttl_seconds=...) is the upstream rate limiter:
| Endpoint | TTL | Hits/hour cap |
|---|---|---|
LUR_pm_3day.json |
300 s | 12 |
LUR_o3_stats.json |
300 s | 12 |
| AirNow forecast | 1800 s | 2 |
| AirNow obs (fallback only) | 900 s | 4 (rare) |
- BoulderAIR's JSONP feed is undocumented; the file path could change without notice. The check script catches that.
- AQI is computed from spot 5-min reads, not from the proper EPA rolling averages (24-hr NowCast for PM2.5, 8-hr for O3). Good enough for at-a-glance display; documented in
design-notes.md. v2 should compute from the timeseries. - This app uses no Open-Meteo / CAMS lookup. AirNow forecast is the only "outlook" source.