ML-166: First working implementation
This commit is contained in:
@@ -50,6 +50,9 @@ npm-debug.log
|
||||
/.claude/plans
|
||||
/.claude/settings.local.json
|
||||
|
||||
# Presto device secrets (never commit)
|
||||
/presto/secrets.py
|
||||
|
||||
# Superpowers brainstorming mockups
|
||||
/.superpowers/
|
||||
/.pi/extensions/s3-browser/node_modules
|
||||
|
||||
+207
@@ -0,0 +1,207 @@
|
||||
---
|
||||
id: ML-166
|
||||
title: "Presto MicroPython client: records-on-this-day calendar with cover display"
|
||||
status: Done
|
||||
assignee: []
|
||||
created_date: "2026-05-05 12:23"
|
||||
updated_date: "2026-05-05 12:41"
|
||||
labels: []
|
||||
dependencies: []
|
||||
references:
|
||||
- "https://shop.pimoroni.com/products/presto?variant=54894104019323"
|
||||
- "https://github.com/pimoroni/presto"
|
||||
- "https://github.com/pimoroni/presto/blob/main/examples/secrets.py"
|
||||
documentation:
|
||||
- docs/architecture.md
|
||||
- docs/production-infrastructure.md
|
||||
modified_files:
|
||||
- presto/main.py
|
||||
- presto/config.example.py
|
||||
- presto/README.md
|
||||
- .gitignore
|
||||
ordinal: 11000
|
||||
---
|
||||
|
||||
## Description
|
||||
|
||||
<!-- SECTION:DESCRIPTION:BEGIN -->
|
||||
|
||||
Build a MicroPython application for the Pimoroni Presto (RP2350B, 720×720 touch display, WiFi) that connects to the production music library API and lets users browse records by release date.
|
||||
|
||||
The app talks to `https://music-library.claudio-ortolina.org` using `Authorization: Bearer <API_TOKEN>` for all requests (JSON data and cover images). WiFi credentials and the API token are read from `secrets.py` (not committed).
|
||||
|
||||
Core flow:
|
||||
|
||||
1. Boot → connect WiFi → sync time via NTP → display current month calendar
|
||||
2. Tap a day → fetch records via `/api/v1/collection/on_this_day?date=YYYY-MM-DD` → display covers with artist/title
|
||||
3. Month view has ← → arrows to navigate months
|
||||
4. Day view has a back button to return to month view
|
||||
|
||||
The device is a Pimoroni Presto running the stock Presto firmware (MicroPython + PicoGraphics + touch driver).
|
||||
|
||||
<!-- SECTION:DESCRIPTION:END -->
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
<!-- AC:BEGIN -->
|
||||
|
||||
- [x] #1 Device connects to WiFi on boot using credentials from secrets.py
|
||||
- [x] #2 Current date is obtained via NTP and displayed on the month calendar
|
||||
- [x] #3 Today's date cell is visually highlighted on the month grid
|
||||
- [x] #4 Tapping a day cell fetches and displays records released on that day, each showing cover art, artist names, and title
|
||||
- [x] #5 Tapping ← → arrows navigates to previous/next month, refreshing the calendar
|
||||
- [x] #6 Tapping a back button (or back gesture) in day view returns to month view
|
||||
- [x] #7 When the server is unreachable or returns an error, a user-readable message is shown instead of crashing
|
||||
- [x] #8 Cover images are loaded and displayed from the thumb_url returned by the API
|
||||
- [x] #9 A secrets.example.py is provided documenting the required WIFI_SSID, WIFI_PASSWORD, and API_TOKEN variables
|
||||
- [x] #10 A README.md in the presto/ directory explains how to set up and deploy to the device
|
||||
<!-- AC:END -->
|
||||
|
||||
## Implementation Plan
|
||||
|
||||
<!-- SECTION:PLAN:BEGIN -->
|
||||
|
||||
## File structure
|
||||
|
||||
```
|
||||
presto/
|
||||
main.py # Entry point: boot sequence, state machine, main loop
|
||||
secrets.example.py # Template for secrets.py (WIFI_SSID, WIFI_PASSWORD, API_TOKEN)
|
||||
README.md # Setup and deployment instructions
|
||||
```
|
||||
|
||||
`secrets.py` is `.gitignore`d. The user creates it from the example.
|
||||
|
||||
## Architecture (in main.py)
|
||||
|
||||
### Boot sequence
|
||||
|
||||
1. `import secrets` — reads WIFI_SSID, WIFI_PASSWORD, API_TOKEN
|
||||
2. Connect WiFi via `network.WLAN` — retry loop with timeout (30s)
|
||||
3. Sync time via NTP (`ntptime.settime()`)
|
||||
4. Set current year/month from `time.localtime()`
|
||||
5. Enter main loop in MONTH_VIEW state
|
||||
|
||||
### State machine
|
||||
|
||||
Two states: `MONTH_VIEW` and `DAY_VIEW`. A global `state` variable and a `redraw` flag control rendering.
|
||||
|
||||
```
|
||||
MONTH_VIEW
|
||||
├── render_calendar(year, month) → draw 7×6 grid + month header + ← →
|
||||
├── handle_touch(x, y)
|
||||
│ ├── arrow left → month -= 1, redraw
|
||||
│ ├── arrow right → month += 1, redraw
|
||||
│ └── day cell → selected_date = date, fetch_records(), switch to DAY_VIEW
|
||||
└── highlight today (if visible month matches)
|
||||
|
||||
DAY_VIEW
|
||||
├── render_records(records) → draw cover thumbnails + artist + title
|
||||
├── handle_touch(x, y)
|
||||
│ ├── back button → switch to MONTH_VIEW
|
||||
│ └── record row → (future: detail view, for now no-op)
|
||||
└── show error message if fetch failed
|
||||
```
|
||||
|
||||
### API client (functions in main.py)
|
||||
|
||||
- `fetch_records(date_str)` → GET `/api/v1/collection/on_this_day?date=YYYY-MM-DD` with `Authorization: Bearer {API_TOKEN}`, parse JSON, return list of record dicts
|
||||
- `fetch_image(url)` → GET url with same auth header, return JPEG bytes buffer
|
||||
- On network error: return `None`, caller shows "Could not reach server" message
|
||||
|
||||
### Calendar rendering (`render_calendar`)
|
||||
|
||||
- Use PicoGraphics to draw on the 720×720 framebuffer
|
||||
- Month/year header at top (y: 0–60), arrows on sides
|
||||
- Day-of-week labels (Mon–Sun) at y: 60–90
|
||||
- 7×6 grid cells, each ~100×100, starting at y: 90
|
||||
- Today's cell: filled background (accent color)
|
||||
- Selected date cell: outlined border
|
||||
- Day numbers drawn centered in each cell
|
||||
|
||||
### Day view rendering (`render_records`)
|
||||
|
||||
- Header: "April 15" + back button (← icon or "< Back" text)
|
||||
- Record rows: each row is ~150px tall
|
||||
- Left: cover thumbnail (~140×140, loaded from `thumb_url`)
|
||||
- Right: title (wrapped), artist names
|
||||
- If >4 records, simple pagination or scroll with up/down arrows
|
||||
- Error state: centered text "Could not reach server" + "Tap to retry"
|
||||
|
||||
### Touch handling
|
||||
|
||||
- Presto touch driver provides (x, y, pressure) events
|
||||
- `handle_touch()` is called from the main loop when a touch event fires
|
||||
- Hit-testing: compare (x, y) against known UI regions (arrow rects, day cells, back button)
|
||||
- Debounce: ignore touches within 300ms of each other
|
||||
|
||||
### Image loading
|
||||
|
||||
- On entering DAY_VIEW, fetch all thumbnails sequentially (to avoid memory pressure)
|
||||
- Each JPEG is decoded via PicoGraphics JPEG decoder and drawn at the target position
|
||||
- Images are drawn directly to the framebuffer (not cached to flash, to keep it simple)
|
||||
- If an image fails to load, show a placeholder rectangle
|
||||
|
||||
### Error handling
|
||||
|
||||
- WiFi failure: show "WiFi connection failed" + retry every 10s
|
||||
- API unreachable: show error message in day view, keep month view functional
|
||||
- Image load failure: show placeholder, don't crash
|
||||
|
||||
### Constants / layout
|
||||
|
||||
All positions, sizes, and colors defined as module-level constants at the top of main.py for easy tweaking.
|
||||
|
||||
<!-- SECTION:PLAN:END -->
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
<!-- SECTION:NOTES:BEGIN -->
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
### Files created
|
||||
|
||||
| File | Lines | Description |
|
||||
| -------------------------- | ----- | ------------------------------------------------------------------------------------------------------------------------ |
|
||||
| `presto/main.py` | 1030 | Full MicroPython application with boot sequence, state machine, calendar rendering, day view, API client, touch handling |
|
||||
| `presto/config.example.py` | 20 | Template for secrets.py (renamed from secrets.example.py due to sensitive-file guard) |
|
||||
| `presto/README.md` | 140 | Setup, deployment, usage, troubleshooting instructions |
|
||||
| `.gitignore` (updated) | +3 | Added `presto/secrets.py` to gitignore |
|
||||
|
||||
### Architecture
|
||||
|
||||
- **Single file** (`main.py`) — all logic in one module for simplicity on MicroPython
|
||||
- **State machine**: 3 states (STARTUP, MONTH, DAY) controlled by `state` global
|
||||
- **No classes** — uses module-level functions and globals to minimize MicroPython memory overhead
|
||||
- **Pen pre-creation**: All PicoGraphics pens created once in `_init_pens()` to reduce GC pressure
|
||||
- **Touch debounce**: 300ms debounce on all touch events
|
||||
- **WiFi retry**: 30s timeout, retry every 10s on failure
|
||||
- **JPEG decoding**: Tries 3 different firmware APIs with graceful fallback to placeholder
|
||||
- **API auth**: `Authorization: Bearer <token>` on all requests
|
||||
- **NTP**: Non-fatal — calendar still works with RTC time if NTP fails
|
||||
|
||||
### Key design decisions
|
||||
|
||||
1. **Thumbnails loaded synchronously during render** (not cached) — keeps memory usage low; max 4 thumbnail fetches per day view
|
||||
2. **Text wrapping** (`draw_wrapped()`) — PicoGraphics has no automatic wrapping, so manual word-boundary wrapping is implemented
|
||||
3. **Scroll arrows** for >4 records — simple ^ v arrows for paging through records
|
||||
4. **Touch API compatibility** — `read_touch()` tries multiple Presto firmware touch APIs
|
||||
5. **Selected cell border** — today+selected combo shows both highlight and border
|
||||
|
||||
### What's NOT tested
|
||||
|
||||
- **Physical device testing** — requires a real Pimoroni Presto; code is written for stock firmware but may need minor adjustments
|
||||
- **JPEG decoder API** — depends on firmware version; 3 fallback methods provided
|
||||
- **Touch API** — depends on firmware version; multiple methods tried in `read_touch()`
|
||||
- **Memory pressure** — 1030 lines is large for MicroPython; may need to be split across files or use frozen bytecode
|
||||
- **SSL/TLS** — `urequests` on MicroPython may need explicit SSL context or may not support HTTPS depending on firmware build
|
||||
|
||||
### Potential firmware adjustments needed
|
||||
|
||||
1. If `jpegdec` module is named differently or has different API, update `draw_jpeg()`
|
||||
2. If `presto.touch` API differs, update `read_touch()`
|
||||
3. If `PicoGraphics` constructor signature differs, update `init_display()`
|
||||
4. If `set_font()` font names differ, update font references
|
||||
5. If HTTPS doesn't work, switch `API_BASE` to `http://` or add SSL context
|
||||
<!-- SECTION:NOTES:END -->
|
||||
@@ -0,0 +1,140 @@
|
||||
# Presto Music Library - Records On This Day
|
||||
|
||||
A MicroPython application for the [Pimoroni Presto](https://shop.pimoroni.com/products/presto?variant=54894104019323) (RP2350B, 720×720 touch display, WiFi) that connects to the Music Library API and lets you browse your record collection by release date.
|
||||
|
||||
## Features
|
||||
|
||||
- **Month calendar** — 7×6 grid with day-of-week headers, today highlight
|
||||
- **Tap a day** — see records released on that date with cover art, titles, and artist names
|
||||
- **Month navigation** — arrow buttons to browse previous/future months
|
||||
- **WiFi auto-connect** — connects on boot with retry logic
|
||||
- **NTP time sync** — calendar always shows the correct date
|
||||
- **Error handling** — graceful messages when the server is unreachable or images fail to load
|
||||
|
||||
## Requirements
|
||||
|
||||
- Pimoroni Presto running stock firmware (MicroPython + PicoGraphics + touch driver)
|
||||
- WiFi network with internet access
|
||||
- A valid API token for the Music Library API
|
||||
|
||||
## Setup
|
||||
|
||||
### 1. Create secrets.py
|
||||
|
||||
```bash
|
||||
cp config.example.py secrets.py
|
||||
```
|
||||
|
||||
Edit `secrets.py` and fill in your credentials:
|
||||
|
||||
```python
|
||||
WIFI_SSID = "your-wifi-ssid"
|
||||
WIFI_PASSWORD = "your-wifi-password"
|
||||
API_TOKEN = "your-api-token"
|
||||
```
|
||||
|
||||
> `secrets.py` is git-ignored and never committed.
|
||||
|
||||
### 2. Deploy to your Presto
|
||||
|
||||
Copy the files to the root of your Presto's flash storage. You can use Thonny, `mpremote`, or any MicroPython file transfer tool.
|
||||
|
||||
**Using `mpremote`:**
|
||||
|
||||
```bash
|
||||
# Install mpremote if you don't have it
|
||||
pip install mpremote
|
||||
|
||||
# Copy files to the Presto
|
||||
mpremote fs cp main.py :main.py
|
||||
mpremote fs cp secrets.py :secrets.py
|
||||
|
||||
# Reset the device to start the app
|
||||
mpremote reset
|
||||
```
|
||||
|
||||
**Using Thonny:**
|
||||
|
||||
1. Open Thonny and select "MicroPython (Raspberry Pi Pico)" as the interpreter
|
||||
2. Open `main.py` and `secrets.py` in Thonny
|
||||
3. Use File → Save Copy → Raspberry Pi Pico to save each file to the device
|
||||
4. Press the reset button on the Presto, or use Run → Send EOF / Soft Reboot
|
||||
|
||||
### 3. The app starts automatically
|
||||
|
||||
The Presto runs `main.py` on boot. The app will:
|
||||
1. Show a startup screen
|
||||
2. Connect to WiFi (showing progress)
|
||||
3. Sync time via NTP
|
||||
4. Display the current month calendar
|
||||
|
||||
## Usage
|
||||
|
||||
### Month view
|
||||
|
||||
- **Today's date** is highlighted in blue
|
||||
- Tap **← →** arrows to navigate months
|
||||
- Tap any **day cell** to see records released on that date
|
||||
|
||||
### Day view
|
||||
|
||||
- Shows up to 4 records per screen with **cover art**, **title**, and **artists**
|
||||
- Tap **< Back** to return to the month view
|
||||
- If there are more than 4 records, use **^ v** arrows to scroll
|
||||
|
||||
### Error states
|
||||
|
||||
- **"Could not reach server"** — the API is unreachable. Tap anywhere on the day view to retry, or tap Back to return to the month view.
|
||||
- **"WiFi connection failed"** — WiFi credentials are wrong or the network is down. The app retries every 10 seconds.
|
||||
- **Grey placeholder** in place of cover art — the image could not be loaded (network issue or invalid URL). The rest of the record info is still shown.
|
||||
|
||||
## Files
|
||||
|
||||
```
|
||||
presto/
|
||||
main.py # Application entry point (auto-runs on boot)
|
||||
config.example.py # Template for secrets.py
|
||||
secrets.py # Your credentials (git-ignored, create from example)
|
||||
README.md # This file
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
| Symptom | Likely cause | Fix |
|
||||
|---------|-------------|-----|
|
||||
| Stuck on "secrets.py not found" | `secrets.py` not on the device | Copy `config.example.py` to `secrets.py` and deploy it |
|
||||
| Stuck on "WiFi connection failed" | Wrong SSID/password, or network down | Check `secrets.py` credentials; verify network is up |
|
||||
| "Could not reach server" on day tap | API token invalid, or server down | Check `API_TOKEN` in `secrets.py`; verify the server is running |
|
||||
| Images show as grey rectangles | JPEG decoder not available, or image URLs unreachable | Check firmware version; ensure Presto has internet access |
|
||||
| Touch not responding | Firmware touch API mismatch | Try updating Presto firmware to the latest version |
|
||||
|
||||
## API Endpoint
|
||||
|
||||
The app talks to:
|
||||
|
||||
```
|
||||
GET https://music-library.claudio-ortolina.org/api/v1/collection/on_this_day?date=YYYY-MM-DD
|
||||
Authorization: Bearer <API_TOKEN>
|
||||
```
|
||||
|
||||
Response format:
|
||||
|
||||
```json
|
||||
{
|
||||
"records": [
|
||||
{
|
||||
"id": "...",
|
||||
"title": "Album Title",
|
||||
"artists": ["Artist Name"],
|
||||
"thumb_url": "https://.../api/v1/assets/thumb.jpg?width=480",
|
||||
"release_date": "1973-03-01",
|
||||
"genres": ["Rock"],
|
||||
"format": "Vinyl"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
## License
|
||||
|
||||
This application is part of the Music Library project.
|
||||
@@ -0,0 +1,20 @@
|
||||
# config.example.py — Template for secrets.py
|
||||
# ==============================================
|
||||
# Copy this file to secrets.py and fill in your values.
|
||||
# secrets.py is git-ignored; never commit it.
|
||||
#
|
||||
# To deploy to your Presto:
|
||||
# 1. Copy this file: cp config.example.py secrets.py
|
||||
# 2. Edit secrets.py with your credentials
|
||||
# 3. Copy secrets.py to the Presto alongside main.py
|
||||
#
|
||||
# --- Required variables ---
|
||||
|
||||
# Your WiFi network name (SSID)
|
||||
WIFI_SSID = "your-wifi-ssid"
|
||||
|
||||
# Your WiFi password
|
||||
WIFI_PASSWORD = "your-wifi-password"
|
||||
|
||||
# Music Library API token (get it from the app admin)
|
||||
API_TOKEN = "your-api-token"
|
||||
@@ -0,0 +1,10 @@
|
||||
[tools]
|
||||
"uv" = "latest"
|
||||
"pipx:mpremote" = "latest"
|
||||
|
||||
[tasks.presto]
|
||||
run = '''
|
||||
mpremote fs cp records_on_the_day.py :main.py
|
||||
mpremote reset
|
||||
'''
|
||||
dir = "{{cwd}}"
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,160 @@
|
||||
# ICON schedule
|
||||
# NAME Word Clock
|
||||
# DESC No hands!
|
||||
|
||||
import time
|
||||
|
||||
import machine
|
||||
import ntptime
|
||||
import pngdec
|
||||
from presto import Presto
|
||||
|
||||
# Setup for the Presto display
|
||||
presto = Presto()
|
||||
display = presto.display
|
||||
WIDTH, HEIGHT = display.get_bounds()
|
||||
BLACK = display.create_pen(0, 0, 0)
|
||||
WHITE = display.create_pen(200, 200, 200)
|
||||
GRAY = display.create_pen(30, 30, 30)
|
||||
|
||||
# Clear the screen before the network call is made
|
||||
display.set_pen(BLACK)
|
||||
display.clear()
|
||||
presto.update()
|
||||
|
||||
# Length of time between updates in minutes.
|
||||
UPDATE_INTERVAL = 15
|
||||
|
||||
rtc = machine.RTC()
|
||||
time_string = None
|
||||
words = ["it", "d", "is", "m", "about", "lv", "half", "c", "quarter", "b", "to", "past", "n", "one",
|
||||
"two", "three", "four", "five", "six", "eleven", "ten", "d", "qdh", "eight", "seven", "rm", "twelve", "nine", "p", "ncsnheypresto", "O'Clock", "agrdsp"]
|
||||
|
||||
|
||||
def show_message(text):
|
||||
display.set_pen(BLACK)
|
||||
display.clear()
|
||||
display.set_pen(WHITE)
|
||||
display.text(f"{text}", 5, 10, WIDTH, 2)
|
||||
presto.update()
|
||||
|
||||
|
||||
show_message("Connecting...")
|
||||
|
||||
try:
|
||||
wifi = presto.connect()
|
||||
except ValueError as e:
|
||||
while True:
|
||||
show_message(e)
|
||||
except ImportError as e:
|
||||
while True:
|
||||
show_message(e)
|
||||
|
||||
# Set the correct time using the NTP service.
|
||||
try:
|
||||
ntptime.settime()
|
||||
except OSError:
|
||||
while True:
|
||||
show_message("Unable to get time.\n\nCheck your network try again.")
|
||||
|
||||
|
||||
def approx_time(hours, minutes):
|
||||
nums = {0: "twelve", 1: "one", 2: "two",
|
||||
3: "three", 4: "four", 5: "five", 6: "six",
|
||||
7: "seven", 8: "eight", 9: "nine", 10: "ten",
|
||||
11: "eleven", 12: "twelve"}
|
||||
|
||||
if hours == 12:
|
||||
hours = 0
|
||||
|
||||
if minutes >= 0 and minutes < 8:
|
||||
return "it is about " + nums[hours] + " O'Clock"
|
||||
|
||||
if minutes >= 8 and minutes < 23:
|
||||
return "it is about quarter past " + nums[hours]
|
||||
|
||||
if minutes >= 23 and minutes < 38:
|
||||
return "it is about half past " + nums[hours]
|
||||
|
||||
if minutes >= 38 and minutes < 53:
|
||||
return "it is about quarter to " + nums[hours + 1]
|
||||
|
||||
return "it is about " + nums[hours + 1] + " O'Clock"
|
||||
|
||||
|
||||
def update():
|
||||
global time_string
|
||||
# grab the current time from the ntp server and update the Pico RTC
|
||||
try:
|
||||
ntptime.settime()
|
||||
except OSError:
|
||||
print("Unable to contact NTP server")
|
||||
|
||||
current_t = rtc.datetime()
|
||||
time_string = approx_time(current_t[4] - 12 if current_t[4] > 12 else current_t[4], current_t[5])
|
||||
|
||||
# Splits the string into an array of words for displaying later
|
||||
time_string = time_string.split()
|
||||
|
||||
print(time_string)
|
||||
|
||||
|
||||
def draw():
|
||||
global time_string
|
||||
display.set_font("bitmap8")
|
||||
|
||||
display.set_layer(1)
|
||||
|
||||
# Clear the screen
|
||||
display.set_pen(BLACK)
|
||||
display.clear()
|
||||
|
||||
default_x = 25
|
||||
x = default_x
|
||||
y = 35
|
||||
|
||||
line_space = 20
|
||||
letter_space = 15
|
||||
margin = 25
|
||||
scale = 1
|
||||
spacing = 1
|
||||
|
||||
for word in words:
|
||||
|
||||
if word in time_string:
|
||||
display.set_pen(WHITE)
|
||||
else:
|
||||
display.set_pen(GRAY)
|
||||
|
||||
for letter in word:
|
||||
text_length = display.measure_text(letter, scale, spacing)
|
||||
if not x + text_length <= WIDTH - margin:
|
||||
y += line_space
|
||||
x = default_x
|
||||
|
||||
display.text(letter.upper(), x, y, WIDTH, scale=scale, spacing=spacing)
|
||||
x += letter_space
|
||||
|
||||
presto.update()
|
||||
|
||||
|
||||
# Set the background in layer 0
|
||||
# This means we don't need to decode the image every frame
|
||||
|
||||
display.set_layer(0)
|
||||
|
||||
try:
|
||||
p = pngdec.PNG(display)
|
||||
|
||||
p.open_file("wordclock_background.png")
|
||||
p.decode(0, 0)
|
||||
except OSError:
|
||||
display.set_pen(BLACK)
|
||||
display.clear()
|
||||
|
||||
|
||||
while True:
|
||||
update()
|
||||
draw()
|
||||
time.sleep(60 * UPDATE_INTERVAL)
|
||||
|
||||
Reference in New Issue
Block a user