From b5e3df105433513840d445da38cb10456bdc2a7c Mon Sep 17 00:00:00 2001 From: Matthias Blankertz Date: Fri, 31 Oct 2025 16:59:33 +0100 Subject: [PATCH 1/2] refactor(app): Refactor tag handling Split the tag handling in PlayerApp into two parts: The low level handling of changed/removed tags from the Nfc reader, including the timeout processing is moved into the nested TagStateMachine class. The main PlayerApp has two new (internal) callbacks "onNewTag" and "onTagRemoved" which are called when a new tag is presented or a tag was actually removed. This will hopefully make the implementation of #28 "Configurable Tag Handling" cleaner. Signed-off-by: Matthias Blankertz --- software/src/app.py | 66 ++++++++++++++++++++------------ software/tests/test_playerapp.py | 17 ++++---- 2 files changed, 52 insertions(+), 31 deletions(-) diff --git a/software/src/app.py b/software/src/app.py index 3b42619..a8f046d 100644 --- a/software/src/app.py +++ b/software/src/app.py @@ -13,12 +13,36 @@ VOLUME_CURVE = [1, 2, 4, 8, 16, 32, 63, 126, 251] class PlayerApp: + class TagStateMachine: + def __init__(self, parent, timer_manager): + self.parent = parent + self.timer_manager = timer_manager + self.current_tag = None + self.current_tag_time = time.ticks_ms() + + 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 + self.parent.onNewTag(new_tag) + else: + self.timer_manager.schedule(time.ticks_ms() + 5000, self.onTagRemoveDelay) + + def onTagRemoveDelay(self): + if self.current_tag is not None: + self.current_tag = None + self.parent.onTagRemoved() + def __init__(self, deps: Dependencies): - self.current_tag = None - self.current_tag_time = time.ticks_ms() self.timer_manager = TimerManager() + self.tag_state_machine = self.TagStateMachine(self, self.timer_manager) self.player = deps.mp3player(self) - self.nfc = deps.nfcreader(self) + self.nfc = deps.nfcreader(self.tag_state_machine) self.playlist_db = deps.playlistdb(self) self.buttons = deps.buttons(self) if deps.buttons is not None else None self.mp3file = None @@ -30,28 +54,22 @@ class PlayerApp: self.mp3file.close() self.mp3file = None - 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 = b''.join('{:02x}'.format(x).encode() for x in new_tag) - self._set_playlist(uid_str) - else: - self.timer_manager.schedule(time.ticks_ms() + 5000, self.onTagRemoveDelay) + def onNewTag(self, new_tag): + """ + Callback (typically called by TagStateMachine) to signal that a new tag has been presented. + """ + uid_str = b''.join('{:02x}'.format(x).encode() for x in new_tag) + self._set_playlist(uid_str) - def onTagRemoveDelay(self): - if self.current_tag is not None: - print('Tag gone, stopping playback') - self.current_tag = None - if self.playlist is not None: - pos = self.player.stop() - if pos is not None: - self.playlist.setPlaybackOffset(pos) + def onTagRemoved(self): + """ + Callback (typically called by TagStateMachine) to signal that a tag has been removed. + """ + print('Tag gone, stopping playback') + if self.playlist is not None: + pos = self.player.stop() + if pos is not None: + self.playlist.setPlaybackOffset(pos) def onButtonPressed(self, what): assert self.buttons is not None diff --git a/software/tests/test_playerapp.py b/software/tests/test_playerapp.py index ecacf35..b71590a 100644 --- a/software/tests/test_playerapp.py +++ b/software/tests/test_playerapp.py @@ -45,7 +45,10 @@ class FakeTimerManager: class FakeNfcReader: - def __init__(self): pass + tag_callback = None + + def __init__(self, tag_callback=None): + FakeNfcReader.tag_callback = tag_callback class FakeButtons: @@ -100,13 +103,13 @@ def test_load_playlist_on_tag(micropythonify, faketimermanager, monkeypatch): fake_db = FakePlaylistDb() fake_mp3 = FakeMp3Player() deps = app.Dependencies(mp3player=lambda _: fake_mp3, - nfcreader=lambda _: FakeNfcReader(), + nfcreader=lambda x: FakeNfcReader(x), buttons=lambda _: FakeButtons(), playlistdb=lambda _: fake_db) dut = app.PlayerApp(deps) with monkeypatch.context() as m: m.setattr(builtins, 'open', fake_open) - dut.onTagChange([23, 42, 1, 2, 3]) + FakeNfcReader.tag_callback.onTagChange([23, 42, 1, 2, 3]) assert fake_mp3.track is not None assert fake_mp3.track.filename == b'test/path.mp3' assert "r" in fake_mp3.track.mode @@ -117,13 +120,13 @@ def test_playlist_seq(micropythonify, faketimermanager, monkeypatch): fake_db = FakePlaylistDb([b'track1.mp3', b'track2.mp3', b'track3.mp3']) fake_mp3 = FakeMp3Player() deps = app.Dependencies(mp3player=lambda _: fake_mp3, - nfcreader=lambda _: FakeNfcReader(), + nfcreader=lambda x: FakeNfcReader(x), buttons=lambda _: FakeButtons(), playlistdb=lambda _: fake_db) dut = app.PlayerApp(deps) with monkeypatch.context() as m: m.setattr(builtins, 'open', fake_open) - dut.onTagChange([23, 42, 1, 2, 3]) + FakeNfcReader.tag_callback.onTagChange([23, 42, 1, 2, 3]) assert fake_mp3.track is not None assert fake_mp3.track.filename == b'track1.mp3' @@ -150,11 +153,11 @@ def test_playlist_unknown_tag(micropythonify, faketimermanager, monkeypatch): fake_db = FakeNoPlaylistDb() fake_mp3 = FakeMp3Player() deps = app.Dependencies(mp3player=lambda _: fake_mp3, - nfcreader=lambda _: FakeNfcReader(), + nfcreader=lambda x: FakeNfcReader(x), buttons=lambda _: FakeButtons(), playlistdb=lambda _: fake_db) dut = app.PlayerApp(deps) with monkeypatch.context() as m: m.setattr(builtins, 'open', fake_open) - dut.onTagChange([23, 42, 1, 2, 3]) + FakeNfcReader.tag_callback.onTagChange([23, 42, 1, 2, 3]) assert fake_mp3.track is None From 7d3cdbabe43b745a608c5e54f1c68e35adf5416b Mon Sep 17 00:00:00 2001 From: Matthias Blankertz Date: Fri, 31 Oct 2025 18:15:03 +0100 Subject: [PATCH 2/2] feat(app): Implement tag handling modes according to #28. If the tagmode setting is 'tagremains' (the default): Play as long as the tag is on the reader, with a 5 second timeout (i.e. playback stops when tag is gone for more than 5 seconds). If the tagmode setting is 'tagstartstop': Playback starts when a tag is seen. Presenting a different tag causes the playback to stop and playback for the new tag to start. Re-presenting the same tag used to start with a no-tag-time of 5 seconds or more in between stops playback. Signed-off-by: Matthias Blankertz --- software/src/app.py | 28 +++++++--- software/src/utils/playlistdb.py | 13 +++++ software/tests/test_playerapp.py | 89 +++++++++++++++++++++++++++++++- 3 files changed, 122 insertions(+), 8 deletions(-) diff --git a/software/src/app.py b/software/src/app.py index a8f046d..95007b7 100644 --- a/software/src/app.py +++ b/software/src/app.py @@ -44,6 +44,9 @@ class PlayerApp: self.player = deps.mp3player(self) self.nfc = deps.nfcreader(self.tag_state_machine) self.playlist_db = deps.playlistdb(self) + self.tag_mode = self.playlist_db.getSetting('tagmode') + self.playing_tag = None + self.playlist = None self.buttons = deps.buttons(self) if deps.buttons is not None else None self.mp3file = None self.volume_pos = 3 @@ -59,17 +62,21 @@ class PlayerApp: Callback (typically called by TagStateMachine) to signal that a new tag has been presented. """ uid_str = b''.join('{:02x}'.format(x).encode() for x in new_tag) - self._set_playlist(uid_str) + if self.tag_mode == 'tagremains' or (self.tag_mode == 'tagstartstop' and new_tag != self.playing_tag): + self._set_playlist(uid_str) + self.playing_tag = new_tag + elif self.tag_mode == 'tagstartstop': + print('Tag presented again, stopping playback') + self._unset_playlist() + self.playing_tag = None def onTagRemoved(self): """ Callback (typically called by TagStateMachine) to signal that a tag has been removed. """ - print('Tag gone, stopping playback') - if self.playlist is not None: - pos = self.player.stop() - if pos is not None: - self.playlist.setPlaybackOffset(pos) + if self.tag_mode == 'tagremains': + print('Tag gone, stopping playback') + self._unset_playlist() def onButtonPressed(self, what): assert self.buttons is not None @@ -89,10 +96,18 @@ class PlayerApp: self._play_next() def _set_playlist(self, tag: bytes): + self._unset_playlist() self.playlist = self.playlist_db.getPlaylistForTag(tag) self._play(self.playlist.getCurrentPath() if self.playlist is not None else None, self.playlist.getPlaybackOffset() if self.playlist is not None else 0) + def _unset_playlist(self): + if self.playlist is not None: + pos = self.player.stop() + if pos is not None: + self.playlist.setPlaybackOffset(pos) + self.playlist = None + def _play_next(self): if self.playlist is None: return @@ -100,6 +115,7 @@ class PlayerApp: self._play(filename) if filename is None: self.playlist = None + self.playing_tag = None def _play(self, filename: bytes | None, offset=0): if self.mp3file is not None: diff --git a/software/src/utils/playlistdb.py b/software/src/utils/playlistdb.py index 483cc83..3f7914a 100644 --- a/software/src/utils/playlistdb.py +++ b/software/src/utils/playlistdb.py @@ -31,6 +31,9 @@ class BTreeDB(IPlaylistDB): PERSIST_NO = b'no' PERSIST_TRACK = b'track' PERSIST_OFFSET = b'offset' + DEFAULT_SETTINGS = { + b'tagmode': b'tagremains' + } class Playlist(IPlaylist): def __init__(self, parent: "BTreeDB", tag: bytes, pos: int, persist, shuffle): @@ -269,6 +272,11 @@ class BTreeDB(IPlaylistDB): self._savePlaylist(tag, entries, persist, shuffle) return self.getPlaylistForTag(tag) + def getSetting(self, key: bytes | str) -> str: + if key is str: + key = key.encode() + return self.db.get(b'settings/' + key, self.DEFAULT_SETTINGS[key]).decode() + def validate(self, dump=False): """ Validate the structure of the playlist database. @@ -286,6 +294,11 @@ class BTreeDB(IPlaylistDB): fields = k.split(b'/') if len(fields) <= 1: fail(f'Malformed key {k!r}') + continue + if fields[0] == b'settings': + val = self.db[k].decode() + print(f'Setting {fields[1].decode()} = {val}') + continue if last_tag != fields[0]: last_tag = fields[0] last_pos = None diff --git a/software/tests/test_playerapp.py b/software/tests/test_playerapp.py index b71590a..b0a07f7 100644 --- a/software/tests/test_playerapp.py +++ b/software/tests/test_playerapp.py @@ -38,11 +38,17 @@ class FakeMp3Player: def play(self, track: FakeFile, offset: int): self.track = track + def stop(self): + self.track = None + class FakeTimerManager: def __init__(self): pass def cancel(self, timer): pass + def schedule(self, when, what): + what() + class FakeNfcReader: tag_callback = None @@ -79,6 +85,11 @@ class FakePlaylistDb: def getPlaylistForTag(self, tag: bytes): return self.FakePlaylist(self) + def getSetting(self, key: bytes | str): + if key == 'tagmode': + return 'tagremains' + return None + def fake_open(filename, mode): return FakeFile(filename, mode) @@ -106,7 +117,7 @@ def test_load_playlist_on_tag(micropythonify, faketimermanager, monkeypatch): nfcreader=lambda x: FakeNfcReader(x), buttons=lambda _: FakeButtons(), playlistdb=lambda _: fake_db) - dut = app.PlayerApp(deps) + app.PlayerApp(deps) with monkeypatch.context() as m: m.setattr(builtins, 'open', fake_open) FakeNfcReader.tag_callback.onTagChange([23, 42, 1, 2, 3]) @@ -150,14 +161,88 @@ def test_playlist_unknown_tag(micropythonify, faketimermanager, monkeypatch): def getPlaylistForTag(self, tag): return None + def getSetting(self, key: bytes | str): + return None + fake_db = FakeNoPlaylistDb() fake_mp3 = FakeMp3Player() deps = app.Dependencies(mp3player=lambda _: fake_mp3, nfcreader=lambda x: FakeNfcReader(x), buttons=lambda _: FakeButtons(), playlistdb=lambda _: fake_db) - dut = app.PlayerApp(deps) + app.PlayerApp(deps) with monkeypatch.context() as m: m.setattr(builtins, 'open', fake_open) FakeNfcReader.tag_callback.onTagChange([23, 42, 1, 2, 3]) assert fake_mp3.track is None + + +def test_tagmode_startstop(micropythonify, faketimermanager, monkeypatch): + class MyFakePlaylistDb(FakePlaylistDb): + def __init__(self, tracklist=[b'test/path.mp3']): + super().__init__(tracklist) + + def getSetting(self, key: bytes | str): + if key == 'tagmode': + return 'tagstartstop' + return None + + fake_db = MyFakePlaylistDb() + fake_mp3 = FakeMp3Player() + deps = app.Dependencies(mp3player=lambda _: fake_mp3, + nfcreader=lambda x: FakeNfcReader(x), + buttons=lambda _: FakeButtons(), + playlistdb=lambda _: fake_db) + app.PlayerApp(deps) + with monkeypatch.context() as m: + m.setattr(builtins, 'open', fake_open) + # Present tag to start playback + FakeNfcReader.tag_callback.onTagChange([23, 42, 1, 2, 3]) + assert fake_mp3.track is not None + assert fake_mp3.track.filename == b'test/path.mp3' + # Removing tag should not stop playback + FakeNfcReader.tag_callback.onTagChange(None) + assert fake_mp3.track is not None + assert fake_mp3.track.filename == b'test/path.mp3' + # Presenting tag should stop playback + FakeNfcReader.tag_callback.onTagChange([23, 42, 1, 2, 3]) + assert fake_mp3.track is None + # Nothing should change here + FakeNfcReader.tag_callback.onTagChange(None) + assert fake_mp3.track is None + # Presenting tag again should start playback again + FakeNfcReader.tag_callback.onTagChange([23, 42, 1, 2, 3]) + assert fake_mp3.track is not None + assert fake_mp3.track.filename == b'test/path.mp3' + + +def test_tagmode_remains(micropythonify, faketimermanager, monkeypatch): + class MyFakePlaylistDb(FakePlaylistDb): + def __init__(self, tracklist=[b'test/path.mp3']): + super().__init__(tracklist) + + def getSetting(self, key: bytes | str): + if key == 'tagmode': + return 'tagremains' + return None + + fake_db = MyFakePlaylistDb() + fake_mp3 = FakeMp3Player() + deps = app.Dependencies(mp3player=lambda _: fake_mp3, + nfcreader=lambda x: FakeNfcReader(x), + buttons=lambda _: FakeButtons(), + playlistdb=lambda _: fake_db) + app.PlayerApp(deps) + with monkeypatch.context() as m: + m.setattr(builtins, 'open', fake_open) + # Present tag to start playback + FakeNfcReader.tag_callback.onTagChange([23, 42, 1, 2, 3]) + assert fake_mp3.track is not None + assert fake_mp3.track.filename == b'test/path.mp3' + # Remove tag to stop playback + FakeNfcReader.tag_callback.onTagChange(None) + assert fake_mp3.track is None + # Presenting tag again should start playback again + FakeNfcReader.tag_callback.onTagChange([23, 42, 1, 2, 3]) + assert fake_mp3.track is not None + assert fake_mp3.track.filename == b'test/path.mp3'