diff --git a/.gitignore b/.gitignore index f058674f..c33c9be3 100644 --- a/.gitignore +++ b/.gitignore @@ -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 diff --git a/backlog/tasks/ml-166 - Presto-MicroPython-client-records-on-this-day-calendar-with-cover-display.md b/backlog/tasks/ml-166 - Presto-MicroPython-client-records-on-this-day-calendar-with-cover-display.md new file mode 100644 index 00000000..3e393f5c --- /dev/null +++ b/backlog/tasks/ml-166 - Presto-MicroPython-client-records-on-this-day-calendar-with-cover-display.md @@ -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 + + + +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 ` 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). + + + +## Acceptance Criteria + + + +- [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 + + +## Implementation Plan + + + +## 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. + + + +## Implementation Notes + + + +## 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 ` 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 + diff --git a/presto/README.md b/presto/README.md new file mode 100644 index 00000000..4982a62d --- /dev/null +++ b/presto/README.md @@ -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 +``` + +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. diff --git a/presto/config.example.py b/presto/config.example.py new file mode 100644 index 00000000..00a34727 --- /dev/null +++ b/presto/config.example.py @@ -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" diff --git a/presto/mise.toml b/presto/mise.toml new file mode 100644 index 00000000..02ceb5e5 --- /dev/null +++ b/presto/mise.toml @@ -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}}" diff --git a/presto/records_on_the_day.py b/presto/records_on_the_day.py new file mode 100644 index 00000000..a1df5de5 --- /dev/null +++ b/presto/records_on_the_day.py @@ -0,0 +1,1040 @@ +""" +Presto Music Library - Records On This Day Calendar +=================================================== +A MicroPython application for the Pimoroni Presto (RP2350B, 720x720 touch +display, WiFi) that connects to the Music Library API and lets you browse +records by release date. + +Features: + - WiFi auto-connect with retry + - NTP time synchronisation + - Month calendar view with day-of-week headers + - Tap a day to see records released on that date (cover art, artist, title) + - Navigate months with arrow buttons + - Error handling for network / API failures + +Hardware: Pimoroni Presto running stock Presto firmware + (MicroPython + PicoGraphics + touch driver) + +Setup: + 1. Copy config.example.py to secrets.py and fill in your credentials + 2. Copy main.py and secrets.py to the root of your Presto's flash + 3. The app starts automatically on boot (main.py is the default entry point) +""" + +# ============================================================================ +# IMPORTS +# ============================================================================ + +import gc +import json +import time +import network +import ntptime + +import urequests + +# Presto hardware module (provided by stock firmware) +from presto import Presto + +# JPEG decoder (provided by stock firmware; may be named jpegdec or picographics +# depending on firmware version — adjust import if needed) +try: + import jpegdec as _jpegdec_lib + _HAS_JPEGDEC = True +except ImportError: + _HAS_JPEGDEC = False + +# ============================================================================ +# CONFIGURATION +# ============================================================================ + +# API endpoint (production server) +API_BASE = "https://music-library.claudio-ortolina.org" + +# Setup for the Presto display +presto = Presto() +display = presto.display +WIDTH, HEIGHT = display.get_bounds() +touch = presto.touch + +# Calendar layout (computed from display bounds) +CELL_GAP = 3 +CELLS_PER_ROW = 7 +MAX_ROWS = 6 + +# Header +HEADER_Y = 0 +HEADER_H = 46 +MONTH_Y = HEADER_Y + 6 +ARROW_W = 48 +ARROW_H = 34 + +# Day-of-week labels +DOW_Y = HEADER_Y + HEADER_H + 2 +DOW_H = 16 + +# Day cells: fit 7 across with comfortable side margins +CELL_SIZE = (WIDTH - 16 - (CELLS_PER_ROW - 1) * CELL_GAP) // CELLS_PER_ROW +if CELL_SIZE > 78: + CELL_SIZE = 78 +GRID_LEFT = (WIDTH - (CELLS_PER_ROW * CELL_SIZE + (CELLS_PER_ROW - 1) * CELL_GAP)) // 2 + +# Grid starts after day-of-week labels with a small gap +CALENDAR_TOP = DOW_Y + DOW_H + 6 + +# Day view (proportional to cell size) +BACK_X = 8 +BACK_Y = 8 +BACK_W = 44 +BACK_H = 32 +RECORD_START_Y = BACK_Y + BACK_H + 12 +THUMB_SIZE = 75 +THUMB_MARGIN = 8 +TEXT_X = THUMB_MARGIN + THUMB_SIZE + 12 +TEXT_W = WIDTH - TEXT_X - 12 + +# Colors (RGB tuples, converted to pens at runtime) +BG = (22, 22, 36) +CELL_BG = (48, 48, 66) +CELL_OTHER_MONTH = (35, 35, 50) +TODAY_BG = (50, 90, 175) +TODAY_TEXT = (255, 255, 255) +NORMAL_TEXT = (215, 215, 235) +DIM_TEXT = (125, 125, 150) +HEADER_TEXT = (240, 240, 255) +ARROW_COLOR = (185, 185, 210) +BACK_COLOR = (210, 210, 235) +TITLE_COLOR = (240, 240, 255) +ARTIST_COLOR = (190, 190, 215) +ERROR_COLOR = (255, 115, 115) +PLACEHOLDER_BG = (65, 65, 85) +STATUS_TEXT = (170, 170, 195) + +# Days of week (Monday first, ISO 8601) +DAY_NAMES = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"] +MONTH_NAMES = [ + "January", "February", "March", "April", "May", "June", + "July", "August", "September", "October", "November", "December", +] + +# Touch debounce (milliseconds) +DEBOUNCE_MS = 300 + +# WiFi connection timeout (seconds) +WIFI_TIMEOUT = 30 + +# ============================================================================ +# GLOBAL STATE +# ============================================================================ + +api_token = "" + +# Time state +today_year = 2026 +today_month = 5 +today_day = 1 +view_year = 2026 +view_month = 5 + +# View state +STATE_STARTUP = 0 +STATE_MONTH = 1 +STATE_DAY = 2 +STATE_ERROR = 3 +state = STATE_STARTUP + +# Day view state +selected_day = 0 # 1-based day of month +records = [] # List of record dicts from API +records_error = False # True if API call failed +# Scroll state +scroll_offset = 0 # Scroll position for day view +_visible_count = 1 # How many records fit on screen (set during draw) + +# Touch debounce +_last_touch = 0 + +# ============================================================================ +# HELPER: PEN CREATION +# ============================================================================ + +# We create pens once and reuse them to reduce GC pressure. +# Stored as module-level variables, initialised in init_display(). + +_pen_bg = None +_pen_cell_bg = None +_pen_cell_other = None +_pen_today_bg = None +_pen_today_text = None +_pen_normal_text = None +_pen_dim_text = None +_pen_header_text = None +_pen_arrow = None +_pen_back = None +_pen_title = None +_pen_artist = None +_pen_error = None +_pen_placeholder = None +_pen_status = None + + +def _init_pens(): + """Pre-create all pens from the RGB color constants.""" + global _pen_bg, _pen_cell_bg, _pen_cell_other, _pen_today_bg + global _pen_today_text, _pen_normal_text, _pen_dim_text, _pen_header_text + global _pen_arrow, _pen_back, _pen_title, _pen_artist, _pen_error + global _pen_placeholder, _pen_status + + _pen_bg = display.create_pen(*BG) + _pen_cell_bg = display.create_pen(*CELL_BG) + _pen_cell_other = display.create_pen(*CELL_OTHER_MONTH) + _pen_today_bg = display.create_pen(*TODAY_BG) + _pen_today_text = display.create_pen(*TODAY_TEXT) + _pen_normal_text = display.create_pen(*NORMAL_TEXT) + _pen_dim_text = display.create_pen(*DIM_TEXT) + _pen_header_text = display.create_pen(*HEADER_TEXT) + _pen_arrow = display.create_pen(*ARROW_COLOR) + _pen_back = display.create_pen(*BACK_COLOR) + _pen_title = display.create_pen(*TITLE_COLOR) + _pen_artist = display.create_pen(*ARTIST_COLOR) + _pen_error = display.create_pen(*ERROR_COLOR) + _pen_placeholder = display.create_pen(*PLACEHOLDER_BG) + _pen_status = display.create_pen(*STATUS_TEXT) + + +# ============================================================================ +# HELPER: TEXT WRAPPING +# ============================================================================ + +def draw_wrapped(text, x, y, max_width, pen, scale=1): + """Draw text, wrapping at word boundaries to fit within max_width pixels. + + PicoGraphics doesn't support automatic wrapping, so we manually break + long strings. Returns the Y position after the last line drawn. + """ + words = text.split(" ") + lines = [] + current_line = "" + + for word in words: + test_line = current_line + (" " if current_line else "") + word + w = display.measure_text(test_line, scale=scale) + if w <= max_width: + current_line = test_line + else: + if current_line: + lines.append(current_line) + # If a single word is too long, we draw it anyway (truncation + # would hide information). + if display.measure_text(word, scale=scale) > max_width: + lines.append(word) + current_line = "" + else: + current_line = word + + if current_line: + lines.append(current_line) + + line_height = 8 * scale + 4 # bitmap8 is 8px tall, plus spacing + cy = y + display.set_pen(pen) + for line in lines: + display.text(line, x, cy, scale=scale) + cy += line_height + + return cy + + +# ============================================================================ +# HELPER: DATE / CALENDAR MATH +# ============================================================================ + +def days_in_month(year, month): + """Return the number of days in the given month.""" + if month == 2: + # Leap year check + if (year % 4 == 0 and year % 100 != 0) or (year % 400 == 0): + return 29 + return 28 + if month in (4, 6, 9, 11): + return 30 + return 31 + + +def day_of_week(year, month, day): + """Return 0=Monday .. 6=Sunday using Zeller-like algorithm adjusted + for our calendar (Monday-first weeks).""" + # Tomohiko Sakamoto's algorithm, adjusted for Monday=0 + t = [0, 3, 2, 5, 0, 3, 5, 1, 4, 6, 2, 4] + y = year - (1 if month < 3 else 0) + d = (y + y // 4 - y // 100 + y // 400 + t[month - 1] + day) % 7 + # Convert Sunday=0 to Sunday=6, Monday=1 to Monday=0, etc. + return (d + 6) % 7 + + +def previous_month(year, month): + """Return (year, month) for the previous month.""" + if month == 1: + return year - 1, 12 + return year, month - 1 + + +def next_month(year, month): + """Return (year, month) for the next month.""" + if month == 12: + return year + 1, 1 + return year, month + 1 + + +# ============================================================================ +# NETWORKING +# ============================================================================ + +def connect_wifi(): + """Connect to WiFi using credentials from secrets.py. + + Returns True on success, False on failure. + Blinks status messages on the display during the attempt. + """ + try: + import secrets + except ImportError: + draw_status("ERROR: secrets.py not found.\n" + "Copy config.example.py to secrets.py") + return False + + ssid = getattr(secrets, "WIFI_SSID", "") + password = getattr(secrets, "WIFI_PASSWORD", "") + global api_token + api_token = getattr(secrets, "API_TOKEN", "") + + if not ssid: + draw_status("ERROR: WIFI_SSID not set in secrets.py") + return False + if not api_token: + draw_status("ERROR: API_TOKEN not set in secrets.py") + return False + + draw_status("Connecting to WiFi:\n{}".format(ssid)) + + wlan = network.WLAN(network.STA_IF) + wlan.active(True) + + if wlan.isconnected(): + draw_status("Already connected.\nSyncing time...") + return True + + wlan.connect(ssid, password) + + # Wait for connection with timeout + elapsed = 0 + while not wlan.isconnected() and elapsed < WIFI_TIMEOUT: + time.sleep(0.5) + elapsed += 0.5 + + if wlan.isconnected(): + ip = wlan.ifconfig()[0] + draw_status("Connected.\nIP: {}\nSyncing time...".format(ip)) + return True + else: + draw_status("WiFi connection failed.\n" + "Check credentials.\n" + "Retrying in 10 seconds...") + return False + + +def sync_time(): + """Synchronise system time via NTP. Non-fatal on failure.""" + try: + ntptime.settime() + except Exception: + # NTP failure is non-fatal; calendar still works (shows RTC time) + pass + + +def set_today(): + """Read current date from system clock into global today_* vars.""" + global today_year, today_month, today_day + lt = time.localtime() + today_year = lt[0] + today_month = lt[1] + today_day = lt[2] + + +def make_date_string(year, month, day): + """Format a date as YYYY-MM-DD for the API.""" + return "{:04d}-{:02d}-{:02d}".format(year, month, day) + + +# ============================================================================ +# API CLIENT +# ============================================================================ + +def _auth_header(): + """Return the Authorization header dict for urequests.""" + return {"Authorization": "Bearer " + api_token} + + +def fetch_records(year, month, day): + """Fetch records released on the given date from the API. + + Returns (records_list, error_flag). + - On success: (list_of_dicts, False) + - On failure: ([], True) + """ + date_str = make_date_string(year, month, day) + url = API_BASE + "/api/v1/collection/on_this_day?date=" + date_str + + try: + resp = urequests.get(url, headers=_auth_header()) + if resp.status_code == 200: + data = resp.json() + recs = data.get("records", []) + resp.close() + gc.collect() + return recs, False + else: + resp.close() + gc.collect() + return [], True + except Exception as e: + # OSError for network failures, ValueError for JSON parse errors + gc.collect() + return [], True + + +def fetch_thumbnail(url): + """Fetch a JPEG thumbnail from the given URL. + + Returns the raw bytes on success, None on failure. + """ + try: + resp = urequests.get(url, headers=_auth_header()) + if resp.status_code == 200: + data = resp.content + resp.close() + return data + resp.close() + except Exception: + pass + return None + + +# ============================================================================ +# JPEG RENDERING +# ============================================================================ + +def draw_jpeg(data, x, y, max_w, max_h): + """Decode and draw a JPEG image, scaled to fit within max_w x max_h + and centered in the bounding box. + + Uses jpegdec module (standard on Pimoroni firmware). + Falls back to a placeholder rectangle on failure. + """ + if data is None: + _draw_placeholder(x, y, max_w, max_h) + return + + # Try jpegdec module with smart scaling + if _HAS_JPEGDEC: + try: + jpeg = _jpegdec_lib.JPEG(display) + try: + jpeg.open_RAM(memoryview(data)) + except Exception: + jpeg.open_RAM(data) + + # Attempt smart scaling: get dimensions, pick best scale, center + try: + img_h = jpeg.get_height() + img_w = jpeg.get_width() + # Try scales from largest to smallest; use first that fits + # (0=full, 2=half, 4=quarter, 8=eighth) + for s in (2, 4, 8): + if img_w // s <= max_w and img_h // s <= max_h: + scale = s + break + else: + scale = 0 + sw = img_w // max(1, scale) + sh = img_h // max(1, scale) + ox = x + (max_w - sw) // 2 + oy = y + (max_h - sh) // 2 + jpeg.decode(ox, oy, scale) + return + except Exception: + pass + + # Fallback: decode at quarter scale (works for most cover art) + jpeg.decode(x, y, 4) + return + except Exception: + pass + return + except Exception: + pass + + # Method 2: PicoGraphics built-in JPEG support + try: + if hasattr(display, "open_jpeg_from_RAM"): + display.open_jpeg_from_RAM(data) + display.decode_jpeg(x, y) + return + except Exception: + pass + + # Method 3: alternate PicoGraphics JPEG API + try: + if hasattr(display, "draw_jpeg"): + display.draw_jpeg(data, x, y) + return + except Exception: + pass + + # All methods failed — draw placeholder + _draw_placeholder(x, y, max_w, max_h) + + +def _draw_placeholder(x, y, w, h): + """Draw a grey placeholder rectangle with a question mark.""" + display.set_pen(_pen_placeholder) + display.rectangle(x, y, w, h) + display.set_pen(_pen_dim_text) + cx = x + w // 2 - display.measure_text("?", scale=2) // 2 + cy = y + h // 2 - 10 + display.text("?", cx, cy, scale=2) + + +# ============================================================================ +# DRAWING: STATUS SCREEN (startup / error) +# ============================================================================ + +def draw_status(message): + """Draw a simple centered status message on the display (used during + startup and for persistent errors).""" + display.set_pen(_pen_bg) + display.clear() + display.set_pen(_pen_status) + display.set_font("bitmap8") + + lines = message.split("\n") + line_h = 20 + total_h = len(lines) * line_h + start_y = (HEIGHT - total_h) // 2 + + for i, line in enumerate(lines): + w = display.measure_text(line, scale=1) + x = (WIDTH - w) // 2 + display.text(line, x, start_y + i * line_h, scale=1) + + presto.update() + + +# ============================================================================ +# DRAWING: MONTH CALENDAR +# ============================================================================ + +def draw_month_view(): + """Render the full month calendar view including header, day-of-week + labels, day grid, and today highlight.""" + display.set_pen(_pen_bg) + display.clear() + + _draw_month_header() + _draw_day_labels() + _draw_day_grid() + presto.update() + + +def _draw_month_header(): + """Draw the month/year title and navigation arrows at the top.""" + # Background bar + display.set_pen(_pen_cell_bg) + display.rectangle(0, HEADER_Y, WIDTH, HEADER_H) + + # Month and year text (centered) + display.set_pen(_pen_header_text) + display.set_font("bitmap14_outline") + title = "{} {}".format(MONTH_NAMES[view_month - 1], view_year) + tw = display.measure_text(title, scale=1) + tx = (WIDTH - tw) // 2 + ty = MONTH_Y + display.text(title, tx, ty, scale=1) + + # Left arrow + lx = 12 + ly = HEADER_Y + (HEADER_H - ARROW_H) // 2 + display.set_pen(_pen_cell_bg) # Match header bg + display.rectangle(lx, ly, ARROW_W, ARROW_H) + display.set_pen(_pen_arrow) + display.set_font("bitmap14_outline") + display.text("<", lx + 14, ly + 8, scale=1) + + # Right arrow + rx = WIDTH - ARROW_W - 12 + ry = HEADER_Y + (HEADER_H - ARROW_H) // 2 + display.set_pen(_pen_cell_bg) + display.rectangle(rx, ry, ARROW_W, ARROW_H) + display.set_pen(_pen_arrow) + display.text(">", rx + 12, ry + 8, scale=1) + + +def _draw_day_labels(): + """Draw Mon..Sun labels below the header.""" + display.set_pen(_pen_dim_text) + display.set_font("bitmap8") + for i, name in enumerate(DAY_NAMES): + cx = GRID_LEFT + i * (CELL_SIZE + CELL_GAP) + CELL_SIZE // 2 + tw = display.measure_text(name, scale=1) + display.text(name, cx - tw // 2, DOW_Y, scale=1) + + +def _draw_day_grid(): + """Draw the 7x6 grid of day cells for the current view month.""" + days = days_in_month(view_year, view_month) + first_dow = day_of_week(view_year, view_month, 1) + + display.set_font("bitmap8") + + # Draw cells for 6 rows + for row in range(MAX_ROWS): + for col in range(CELLS_PER_ROW): + cell_index = row * CELLS_PER_ROW + col + day_num = cell_index - first_dow + 1 + + cx = GRID_LEFT + col * (CELL_SIZE + CELL_GAP) + cy = CALENDAR_TOP + row * (CELL_SIZE + CELL_GAP) + + if day_num < 1 or day_num > days: + # Cell belongs to previous/next month — dim it + display.set_pen(_pen_cell_other) + display.rectangle(cx, cy, CELL_SIZE, CELL_SIZE) + if day_num > days: + # Show overflow day numbers dimmed + overflow = day_num - days + display.set_pen(_pen_dim_text) + _draw_centered_num(overflow, cx + CELL_SIZE // 2, cy + CELL_SIZE // 2) + continue + + # Determine cell style + is_today = ( + view_year == today_year + and view_month == today_month + and day_num == today_day + ) + is_selected = (state == STATE_DAY and day_num == selected_day) + + # Handle today + selected combo specially (show both highlight and border) + if is_today and is_selected: + display.set_pen(_pen_today_bg) + display.rectangle(cx, cy, CELL_SIZE, CELL_SIZE) + # Selection border on top of today highlight + display.set_pen(_pen_arrow) + display.line(cx, cy, cx + CELL_SIZE - 1, cy) + display.line(cx, cy + CELL_SIZE - 1, cx + CELL_SIZE - 1, cy + CELL_SIZE - 1) + display.line(cx, cy, cx, cy + CELL_SIZE - 1) + display.line(cx + CELL_SIZE - 1, cy, cx + CELL_SIZE - 1, cy + CELL_SIZE - 1) + display.set_pen(_pen_today_text) + _draw_centered_num(day_num, cx + CELL_SIZE // 2, cy + CELL_SIZE // 2) + continue + + if is_today: + display.set_pen(_pen_today_bg) + elif is_selected: + # Outline the selected cell (non-today) + display.set_pen(_pen_cell_bg) + display.rectangle(cx, cy, CELL_SIZE, CELL_SIZE) + display.set_pen(_pen_arrow) + display.line(cx, cy, cx + CELL_SIZE - 1, cy) + display.line(cx, cy + CELL_SIZE - 1, cx + CELL_SIZE - 1, cy + CELL_SIZE - 1) + display.line(cx, cy, cx, cy + CELL_SIZE - 1) + display.line(cx + CELL_SIZE - 1, cy, cx + CELL_SIZE - 1, cy + CELL_SIZE - 1) + display.set_pen(_pen_normal_text) + _draw_centered_num(day_num, cx + CELL_SIZE // 2, cy + CELL_SIZE // 2) + continue + else: + display.set_pen(_pen_cell_bg) + + display.rectangle(cx, cy, CELL_SIZE, CELL_SIZE) + + # Day number + if is_today: + display.set_pen(_pen_today_text) + else: + display.set_pen(_pen_normal_text) + _draw_centered_num(day_num, cx + CELL_SIZE // 2, cy + CELL_SIZE // 2) + + +def _draw_centered_num(num, cx, cy): + """Draw a number centered at (cx, cy).""" + text = str(num) + tw = display.measure_text(text, scale=1) + display.text(text, cx - tw // 2, cy - 5, scale=1) + + +# ============================================================================ +# DRAWING: DAY VIEW (records list) +# ============================================================================ + +def draw_day_view(): + """Render the day view showing records for the selected date.""" + display.set_pen(_pen_bg) + display.clear() + + _draw_day_header() + + if records_error: + _draw_day_error() + presto.update() + return + + if not records: + _draw_day_empty() + presto.update() + return + + _draw_record_list() + presto.update() + + +def _draw_day_header(): + """Draw the header with formatted date and back button.""" + # Background bar + display.set_pen(_pen_cell_bg) + display.rectangle(0, BACK_Y - 4, WIDTH, BACK_H + 8) + + # Back button + bx, by = BACK_X, BACK_Y + display.set_pen(_pen_placeholder) + display.rectangle(bx, by, BACK_W, BACK_H) + display.set_pen(_pen_back) + display.set_font("bitmap8") + display.text("< Back", bx + 10, by + 14, scale=1) + + # Formatted date + date_str = "{} {}, {}".format( + MONTH_NAMES[view_month - 1], selected_day, view_year + ) + display.set_pen(_pen_header_text) + display.set_font("bitmap14_outline") + tw = display.measure_text(date_str, scale=1) + display.text(date_str, (WIDTH - tw) // 2, BACK_Y + 8, scale=1) + + # Record count + count_str = "{} record{}".format(len(records), "s" if len(records) != 1 else "") + display.set_pen(_pen_dim_text) + display.set_font("bitmap8") + cw = display.measure_text(count_str, scale=1) + # display.text(count_str, WIDTH - cw - 16, BACK_Y + BACK_H - 4, scale=1) + display.text(count_str, WIDTH - cw - 16, BACK_Y + 14, scale=1) + + +def _draw_day_error(): + """Show error message when API call failed.""" + display.set_pen(_pen_error) + display.set_font("bitmap8") + msg = "Could not reach server" + mw = display.measure_text(msg, scale=1) + display.text(msg, (WIDTH - mw) // 2, HEIGHT // 2 - 20, scale=1) + + display.set_pen(_pen_dim_text) + hint = "Tap < Back to return" + hw = display.measure_text(hint, scale=1) + display.text(hint, (WIDTH - hw) // 2, HEIGHT // 2 + 10, scale=1) + + +def _draw_day_empty(): + """Show message when no records exist for the selected date.""" + display.set_pen(_pen_dim_text) + display.set_font("bitmap8") + msg = "No records on this day" + mw = display.measure_text(msg, scale=1) + display.text(msg, (WIDTH - mw) // 2, HEIGHT // 2 - 10, scale=1) + + +def _draw_record_list(): + """Draw the scrollable list of record rows with cover thumbnails. + Uses dynamic row heights so wrapped text doesn't break layout.""" + start_idx = scroll_offset + total = len(records) + + # Up arrow if scrolled down + if scroll_offset > 0: + display.set_pen(_pen_arrow) + display.set_font("bitmap14_outline") + display.text("^", WIDTH // 2 - 8, RECORD_START_Y - 28, scale=1) + + # Draw rows with dynamic heights + cy = RECORD_START_Y + visible_count = 0 + for i in range(start_idx, total): + rec = records[i] + row_bottom = _draw_record_row(rec, cy) + # Stop if the row would overflow the display + if row_bottom > HEIGHT - 28: + break + cy = row_bottom + visible_count += 1 + + # Down arrow if more records below + if start_idx + visible_count < total: + display.set_pen(_pen_arrow) + display.set_font("bitmap14_outline") + display.text("v", WIDTH // 2 - 8, cy + 4, scale=1) + + # Store how many fit for scroll logic + global _visible_count + _visible_count = max(1, visible_count) + + +def _draw_record_row(rec, y): + """Draw a single record row. Returns the Y position after the row + (including separator), so the next row can be positioned correctly + even when title/artist text wraps to multiple lines.""" + min_h = THUMB_SIZE + 8 # Minimum row height (thumbnail + padding) + + # Fetch and draw thumbnail (top-aligned in row) + thumb_url = rec.get("mini_cover_url", "") + thumb_y = y + 4 + if thumb_url: + jpeg_data = fetch_thumbnail(thumb_url) + draw_jpeg(jpeg_data, THUMB_MARGIN, thumb_y, THUMB_SIZE, THUMB_SIZE) + gc.collect() + else: + _draw_placeholder(THUMB_MARGIN, thumb_y, THUMB_SIZE, THUMB_SIZE) + + # Title (starts at same height as thumbnail) + title = rec.get("title", "Unknown Title") + display.set_font("bitmap8") + ty = y + 4 + ty = draw_wrapped(title, TEXT_X, ty, TEXT_W, _pen_title, scale=1) + + # Artists + artists = rec.get("artists", []) + if artists: + artist_str = ", ".join(artists) + ty += 2 + ty = draw_wrapped(artist_str, TEXT_X, ty, TEXT_W, _pen_artist, scale=1) + + # Bottom of content: at least thumbnail bottom, at least min_h + content_bottom = max(thumb_y + THUMB_SIZE, ty) + 6 + row_bottom = max(y + min_h, content_bottom) + + # Separator line at the computed bottom + display.set_pen(_pen_cell_other) + display.line(THUMB_MARGIN, row_bottom - 2, WIDTH - THUMB_MARGIN, row_bottom - 2) + + return row_bottom + + +# ============================================================================ +# TOUCH HANDLING +# ============================================================================ +def touch_to_calendar_cell(x, y): + """Map a touch (x, y) to (row, col) in the calendar grid, or None + if the touch is outside the grid.""" + if y < CALENDAR_TOP: + return None + + col = (x - GRID_LEFT) // (CELL_SIZE + CELL_GAP) + row = (y - CALENDAR_TOP) // (CELL_SIZE + CELL_GAP) + + # Check within cell (not in gap) + cx = GRID_LEFT + col * (CELL_SIZE + CELL_GAP) + cy = CALENDAR_TOP + row * (CELL_SIZE + CELL_GAP) + if cx <= x < cx + CELL_SIZE and cy <= y < cy + CELL_SIZE: + if 0 <= col < CELLS_PER_ROW and 0 <= row < MAX_ROWS: + return row, col + return None + + +def cell_to_day(row, col): + """Convert a calendar cell (row, col) to a day number (1-based) + for the current view month. Returns None if the cell is outside + the current month.""" + first_dow = day_of_week(view_year, view_month, 1) + day_num = row * CELLS_PER_ROW + col - first_dow + 1 + days = days_in_month(view_year, view_month) + if 1 <= day_num <= days: + return day_num + return None + + +def handle_month_touch(x, y): + """Process a touch event in month view state.""" + global view_year, view_month, selected_day, state, records, records_error, scroll_offset + + # Check left arrow + lx, ly = 12, HEADER_Y + (HEADER_H - ARROW_H) // 2 + if lx <= x <= lx + ARROW_W and ly <= y <= ly + ARROW_H: + view_year, view_month = previous_month(view_year, view_month) + draw_month_view() + return + + # Check right arrow + rx, ry = WIDTH - ARROW_W - 12, HEADER_Y + (HEADER_H - ARROW_H) // 2 + if rx <= x <= rx + ARROW_W and ry <= y <= ry + ARROW_H: + view_year, view_month = next_month(view_year, view_month) + draw_month_view() + return + + # Check calendar cells + cell = touch_to_calendar_cell(x, y) + if cell is None: + return + + row, col = cell + day_num = cell_to_day(row, col) + if day_num is None: + return + + # Valid day tapped — fetch records + selected_day = day_num + state = STATE_DAY + scroll_offset = 0 + records_error = False + records = [] + + # Show loading state + draw_status("Loading records for\n{} {}, {}...".format( + MONTH_NAMES[view_month - 1], day_num, view_year + )) + + # Fetch from API + recs, had_error = fetch_records(view_year, view_month, day_num) + records = recs + records_error = had_error + + # Render day view + draw_day_view() + + +def handle_day_touch(x, y): + """Process a tap event in day view state. + Back button and scroll are handled by drag logic in the main loop.""" + global records, records_error + + # Tap in error state: retry fetching records + if records_error: + draw_status("Retrying...") + recs, had_error = fetch_records(view_year, view_month, selected_day) + records = recs + records_error = had_error + draw_day_view() + + +# ============================================================================ +# MAIN LOOP +# ============================================================================ + +def init_display(): + """Initialise pens (display is already set up at module level).""" + try: + display.set_font("bitmap8") + except Exception: + pass + _init_pens() + + +def main(): + """Application entry point. Runs the boot sequence then enters the + main event loop.""" + global state, view_year, view_month, _last_touch + global selected_day, records, records_error, scroll_offset + + # -- Init display -- + init_display() + draw_status("Music Library\nStarting up...") + time.sleep(0.5) + + # -- WiFi -- + if not connect_wifi(): + # Show persistent error; try to reconnect in a loop + while not connect_wifi(): + time.sleep(10) + gc.collect() + + # -- NTP sync -- + sync_time() + set_today() + + # Start at current month and today's day view + view_year = today_year + view_month = today_month + selected_day = today_day + state = STATE_DAY + records_error = False + + # Fetch today's records + draw_status("Loading records for\n{} {}, {}...".format( + MONTH_NAMES[view_month - 1], today_day, view_year + )) + recs, had_error = fetch_records(view_year, view_month, today_day) + records = recs + records_error = had_error + + # Enter main loop + draw_day_view() + + while True: + touch.poll() + + if not touch.state: + time.sleep(0.03) + continue + + x, y = int(touch.x), int(touch.y) + now = time.ticks_ms() + if time.ticks_diff(now, _last_touch) < DEBOUNCE_MS: + continue + _last_touch = now + + if state == STATE_MONTH: + handle_month_touch(x, y) + # Wait for release to avoid re-triggering + while touch.state: + touch.poll() + time.sleep(0.02) + + elif state == STATE_DAY: + # Check back button immediately (no drag needed) + if BACK_X <= x <= BACK_X + BACK_W and BACK_Y <= y <= BACK_Y + BACK_H: + state = STATE_MONTH + draw_month_view() + while touch.state: + touch.poll() + time.sleep(0.02) + continue + + # Track vertical drag for scrolling + drag_start_y = y + dragged = False + while touch.state: + touch.poll() + time.sleep(0.02) + if touch.state: + dy = drag_start_y - int(touch.y) + if abs(dy) > 5: + dragged = True + # Every ~50px of drag = one record scroll + if abs(dy) >= 50: + total = len(records) + if dy > 0: + scroll_offset = min(total - _visible_count, scroll_offset + 1) + else: + scroll_offset = max(0, scroll_offset - 1) + draw_day_view() + drag_start_y = int(touch.y) + + if not dragged: + # It was a tap — handle normally + handle_day_touch(x, y) + + +# ============================================================================ +# STARTUP +# ============================================================================ + +main() + diff --git a/presto/word_clock.py b/presto/word_clock.py new file mode 100644 index 00000000..e83313bf --- /dev/null +++ b/presto/word_clock.py @@ -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) +