Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 24 additions & 13 deletions apps/mp3/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
"""MP3 player: the songs in a folder, with the spectrum of what plays.

On a computer the songs are in ``$MP3_DIR``, else ``~/music``; on a board,
``/sd/music`` on the SD card.
``/sd/music`` on the SD card, or ``/flash/music`` on a board with no card slot.

the archived pydevices-examples' mp3_from_sd, with buttons: play and stop, previous and
next, volume. A song that ends goes on to the next one. On a board the card is mounted
Expand All @@ -22,6 +22,7 @@
from touchkit import ui

MOUNT = "/sd"
FLASH_MUSIC = "/flash/music" # where a board with no SD card keeps its songs
VOLUME = 80 # ES8311 steps of 0.5 dB: 80 is -10 dB
TOP = 132 # the spectrum's first row, in the app's area

Expand Down Expand Up @@ -82,11 +83,18 @@ def main(scope):
try:
_mount(scope)
except AttributeError: # no sdcard role on this board
ui.missing(
scope, "missing: SD card",
"The MP3 player plays the songs on the board's SD card, and this board has no sdcard (board_peripherals).",
)
return
# A board with no card slot (the Arduino GIGA R1) plays from its
# own flash instead, when there's a music folder there.
try:
os.listdir(FLASH_MUSIC)
folder = FLASH_MUSIC
except OSError:
ui.missing(
scope, "missing: SD card",
"The MP3 player plays the songs on the board's SD card, and this board has no sdcard "
"(board_peripherals). Without one it plays the .mp3 files in %s." % FLASH_MUSIC,
)
return
except Exception as e:
status.set_text("no SD card")
ui.label(scr, "No SD card: %s\n\nPut .mp3 files in %s on the card, then open the MP3 player again." % (e, folder), ui.font, ui.WHITE).align(
Expand Down Expand Up @@ -157,14 +165,17 @@ def play(i=None):
st["started"] = ticks_ms()
title.set_text("%d/%d %s (%d Hz, %s)" % (st["i"] + 1, len(songs), name[:-4], song.sample_rate, "stereo" if song.channel_count == 2 else "mono"))
play_lbl.set_text(lv.SYMBOL.STOP + " Stop")
if getattr(out, "pumped", False):
from spectrum.analyzer import levels_for

m = spectrum.meter(scope, levels_for(out), TOP)
st["meter"] = m
spectrum.start(m, scope, (hdr,))
else:
if not getattr(out, "pumped", False):
status.set_text("no spectrum: not on the audio pump")
return
try:
from spectrum.analyzer import levels_for
except ImportError as e: # the spectrum draws with pygraphics
status.set_text("no spectrum: %s" % (e,))
return
m = spectrum.meter(scope, levels_for(out), TOP)
st["meter"] = m
spectrum.start(m, scope, (hdr,))

def stop():
st["playing"] = False
Expand Down
32 changes: 28 additions & 4 deletions apps/synthstation/midi.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,11 @@

On a board with a USB host port that's every MIDI device plugged into it,
merged by ``midiports.usb_host.all_ports()``; devices plugged in later are
heard too. On Windows and Linux it's every MIDI input port the computer
has, through ``midiports.windows`` or ``midiports.linux``. In a browser it's
every MIDI input the browser offers, through ``midiports.webmidi``.
heard too. A board without one whose ``board_peripherals`` lists a
``usb_midi`` role becomes a USB MIDI device the computer plays. On Windows
and Linux it's every MIDI input port the computer has, through
``midiports.windows`` or ``midiports.linux``. In a browser it's every MIDI
input the browser offers, through ``midiports.webmidi``.

`Parser` turns the bytes into calls on an object you pass, with no
allocation per message, so a quiet keyboard costs nothing beside the audio.
Expand Down Expand Up @@ -39,7 +41,7 @@ def _name(dev):
class Inputs:
"""The open MIDI inputs, read together.

