feat(app): Implement tag handling modes according to #28.
All checks were successful
Build RPi Pico firmware image / Build-Firmware (push) Successful in 3m24s
Check code formatting / Check-C-Format (push) Successful in 7s
Check code formatting / Check-Python-Flake8 (push) Successful in 10s
Check code formatting / Check-Bash-Shellcheck (push) Successful in 6s
Run unit tests on host / Run-Unit-Tests (push) Successful in 9s
Run pytests / Check-Pytest (push) Successful in 11s
All checks were successful
Build RPi Pico firmware image / Build-Firmware (push) Successful in 3m24s
Check code formatting / Check-C-Format (push) Successful in 7s
Check code formatting / Check-Python-Flake8 (push) Successful in 10s
Check code formatting / Check-Bash-Shellcheck (push) Successful in 6s
Run unit tests on host / Run-Unit-Tests (push) Successful in 9s
Run pytests / Check-Pytest (push) Successful in 11s
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 <matthias@blankertz.org>
This commit is contained in:
@@ -44,6 +44,9 @@ class PlayerApp:
|
|||||||
self.player = deps.mp3player(self)
|
self.player = deps.mp3player(self)
|
||||||
self.nfc = deps.nfcreader(self.tag_state_machine)
|
self.nfc = deps.nfcreader(self.tag_state_machine)
|
||||||
self.playlist_db = deps.playlistdb(self)
|
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.buttons = deps.buttons(self) if deps.buttons is not None else None
|
||||||
self.mp3file = None
|
self.mp3file = None
|
||||||
self.volume_pos = 3
|
self.volume_pos = 3
|
||||||
@@ -59,17 +62,21 @@ class PlayerApp:
|
|||||||
Callback (typically called by TagStateMachine) to signal that a new tag has been presented.
|
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)
|
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):
|
def onTagRemoved(self):
|
||||||
"""
|
"""
|
||||||
Callback (typically called by TagStateMachine) to signal that a tag has been removed.
|
Callback (typically called by TagStateMachine) to signal that a tag has been removed.
|
||||||
"""
|
"""
|
||||||
print('Tag gone, stopping playback')
|
if self.tag_mode == 'tagremains':
|
||||||
if self.playlist is not None:
|
print('Tag gone, stopping playback')
|
||||||
pos = self.player.stop()
|
self._unset_playlist()
|
||||||
if pos is not None:
|
|
||||||
self.playlist.setPlaybackOffset(pos)
|
|
||||||
|
|
||||||
def onButtonPressed(self, what):
|
def onButtonPressed(self, what):
|
||||||
assert self.buttons is not None
|
assert self.buttons is not None
|
||||||
@@ -89,10 +96,18 @@ class PlayerApp:
|
|||||||
self._play_next()
|
self._play_next()
|
||||||
|
|
||||||
def _set_playlist(self, tag: bytes):
|
def _set_playlist(self, tag: bytes):
|
||||||
|
self._unset_playlist()
|
||||||
self.playlist = self.playlist_db.getPlaylistForTag(tag)
|
self.playlist = self.playlist_db.getPlaylistForTag(tag)
|
||||||
self._play(self.playlist.getCurrentPath() if self.playlist is not None else None,
|
self._play(self.playlist.getCurrentPath() if self.playlist is not None else None,
|
||||||
self.playlist.getPlaybackOffset() if self.playlist is not None else 0)
|
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):
|
def _play_next(self):
|
||||||
if self.playlist is None:
|
if self.playlist is None:
|
||||||
return
|
return
|
||||||
@@ -100,6 +115,7 @@ class PlayerApp:
|
|||||||
self._play(filename)
|
self._play(filename)
|
||||||
if filename is None:
|
if filename is None:
|
||||||
self.playlist = None
|
self.playlist = None
|
||||||
|
self.playing_tag = None
|
||||||
|
|
||||||
def _play(self, filename: bytes | None, offset=0):
|
def _play(self, filename: bytes | None, offset=0):
|
||||||
if self.mp3file is not None:
|
if self.mp3file is not None:
|
||||||
|
|||||||
@@ -31,6 +31,9 @@ class BTreeDB(IPlaylistDB):
|
|||||||
PERSIST_NO = b'no'
|
PERSIST_NO = b'no'
|
||||||
PERSIST_TRACK = b'track'
|
PERSIST_TRACK = b'track'
|
||||||
PERSIST_OFFSET = b'offset'
|
PERSIST_OFFSET = b'offset'
|
||||||
|
DEFAULT_SETTINGS = {
|
||||||
|
b'tagmode': b'tagremains'
|
||||||
|
}
|
||||||
|
|
||||||
class Playlist(IPlaylist):
|
class Playlist(IPlaylist):
|
||||||
def __init__(self, parent: "BTreeDB", tag: bytes, pos: int, persist, shuffle):
|
def __init__(self, parent: "BTreeDB", tag: bytes, pos: int, persist, shuffle):
|
||||||
@@ -269,6 +272,11 @@ class BTreeDB(IPlaylistDB):
|
|||||||
self._savePlaylist(tag, entries, persist, shuffle)
|
self._savePlaylist(tag, entries, persist, shuffle)
|
||||||
return self.getPlaylistForTag(tag)
|
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):
|
def validate(self, dump=False):
|
||||||
"""
|
"""
|
||||||
Validate the structure of the playlist database.
|
Validate the structure of the playlist database.
|
||||||
@@ -286,6 +294,11 @@ class BTreeDB(IPlaylistDB):
|
|||||||
fields = k.split(b'/')
|
fields = k.split(b'/')
|
||||||
if len(fields) <= 1:
|
if len(fields) <= 1:
|
||||||
fail(f'Malformed key {k!r}')
|
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]:
|
if last_tag != fields[0]:
|
||||||
last_tag = fields[0]
|
last_tag = fields[0]
|
||||||
last_pos = None
|
last_pos = None
|
||||||
|
|||||||
@@ -38,11 +38,17 @@ class FakeMp3Player:
|
|||||||
def play(self, track: FakeFile, offset: int):
|
def play(self, track: FakeFile, offset: int):
|
||||||
self.track = track
|
self.track = track
|
||||||
|
|
||||||
|
def stop(self):
|
||||||
|
self.track = None
|
||||||
|
|
||||||
|
|
||||||
class FakeTimerManager:
|
class FakeTimerManager:
|
||||||
def __init__(self): pass
|
def __init__(self): pass
|
||||||
def cancel(self, timer): pass
|
def cancel(self, timer): pass
|
||||||
|
|
||||||
|
def schedule(self, when, what):
|
||||||
|
what()
|
||||||
|
|
||||||
|
|
||||||
class FakeNfcReader:
|
class FakeNfcReader:
|
||||||
tag_callback = None
|
tag_callback = None
|
||||||
@@ -79,6 +85,11 @@ class FakePlaylistDb:
|
|||||||
def getPlaylistForTag(self, tag: bytes):
|
def getPlaylistForTag(self, tag: bytes):
|
||||||
return self.FakePlaylist(self)
|
return self.FakePlaylist(self)
|
||||||
|
|
||||||
|
def getSetting(self, key: bytes | str):
|
||||||
|
if key == 'tagmode':
|
||||||
|
return 'tagremains'
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
def fake_open(filename, mode):
|
def fake_open(filename, mode):
|
||||||
return FakeFile(filename, mode)
|
return FakeFile(filename, mode)
|
||||||
@@ -106,7 +117,7 @@ def test_load_playlist_on_tag(micropythonify, faketimermanager, monkeypatch):
|
|||||||
nfcreader=lambda x: FakeNfcReader(x),
|
nfcreader=lambda x: FakeNfcReader(x),
|
||||||
buttons=lambda _: FakeButtons(),
|
buttons=lambda _: FakeButtons(),
|
||||||
playlistdb=lambda _: fake_db)
|
playlistdb=lambda _: fake_db)
|
||||||
dut = app.PlayerApp(deps)
|
app.PlayerApp(deps)
|
||||||
with monkeypatch.context() as m:
|
with monkeypatch.context() as m:
|
||||||
m.setattr(builtins, 'open', fake_open)
|
m.setattr(builtins, 'open', fake_open)
|
||||||
FakeNfcReader.tag_callback.onTagChange([23, 42, 1, 2, 3])
|
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):
|
def getPlaylistForTag(self, tag):
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
def getSetting(self, key: bytes | str):
|
||||||
|
return None
|
||||||
|
|
||||||
fake_db = FakeNoPlaylistDb()
|
fake_db = FakeNoPlaylistDb()
|
||||||
fake_mp3 = FakeMp3Player()
|
fake_mp3 = FakeMp3Player()
|
||||||
deps = app.Dependencies(mp3player=lambda _: fake_mp3,
|
deps = app.Dependencies(mp3player=lambda _: fake_mp3,
|
||||||
nfcreader=lambda x: FakeNfcReader(x),
|
nfcreader=lambda x: FakeNfcReader(x),
|
||||||
buttons=lambda _: FakeButtons(),
|
buttons=lambda _: FakeButtons(),
|
||||||
playlistdb=lambda _: fake_db)
|
playlistdb=lambda _: fake_db)
|
||||||
dut = app.PlayerApp(deps)
|
app.PlayerApp(deps)
|
||||||
with monkeypatch.context() as m:
|
with monkeypatch.context() as m:
|
||||||
m.setattr(builtins, 'open', fake_open)
|
m.setattr(builtins, 'open', fake_open)
|
||||||
FakeNfcReader.tag_callback.onTagChange([23, 42, 1, 2, 3])
|
FakeNfcReader.tag_callback.onTagChange([23, 42, 1, 2, 3])
|
||||||
assert fake_mp3.track is None
|
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'
|
||||||
|
|||||||
Reference in New Issue
Block a user