diff --git a/apps/mp3/__init__.py b/apps/mp3/__init__.py index 9dbb4a8..f41ce8c 100644 --- a/apps/mp3/__init__.py +++ b/apps/mp3/__init__.py @@ -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 @@ -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 @@ -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( @@ -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 diff --git a/apps/synthstation/midi.py b/apps/synthstation/midi.py index e0d6dd1..b3a60bb 100644 --- a/apps/synthstation/midi.py +++ b/apps/synthstation/midi.py @@ -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. @@ -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. """ @@ -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: @@ -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): diff --git a/apps/weather/__init__.py b/apps/weather/__init__.py index 8563039..091b8b9 100644 --- a/apps/weather/__init__.py +++ b/apps/weather/__init__.py @@ -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): @@ -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() diff --git a/apps/weather/zippad.py b/apps/weather/zippad.py index c524089..aa10679 100644 --- a/apps/weather/zippad.py +++ b/apps/weather/zippad.py @@ -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 @@ -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) diff --git a/lib/appkit/data.py b/lib/appkit/data.py index acb9d8d..ec11005 100644 --- a/lib/appkit/data.py +++ b/lib/appkit/data.py @@ -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`` ============== ========================================================== @@ -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 diff --git a/tests/appkit/services_contract.py b/tests/appkit/services_contract.py index 71abf3c..3285e96 100644 --- a/tests/appkit/services_contract.py +++ b/tests/appkit/services_contract.py @@ -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"), diff --git a/tests/test_synthstation_midi.py b/tests/test_synthstation_midi.py new file mode 100644 index 0000000..058bffe --- /dev/null +++ b/tests/test_synthstation_midi.py @@ -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()