``kind`` is ``"usb"``, ``"desktop"``, ``"web"`` or None when there's nothing to
``kind`` is ``"usb"``, ``"device"``, ``"desktop"``, ``"web"`` or None when there's nothing to
open; ``why`` then says why in a sentence. ``names()`` lists the
devices heard now.
"""
Expand Down Expand Up @@ -88,9 +90,29 @@ def open(self):
self.ports = [port]
self.kind = "usb"
return self
if self._open_device():
return self
self._open_desktop()
return self

def _open_device(self):
"""Make this board a USB MIDI device, where its board says it may
(a ``usb_midi`` role): starting one re-enumerates the USB port."""
try:
import board_peripherals as bp
except ImportError:
return False
if "usb_midi" not in getattr(bp, "PERIPHERALS", ()):
return False
try:
port, _out = bp.usb_midi()
except Exception as e:
self.why = "the USB MIDI device didn't start: %s" % (e,)
return True
self.ports = [port]
self.kind = "device"
return True

def _open_desktop(self):
platform = getattr(sys, "platform", "")
try:
Expand Down Expand Up @@ -143,6 +165,8 @@ def names(self):
return [_name(d) for d in getattr(self.ports[0], "devices", ())]
if self.kind == "web":
return self.ports[0].names
if self.kind == "device":
return ["USB MIDI"]
return list(self._desktop_names)

def poll(self, parser):
Expand Down
4 changes: 3 additions & 1 deletion apps/weather/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@
_ICON = 130
_SMALL_ICON = 44
_BIG_TEMP_SCALE = 512 # the largest compiled font, doubled
# The temperature's font: 40 px, or the largest a firmware without it has.
_TEMP = getattr(lv, "font_montserrat_40", None) or getattr(lv, "font_montserrat_32", lv.font_montserrat_24)


def _clean(obj):
Expand Down Expand Up @@ -104,7 +106,7 @@ def _build_now(self, place, data, background):
icon = icons.make(panel, data["code"], data["is_day"], _ICON, background)
icon.align(lv.ALIGN.TOP_MID, 0, self.h * 13 // 100)

temp = _label(panel, lv.font_montserrat_40)
temp = _label(panel, _TEMP)
temp.set_text("%d°" % forecast.degrees(data["temp"]))
temp.set_style_transform_scale(_BIG_TEMP_SCALE, 0)
temp.update_layout()
Expand Down
5 changes: 4 additions & 1 deletion apps/weather/zippad.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,9 @@
import lvgl as lv
from deskkit import forecast

# The ZIP code's digits: 40 px, or the largest a firmware without it has.
_DIGITS = getattr(lv, "font_montserrat_40", None) or getattr(lv, "font_montserrat_32", lv.font_montserrat_24)

_LENGTH = 5
_BG = 0x0A1020
_KEY = 0x24344A
Expand Down Expand Up @@ -52,7 +55,7 @@ def __init__(self, scope, width, height, done):
self.title.align(lv.ALIGN.TOP_MID, 0, height * 8 // 100)

self.shown = lv.label(panel)
self.shown.set_style_text_font(lv.font_montserrat_40, 0)
self.shown.set_style_text_font(_DIGITS, 0)
self.shown.set_style_text_color(lv.color_hex(_WHITE), 0)
self.shown.align(lv.ALIGN.TOP_MID, 0, height * 13 // 100)

Expand Down
6 changes: 4 additions & 2 deletions lib/appkit/data.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
Linux ``$XDG_DATA_HOME/appkit``, or ``~/.local/share/appkit``
Windows ``%LOCALAPPDATA%\\appkit``
macOS ``~/Library/Application Support/appkit``
a board ``/data``
a board ``/data`` (``/flash/data`` on an STM32, whose ``/`` only holds mounts)
the browser ``/data``
============== ==========================================================

Expand Down Expand Up @@ -62,7 +62,9 @@ def default_root(platform=None, implementation=None, getenv=None):
if getenv is None:
getenv = _getenv
if not desktop(platform, implementation):
return "/data"
# The stm32 port's "/" is only where /flash and /sd are mounted, and
# a folder can't be made there.
return "/flash/data" if platform == "pyboard" else "/data"
explicit = getenv("APPKIT_DATA")
if explicit:
return explicit
Expand Down
1 change: 1 addition & 0 deletions tests/appkit/services_contract.py
Original file line number Diff line number Diff line change
Expand Up @@ -436,6 +436,7 @@ def data_root_follows_each_system():
("linux", "cpython", {"HOME": "/home/u", "APPKIT_DATA": "/elsewhere"}, "/elsewhere"),
("esp32", "micropython", {}, "/data"),
("rp2", "micropython", {"APPKIT_DATA": "/elsewhere"}, "/data"),
("pyboard", "micropython", {}, "/flash/data"),
("Espressif", "circuitpython", {}, "/data"),
("webassembly", "micropython", {"HOME": "/home/u"}, "/data"),
("emscripten", "cpython", {"HOME": "/home/u"}, "/data"),
Expand Down
60 changes: 60 additions & 0 deletions tests/test_synthstation_midi.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
# SPDX-FileCopyrightText: 2026 PyDevices
#
# SPDX-License-Identifier: MIT
"""Synth Station turns a board's USB port into a MIDI device only where the
board lists a ``usb_midi`` role: starting one re-enumerates the port.

python -m unittest tests.test_synthstation_midi
"""

import os
import sys
import types
import unittest
from unittest import mock

ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
sys.path.insert(0, os.path.join(ROOT, "apps"))

from synthstation.midi import Inputs # noqa: E402


def _board(roles, started):
bp = types.ModuleType("board_peripherals")
bp.PERIPHERALS = frozenset(roles)

def usb_midi():
started.append(True)
return ("in", "out")

bp.usb_midi = usb_midi
return bp


class DeviceModeTests(unittest.TestCase):
def _open(self, roles):
started = []
inputs = Inputs()
with mock.patch.dict(sys.modules, {"board_peripherals": _board(roles, started)}):
opened = inputs._open_device()
return inputs, opened, started

def test_a_board_with_usb_midi_becomes_a_midi_device(self):
inputs, opened, started = self._open({"usb_device", "usb_midi"})
self.assertTrue(opened)
self.assertEqual(started, [True])
self.assertEqual((inputs.kind, inputs.ports, inputs.names()), ("device", ["in"], ["USB MIDI"]))

def test_usb_device_alone_leaves_the_port_alone(self):
inputs, opened, started = self._open({"usb_device", "wlan"})
self.assertFalse(opened)
self.assertEqual(started, [])
self.assertIsNone(inputs.kind)

def test_no_board_peripherals_leaves_the_port_alone(self):
with mock.patch.dict(sys.modules, {"board_peripherals": None}):
self.assertFalse(Inputs()._open_device())


if __name__ == "__main__":
unittest.main()
Loading