Files
music_library/presto/poc.py
T
2026-05-12 13:52:37 +01:00

587 lines
15 KiB
Python

"""
Elm-style architecture proof of concept for Pimoroni Presto.
Shows WiFi status, an idle timer, and device time. Tapping the display flashes
a small red circle at the touch point.
"""
import gc
import time
import network
import ntptime
from presto import Presto
# ============================================================================
# HARDWARE
# ============================================================================
presto = Presto()
display = presto.display
touch = presto.touch
WIDTH, HEIGHT = display.get_bounds()
# ============================================================================
# LAYOUT AND TIMING
# ============================================================================
LOOP_SLEEP_MS = 30
TICK_MS = 1_000
WIFI_CHECK_MS = 5_000
WIFI_RETRY_MS = 30_000
WIFI_CONNECT_TIMEOUT_MS = 10_000
WIFI_CONNECT_POLL_MS = 250
FLASH_MS = 180
SCREEN_PADDING = 28
TITLE_Y = 28
LABEL_X = SCREEN_PADDING
VALUE_X = 150
ROW_1_Y = 90
ROW_2_Y = 130
ROW_3_Y = 170
ROW_4_Y = 210
FOOTER_Y = HEIGHT - 50
FLASH_RADIUS = 14
# ============================================================================
# COLORS
# ============================================================================
BG = (24, 24, 27)
PANEL = (39, 39, 42)
TITLE = (244, 244, 245)
LABEL = (161, 161, 170)
VALUE = (228, 228, 231)
OK = (34, 197, 94)
WARN = (250, 204, 21)
ERROR = (239, 68, 68)
DIM = (113, 113, 122)
RED = (239, 68, 68)
_pen_bg = None
_pen_panel = None
_pen_title = None
_pen_label = None
_pen_value = None
_pen_ok = None
_pen_warn = None
_pen_error = None
_pen_dim = None
_pen_red = None
# ============================================================================
# MESSAGES
# ============================================================================
MSG_BOOT = 1
MSG_TICK = 2
MSG_TOUCH_DOWN = 3
MSG_TOUCH_UP = 4
MSG_FLASH_EXPIRED = 5
MSG_WIFI_CONNECTED = 6
MSG_WIFI_FAILED = 7
MSG_WIFI_STATUS = 8
MSG_TIME_SYNCED = 9
# ============================================================================
# EFFECTS
# ============================================================================
FX_RENDER = 1
FX_CONNECT_WIFI = 2
FX_CHECK_WIFI = 3
FX_SYNC_TIME = 4
FX_COLLECT_GARBAGE = 5
# ============================================================================
# MODEL
# ============================================================================
class Model:
"""Mutable application model.
The POC keeps model updates cheap and explicit. Hardware interaction stays
in effect functions, not in update().
"""
def __init__(self):
now = time.ticks_ms()
self.now_ms = now
self.started_ms = now
self.last_activity_ms = now
self.last_wifi_check_ms = 0
self.next_wifi_retry_ms = 0
self.wifi_status = "starting"
self.wifi_detail = ""
self.time_status = "not synced"
self.current_time = format_device_time()
self.idle_seconds = 0
self.flash_x = None
self.flash_y = None
self.flash_until_ms = 0
class Runtime:
"""Runtime-only event wiring state."""
def __init__(self):
now = time.ticks_ms()
self.next_tick_ms = now
self.touch_down = False
# ============================================================================
# UPDATE
# ============================================================================
def update(model, msg):
"""Apply a message to the model and return effect commands."""
tag = msg[0]
if tag == MSG_BOOT:
model.wifi_status = "connecting"
model.wifi_detail = "Looking for secrets.py"
model.time_status = "waiting for WiFi"
model.current_time = format_device_time()
return [
(FX_RENDER,),
(FX_CONNECT_WIFI,),
]
if tag == MSG_TICK:
now = msg[1]
model.now_ms = now
model.current_time = format_device_time()
model.idle_seconds = time.ticks_diff(now, model.last_activity_ms) // 1000
commands = [(FX_RENDER,)]
if should_retry_wifi(model, now):
model.wifi_status = "connecting"
model.wifi_detail = "Retrying"
commands.append((FX_RENDER,))
commands.append((FX_CONNECT_WIFI,))
return commands
if time.ticks_diff(now, model.last_wifi_check_ms) >= WIFI_CHECK_MS:
model.last_wifi_check_ms = now
commands.append((FX_CHECK_WIFI,))
return commands
if tag == MSG_TOUCH_DOWN:
x, y, now = msg[1], msg[2], msg[3]
model.now_ms = now
model.last_activity_ms = now
model.idle_seconds = 0
model.flash_x = x
model.flash_y = y
model.flash_until_ms = time.ticks_add(now, FLASH_MS)
return [(FX_RENDER,)]
if tag == MSG_TOUCH_UP:
return []
if tag == MSG_FLASH_EXPIRED:
model.flash_x = None
model.flash_y = None
model.flash_until_ms = 0
return [(FX_RENDER,)]
if tag == MSG_WIFI_CONNECTED:
ip = msg[1]
now = time.ticks_ms()
model.wifi_status = "connected"
model.wifi_detail = ip
model.last_wifi_check_ms = now
model.next_wifi_retry_ms = 0
model.time_status = "syncing"
return [
(FX_RENDER,),
(FX_SYNC_TIME,),
(FX_COLLECT_GARBAGE,),
]
if tag == MSG_WIFI_FAILED:
reason = msg[1]
now = time.ticks_ms()
model.wifi_status = "failed"
model.wifi_detail = reason
model.last_wifi_check_ms = now
model.next_wifi_retry_ms = time.ticks_add(now, WIFI_RETRY_MS)
model.time_status = "not synced"
return [
(FX_RENDER,),
(FX_COLLECT_GARBAGE,),
]
if tag == MSG_WIFI_STATUS:
connected, ip = msg[1], msg[2]
if connected:
model.wifi_status = "connected"
model.wifi_detail = ip
model.next_wifi_retry_ms = 0
elif model.wifi_status == "connected":
model.wifi_status = "disconnected"
model.wifi_detail = "Will retry"
model.next_wifi_retry_ms = time.ticks_add(time.ticks_ms(), WIFI_RETRY_MS)
return [(FX_RENDER,)]
if tag == MSG_TIME_SYNCED:
ok = msg[1]
model.current_time = format_device_time()
model.time_status = "synced" if ok else "sync failed"
return [(FX_RENDER,)]
return []
def should_retry_wifi(model, now):
if model.wifi_status == "connected" or model.wifi_status == "connecting":
return False
if model.next_wifi_retry_ms == 0:
return False
return time.ticks_diff(now, model.next_wifi_retry_ms) >= 0
# ============================================================================
# EVENT WIRING
# ============================================================================
def wire_events(runtime, model):
"""Convert hardware polling and time into app messages."""
now = time.ticks_ms()
messages = []
if time.ticks_diff(now, runtime.next_tick_ms) >= 0:
runtime.next_tick_ms = time.ticks_add(now, TICK_MS)
messages.append((MSG_TICK, now))
point = read_touch()
if point is None:
if runtime.touch_down:
runtime.touch_down = False
messages.append((MSG_TOUCH_UP, now))
elif not runtime.touch_down:
runtime.touch_down = True
messages.append((MSG_TOUCH_DOWN, point[0], point[1], now))
if model.flash_until_ms and time.ticks_diff(now, model.flash_until_ms) >= 0:
messages.append((MSG_FLASH_EXPIRED,))
return messages
# ============================================================================
# EFFECT RUNNER
# ============================================================================
def dispatch(model, first_msg):
"""Process messages, run returned effects, and enqueue follow-up messages."""
messages = [first_msg]
index = 0
while index < len(messages):
msg = messages[index]
index += 1
commands = update(model, msg)
for command in commands:
follow_up = run_effect(model, command)
if follow_up:
messages.extend(follow_up)
def run_effect(model, command):
kind = command[0]
if kind == FX_RENDER:
render(model)
return []
if kind == FX_CONNECT_WIFI:
return [connect_wifi_effect()]
if kind == FX_CHECK_WIFI:
return [check_wifi_effect()]
if kind == FX_SYNC_TIME:
return [sync_time_effect()]
if kind == FX_COLLECT_GARBAGE:
gc.collect()
return []
return []
def connect_wifi_effect():
try:
import secrets
except ImportError:
return (MSG_WIFI_FAILED, "secrets.py missing")
ssid = getattr(secrets, "WIFI_SSID", "")
password = getattr(secrets, "WIFI_PASSWORD", "")
if not ssid:
return (MSG_WIFI_FAILED, "WIFI_SSID missing")
try:
wlan = network.WLAN(network.STA_IF)
wlan.active(True)
if wlan.isconnected():
return (MSG_WIFI_CONNECTED, wlan.ifconfig()[0])
wlan.connect(ssid, password)
start = time.ticks_ms()
while not wlan.isconnected():
if time.ticks_diff(time.ticks_ms(), start) >= WIFI_CONNECT_TIMEOUT_MS:
return (MSG_WIFI_FAILED, "connect timeout")
sleep_ms(WIFI_CONNECT_POLL_MS)
return (MSG_WIFI_CONNECTED, wlan.ifconfig()[0])
except Exception:
return (MSG_WIFI_FAILED, "connect error")
def check_wifi_effect():
try:
wlan = network.WLAN(network.STA_IF)
wlan.active(True)
if wlan.isconnected():
return (MSG_WIFI_STATUS, True, wlan.ifconfig()[0])
return (MSG_WIFI_STATUS, False, "")
except Exception:
return (MSG_WIFI_STATUS, False, "")
def sync_time_effect():
try:
ntptime.settime()
return (MSG_TIME_SYNCED, True)
except Exception:
return (MSG_TIME_SYNCED, False)
# ============================================================================
# RENDER
# ============================================================================
def render(model):
"""Imperative renderer. Drawing and display update are intentional effects."""
display.set_pen(_pen_bg)
display.clear()
draw_header()
draw_status_rows(model)
draw_flash(model)
draw_footer()
presto.update()
def draw_header():
display.set_pen(_pen_title)
display.set_font("bitmap14_outline")
text = "Elm-style Presto POC"
draw_centered_text(text, TITLE_Y, scale=1)
def draw_status_rows(model):
draw_row("WiFi", wifi_status_text(model), ROW_1_Y, wifi_pen(model))
draw_row("Detail", model.wifi_detail or "-", ROW_2_Y, _pen_value)
draw_row("Idle", format_idle(model.idle_seconds), ROW_3_Y, _pen_value)
draw_row("Time", model.current_time + " " + model.time_status, ROW_4_Y, _pen_value)
def draw_row(label, value, y, value_pen):
display.set_pen(_pen_panel)
display.rectangle(SCREEN_PADDING, y - 12, WIDTH - 2 * SCREEN_PADDING, 22)
display.set_font("bitmap8")
display.set_pen(_pen_label)
display.text(label, LABEL_X, y, scale=1)
display.set_pen(value_pen)
display.text(value, VALUE_X, y, scale=1)
def draw_flash(model):
if model.flash_x is None or model.flash_y is None:
return
display.set_pen(_pen_red)
try:
display.circle(model.flash_x, model.flash_y, FLASH_RADIUS)
except Exception:
display.rectangle(
model.flash_x - FLASH_RADIUS,
model.flash_y - FLASH_RADIUS,
FLASH_RADIUS * 2,
FLASH_RADIUS * 2
)
def draw_footer():
display.set_pen(_pen_dim)
display.set_font("bitmap8")
draw_centered_text("Tap anywhere", FOOTER_Y, scale=1)
def draw_centered_text(text, y, scale=1):
width = display.measure_text(text, scale=scale)
display.text(text, (WIDTH - width) // 2, y, scale=scale)
def wifi_status_text(model):
if model.wifi_status == "connected":
return "Connected"
if model.wifi_status == "connecting":
return "Connecting"
if model.wifi_status == "disconnected":
return "Disconnected"
if model.wifi_status == "failed":
return "Failed"
return "Starting"
def wifi_pen(model):
if model.wifi_status == "connected":
return _pen_ok
if model.wifi_status == "connecting":
return _pen_warn
return _pen_error
# ============================================================================
# HARDWARE HELPERS
# ============================================================================
def init_display():
global _pen_bg, _pen_panel, _pen_title, _pen_label, _pen_value
global _pen_ok, _pen_warn, _pen_error, _pen_dim, _pen_red
_pen_bg = display.create_pen(*BG)
_pen_panel = display.create_pen(*PANEL)
_pen_title = display.create_pen(*TITLE)
_pen_label = display.create_pen(*LABEL)
_pen_value = display.create_pen(*VALUE)
_pen_ok = display.create_pen(*OK)
_pen_warn = display.create_pen(*WARN)
_pen_error = display.create_pen(*ERROR)
_pen_dim = display.create_pen(*DIM)
_pen_red = display.create_pen(*RED)
try:
display.set_font("bitmap8")
except Exception:
pass
try:
presto.set_backlight(1.0)
except Exception:
pass
def read_touch():
try:
touch.poll()
except Exception:
pass
try:
if touch.state:
return int(touch.x), int(touch.y)
except Exception:
pass
for method_name in ("read", "get_touch"):
method = getattr(touch, method_name, None)
if method is None:
continue
try:
point = method()
except Exception:
continue
normalised = normalise_touch(point)
if normalised is not None:
return normalised
return None
def normalise_touch(point):
if not point:
return None
if isinstance(point, (tuple, list)):
if len(point) == 2:
return int(point[0]), int(point[1])
if len(point) >= 3 and point[0] is not None:
return int(point[1]), int(point[2])
return None
try:
if hasattr(point, "state") and not point.state:
return None
return int(point.x), int(point.y)
except Exception:
return None
# ============================================================================
# FORMAT HELPERS
# ============================================================================
def format_device_time():
lt = time.localtime()
return "{:02d}:{:02d}:{:02d}".format(lt[3], lt[4], lt[5])
def format_idle(seconds):
minutes = seconds // 60
remaining = seconds % 60
return "{:02d}:{:02d}".format(minutes, remaining)
def sleep_ms(ms):
try:
time.sleep_ms(ms)
except AttributeError:
time.sleep(ms / 1000.0)
# ============================================================================
# MAIN
# ============================================================================
def main():
init_display()
model = Model()
runtime = Runtime()
dispatch(model, (MSG_BOOT,))
while True:
messages = wire_events(runtime, model)
for msg in messages:
dispatch(model, msg)
sleep_ms(LOOP_SLEEP_MS)
main()