A desk mini player for SUB/WAVE. Shows what's playing on the LCD and skips the track from a physical button.
Runs MicroPython on a Raspberry Pi Pico 2 W in a Pimoroni Pico Explorer Base.
Skip is an operator override. SUB/WAVE is one shared broadcast — skipping skips it for every listener, not just this device. That's why it's a deliberate hold, not a tap.
| Button | Action |
|---|---|
| A | Now Playing screen |
| A (hold 1s) | Skip track — needs admin credentials |
| B | Up Next — what's queued |
| X | Track info — BPM, key, mood, genre, energy, year |
| Y | Backlight on/off (any button wakes) |
Now Playing shows title, artist, album, and a progress bar. The status bar carries the DJ name, listener count, and a health dot: green fresh, amber stale, red no WiFi.
Several fields are best-effort on the Subwave side and degrade rather than fail:
durationis absent for tracks unknown to both the queue and the library → elapsed only, no bar.- BPM / key / mood / genre appear only once the analyzer has tagged the track →
-. nowPlayingisnullwhen the station is off air → OFF AIR.
- Flash Pimoroni MicroPython firmware
(provides
picographicsandpimoroni). pipx install mpremote- Configure:
cp src/wifi_config.template.py src/wifi_config.py # WiFi cp src/subwave_config.template.py src/subwave_config.py # admin creds
./push.sh
Both config files are gitignored.
Get the credentials right first time. Subwave allows 10 failed admin attempts per IP, then blocks that IP for 15 minutes — and the Pico shares your public IP. On a 401 the firmware latches and refuses further skips rather than burning the budget; fix the config and reboot to clear it.
Credentials are only needed for skip. Everything else works without them —
/api/now-playing is public.
One knob, in src/config.py:
API_BASE = "https://radio.klair.co/api" # public deployment (Caddy, /api prefix)
# API_BASE = "http://192.168.1.101:7701" # LAN/byo install — routes at the rootThree async tasks: poll /api/now-playing every 5s, scan buttons every 20ms, redraw
every 1s so the progress bar ticks between polls.
| Module | Purpose |
|---|---|
main.py |
Boot, WiFi, task orchestration |
config.py |
API base, pins, timings |
net.py |
HTTP/HTTPS client with chunked decoding |
subwave.py |
API client + parsing; the skip auth latch |
screens.py |
LCD rendering + layout helpers |
buttons.py |
Debounced A/B/X/Y, short vs long press |
wifi.py |
STA connect + NTP sync (copied from pico-hook) |
buzzer.py |
Tones (copied from pico-hook) |
subwave.py fetches and never draws; screens.py draws and never fetches. That split is
what lets the parsing and layout logic be tested off-device.
- Cloudflare returns
Transfer-Encoding: chunked. pico-hook'sntfy.py:_http_getdoesn't de-chunk — it works against ntfy.sh only because ntfy doesn't chunk.net.pyhandles it. Don't copy the old helper here. - NTP is mandatory, not cosmetic. The API has no
elapsedfield; it's derived asnow - timestampagainst the server's clock.timestampis unix seconds while the rest of the API uses epoch milliseconds. - The module is
net.py, nothttp.py—httpshadows a CPython stdlib package and breaks the host tests. - TLS is unvalidated. MicroPython does no certificate checking by default, so admin credentials cross the internet encrypted but unauthenticated. Pinning was attempted and hard-wedged the board; see the design doc's "Out of scope".
Parsing and layout are pure functions, tested on the host against payloads captured live
from radio.klair.co:
python3 -m pytest tests/ -q- Raspberry Pi Pico 2 W (RP2350)
- Pimoroni Pico Explorer Base — 240x240 IPS LCD, buttons on GP12 (A), GP13 (B), GP14 (X), GP15 (Y)
- Passive buzzer on GP0 via jumper (optional; only used for skip feedback)
See docs/superpowers/specs/2026-07-16-pico-subwave-design.md.