9 Commits

Author SHA1 Message Date
16e87b6b20 wip: led patterns, auto power off
Some checks failed
Build RPi Pico firmware image / Build-Firmware (push) Successful in 3m25s
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 4s
Run unit tests on host / Run-Unit-Tests (push) Successful in 8s
Run pytests / Check-Pytest (push) Failing after 11s
2025-11-07 23:05:14 +01:00
0820ec1fc8 Merge pull request '28-configurable-tag-handling' (#48) from 28-configurable-tag-handling into main
All checks were successful
Build RPi Pico firmware image / Build-Firmware (push) Successful in 3m21s
Check code formatting / Check-C-Format (push) Successful in 8s
Check code formatting / Check-Python-Flake8 (push) Successful in 10s
Check code formatting / Check-Bash-Shellcheck (push) Successful in 5s
Run unit tests on host / Run-Unit-Tests (push) Successful in 8s
Run pytests / Check-Pytest (push) Successful in 11s
Reviewed-on: #48
Reviewed-by: Stefan Kratochwil <kratochwil-la@gmx.de>
2025-11-04 18:54:54 +00:00
7d3cdbabe4 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
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>
2025-11-04 19:25:10 +01:00
b5e3df1054 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 <matthias@blankertz.org>
2025-11-04 19:25:10 +01:00
55718aa1ff Merge pull request '24-playlist-modes' (#46) from 24-playlist-modes into main
All checks were successful
Build RPi Pico firmware image / Build-Firmware (push) Successful in 3m23s
Check code formatting / Check-C-Format (push) Successful in 8s
Check code formatting / Check-Python-Flake8 (push) Successful in 11s
Check code formatting / Check-Bash-Shellcheck (push) Successful in 5s
Run unit tests on host / Run-Unit-Tests (push) Successful in 9s
Run pytests / Check-Pytest (push) Successful in 11s
Reviewed-on: #46
Reviewed-by: Stefan Kratochwil <kratochwil-la@gmx.de>
2025-11-04 18:23:57 +00:00
6a9ff9eb0a playlistdb: Implement shuffle
All checks were successful
Build RPi Pico firmware image / Build-Firmware (push) Successful in 3m22s
Check code formatting / Check-C-Format (push) Successful in 8s
Check code formatting / Check-Python-Flake8 (push) Successful in 10s
Check code formatting / Check-Bash-Shellcheck (push) Successful in 5s
Run unit tests on host / Run-Unit-Tests (push) Successful in 8s
Run pytests / Check-Pytest (push) Successful in 11s
Initial implementation of shuffle with a naive algorithm.

Signed-off-by: Matthias Blankertz <matthias@blankertz.org>
2025-10-31 14:21:35 +01:00
7327549eea refactor(playlistdb): Enforce constant entry index length
To simplify the playlist handling, enforce that the indices are always
formatted to the same length (5, which allows for 100000 entries, that
should be enough).

Then make the position stored in the Playlist object be a simple integer
instead of a database key. This simplifies the code, and will make
implementing shuffle much easier.

Signed-off-by: Matthias Blankertz <matthias@blankertz.org>
2025-10-31 14:21:35 +01:00
7e532ec641 app, mp3player: Hook up playlists 'offset' persist mode
Signed-off-by: Matthias Blankertz <matthias@blankertz.org>
2025-10-31 14:21:35 +01:00
5013e2359d playlistdb: Add shuffle and persist settings
Add documentation of playlist database schema in DEVELOP.md.

Add settings for persist and shuffle to BTreeDB. Implement the different
persist modes. Shuffle will be implemented in a followup commit.

Signed-off-by: Matthias Blankertz <matthias@blankertz.org>
2025-10-31 14:21:35 +01:00
12 changed files with 648 additions and 157 deletions

View File

@@ -9,3 +9,46 @@ python -m venv test-venv
pip install -r tests/requirements.txt
pip install -U micropython-rp2-pico_w-stubs --target typings
```
## 'database' schema for btree db
### Playlist storage
The playlist data is stored in the btree database in a hierarchical schema. The hierarchy levels are
separated by the '/' character. Currently, the schema is as follows: The top level for a playlist
is the 'tag' id encoded as a hexadecimal string. Beneath this, the 'playlist' key contains the
elements in the playlist. The keys used for the playlist entries must be decimal integers,
left-padded with zeros so their length is 5 (e.g. format `{:05}`).
#### Playlist modes
The 'playlistshuffle' key located under the 'tag' key can be 'no' or 'yes' and specifies whether the
playlist is in shuffle mode. Should this key be absent the default value is 'no'.
The 'playlistpersist' key located under the 'tag' key can be 'no', 'track' or 'offset'. Should this
key be absent the default value is 'track'.
* When it is 'no', the playlist position is not saved when playback stops. If shuffle mode is
active, the shuffle random seed is also not saved.
* When it is 'track', the currently playing track is saved when playback stops. If shuffle mode is
active, the shuffle random seed is also saved. Should playback reach the last track (in shuffle
mode: the last track in the permutated order), the saved position is reset and playback is
stopped. The next time the playlist is started it will start from the first track and with a new
shuffle seed if applicable.
* When it is 'offset', the operation is basically the same as in 'track' mode. The difference is
that the offset in the currently playing track is also saved and playback will resume at that
position.
The 'playlistpos' key located under the 'tag' key stores the key of the current playlist
entry. The 'playlistshuffleseed' key stores the random seed used to shuffle the playlist.
The 'playlistposoffset' key stores the offset in the current playlist entry.
#### Example
For example, a playlist with two entries 'a.mp3' and 'b.mp3' for a tag with the id '00aa11bb22'
would be stored in the following key/value pairs in the btree db:
* 00aa11bb22/playlist/00000: a.mp3
* 00aa11bb22/playlist/00001: b.mp3
* 00aa11bb22/playlistpos: 00000

View File

@@ -31,7 +31,7 @@ for hwconfig in src/hwconfig_*.py; do
hwconfig_base=$(basename "$hwconfig")
hwname=${hwconfig_base##hwconfig_}
hwname=${hwname%%.py}
find src/ -iname '*.py' \! -iname 'hwconfig_*.py' | cpio -pdm "$FS_STAGE_DIR"
find src/ -iname '*.py' \! -iname 'hwconfig*.py' | cpio -pdm "$FS_STAGE_DIR"
cp "$hwconfig" "$FS_STAGE_DIR"/src/hwconfig.py
tools/mklittlefs/mklittlefs -p 256 -s 868352 -c "$FS_STAGE_DIR"/src $BUILDDIR/filesystem.bin
truncate -s 2M $BUILDDIR/firmware-filesystem.bin

View File

@@ -6,49 +6,80 @@ import time
from utils import TimerManager
Dependencies = namedtuple('Dependencies', ('mp3player', 'nfcreader', 'buttons', 'playlistdb'))
Dependencies = namedtuple('Dependencies', ('mp3player', 'nfcreader', 'buttons', 'playlistdb', 'hwconfig', 'leds'))
# Should be ~ 6dB steps
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.hwconfig = deps.hwconfig(self)
self.leds = deps.leds(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
self.player.set_volume(VOLUME_CURVE[self.volume_pos])
self._onIdle()
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)
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)
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)
if self.tag_mode == 'tagremains' or (self.tag_mode == 'tagstartstop' and new_tag != self.playing_tag):
self._set_playlist(uid_str)
else:
self.timer_manager.schedule(time.ticks_ms() + 5000, self.onTagRemoveDelay)
self.playing_tag = new_tag
elif self.tag_mode == 'tagstartstop':
print('Tag presented again, stopping playback')
self._unset_playlist()
self.playing_tag = None
def onTagRemoveDelay(self):
if self.current_tag is not None:
def onTagRemoved(self):
"""
Callback (typically called by TagStateMachine) to signal that a tag has been removed.
"""
if self.tag_mode == 'tagremains':
print('Tag gone, stopping playback')
self.current_tag = None
self.player.stop()
self._unset_playlist()
def onButtonPressed(self, what):
assert self.buttons is not None
@@ -67,9 +98,30 @@ class PlayerApp:
self.mp3file = None
self._play_next()
def onIdleTimeout(self):
print('IdleTimeout')
if self.hwconfig.get_on_battery():
self.hwconfig.power_off()
else:
# Check again in a minute
self.timer_manager.schedule(time.ticks_ms() + 60*1000, self.onIdleTimeout)
def _set_playlist(self, tag: bytes):
if self.playlist is not None:
pos = self.player.stop()
if pos is not None:
self.playlist.setPlaybackOffset(pos)
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)
def _unset_playlist(self):
if self.playlist is not None:
pos = self.player.stop()
self._onIdle()
if pos is not None:
self.playlist.setPlaybackOffset(pos)
self.playlist = None
def _play_next(self):
if self.playlist is None:
@@ -78,13 +130,26 @@ class PlayerApp:
self._play(filename)
if filename is None:
self.playlist = None
self.playing_tag = None
def _play(self, filename: bytes | None):
def _play(self, filename: bytes | None, offset=0):
if self.mp3file is not None:
self.player.stop()
self.mp3file.close()
self.mp3file = None
self._onIdle()
if filename is not None:
print(f'Playing {filename!r}')
self.mp3file = open(filename, 'rb')
self.player.play(self.mp3file)
self.player.play(self.mp3file, offset)
self._onActive()
def _onIdle(self):
print('Idle')
self.timer_manager.schedule(time.ticks_ms() + 60*1000, self.onIdleTimeout)
self.leds.set_state('idle')
def _onActive(self):
print('Active')
self.timer_manager.cancel(self.onIdleTimeout)
self.leds.set_state('playing')

View File

@@ -28,7 +28,7 @@ RC522_SS = Pin.board.GP13
# WS2812
LED_DIN = Pin.board.GP16
LED_COUNT = 1
LED_COUNT = 8
# Buttons
BUTTON_VOLUP = Pin.board.GP17
@@ -39,6 +39,7 @@ BUTTON_POWER = Pin.board.GP21
# Power
POWER_EN = Pin.board.GP22
VBAT_ADC = Pin.board.GP26
VBUS_DET = Pin.board.WL_GPIO2
def board_init():
@@ -61,3 +62,13 @@ def get_battery_voltage():
adc = machine.ADC(VBAT_ADC) # create ADC object on ADC pin
battv = adc.read_u16()/65535.0*3.3*2
return battv
def power_off():
POWER_EN.init(mode=Pin.OUT)
POWER_EN.value(0)
def get_on_battery():
vbus = VBUS_DET.value()
return not vbus

View File

@@ -47,3 +47,12 @@ def board_init():
def get_battery_voltage():
# Not supported on breadboard
return None
def power_off():
# Not supported on breadboard
pass
def get_on_battery():
return False

View File

@@ -32,25 +32,54 @@ micropython.alloc_emergency_exception_buf(100)
hwconfig.board_init()
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)
class LedManager:
def __init__(self, np):
self.led_state = 'boot'
self.np = np
self.brightness = 0.1
self.leds = len(self.np)
asyncio.create_task(self.run())
brightness = 0.05
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.02 * leds
before = time.ticks_ms()
await np.async_write()
now = time.ticks_ms()
if before + 20 > now:
await asyncio.sleep_ms(20 - (now - before))
def set_state(self, state):
assert state in ['boot', 'idle', 'playing']
self.led_state = state
def _gamma(self, value, X=2.2):
result = min(max(int(self.brightness * pow(value / 255.0, X) * 255.0 + 0.5), 0), 255)
if value > 0:
result = max(1, result)
return result
def _rainbow(self, time):
for i in range(self.leds):
ofs = (time * self.leds + i) % self.leds
self.np[i] = (self._gamma((sin(ofs / self.leds * 2 * pi) + 1) * 127),
self._gamma((sin(ofs / self.leds * 2 * pi + 2/3*pi) + 1) * 127),
self._gamma((sin(ofs / self.leds * 2 * pi + 4/3*pi) + 1) * 127))
def _pulse(self, time, color, speed):
scaled_sin = max(1, abs(sin(time / speed * 2 * pi)) * 255)
val = (self._gamma(color[0]*scaled_sin),
self._gamma(color[1]*scaled_sin),
self._gamma(color[2]*scaled_sin))
for i in range(self.leds):
self.np[i] = val
async def run(self):
time_ = 0.0
while True:
if self.led_state == 'idle':
self._pulse(time_, (0, 1, 0), 3)
elif self.led_state == 'boot':
self._pulse(time_, (1, 1, 0), 1)
elif self.led_state == 'playing':
self._rainbow(time_)
time_ += 0.02
before = time.ticks_ms()
await self.np.async_write()
now = time.ticks_ms()
if before + 20 > now:
await asyncio.sleep_ms(20 - (now - before))
# high prio for proc 1
@@ -71,7 +100,6 @@ def run():
asyncio.new_event_loop()
# Setup LEDs
np = NeoPixel(hwconfig.LED_DIN, hwconfig.LED_COUNT, sm=1)
asyncio.create_task(rainbow(np))
# Wifi with default config
setup_wifi()
@@ -94,14 +122,15 @@ def run():
# Setup NFC
reader = MFRC522(spi_id=hwconfig.RC522_SPIID, sck=hwconfig.RC522_SCK, miso=hwconfig.RC522_MISO,
mosi=hwconfig.RC522_MOSI, cs=hwconfig.RC522_SS, rst=hwconfig.RC522_RST, tocard_retries=20)
# Setup app
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, pin_volup=hwconfig.BUTTON_VOLUP,
pin_voldown=hwconfig.BUTTON_VOLDOWN,
pin_next=hwconfig.BUTTON_NEXT),
playlistdb=lambda _: playlistdb)
playlistdb=lambda _: playlistdb,
hwconfig=lambda _: hwconfig,
leds=lambda _: LedManager(np))
the_app = app.PlayerApp(deps)
# Start

View File

@@ -21,14 +21,19 @@ class MP3Player:
self.mp3task = None
self.volume = 128
self.cb = cb
self.pos = 0
def play(self, stream):
def play(self, stream, offset=0):
"""
Play from byte stream.
If offset > 0, discard the first offset bytes
"""
if self.mp3task is not None:
self.mp3task.cancel()
self.mp3task = None
if offset > 0:
stream.seek(offset, 1)
self.pos = offset
self.mp3task = asyncio.create_task(self._play_task(stream))
def stop(self):
@@ -38,6 +43,8 @@ class MP3Player:
if self.mp3task is not None:
self.mp3task.cancel()
self.mp3task = None
return self.pos
return None
def set_volume(self, volume: int):
"""
@@ -60,6 +67,7 @@ class MP3Player:
# End of file
break
_, _, underruns = await self.audiocore.async_put(data[:bytes_read])
self.pos += bytes_read
if underruns > known_underruns:
print(f"{underruns:x}")
known_underruns = underruns

View File

@@ -2,6 +2,8 @@
# Copyright (c) 2025 Matthias Blankertz <matthias@blankertz.org>
import btree
import random
import time
try:
import typing
from typing import TYPE_CHECKING, Iterable # type: ignore
@@ -24,11 +26,56 @@ else:
class BTreeDB(IPlaylistDB):
SHUFFLE_NO = b'no'
SHUFFLE_YES = b'yes'
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, tag, pos):
def __init__(self, parent: "BTreeDB", tag: bytes, pos: int, persist, shuffle):
self.parent = parent
self.tag = tag
self.pos = pos
self.persist = persist
self.shuffle = shuffle
self.length = self.parent._getPlaylistLength(self.tag)
self._shuffle()
def _getPlaylistPos(self):
"""
Gets the position to pass to parent._getPlaylistEntry etc.
"""
if self.shuffle == BTreeDB.SHUFFLE_YES:
return self.shuffle_order[self.pos]
else:
return self.pos
def _shuffle(self, reshuffle=False):
if self.shuffle == BTreeDB.SHUFFLE_NO:
return
self.shuffle_seed = None
# Try to get seed from DB if persisted
if self.persist != BTreeDB.PERSIST_NO and not reshuffle:
self.shuffle_seed = self.parent._getPlaylistShuffleSeed(self.tag)
if self.shuffle_seed is None:
# Either not persisted or could not read from db
self.shuffle_seed = time.ticks_cpu()
if self.persist != BTreeDB.PERSIST_NO:
self.parent._setPlaylistShuffleSeed(self.tag, self.shuffle_seed)
# TODO: Find an algorithm for shuffling that does not use O(n) memory for playlist of length n
random.seed(self.shuffle_seed)
entries = list(range(0, self.length))
# We don't have random.shuffle in micropython, so emulate it with random.choice
self.shuffle_order = []
while len(entries) > 0:
chosen = random.choice(entries)
self.shuffle_order.append(chosen)
entries.remove(chosen)
def getPaths(self):
"""
@@ -40,21 +87,42 @@ class BTreeDB(IPlaylistDB):
"""
Get path of file that should be played.
"""
return self.parent._getPlaylistEntry(self.tag, self.pos)
return self.parent._getPlaylistEntry(self.tag, self._getPlaylistPos())
def getNextPath(self):
"""
Select next track and return path.
"""
try:
self.pos = self.parent._getNextTrack(self.tag, self.pos)
except StopIteration:
self.pos = self.parent._getFirstTrack(self.tag)
if self.pos + 1 >= self.length:
self.pos = 0
if self.persist != BTreeDB.PERSIST_NO:
self.parent._setPlaylistPos(self.tag, self.pos)
self.setPlaybackOffset(0)
self._shuffle(True)
return None
finally:
self.pos += 1
if self.persist != BTreeDB.PERSIST_NO:
self.parent._setPlaylistPos(self.tag, self.pos)
self.setPlaybackOffset(0)
return self.getCurrentPath()
def setPlaybackOffset(self, offset):
"""
Store the current position in the track for PERSIST_OFFSET mode
"""
if self.persist != BTreeDB.PERSIST_OFFSET:
return
self.parent._setPlaylistPosOffset(self.tag, offset)
def getPlaybackOffset(self):
"""
Get the current position in the track for PERSIST_OFFSET mode
"""
if self.persist != BTreeDB.PERSIST_OFFSET:
return 0
return self.parent._getPlaylistPosOffset(self.tag)
def __init__(self, db: btree.BTree, flush_func: typing.Callable | None = None):
self.db = db
self.flush_func = flush_func
@@ -63,6 +131,22 @@ class BTreeDB(IPlaylistDB):
def _keyPlaylistPos(tag):
return b''.join([tag, b'/playlistpos'])
@staticmethod
def _keyPlaylistPosOffset(tag):
return b''.join([tag, b'/playlistposoffset'])
@staticmethod
def _keyPlaylistShuffle(tag):
return b''.join([tag, b'/playlistshuffle'])
@staticmethod
def _keyPlaylistShuffleSeed(tag):
return b''.join([tag, b'/playlistshuffleseed'])
@staticmethod
def _keyPlaylistPersist(tag):
return b''.join([tag, b'/playlistpersist'])
@staticmethod
def _keyPlaylistEntry(tag, pos):
return b''.join([tag, b'/playlist/', '{:05}'.format(pos).encode()])
@@ -84,23 +168,58 @@ class BTreeDB(IPlaylistDB):
if self.flush_func is not None:
self.flush_func()
def _getPlaylistValueIterator(self, tag):
def _getPlaylistValueIterator(self, tag: bytes):
start, end = self._keyPlaylistStartEnd(tag)
return self.db.values(start, end)
def _getPlaylistEntry(self, _, pos):
return self.db[pos]
def _getPlaylistEntry(self, tag: bytes, pos: int) -> bytes:
return self.db[self._keyPlaylistEntry(tag, pos)]
def _setPlaylistPos(self, tag, pos, flush=True):
assert pos.startswith(self._keyPlaylistStart(tag))
self.db[self._keyPlaylistPos(tag)] = pos[len(self._keyPlaylistStart(tag)):]
def _setPlaylistPos(self, tag: bytes, pos: int, flush=True):
self.db[self._keyPlaylistPos(tag)] = str(pos).encode()
if flush:
self._flush()
def _savePlaylist(self, tag, entries, flush=True):
def _setPlaylistPosOffset(self, tag: bytes, offset: int, flush=True):
self.db[self._keyPlaylistPosOffset(tag)] = str(offset).encode()
if flush:
self._flush()
def _getPlaylistPosOffset(self, tag: bytes) -> int:
return int(self.db.get(self._keyPlaylistPosOffset(tag), b'0'))
def _getPlaylistShuffleSeed(self, tag: bytes) -> int | None:
try:
return int(self.db[self._keyPlaylistShuffleSeed(tag)])
except (ValueError, KeyError):
return None
def _setPlaylistShuffleSeed(self, tag, seed: int, flush=True):
self.db[self._keyPlaylistShuffleSeed(tag)] = str(seed).encode()
if flush:
self._flush()
def _getPlaylistLength(self, tag: bytes) -> int:
start, end = self._keyPlaylistStartEnd(tag)
for k in self.db.keys(end, start, btree.DESC):
# There is a bug in btreedb that causes an additional key after 'end' to be returned when iterating in
# descending order
# Check for this and skip it if needed
elements = k.split(b'/')
if len(elements) >= 2 and elements[1] == b'playlist':
last = k
break
elements = last.split(b'/')
if len(elements) != 3:
raise RuntimeError("Malformed playlist key")
return int(elements[2])+1
def _savePlaylist(self, tag, entries, persist, shuffle, flush=True):
self._deletePlaylist(tag, False)
for idx, entry in enumerate(entries):
self.db[self._keyPlaylistEntry(tag, idx)] = entry
self.db[self._keyPlaylistPersist(tag)] = persist
self.db[self._keyPlaylistShuffle(tag)] = shuffle
if flush:
self._flush()
@@ -111,60 +230,74 @@ class BTreeDB(IPlaylistDB):
del self.db[k]
except KeyError:
pass
try:
del self.db[self._keyPlaylistPos(tag)]
except KeyError:
pass
for k in (self._keyPlaylistPos(tag), self._keyPlaylistPosOffset(tag),
self._keyPlaylistPersist(tag), self._keyPlaylistShuffle(tag),
self._keyPlaylistShuffleSeed(tag)):
try:
del self.db[k]
except KeyError:
pass
if flush:
self._flush()
def _getFirstTrack(self, tag: bytes):
start_key, end_key = self._keyPlaylistStartEnd(tag)
return next(self.db.keys(start_key, end_key))
def _getNextTrack(self, tag, pos):
_, end_key = self._keyPlaylistStartEnd(tag)
it = self.db.keys(pos, end_key)
next(it)
return next(it)
def getPlaylistForTag(self, tag: bytes):
"""
Lookup the playlist for 'tag' and return the Playlist object. Return None if no playlist exists for the given
tag.
"""
pos = self.db.get(self._keyPlaylistPos(tag))
if pos is None:
persist = self.db.get(self._keyPlaylistPersist(tag), self.PERSIST_TRACK)
pos = 0
if persist != self.PERSIST_NO and self._keyPlaylistPos(tag) in self.db:
try:
pos = self._getFirstTrack(tag)
except StopIteration:
# playist does not exist
return None
else:
pos = self._keyPlaylistStart(tag) + pos
return self.Playlist(self, tag, pos)
pos = int(self.db[self._keyPlaylistPos(tag)])
except ValueError:
pass
if self._keyPlaylistEntry(tag, 0) not in self.db:
# Empty playlist
return None
if self._keyPlaylistEntry(tag, pos) not in self.db:
pos = 0
shuffle = self.db.get(self._keyPlaylistShuffle(tag), self.SHUFFLE_NO)
return self.Playlist(self, tag, pos, persist, shuffle)
def createPlaylistForTag(self, tag: bytes, entries: typing.Iterable[bytes]):
def createPlaylistForTag(self, tag: bytes, entries: typing.Iterable[bytes], persist=PERSIST_TRACK,
shuffle=SHUFFLE_NO):
"""
Create and save a playlist for 'tag' and return the Playlist object. If a playlist already existed for 'tag' it
is overwritten.
"""
self._savePlaylist(tag, entries)
assert persist in (self.PERSIST_NO, self.PERSIST_TRACK, self.PERSIST_OFFSET)
assert shuffle in (self.SHUFFLE_NO, self.SHUFFLE_YES)
self._savePlaylist(tag, entries, persist, shuffle)
return self.getPlaylistForTag(tag)
def getSetting(self, key: bytes | str) -> str:
if type(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.
"""
result = True
def fail(msg):
nonlocal result
print(msg)
result = False
last_tag = None
last_pos = None
index_width = None
for k in self.db.keys():
fields = k.split(b'/')
if len(fields) <= 1:
print(f'Malformed key {k!r}')
result = False
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
@@ -172,24 +305,19 @@ class BTreeDB(IPlaylistDB):
print(f'Tag {fields[0]}')
if fields[1] == b'playlist':
if len(fields) != 3:
print(f'Malformed playlist entry: {k!r}')
result = False
fail(f'Malformed playlist entry: {k!r}')
continue
try:
idx = int(fields[2])
except ValueError:
print(f'Malformed playlist entry: {k!r}')
result = False
fail(f'Malformed playlist entry: {k!r}')
continue
if index_width is not None and len(fields[2]) != index_width:
print(f'Inconsistent index width for {last_tag} at {idx}')
result = False
if len(fields[2]) != 5:
fail(f'Bad index width for {last_tag} at {idx}')
if (last_pos is not None and last_pos + 1 != idx) or \
(last_pos is None and idx != 0):
print(f'Bad playlist entry sequence for {last_tag} at {idx}')
result = False
fail(f'Bad playlist entry sequence for {last_tag} at {idx}')
last_pos = idx
index_width = len(fields[2])
if dump:
print(f'\tTrack {idx}: {self.db[k]!r}')
elif fields[1] == b'playlistpos':
@@ -197,17 +325,38 @@ class BTreeDB(IPlaylistDB):
try:
idx = int(val)
except ValueError:
print(f'Malformed playlist position: {val!r}')
result = False
fail(f'Malformed playlist position: {val!r}')
continue
if 0 > idx or idx > last_pos:
print(f'Playlist position out of range for {last_tag}: {idx}')
result = False
if dump:
fail(f'Playlist position out of range for {last_tag}: {idx}')
elif dump:
print(f'\tPosition {idx}')
elif fields[1] == b'playlistshuffle':
val = self.db[k]
if val not in (b'no', b'yes'):
fail(f'Bad playlistshuffle value for {last_tag}: {val!r}')
if dump and val == b'yes':
print('\tShuffle')
elif fields[1] == b'playlistpersist':
val = self.db[k]
if val not in (b'no', b'track', b'offset'):
fail(f'Bad playlistpersist value for {last_tag}: {val!r}')
elif dump:
print(f'\tPersist: {val.decode()}')
elif fields[1] == b'playlistshuffleseed':
val = self.db[k]
try:
_ = int(val)
except ValueError:
fail(f' Bad playlistshuffleseed value for {last_tag}: {val!r}')
elif fields[1] == b'playlistposoffset':
val = self.db[k]
try:
_ = int(val)
except ValueError:
fail(f' Bad playlistposoffset value for {last_tag}: {val!r}')
else:
print(f'Unknown key {k!r}')
result = False
fail(f'Unknown key {k!r}')
return result

View File

@@ -19,3 +19,7 @@ class BTree:
def open(dbfile) -> BTree:
pass
DESC = 1
INCL = 2

View File

@@ -35,17 +35,26 @@ class FakeMp3Player:
def set_volume(self, vol: int):
self.volume = vol
def play(self, track: FakeFile):
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:
def __init__(self): pass
tag_callback = None
def __init__(self, tag_callback=None):
FakeNfcReader.tag_callback = tag_callback
class FakeButtons:
@@ -67,12 +76,28 @@ class FakePlaylistDb:
return None
return self.parent.tracklist[self.pos]
def getPlaybackOffset(self):
return 0
def __init__(self, tracklist=[b'test/path.mp3']):
self.tracklist = tracklist
def getPlaylistForTag(self, tag: bytes):
return self.FakePlaylist(self)
def getSetting(self, key: bytes | str):
if key == 'tagmode':
return 'tagremains'
return None
class FakeHwconfig:
def power_off(self): pass
class FakeLeds:
def set_state(self, state): pass
def fake_open(filename, mode):
return FakeFile(filename, mode)
@@ -83,27 +108,32 @@ def faketimermanager(monkeypatch):
monkeypatch.setattr(utils.timer.TimerManager, '_instance', FakeTimerManager())
def _makedeps(mp3player=FakeMp3Player, nfcreader=FakeNfcReader, buttons=FakeButtons,
playlistdb=FakePlaylistDb, hwconfig=FakeHwconfig, leds=FakeLeds):
return app.Dependencies(mp3player=lambda _: mp3player(),
nfcreader=lambda x: nfcreader(x),
buttons=lambda _: buttons(),
playlistdb=lambda _: playlistdb(),
hwconfig=lambda _: hwconfig(),
leds=lambda _: leds())
def test_construct_app(micropythonify, faketimermanager):
fake_mp3 = FakeMp3Player()
deps = app.Dependencies(mp3player=lambda _: fake_mp3,
nfcreader=lambda _: FakeNfcReader(),
buttons=lambda _: FakeButtons(),
playlistdb=lambda _: FakePlaylistDb())
deps = _makedeps(mp3player=fake_mp3)
_ = app.PlayerApp(deps)
fake_mp3 = app.mp3player
assert fake_mp3.volume is not None
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(),
buttons=lambda _: FakeButtons(),
playlistdb=lambda _: fake_db)
dut = app.PlayerApp(deps)
deps = _makedeps(mp3player=fake_mp3, playlistdb=fake_db)
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
@@ -113,14 +143,11 @@ def test_load_playlist_on_tag(micropythonify, faketimermanager, monkeypatch):
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(),
buttons=lambda _: FakeButtons(),
playlistdb=lambda _: fake_db)
deps = _makedeps(mp3player=fake_mp3, playlistdb=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'
@@ -144,14 +171,89 @@ 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 _: FakeNfcReader(),
buttons=lambda _: FakeButtons(),
playlistdb=lambda _: fake_db)
dut = app.PlayerApp(deps)
deps = _makedeps(mp3player=fake_mp3, playlistdb=fake_db)
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
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,
hwconfig=lambda _: FakeHwconfig(),
leds=lambda _: FakeLeds())
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,
hwconfig=lambda _: FakeHwconfig(),
leds=lambda _: FakeLeds())
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'

View File

@@ -1,9 +1,21 @@
# SPDX-License-Identifier: MIT
# Copyright (c) 2025 Matthias Blankertz <matthias@blankertz.org>
import btree
import pytest
import time
from utils import BTreeDB
@pytest.fixture(autouse=True)
def micropythonify():
def time_ticks_cpu():
return time.time_ns()
time.ticks_cpu = time_ticks_cpu
yield
del time.ticks_cpu
class FakeDB:
def __init__(self, contents):
self.contents = contents
@@ -17,18 +29,24 @@ class FakeDB:
for key in sorted(self.contents):
if start_key is not None and start_key > key:
continue
if end_key is not None and end_key < key:
if end_key is not None and end_key <= key:
break
yield self.contents[key]
res.append(self.contents[key])
def keys(self, start_key=None, end_key=None, flags=None):
keys = []
if flags is not None and flags & btree.DESC != 0:
start_key, end_key = end_key, start_key
for key in sorted(self.contents):
if start_key is not None and start_key > key:
continue
if end_key is not None and end_key < key:
if end_key is not None and end_key <= key:
break
yield key
keys.append(key)
if flags is not None and flags & btree.DESC != 0:
keys.reverse()
return iter(keys)
def get(self, key, default=None):
return self.contents.get(key, default)
@@ -42,11 +60,14 @@ class FakeDB:
def __delitem__(self, key):
del self.contents[key]
def __contains__(self, key):
return key in self.contents
def test_playlist_load():
contents = {b'foo/part': b'no',
b'foo/playlist/0': b'track1',
b'foo/playlist/1': b'track2',
b'foo/playlist/00000': b'track1',
b'foo/playlist/00001': b'track2',
b'foo/playlisttt': b'no'
}
uut = BTreeDB(FakeDB(contents))
@@ -57,8 +78,8 @@ def test_playlist_load():
def test_playlist_nextpath():
contents = FakeDB({b'foo/part': b'no',
b'foo/playlist/0': b'track1',
b'foo/playlist/1': b'track2',
b'foo/playlist/00000': b'track1',
b'foo/playlist/00001': b'track2',
b'foo/playlisttt': b'no'
})
uut = BTreeDB(contents)
@@ -68,8 +89,8 @@ def test_playlist_nextpath():
def test_playlist_nextpath_last():
contents = FakeDB({b'foo/playlist/0': b'track1',
b'foo/playlist/1': b'track2',
contents = FakeDB({b'foo/playlist/00000': b'track1',
b'foo/playlist/00001': b'track2',
b'foo/playlistpos': b'1'
})
uut = BTreeDB(contents)
@@ -79,8 +100,8 @@ def test_playlist_nextpath_last():
def test_playlist_create():
contents = FakeDB({b'foo/playlist/0': b'track1',
b'foo/playlist/1': b'track2',
contents = FakeDB({b'foo/playlist/00000': b'track1',
b'foo/playlist/00001': b'track2',
b'foo/playlistpos': b'1'
})
newplaylist = [b'never gonna give you up.mp3', b'durch den monsun.mp3']
@@ -92,35 +113,85 @@ def test_playlist_create():
def test_playlist_load_notexist():
contents = FakeDB({b'foo/playlist/0': b'track1',
b'foo/playlist/1': b'track2',
contents = FakeDB({b'foo/playlist/00000': b'track1',
b'foo/playlist/00001': b'track2',
b'foo/playlistpos': b'1'
})
uut = BTreeDB(contents)
assert uut.getPlaylistForTag(b'notfound') is None
def test_playlist_remains_lexicographically_ordered_by_key():
contents = FakeDB({b'foo/playlist/3': b'track3',
b'foo/playlist/2': b'track2',
b'foo/playlist/1': b'track1',
b'foo/playlistpos': b'1'
def test_playlist_starts_at_beginning_in_persist_no_mode():
contents = FakeDB({b'foo/playlist/00000': b'track1',
b'foo/playlist/00001': b'track2',
b'foo/playlistpersist': b'no',
})
uut = BTreeDB(contents)
pl = uut.getPlaylistForTag(b'foo')
assert pl.getCurrentPath() == b'track1'
assert pl.getNextPath() == b'track2'
assert pl.getNextPath() == b'track3'
del pl
pl = uut.getPlaylistForTag(b'foo')
assert pl.getCurrentPath() == b'track1'
def test_playlist_remains_lexicographically_ordered_with_non_numeric_keys():
contents = FakeDB({b'foo/playlist/k': b'trackk',
b'foo/playlist/l': b'trackl',
b'foo/playlist/i': b'tracki',
b'foo/playlistpos': b'k'
@pytest.mark.parametrize("mode", [b'no', b'track'])
def test_playlist_ignores_offset_in_other_modes(mode):
contents = FakeDB({b'foo/playlist/00000': b'track1',
b'foo/playlist/00001': b'track2',
b'foo/playlistpersist': mode,
})
uut = BTreeDB(contents)
pl = uut.getPlaylistForTag(b'foo')
assert pl.getCurrentPath() == b'trackk'
assert pl.getNextPath() == b'trackl'
assert pl.getNextPath() is None
pl.setPlaybackOffset(42)
del pl
pl = uut.getPlaylistForTag(b'foo')
assert pl.getPlaybackOffset() == 0
def test_playlist_stores_offset_in_offset_mode():
contents = FakeDB({b'foo/playlist/00000': b'track1',
b'foo/playlist/00001': b'track2',
b'foo/playlistpersist': b'offset',
})
uut = BTreeDB(contents)
pl = uut.getPlaylistForTag(b'foo')
pl.setPlaybackOffset(42)
del pl
pl = uut.getPlaylistForTag(b'foo')
assert pl.getPlaybackOffset() == 42
def test_playlist_resets_offset_on_next_track():
contents = FakeDB({b'foo/playlist/00000': b'track1',
b'foo/playlist/00001': b'track2',
b'foo/playlistpersist': b'offset',
})
uut = BTreeDB(contents)
pl = uut.getPlaylistForTag(b'foo')
pl.setPlaybackOffset(42)
assert pl.getNextPath() == b'track2'
del pl
pl = uut.getPlaylistForTag(b'foo')
assert pl.getCurrentPath() == b'track2'
assert pl.getPlaybackOffset() == 0
def test_playlist_shuffle():
contents_dict = {b'foo/playlistpersist': b'track',
b'foo/playlistshuffle': b'yes',
}
for i in range(256):
contents_dict['foo/playlist/{:05}'.format(i).encode()] = 'track{}'.format(i).encode()
contents = FakeDB(contents_dict)
uut = BTreeDB(contents)
pl = uut.getPlaylistForTag(b'foo')
shuffled = False
last_idx = int(pl.getCurrentPath().removeprefix(b'track'))
while (t := pl.getNextPath()) is not None:
idx = int(t.removeprefix(b'track'))
if idx != last_idx + 1:
shuffled = True
break
# A false negative ratr of 1 in 256! should be good enough for this test
assert shuffled