From fb496b6991614bd79ce06b5e84e0a817118badb0 Mon Sep 17 00:00:00 2001 From: Matthias Blankertz Date: Sat, 22 Mar 2025 14:16:09 +0100 Subject: [PATCH 1/9] nfc: Add tag change notification callback --- software/src/nfc/__init__.py | 3 ++- software/src/nfc/nfc.py | 26 +++++++++++++++++++------- 2 files changed, 21 insertions(+), 8 deletions(-) diff --git a/software/src/nfc/__init__.py b/software/src/nfc/__init__.py index bb3810a..7ce3aac 100644 --- a/software/src/nfc/__init__.py +++ b/software/src/nfc/__init__.py @@ -1,7 +1,8 @@ ''' SPDX-License-Identifier: MIT Copyright (c) 2025 Stefan Kratochwil (Kratochwil-LA@gmx.de) +Copyright (c) 2025 Matthias Blankertz ''' from nfc.nfc import Nfc -__all__ = ['Nfc'] +__all__ = (Nfc) diff --git a/software/src/nfc/nfc.py b/software/src/nfc/nfc.py index 13f8e6d..7546c27 100644 --- a/software/src/nfc/nfc.py +++ b/software/src/nfc/nfc.py @@ -1,6 +1,7 @@ ''' SPDX-License-Identifier: MIT Copyright (c) 2025 Stefan Kratochwil (Kratochwil-LA@gmx.de) +Copyright (c) 2025 Matthias Blankertz ''' import asyncio @@ -28,10 +29,11 @@ class Nfc: asyncio.run(main()) ''' - def __init__(self, reader: MFRC522): + def __init__(self, reader: MFRC522, onTagChange=None): self.reader = reader self.last_uid = None self.last_uid_timestamp = None + self.onTagChange = onTagChange self.task = asyncio.create_task(self._reader_poll_task()) @staticmethod @@ -41,20 +43,30 @@ class Nfc: ''' return '0x' + ''.join(f'{i:02x}' for i in uid) + def _read_tag_sn(self) -> list[int]: + (stat, _) = self.reader.request(self.reader.REQIDL) + if stat == self.reader.OK: + (stat, uid) = self.reader.SelectTagSN() + if stat == self.reader.OK: + return uid + return None + async def _reader_poll_task(self, poll_interval_ms: int = 50): ''' Periodically polls the nfc reader. Stores tag uid and timestamp if a new tag was found. ''' + last_callback_uid = None while True: self.reader.init() # For now we omit the tag type - (stat, _) = self.reader.request(self.reader.REQIDL) - if stat == self.reader.OK: - (stat, uid) = self.reader.SelectTagSN() - if stat == self.reader.OK: - self.last_uid = uid - self.last_uid_timestamp = time.ticks_us() + uid = self._read_tag_sn() + if uid is not None: + self.last_uid = uid + self.last_uid_timestamp = time.ticks_us() + if self.onTagChange is not None and last_callback_uid != uid: + self.onTagChange(uid) + last_callback_uid = uid await asyncio.sleep_ms(poll_interval_ms) -- 2.39.5 From d02776eea8a79a2d233d969f787ada6c1a4283c5 Mon Sep 17 00:00:00 2001 From: Matthias Blankertz Date: Sat, 22 Mar 2025 14:36:32 +0100 Subject: [PATCH 2/9] Add basic application for playback based on NFC tags Copy and clean up test.py to main.py. Add app module (currently on app.py, maybe make it a directory later) for application classes. Implement app.TimerManager to allow scheduling of delayed events in the micropython async framework. Implement app.TagPlaybackManager which handles playing back MP3 files based on the NFC tag reader. Currently, simply plays all MP3 files in a folder, for which the folder name matches the tag id, in order. Resume, random and other features not yet supported. --- software/src/app.py | 102 +++++++++++++++++++++++++++++++++++++++++++ software/src/main.py | 83 +++++++++++++++++++++++++++++++++++ 2 files changed, 185 insertions(+) create mode 100644 software/src/app.py create mode 100644 software/src/main.py diff --git a/software/src/app.py b/software/src/app.py new file mode 100644 index 0000000..1b3ff96 --- /dev/null +++ b/software/src/app.py @@ -0,0 +1,102 @@ +# SPDX-License-Identifier: MIT +# Copyright (c) 2025 Matthias Blankertz + +import asyncio +import heapq +import os +import time + + +class TimerManager: + def __init__(self, timer_debug=False): + self.timers = [] + self.timer_debug = timer_debug + self.task = asyncio.create_task(self._timer_worker()) + self.worker_event = asyncio.Event() + + def schedule(self, when, what): + cur_nearest = self.timers[0][0] if len(self.timers) > 0 else None + heapq.heappush(self.timers, (when, what)) + if cur_nearest is None or cur_nearest > self.timers[0][0]: + # New timer is closer than previous closest timer + if self.timer_debug: + print(f'cur_nearest: {cur_nearest}, new next: {self.timers[0][0]}') + print("schedule: wake") + self.worker_event.set() + + def cancel(self, what): + try: + (when, _), i = next(filter(lambda item: item[0][1] == what, zip(self.timers, range(len(self.timers))))) + except StopIteration: + return False + del self.timers[i] + heapq.heapify(self.timers) + if i == 0: + # Cancel timer was closest timer + if self.timer_debug: + print("cancel: wake") + self.worker_event.set() + return True + + async def _timer_worker(self): + while True: + if len(self.timers) == 0: + # Nothing to do + await self.worker_event.wait() + if self.timer_debug: + print("_timer_worker: event 0") + self.worker_event.clear() + continue + cur_nearest = self.timers[0][0] + wait_time = cur_nearest - time.ticks_ms() + if wait_time > 0: + if self.timer_debug: + print(f"_timer_worker: next is {self.timers[0]}, sleep {wait_time} ms") + try: + await asyncio.wait_for_ms(self.worker_event.wait(), wait_time) + if self.timer_debug: + print("_timer_worker: event 1") + # got woken up due to event + self.worker_event.clear() + continue + except asyncio.TimeoutError: + pass + _, callback = heapq.heappop(self.timers) + callback() + + +class TagPlaybackManager: + def __init__(self, timer_manager, player): + self.current_tag = None + self.current_tag_time = time.ticks_ms() + self.timer_manager = timer_manager + self.player = player + + def onTagChange(self, new_tag): + if new_tag is not None: + self.timer_manager.cancel(self.onTagRemoveDelay) + if new_tag == self.current_tag: + return + # Change playlist on new tag + if new_tag is not None: + self.current_tag_time = time.ticks_ms() + self.current_tag = new_tag + uid_str = ''.join('{:02x}'.format(x) for x in new_tag) + try: + testfiles = [f'/sd/{uid_str}/'.encode() + name for name in os.listdir(f'/sd/{uid_str}'.encode()) + if name.endswith(b'mp3')] + except OSError as ex: + print(f'Could not get playlist for tag {uid_str}: {ex}') + self.current_tag = None + self.player.stop() + return + testfiles.sort() + self.player.set_playlist(testfiles) + else: + self.timer_manager.schedule(time.ticks_ms() + 5000, self.onTagRemoveDelay) + + def onTagRemoveDelay(self): + if self.current_tag is not None: + print('Tag gone, stopping playback') + self.current_tag = None + self.player.stop() diff --git a/software/src/main.py b/software/src/main.py new file mode 100644 index 0000000..d1425b5 --- /dev/null +++ b/software/src/main.py @@ -0,0 +1,83 @@ +# SPDX-License-Identifier: MIT +# Copyright (c) 2024-2025 Matthias Blankertz + +import aiorepl +import asyncio +import machine +import micropython +import os +import time +from machine import Pin +from math import pi, sin, pow + +# Own modules +from app import TimerManager, TagPlaybackManager +from audiocore import Audiocore +from mp3player import MP3Player +from nfc import Nfc +from rp2_neopixel import NeoPixel +from rp2_sd import SDCard + +micropython.alloc_emergency_exception_buf(100) + + +async def rainbow(np, period=10): + def gamma(value, X=2.2): + return min(max(int(brightness * pow(value / 255.0, X) * 255.0 + 0.5), 0), 255) + + brightness = 0.5 + count = 0.0 + leds = len(np) + while True: + for i in range(leds): + ofs = (count + i) % leds + np[i] = (gamma((sin(ofs / leds * 2 * pi) + 1) * 127), + gamma((sin(ofs / leds * 2 * pi + 2/3*pi) + 1) * 127), + gamma((sin(ofs / leds * 2 * pi + 4/3*pi) + 1) * 127)) + count += 0.2 + before = time.ticks_ms() + await np.async_write() + now = time.ticks_ms() + if before + 20 > now: + await asyncio.sleep_ms(20 - (now - before)) + + +# Machine setup + +# Set 8 mA drive strength and fast slew rate +machine.mem32[0x4001c004 + 6*4] = 0x67 +machine.mem32[0x4001c004 + 7*4] = 0x67 +machine.mem32[0x4001c004 + 8*4] = 0x67 +# high prio for proc 1 +machine.mem32[0x40030000 + 0x00] = 0x10 + + +# Setup SD card +try: + sd = SDCard(mosi=Pin(3), miso=Pin(4), sck=Pin(2), ss=Pin(5), baudrate=15000000) + os.mount(sd, '/sd') +except OSError as ex: + print(f'Failed to setup SD card: {ex}') + +# Setup LEDs +pin = Pin.board.GP16 +np = NeoPixel(pin, 10, sm=1) +asyncio.create_task(rainbow(np)) + +# Setup MP3 player +audioctx = Audiocore(Pin(8), Pin(6)) +player = MP3Player(audioctx) +player.set_volume(128) +asyncio.create_task(player.task()) + +# Setup app +timer_manager = TimerManager(True) +playback_manager = TagPlaybackManager(timer_manager, player) + +# Setup NFC +nfc = Nfc(playback_manager.onTagChange) + +# Start +asyncio.create_task(aiorepl.task({'player': player, 'timer_manager': timer_manager, + 'playback_manager': playback_manager})) +asyncio.get_event_loop().run_forever() -- 2.39.5 From f0c3fe4db883ac54058b54bd2ef6166033009f9a Mon Sep 17 00:00:00 2001 From: Matthias Blankertz Date: Sun, 23 Mar 2025 21:13:11 +0100 Subject: [PATCH 3/9] Use context managers to ensure deinit of audiocore and sd --- software/modules/audiocore/audiocore.py | 19 ++++++- software/modules/rp2_sd/module.c | 1 + software/src/main.py | 76 +++++++++++++++++-------- 3 files changed, 69 insertions(+), 27 deletions(-) diff --git a/software/modules/audiocore/audiocore.py b/software/modules/audiocore/audiocore.py index 6bcc0db..c5e6b57 100644 --- a/software/modules/audiocore/audiocore.py +++ b/software/modules/audiocore/audiocore.py @@ -5,9 +5,11 @@ from asyncio import ThreadSafeFlag class Audiocore: def __init__(self, pin, sideset): self.notify = ThreadSafeFlag() - self._audiocore = _audiocore.Audiocore(pin, sideset, self._interrupt) + self.pin = pin + self.sideset = sideset + self._audiocore = _audiocore.Audiocore(self.pin, self.sideset, self._interrupt) - def __del__(self): + def deinit(self): self._audiocore.deinit() def _interrupt(self, _): @@ -35,3 +37,16 @@ class Audiocore: if pos >= len(buffer): return (pos, buf_space, underruns) await self.notify.wait() + + +class AudioContext: + def __init__(self, pin, sideset): + self.pin = pin + self.sideset = sideset + + def __enter__(self): + self._audiocore = Audiocore(self.pin, self.sideset) + return self._audiocore + + def __exit__(self, exc_type, exc_value, traceback): + self._audiocore.deinit() diff --git a/software/modules/rp2_sd/module.c b/software/modules/rp2_sd/module.c index e614cf3..060dea4 100644 --- a/software/modules/rp2_sd/module.c +++ b/software/modules/rp2_sd/module.c @@ -96,6 +96,7 @@ static MP_DEFINE_CONST_FUN_OBJ_3(sdcard_ioctl_obj, sdcard_ioctl); static const mp_rom_map_elem_t sdcard_locals_dict_table[] = { {MP_ROM_QSTR(MP_QSTR___del__), MP_ROM_PTR(&sdcard_deinit_obj)}, + {MP_ROM_QSTR(MP_QSTR_deinit), MP_ROM_PTR(&sdcard_deinit_obj)}, {MP_ROM_QSTR(MP_QSTR_ioctl), MP_ROM_PTR(&sdcard_ioctl_obj)}, {MP_ROM_QSTR(MP_QSTR_readblocks), MP_ROM_PTR(&sdcard_readblocks_obj)}, }; diff --git a/software/src/main.py b/software/src/main.py index d1425b5..563633e 100644 --- a/software/src/main.py +++ b/software/src/main.py @@ -12,7 +12,8 @@ from math import pi, sin, pow # Own modules from app import TimerManager, TagPlaybackManager -from audiocore import Audiocore +from audiocore import AudioContext +from mfrc522 import MFRC522 from mp3player import MP3Player from nfc import Nfc from rp2_neopixel import NeoPixel @@ -52,32 +53,57 @@ machine.mem32[0x4001c004 + 8*4] = 0x67 machine.mem32[0x40030000 + 0x00] = 0x10 -# Setup SD card -try: - sd = SDCard(mosi=Pin(3), miso=Pin(4), sck=Pin(2), ss=Pin(5), baudrate=15000000) - os.mount(sd, '/sd') -except OSError as ex: - print(f'Failed to setup SD card: {ex}') +class SDContext: + def __init__(self, mosi, miso, sck, ss, baudrate): + self.mosi = mosi + self.miso = miso + self.sck = sck + self.ss = ss + self.baudrate = baudrate -# Setup LEDs -pin = Pin.board.GP16 -np = NeoPixel(pin, 10, sm=1) -asyncio.create_task(rainbow(np)) + def __enter__(self): + self.sdcard = SDCard(self.mosi, self.miso, self.sck, self.ss, self.baudrate) + try: + os.mount(self.sdcard, '/sd') + except Exception: + self.sdcard.deinit() + raise + return self -# Setup MP3 player -audioctx = Audiocore(Pin(8), Pin(6)) -player = MP3Player(audioctx) -player.set_volume(128) -asyncio.create_task(player.task()) + def __exit__(self, exc_type, exc_value, traceback): + try: + os.umount('/sd') + finally: + self.sdcard.deinit() -# Setup app -timer_manager = TimerManager(True) -playback_manager = TagPlaybackManager(timer_manager, player) -# Setup NFC -nfc = Nfc(playback_manager.onTagChange) +def run(): + asyncio.new_event_loop() + # Setup LEDs + pin = Pin.board.GP16 + np = NeoPixel(pin, 10, sm=1) + asyncio.create_task(rainbow(np)) -# Start -asyncio.create_task(aiorepl.task({'player': player, 'timer_manager': timer_manager, - 'playback_manager': playback_manager})) -asyncio.get_event_loop().run_forever() + # Setup MP3 player + with SDContext(mosi=Pin(3), miso=Pin(4), sck=Pin(2), ss=Pin(5), baudrate=15000000), \ + AudioContext(Pin(8), Pin(6)) as audioctx: + player = MP3Player(audioctx) + player.set_volume(128) + asyncio.create_task(player.task()) + + # Setup app + timer_manager = TimerManager(True) + playback_manager = TagPlaybackManager(timer_manager, player) + + # Setup NFC + reader = MFRC522(spi_id=1, sck=10, miso=12, mosi=11, cs=13, rst=9, tocard_retries=20) + nfc = Nfc(reader, playback_manager.onTagChange) + + # Start + asyncio.create_task(aiorepl.task({'player': player, 'timer_manager': timer_manager, + 'playback_manager': playback_manager, 'nfc': nfc})) + asyncio.get_event_loop().run_forever() + + +if __name__ == '__main__': + run() -- 2.39.5 From b477aba94c04187f7850439fa33bd56b3f556d32 Mon Sep 17 00:00:00 2001 From: Matthias Blankertz Date: Tue, 1 Apr 2025 22:55:03 +0200 Subject: [PATCH 4/9] Add initial button handling --- software/src/main.py | 46 ++++++++++++++++++++++++++++++++++++++- software/src/mp3player.py | 5 +++++ 2 files changed, 50 insertions(+), 1 deletion(-) diff --git a/software/src/main.py b/software/src/main.py index 563633e..9ea060d 100644 --- a/software/src/main.py +++ b/software/src/main.py @@ -53,6 +53,47 @@ machine.mem32[0x4001c004 + 8*4] = 0x67 machine.mem32[0x40030000 + 0x00] = 0x10 +class Buttons: + def __init__(self, player, pin_volup=17, pin_voldown=19, pin_next=18): + self._VOLUP = micropython.const(1) + self._VOLDOWN = micropython.const(2) + self._NEXT = micropython.const(3) + self.player = player + self.buttons = {machine.Pin(pin_volup, machine.Pin.IN, machine.Pin.PULL_UP): self._VOLUP, + machine.Pin(pin_voldown, machine.Pin.IN, machine.Pin.PULL_UP): self._VOLDOWN, + machine.Pin(pin_next, machine.Pin.IN, machine.Pin.PULL_UP): self._NEXT} + self.int_flag = asyncio.ThreadSafeFlag() + self.pressed = [] + self.last = {} + for button in self.buttons.keys(): + button.irq(handler=self._interrupt, trigger=machine.Pin.IRQ_FALLING | machine.Pin.IRQ_RISING) + + def _interrupt(self, button): + keycode = self.buttons[button] + last = self.last.get(keycode, 0) + now = time.ticks_ms() + self.last[keycode] = now + if now - last < 10: + # debounce, discard + return + if button.value() == 0: + # print(f'B{keycode} {now}') + self.pressed.append(keycode) + self.int_flag.set() + + async def task(self): + while True: + await self.int_flag.wait() + while len(self.pressed) > 0: + what = self.pressed.pop() + if what == self._VOLUP: + self.player.set_volume(min(255, self.player.get_volume()+1)) + elif what == self._VOLDOWN: + self.player.set_volume(max(0, self.player.get_volume()-1)) + elif what == self._NEXT: + self.player.play_next() + + class SDContext: def __init__(self, mosi, miso, sck, ss, baudrate): self.mosi = mosi @@ -88,9 +129,12 @@ def run(): with SDContext(mosi=Pin(3), miso=Pin(4), sck=Pin(2), ss=Pin(5), baudrate=15000000), \ AudioContext(Pin(8), Pin(6)) as audioctx: player = MP3Player(audioctx) - player.set_volume(128) + player.set_volume(32) asyncio.create_task(player.task()) + buttons = Buttons(player) + asyncio.create_task(buttons.task()) + # Setup app timer_manager = TimerManager(True) playback_manager = TagPlaybackManager(timer_manager, player) diff --git a/software/src/mp3player.py b/software/src/mp3player.py index 5b2c5b9..7659dd1 100644 --- a/software/src/mp3player.py +++ b/software/src/mp3player.py @@ -12,6 +12,7 @@ class MP3Player: self.command_event = asyncio.Event() self.playlist = [] self.mp3task = None + self.volume = 128 def set_playlist(self, mp3files): """ @@ -52,8 +53,12 @@ class MP3Player: """ Set volume (0..255). """ + self.volume = volume self.audiocore.set_volume(volume) + def get_volume(self) -> int: + return self.volume + def _send_command(self, command: str): self.commands.append(command) self.command_event.set() -- 2.39.5 From 903840f98209b72465f330bfb15b14dd185630b2 Mon Sep 17 00:00:00 2001 From: Matthias Blankertz Date: Tue, 29 Apr 2025 22:05:58 +0200 Subject: [PATCH 5/9] wip: New architecture Change PlayerApp to new architecture - depedencies injected via named tuple - some initial type checking - move on button press logic to PlayerApp TODO: Adapt MP3 player --- software/src/app.py | 78 +++++++--------------------------- software/src/main.py | 59 +++++-------------------- software/src/nfc/nfc.py | 23 +++++++--- software/src/utils/__init__.py | 7 +++ software/src/utils/buttons.py | 53 +++++++++++++++++++++++ software/src/utils/timer.py | 64 ++++++++++++++++++++++++++++ 6 files changed, 166 insertions(+), 118 deletions(-) create mode 100644 software/src/utils/__init__.py create mode 100644 software/src/utils/buttons.py create mode 100644 software/src/utils/timer.py diff --git a/software/src/app.py b/software/src/app.py index 1b3ff96..2616afd 100644 --- a/software/src/app.py +++ b/software/src/app.py @@ -1,76 +1,22 @@ # SPDX-License-Identifier: MIT # Copyright (c) 2025 Matthias Blankertz -import asyncio -import heapq +from collections import namedtuple import os import time -class TimerManager: - def __init__(self, timer_debug=False): - self.timers = [] - self.timer_debug = timer_debug - self.task = asyncio.create_task(self._timer_worker()) - self.worker_event = asyncio.Event() - - def schedule(self, when, what): - cur_nearest = self.timers[0][0] if len(self.timers) > 0 else None - heapq.heappush(self.timers, (when, what)) - if cur_nearest is None or cur_nearest > self.timers[0][0]: - # New timer is closer than previous closest timer - if self.timer_debug: - print(f'cur_nearest: {cur_nearest}, new next: {self.timers[0][0]}') - print("schedule: wake") - self.worker_event.set() - - def cancel(self, what): - try: - (when, _), i = next(filter(lambda item: item[0][1] == what, zip(self.timers, range(len(self.timers))))) - except StopIteration: - return False - del self.timers[i] - heapq.heapify(self.timers) - if i == 0: - # Cancel timer was closest timer - if self.timer_debug: - print("cancel: wake") - self.worker_event.set() - return True - - async def _timer_worker(self): - while True: - if len(self.timers) == 0: - # Nothing to do - await self.worker_event.wait() - if self.timer_debug: - print("_timer_worker: event 0") - self.worker_event.clear() - continue - cur_nearest = self.timers[0][0] - wait_time = cur_nearest - time.ticks_ms() - if wait_time > 0: - if self.timer_debug: - print(f"_timer_worker: next is {self.timers[0]}, sleep {wait_time} ms") - try: - await asyncio.wait_for_ms(self.worker_event.wait(), wait_time) - if self.timer_debug: - print("_timer_worker: event 1") - # got woken up due to event - self.worker_event.clear() - continue - except asyncio.TimeoutError: - pass - _, callback = heapq.heappop(self.timers) - callback() +Dependencies = namedtuple('PlayerAppDependencies', ('mp3player', 'timermanager', 'nfcreader', 'buttons')) -class TagPlaybackManager: - def __init__(self, timer_manager, player): +class PlayerApp: + def __init__(self, deps: Dependencies): self.current_tag = None self.current_tag_time = time.ticks_ms() - self.timer_manager = timer_manager - self.player = player + self.timer_manager = deps.timermanager(self) + self.player = deps.mp3player(self) + self.nfc = deps.nfcreader(self) + self.buttons = deps.buttons(self) if deps.buttons is not None else None def onTagChange(self, new_tag): if new_tag is not None: @@ -100,3 +46,11 @@ class TagPlaybackManager: print('Tag gone, stopping playback') self.current_tag = None self.player.stop() + + def onButtonPressed(self, what): + if what == self.buttons.VOLUP: + self.player.set_volume(min(255, self.player.get_volume()+1)) + elif what == self.buttons.VOLDOWN: + self.player.set_volume(max(0, self.player.get_volume()-1)) + elif what == self.buttons.NEXT: + self.player.play_next() diff --git a/software/src/main.py b/software/src/main.py index 9ea060d..dad9c4c 100644 --- a/software/src/main.py +++ b/software/src/main.py @@ -11,13 +11,14 @@ from machine import Pin from math import pi, sin, pow # Own modules -from app import TimerManager, TagPlaybackManager +import app from audiocore import AudioContext from mfrc522 import MFRC522 from mp3player import MP3Player from nfc import Nfc from rp2_neopixel import NeoPixel from rp2_sd import SDCard +from utils import Buttons, TimerManager micropython.alloc_emergency_exception_buf(100) @@ -53,47 +54,6 @@ machine.mem32[0x4001c004 + 8*4] = 0x67 machine.mem32[0x40030000 + 0x00] = 0x10 -class Buttons: - def __init__(self, player, pin_volup=17, pin_voldown=19, pin_next=18): - self._VOLUP = micropython.const(1) - self._VOLDOWN = micropython.const(2) - self._NEXT = micropython.const(3) - self.player = player - self.buttons = {machine.Pin(pin_volup, machine.Pin.IN, machine.Pin.PULL_UP): self._VOLUP, - machine.Pin(pin_voldown, machine.Pin.IN, machine.Pin.PULL_UP): self._VOLDOWN, - machine.Pin(pin_next, machine.Pin.IN, machine.Pin.PULL_UP): self._NEXT} - self.int_flag = asyncio.ThreadSafeFlag() - self.pressed = [] - self.last = {} - for button in self.buttons.keys(): - button.irq(handler=self._interrupt, trigger=machine.Pin.IRQ_FALLING | machine.Pin.IRQ_RISING) - - def _interrupt(self, button): - keycode = self.buttons[button] - last = self.last.get(keycode, 0) - now = time.ticks_ms() - self.last[keycode] = now - if now - last < 10: - # debounce, discard - return - if button.value() == 0: - # print(f'B{keycode} {now}') - self.pressed.append(keycode) - self.int_flag.set() - - async def task(self): - while True: - await self.int_flag.wait() - while len(self.pressed) > 0: - what = self.pressed.pop() - if what == self._VOLUP: - self.player.set_volume(min(255, self.player.get_volume()+1)) - elif what == self._VOLDOWN: - self.player.set_volume(max(0, self.player.get_volume()-1)) - elif what == self._NEXT: - self.player.play_next() - - class SDContext: def __init__(self, mosi, miso, sck, ss, baudrate): self.mosi = mosi @@ -132,20 +92,21 @@ def run(): player.set_volume(32) asyncio.create_task(player.task()) - buttons = Buttons(player) - asyncio.create_task(buttons.task()) - - # Setup app timer_manager = TimerManager(True) - playback_manager = TagPlaybackManager(timer_manager, player) # Setup NFC reader = MFRC522(spi_id=1, sck=10, miso=12, mosi=11, cs=13, rst=9, tocard_retries=20) - nfc = Nfc(reader, playback_manager.onTagChange) + + # Setup app + deps = app.Dependencies(mp3player=lambda _: player, + timermanager=lambda _: timer_manager, + nfcreader=lambda the_app: Nfc(reader, the_app), + buttons=lambda the_app: Buttons(the_app)) + the_app = app.PlayerApp(deps) # Start asyncio.create_task(aiorepl.task({'player': player, 'timer_manager': timer_manager, - 'playback_manager': playback_manager, 'nfc': nfc})) + 'app': the_app})) asyncio.get_event_loop().run_forever() diff --git a/software/src/nfc/nfc.py b/software/src/nfc/nfc.py index 7546c27..01fd8b8 100644 --- a/software/src/nfc/nfc.py +++ b/software/src/nfc/nfc.py @@ -8,6 +8,15 @@ import asyncio import time from mfrc522 import MFRC522 +try: + from typing import TYPE_CHECKING # type: ignore +except ImportError: + TYPE_CHECKING = False +if TYPE_CHECKING: + import typing + + class TagCallback(typing.Protocol): + def onTagChange(self, uid: list[int]) -> None: ... class Nfc: @@ -29,11 +38,11 @@ class Nfc: asyncio.run(main()) ''' - def __init__(self, reader: MFRC522, onTagChange=None): + def __init__(self, reader: MFRC522, cb: TagCallback | None = None): self.reader = reader - self.last_uid = None - self.last_uid_timestamp = None - self.onTagChange = onTagChange + self.last_uid: list[int] | None = None + self.last_uid_timestamp: int | None = None + self.cb = cb self.task = asyncio.create_task(self._reader_poll_task()) @staticmethod @@ -43,7 +52,7 @@ class Nfc: ''' return '0x' + ''.join(f'{i:02x}' for i in uid) - def _read_tag_sn(self) -> list[int]: + def _read_tag_sn(self) -> list[int] | None: (stat, _) = self.reader.request(self.reader.REQIDL) if stat == self.reader.OK: (stat, uid) = self.reader.SelectTagSN() @@ -64,8 +73,8 @@ class Nfc: if uid is not None: self.last_uid = uid self.last_uid_timestamp = time.ticks_us() - if self.onTagChange is not None and last_callback_uid != uid: - self.onTagChange(uid) + if self.cb is not None and last_callback_uid != uid: + self.cb.onTagChange(uid) last_callback_uid = uid await asyncio.sleep_ms(poll_interval_ms) diff --git a/software/src/utils/__init__.py b/software/src/utils/__init__.py new file mode 100644 index 0000000..b509487 --- /dev/null +++ b/software/src/utils/__init__.py @@ -0,0 +1,7 @@ +# SPDX-License-Identifier: MIT +# Copyright (c) 2025 Matthias Blankertz + +from utils.buttons import Buttons +from utils.timer import TimerManager + +__all__ = ["Buttons", "TimerManager"] diff --git a/software/src/utils/buttons.py b/software/src/utils/buttons.py new file mode 100644 index 0000000..12109fa --- /dev/null +++ b/software/src/utils/buttons.py @@ -0,0 +1,53 @@ +# SPDX-License-Identifier: MIT +# Copyright (c) 2025 Matthias Blankertz + +import asyncio +import machine +import micropython +import time +try: + from typing import TYPE_CHECKING # type: ignore +except ImportError: + TYPE_CHECKING = False +if TYPE_CHECKING: + import typing + + class ButtonCallback(typing.Protocol): + def onButtonPressed(self, what: int) -> None: ... + + +class Buttons: + def __init__(self, cb: ButtonCallback, pin_volup=17, pin_voldown=19, pin_next=18): + self.VOLUP = micropython.const(1) + self.VOLDOWN = micropython.const(2) + self.NEXT = micropython.const(3) + self.cb = cb + self.buttons = {machine.Pin(pin_volup, machine.Pin.IN, machine.Pin.PULL_UP): self.VOLUP, + machine.Pin(pin_voldown, machine.Pin.IN, machine.Pin.PULL_UP): self.VOLDOWN, + machine.Pin(pin_next, machine.Pin.IN, machine.Pin.PULL_UP): self.NEXT} + self.int_flag = asyncio.ThreadSafeFlag() + self.pressed: list[int] = [] + self.last: dict[int, int] = {} + for button in self.buttons.keys(): + button.irq(handler=self._interrupt, trigger=machine.Pin.IRQ_FALLING | machine.Pin.IRQ_RISING) + asyncio.create_task(self.task()) + + def _interrupt(self, button): + keycode = self.buttons[button] + last = self.last.get(keycode, 0) + now = time.ticks_ms() + self.last[keycode] = now + if now - last < 10: + # debounce, discard + return + if button.value() == 0: + # print(f'B{keycode} {now}') + self.pressed.append(keycode) + self.int_flag.set() + + async def task(self): + while True: + await self.int_flag.wait() + while len(self.pressed) > 0: + what = self.pressed.pop() + self.cb.onButtonPressed(what) diff --git a/software/src/utils/timer.py b/software/src/utils/timer.py new file mode 100644 index 0000000..22ae55b --- /dev/null +++ b/software/src/utils/timer.py @@ -0,0 +1,64 @@ +# SPDX-License-Identifier: MIT +# Copyright (c) 2025 Matthias Blankertz + +import asyncio +import heapq +import time + + +class TimerManager: + def __init__(self, timer_debug=False): + self.timers = [] + self.timer_debug = timer_debug + self.task = asyncio.create_task(self._timer_worker()) + self.worker_event = asyncio.Event() + + def schedule(self, when, what): + cur_nearest = self.timers[0][0] if len(self.timers) > 0 else None + heapq.heappush(self.timers, (when, what)) + if cur_nearest is None or cur_nearest > self.timers[0][0]: + # New timer is closer than previous closest timer + if self.timer_debug: + print(f'cur_nearest: {cur_nearest}, new next: {self.timers[0][0]}') + print("schedule: wake") + self.worker_event.set() + + def cancel(self, what): + try: + (when, _), i = next(filter(lambda item: item[0][1] == what, zip(self.timers, range(len(self.timers))))) + except StopIteration: + return False + del self.timers[i] + heapq.heapify(self.timers) + if i == 0: + # Cancel timer was closest timer + if self.timer_debug: + print("cancel: wake") + self.worker_event.set() + return True + + async def _timer_worker(self): + while True: + if len(self.timers) == 0: + # Nothing to do + await self.worker_event.wait() + if self.timer_debug: + print("_timer_worker: event 0") + self.worker_event.clear() + continue + cur_nearest = self.timers[0][0] + wait_time = cur_nearest - time.ticks_ms() + if wait_time > 0: + if self.timer_debug: + print(f"_timer_worker: next is {self.timers[0]}, sleep {wait_time} ms") + try: + await asyncio.wait_for_ms(self.worker_event.wait(), wait_time) + if self.timer_debug: + print("_timer_worker: event 1") + # got woken up due to event + self.worker_event.clear() + continue + except asyncio.TimeoutError: + pass + _, callback = heapq.heappop(self.timers) + callback() -- 2.39.5 From 69b6f6e8601bf53d7f19b1b6cbd52f92aec45e02 Mon Sep 17 00:00:00 2001 From: Matthias Blankertz Date: Tue, 20 May 2025 20:16:26 +0200 Subject: [PATCH 6/9] build: Copy python files to staging dir for littlefs --- software/Dockerfile.build | 2 +- software/build.sh | 5 ++++- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/software/Dockerfile.build b/software/Dockerfile.build index 2ddba22..be1b4e4 100644 --- a/software/Dockerfile.build +++ b/software/Dockerfile.build @@ -4,5 +4,5 @@ FROM gitea/runner-images:ubuntu-22.04 # Install gcc-arm-none-eabi RUN apt update && \ DEBIAN_FRONTEND=noninteractive \ - apt install -y --no-install-recommends cmake gcc-arm-none-eabi libnewlib-arm-none-eabi libstdc++-arm-none-eabi-newlib \ + apt install -y --no-install-recommends cmake gcc-arm-none-eabi libnewlib-arm-none-eabi libstdc++-arm-none-eabi-newlib cpio \ && apt clean && rm -rf /var/lib/apt/lists/* diff --git a/software/build.sh b/software/build.sh index 317ddd9..fc37429 100755 --- a/software/build.sh +++ b/software/build.sh @@ -25,7 +25,10 @@ if ! command -v $PICOTOOL >/dev/null 2>&1; then fi fi BUILDDIR=lib/micropython/ports/rp2/build-TONBERRY_RPI_PICO_W/ -tools/mklittlefs/mklittlefs -p 256 -s 868352 -c src/ $BUILDDIR/filesystem.bin +FS_STAGE_DIR=$(mktemp -d) +trap 'rm -rf $FS_STAGE_DIR' EXIT +find src/ -iname '*.py' | cpio -pdm "$FS_STAGE_DIR" +tools/mklittlefs/mklittlefs -p 256 -s 868352 -c "$FS_STAGE_DIR"/src $BUILDDIR/filesystem.bin truncate -s 2M $BUILDDIR/firmware-filesystem.bin dd if=$BUILDDIR/firmware.bin of=$BUILDDIR/firmware-filesystem.bin bs=1k dd if=$BUILDDIR/filesystem.bin of=$BUILDDIR/firmware-filesystem.bin bs=1k seek=1200 -- 2.39.5 From 7712c256277cb3f123bfe652965cb434ac6cf5cd Mon Sep 17 00:00:00 2001 From: Matthias Blankertz Date: Tue, 20 May 2025 20:18:25 +0200 Subject: [PATCH 7/9] Turn TimerManager into a Singleton --- software/src/app.py | 5 +++-- software/src/main.py | 5 +---- software/src/utils/timer.py | 19 +++++++++++++------ 3 files changed, 17 insertions(+), 12 deletions(-) diff --git a/software/src/app.py b/software/src/app.py index 2616afd..a4822e8 100644 --- a/software/src/app.py +++ b/software/src/app.py @@ -4,16 +4,17 @@ from collections import namedtuple import os import time +from utils import TimerManager -Dependencies = namedtuple('PlayerAppDependencies', ('mp3player', 'timermanager', 'nfcreader', 'buttons')) +Dependencies = namedtuple('Dependencies', ('mp3player', 'nfcreader', 'buttons')) class PlayerApp: def __init__(self, deps: Dependencies): self.current_tag = None self.current_tag_time = time.ticks_ms() - self.timer_manager = deps.timermanager(self) + self.timer_manager = TimerManager() self.player = deps.mp3player(self) self.nfc = deps.nfcreader(self) self.buttons = deps.buttons(self) if deps.buttons is not None else None diff --git a/software/src/main.py b/software/src/main.py index dad9c4c..96175df 100644 --- a/software/src/main.py +++ b/software/src/main.py @@ -92,20 +92,17 @@ def run(): player.set_volume(32) asyncio.create_task(player.task()) - timer_manager = TimerManager(True) - # Setup NFC reader = MFRC522(spi_id=1, sck=10, miso=12, mosi=11, cs=13, rst=9, tocard_retries=20) # Setup app deps = app.Dependencies(mp3player=lambda _: player, - timermanager=lambda _: timer_manager, nfcreader=lambda the_app: Nfc(reader, the_app), buttons=lambda the_app: Buttons(the_app)) the_app = app.PlayerApp(deps) # Start - asyncio.create_task(aiorepl.task({'player': player, 'timer_manager': timer_manager, + asyncio.create_task(aiorepl.task({'timer_manager': TimerManager(), 'app': the_app})) asyncio.get_event_loop().run_forever() diff --git a/software/src/utils/timer.py b/software/src/utils/timer.py index 22ae55b..853416b 100644 --- a/software/src/utils/timer.py +++ b/software/src/utils/timer.py @@ -5,13 +5,20 @@ import asyncio import heapq import time +TIMER_DEBUG = True -class TimerManager: - def __init__(self, timer_debug=False): - self.timers = [] - self.timer_debug = timer_debug - self.task = asyncio.create_task(self._timer_worker()) - self.worker_event = asyncio.Event() + +class TimerManager(object): + _instance = None + + def __new__(cls): + if cls._instance is None: + cls._instance = super(TimerManager, cls).__new__(cls) + cls._instance.timers = [] + cls._instance.timer_debug = TIMER_DEBUG + cls._instance.task = asyncio.create_task(cls._instance._timer_worker()) + cls._instance.worker_event = asyncio.Event() + return cls._instance def schedule(self, when, what): cur_nearest = self.timers[0][0] if len(self.timers) > 0 else None -- 2.39.5 From 7778147b66fa9560ec5f69bde59cda027313dc5e Mon Sep 17 00:00:00 2001 From: Matthias Blankertz Date: Tue, 20 May 2025 20:19:13 +0200 Subject: [PATCH 8/9] app: Implement volume curve --- software/src/app.py | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/software/src/app.py b/software/src/app.py index a4822e8..9ecf806 100644 --- a/software/src/app.py +++ b/software/src/app.py @@ -9,6 +9,9 @@ from utils import TimerManager Dependencies = namedtuple('Dependencies', ('mp3player', 'nfcreader', 'buttons')) +# Should be ~ 6dB steps +VOLUME_CURVE = [1, 2, 4, 8, 16, 32, 63, 126, 251] + class PlayerApp: def __init__(self, deps: Dependencies): @@ -18,6 +21,8 @@ class PlayerApp: self.player = deps.mp3player(self) self.nfc = deps.nfcreader(self) self.buttons = deps.buttons(self) if deps.buttons is not None else None + self.volume_pos = 3 + self.player.set_volume(VOLUME_CURVE[self.volume_pos]) def onTagChange(self, new_tag): if new_tag is not None: @@ -50,8 +55,10 @@ class PlayerApp: def onButtonPressed(self, what): if what == self.buttons.VOLUP: - self.player.set_volume(min(255, self.player.get_volume()+1)) + self.volume_pos = min(self.volume_pos + 1, len(VOLUME_CURVE) - 1) + self.player.set_volume(VOLUME_CURVE[self.volume_pos]) elif what == self.buttons.VOLDOWN: - self.player.set_volume(max(0, self.player.get_volume()-1)) + self.volume_pos = max(self.volume_pos - 1, 0) + self.player.set_volume(VOLUME_CURVE[self.volume_pos]) elif what == self.buttons.NEXT: self.player.play_next() -- 2.39.5 From ce02daad3add3cd8924e807e6cf1154915d09b80 Mon Sep 17 00:00:00 2001 From: Matthias Blankertz Date: Tue, 20 May 2025 20:20:08 +0200 Subject: [PATCH 9/9] Move playlist handling from mp3player to app This completes the move of the nfc-mp3-demo to the new architecture. --- software/src/app.py | 33 +++++++++- software/src/main.py | 5 +- software/src/mp3player.py | 130 ++++++++++++-------------------------- 3 files changed, 71 insertions(+), 97 deletions(-) diff --git a/software/src/app.py b/software/src/app.py index 9ecf806..6d9ff3c 100644 --- a/software/src/app.py +++ b/software/src/app.py @@ -21,9 +21,15 @@ class PlayerApp: self.player = deps.mp3player(self) self.nfc = deps.nfcreader(self) self.buttons = deps.buttons(self) if deps.buttons is not None else None + self.mp3file = None self.volume_pos = 3 self.player.set_volume(VOLUME_CURVE[self.volume_pos]) + def __del__(self): + if self.mp3file is not None: + self.mp3file.close() + self.mp3file = None + def onTagChange(self, new_tag): if new_tag is not None: self.timer_manager.cancel(self.onTagRemoveDelay) @@ -43,7 +49,7 @@ class PlayerApp: self.player.stop() return testfiles.sort() - self.player.set_playlist(testfiles) + self._set_playlist(testfiles) else: self.timer_manager.schedule(time.ticks_ms() + 5000, self.onTagRemoveDelay) @@ -61,4 +67,27 @@ class PlayerApp: self.volume_pos = max(self.volume_pos - 1, 0) self.player.set_volume(VOLUME_CURVE[self.volume_pos]) elif what == self.buttons.NEXT: - self.player.play_next() + self._play_next() + + def onPlaybackDone(self): + self.mp3file.close() + self.mp3file = None + self._play_next() + + def _set_playlist(self, files: list[bytes]): + self.playlist_pos = 0 + self.playlist = files + self._play(self.playlist[self.playlist_pos]) + + def _play_next(self): + if self.playlist_pos + 1 < len(self.playlist): + self.playlist_pos += 1 + self._play(self.playlist[self.playlist_pos]) + + def _play(self, filename: bytes): + if self.mp3file is not None: + self.player.stop() + self.mp3file.close() + self.mp3file = None + self.mp3file = open(filename, 'rb') + self.player.play(self.mp3file) diff --git a/software/src/main.py b/software/src/main.py index 96175df..1ef96d1 100644 --- a/software/src/main.py +++ b/software/src/main.py @@ -88,15 +88,12 @@ def run(): # Setup MP3 player with SDContext(mosi=Pin(3), miso=Pin(4), sck=Pin(2), ss=Pin(5), baudrate=15000000), \ AudioContext(Pin(8), Pin(6)) as audioctx: - player = MP3Player(audioctx) - player.set_volume(32) - asyncio.create_task(player.task()) # Setup NFC reader = MFRC522(spi_id=1, sck=10, miso=12, mosi=11, cs=13, rst=9, tocard_retries=20) # Setup app - deps = app.Dependencies(mp3player=lambda _: player, + deps = app.Dependencies(mp3player=lambda the_app: MP3Player(audioctx, the_app), nfcreader=lambda the_app: Nfc(reader, the_app), buttons=lambda the_app: Buttons(the_app)) the_app = app.PlayerApp(deps) diff --git a/software/src/mp3player.py b/software/src/mp3player.py index 7659dd1..7f17d90 100644 --- a/software/src/mp3player.py +++ b/software/src/mp3player.py @@ -3,51 +3,41 @@ import asyncio from array import array +from utils import TimerManager +try: + from typing import TYPE_CHECKING # type: ignore +except ImportError: + TYPE_CHECKING = False +if TYPE_CHECKING: + import typing + + class PlayerCallback(typing.Protocol): + def onPlaybackDone(self) -> None: ... class MP3Player: - def __init__(self, audiocore): + def __init__(self, audiocore, cb: PlayerCallback): self.audiocore = audiocore - self.commands = [] - self.command_event = asyncio.Event() - self.playlist = [] self.mp3task = None self.volume = 128 + self.cb = cb - def set_playlist(self, mp3files): + def play(self, stream): """ - Set a new playlist and start playing from the first entry. - For convenience a single file name can also be passed. + Play from byte stream. """ - if type(mp3files) is bytes: - self.playlist = [mp3files] - else: - self.playlist = mp3files - self._send_command('newplaylist') - - def play_next(self): - """ - Skip to the next track in the playlist. Reaching the end of the playlist stops playback. - """ - self._send_command('next') - - def play_prev(self): - """ - Skip to the previous track in the playlist. - """ - self._send_command('prev') + if self.mp3task is not None: + self.mp3task.cancel() + self.mp3task = None + self.mp3task = asyncio.create_task(self._play_task(stream)) def stop(self): """ - Stop playback, remembering the current position in the playlist (but not inside a track). + Stop playback """ - self._send_command('stop') - - def play(self): - """ - Start playback. - """ - self._send_command('play') + if self.mp3task is not None: + self.mp3task.cancel() + self.mp3task = None def set_volume(self, volume: int): """ @@ -59,67 +49,25 @@ class MP3Player: def get_volume(self) -> int: return self.volume - def _send_command(self, command: str): - self.commands.append(command) - self.command_event.set() - - async def _play_task(self, mp3path): + async def _play_task(self, stream): known_underruns = 0 + send_done = False data = array('b', range(512)) try: - print(b'Playing ' + mp3path) - with open(mp3path, 'rb') as mp3file: - while True: - bytes_read = mp3file.readinto(data) - if bytes_read == 0: - # End of file - break - _, _, underruns = await self.audiocore.async_put(data[:bytes_read]) - if underruns > known_underruns: - print(f"{underruns:x}") - known_underruns = underruns - # Intentionally do not use _send_command, we don't want to set command_event yet - self.commands.append('done') + while True: + bytes_read = stream.readinto(data) + if bytes_read == 0: + # End of file + break + _, _, underruns = await self.audiocore.async_put(data[:bytes_read]) + if underruns > known_underruns: + print(f"{underruns:x}") + known_underruns = underruns + # Call onPlaybackDone after flush + send_done = True finally: self.audiocore.flush() - self.command_event.set() - - def _play(self, mp3path): - if self.mp3task is not None: - self.mp3task.cancel() - self.mp3task = None - if mp3path is not None: - self.mp3task = asyncio.create_task(self._play_task(mp3path)) - - async def task(self): - playlist_pos = 0 - while True: - await self.command_event.wait() - self.command_event.clear() - change_play = False - while len(self.commands) > 0: - command = self.commands.pop() - if command == 'next' or command == 'done': - if playlist_pos + 1 < len(self.playlist): - playlist_pos += 1 - change_play = True - else: - # reaching the end of the playlist stops playback - self._play(None) - elif command == 'prev': - if playlist_pos > 0: - playlist_pos -= 1 - change_play = True - elif command == 'stop': - self._play(None) - elif command == 'play': - if self.mp3task is None: - change_play = True - elif command == 'newplaylist': - if len(self.playlist) > 0: - playlist_pos = 0 - change_play = True - else: - self._play(None) - if change_play: - self._play(self.playlist[playlist_pos]) + if send_done: + # Only call onPlaybackDone if exit due to end of stream + # Use timer with time 0 to call callback "immediately" but from a different task + TimerManager().schedule(0, self.cb.onPlaybackDone) -- 2.39.5