Merge pull request 'misc-features' (#62) from misc-features into main
All checks were successful
Build RPi Pico firmware image / Build-Firmware (push) Successful in 4m45s
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 9s
Run pytests / Check-Pytest (push) Successful in 11s

Reviewed-on: #62
Reviewed-by: Stefan Kratochwil <kratochwil-la@gmx.de>
This commit was merged in pull request #62.
This commit is contained in:
2026-01-13 21:31:18 +00:00
13 changed files with 346 additions and 99 deletions

View File

@@ -0,0 +1,29 @@
// SPDX-License-Identifier: MIT
// Copyright (c) 2025 Matthias Blankertz <matthias@blankertz.org>
#include "py/obj.h"
#include "py/runtime.h"
#include "py/objstr.h"
#ifndef TONBERRY_GIT_REVISION
#define TONBERRY_GIT_REVISION "unknown"
#endif
#ifndef TONBERRY_VERSION
#define TONBERRY_VERSION "unknown"
#endif
static const MP_DEFINE_STR_OBJ(tonberry_git_revision_obj, TONBERRY_GIT_REVISION);
static const MP_DEFINE_STR_OBJ(tonberry_version_obj, TONBERRY_VERSION);
static const mp_rom_map_elem_t board_module_globals_table[] = {
{MP_ROM_QSTR(MP_QSTR___name__), MP_ROM_QSTR(MP_QSTR_board)},
{MP_ROM_QSTR(MP_QSTR_revision), MP_ROM_PTR(&tonberry_git_revision_obj)},
{MP_ROM_QSTR(MP_QSTR_version), MP_ROM_PTR(&tonberry_version_obj)},
};
static MP_DEFINE_CONST_DICT(board_module_globals, board_module_globals_table);
const mp_obj_module_t board_cmodule = {
.base = {&mp_type_module},
.globals = (mp_obj_dict_t *)&board_module_globals,
};
MP_REGISTER_MODULE(MP_QSTR_board, board_cmodule);

View File

@@ -20,3 +20,22 @@ set(GEN_PINS_CSV_ARG --board-csv "${GEN_PINS_BOARD_CSV}")
add_link_options("-Wl,--print-memory-usage")
set(PICO_USE_FASTEST_SUPPORTED_CLOCK 1)
find_program(GIT git)
execute_process(COMMAND ${GIT} rev-parse HEAD
WORKING_DIRECTORY "${CMAKE_CURRENT_LIST_DIR}"
OUTPUT_VARIABLE TONBERRY_GIT_REVISION
OUTPUT_STRIP_TRAILING_WHITESPACE
)
execute_process(COMMAND ${GIT} describe --match 'v*' --always --dirty
WORKING_DIRECTORY "${CMAKE_CURRENT_LIST_DIR}"
OUTPUT_VARIABLE TONBERRY_VERSION
OUTPUT_STRIP_TRAILING_WHITESPACE
)
set(MICROPY_SOURCE_BOARD "${CMAKE_CURRENT_LIST_DIR}/board.c")
set(MICROPY_DEF_BOARD
TONBERRY_GIT_REVISION="${TONBERRY_GIT_REVISION}"
TONBERRY_VERSION="${TONBERRY_VERSION}")

View File

@@ -2,7 +2,7 @@
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Device Admin</title>
<title>TonBERRY pico</title>
<style>
body {
font-family: Arial, sans-serif;
@@ -131,11 +131,17 @@
list-style: none;
}
.footer {
font-size: x-small;
color: gray;
margin-top: 20px;
}
</style>
</head>
<body>
<h1>Device Admin</h1>
<h1>TonBERRY pico</h1>
<nav>
<button onclick="showScreen('menu')">🏠 Main Menu</button>
@@ -161,7 +167,7 @@
<div id="screen-config" class="screen">
<h2>Configuration Editor</h2>
<div id="config-container">Loading…</div>
<button id="config-save-btn" disabled>Save</button>
<button id="config-save-btn" disabled>Save &amp; Reboot</button>
</div>
<!-- PLAYLIST EDITOR SCREEN 1: list of playlists -->
@@ -207,6 +213,10 @@
<option value="audioplay">Audioplay (no shuffle, start at previous track)</option>
</select>
</div>
<div>
<label>Playlist name</label>
<input type="text" placeholder="Playlist name" id="playlist-edit-name" />
</div>
<div class="flex-horizontal">
<div style="flex-grow: 1">
<label>Tracks</label>
@@ -257,7 +267,17 @@
</div>
</div>
</div>
<div class="footer">
<hr>
<div class="flex-horizontal">
<div>
<a href="https://git.ka.blankertz.org/TonBERRY/tonberry-pico">TonBERRY pico</a>
</div>
<div>
Version: <span id="footer-version">unknown</span>
</div>
</div>
</div>
<script>
const Screens = {};
let activeScreen = null;
@@ -296,7 +316,9 @@
return;
}
alert("Configuration saved successfully!");
alert("Configuration saved successfully, device will now reboot/shutdown! " +
"On battery, press Power button after shutdown to restart.");
await fetch('/api/v1/reboot/application', {'method': 'POST'});
} catch (err) {
alert("Error saving configuration: " + err);
}
@@ -334,7 +356,13 @@
'root.BUTTON_MAP.PLAY_PAUSE': 'Play/Pause',
'root.TAG_TIMEOUT_SECS': 'Tag removal timeout (seconds)',
'root.TAGMODE': 'Tag mode',
'root.LED_COUNT': 'Length of WS2182 (Neopixel) LED chain'
'root.LED_COUNT': 'Length of WS2182 (Neopixel) LED chain',
'root.VOLUME_MAX': 'Maximum volume (0-255)',
'root.VOLUME_BOOT': 'Volume at startup (0-255)',
'root.LED_MAX': 'Maximum LED brightness (0-255)',
'root.WIFI.SSID': 'Network name (SSID) (leave empty for AP mode)',
'root.WIFI.PASSPHRASE': 'Password',
'root.WIFI.SECURITY': 'Security mode'
};
const config_input_override = {
'root.TAGMODE': {
@@ -344,6 +372,21 @@
'tagstartstop': 'Present tag once to start, present again to stop playback'
}
},
'root.BUTTON_MAP.NEXT': {
'input-type': 'number'
},
'root.BUTTON_MAP.PREV': {
'input-type': 'number'
},
'root.BUTTON_MAP.VOLUP': {
'input-type': 'number'
},
'root.BUTTON_MAP.VOLDOWN': {
'input-type': 'number'
},
'root.BUTTON_MAP.PLAY_PAUSE': {
'input-type': 'number'
},
'root.IDLE_TIMEOUT_SECS': {
'input-type': 'number'
},
@@ -353,6 +396,30 @@
'root.LED_COUNT': {
'input-type': 'number'
},
'root.WIFI.SSID': {
'input-type': 'text'
},
'root.WIFI.PASSPHRASE': {
'input-type': 'text'
},
'root.WIFI.SECURITY': {
'element': 'select',
'values': {
'open': 'Open',
'wpa_wpa2': 'WPA/WPA2 (PSK Mixed)',
'wpa3': 'WPA3 (SAE AES)',
'wpa2_wpa3': 'WPA2/WPA3 (PSK AES)'
}
},
'root.VOLUME_MAX': {
'input-type': 'number'
},
'root.VOLUME_BOOT': {
'input-type': 'number'
},
'root.LED_MAX': {
'input-type': 'number'
}
};
function renderObject(obj, path) {
@@ -423,7 +490,7 @@
let val = input.value.trim();
if (val === "") val = null;
else if (!isNaN(val)) val = Number(val);
else if (!isNaN(val) && input.type != 'text') val = Number(val);
current[path[path.length - 1]] = val;
});
@@ -456,7 +523,7 @@
document.getElementById('playlist-exist-button-delete')
.addEventListener('click', (e) => {
if (lastSelected === null) return;
const tagid = lastSelected.innerText;
const tagid = lastSelected.getAttribute('data-tag');
if(confirm(`Really delete playlist ${tagid}?`)) {
fetch(`/api/v1/playlist/${tagid}`, {
method: 'DELETE'})
@@ -467,7 +534,7 @@
.addEventListener('click', selectLastTag);
document.getElementById('playlist-exist-button-edit').addEventListener("click", (e) => {
if (lastSelected !== null)
showScreen("playlist_edit", {load: lastSelected.innerText});
showScreen("playlist_edit", {load: lastSelected.getAttribute('data-tag')});
});
document.getElementById('playlist-exist-list').addEventListener("click", (e) => {
const node = e.target.closest("li");
@@ -522,8 +589,9 @@
container.innerHTML = "";
for (const playlist of playlists) {
const li = document.createElement("li");
li.innerHTML = playlist;
li.innerHTML = `${playlist.name} (${playlist.tag})`;
li.className = "node"
li.setAttribute('data-tag', playlist.tag);
container.appendChild(li)
}
}
@@ -548,7 +616,7 @@
document.getElementById('playlist-exist-list')
.querySelectorAll("li")
.forEach(n => {
if (n.innerText == tagtext) {
if (n.getAttribute('data-tag') == tagtext) {
n.classList.add("selected");
lastSelected = n;
} else {
@@ -650,6 +718,8 @@
} else if (playlist.persist === "track" && playlist.shuffle === "no") {
playlisttype.value = "audioplay";
}
const playlistname = document.getElementById('playlist-edit-name');
playlistname.value = playlist.name;
}
async function save() {
@@ -672,6 +742,8 @@
playlistData.shuffle = "no";
break;
}
const playlistname = document.getElementById('playlist-edit-name');
playlistData.name = playlistname.value;
const container = document.getElementById('playlist-edit-list');
playlistData.paths = [...container.querySelectorAll("li")].map((node) => node.innerText);
const saveRes = await fetch(`/api/v1/playlist/${playlistId}`,
@@ -736,6 +808,10 @@
async function onShow(intent) {
document.getElementById('playlist-filebrowser-addtrack').disabled = true;
if (intent !== 'refresh') {
document.getElementById('playlist-filebrowser-upload-progress').value = 0;
document.getElementById("playlist-filebrowser-upload-files").value = "";
}
tree = document.getElementById("playlist-filebrowser-tree");
tree.innerHTML = "Loading...";
fetch('/api/v1/audiofiles')
@@ -770,7 +846,6 @@
if (type === 'directory') {
const nested = document.createElement('ul');
node.appendChild(nested);
node.classList.add('expanded');
parent.appendChild(node);
return nested;
}
@@ -857,7 +932,7 @@
}
if (donecount + 1 === totalcount) {
// Reload file list from device
onShow();
onShow('refresh');
} else {
uploadFileHelper(totalcount, donecount + 1, destdir, files.slice(1));
}
@@ -884,7 +959,7 @@
const saveRes = await fetch(`/api/v1/audiofiles?type=directory&location=${location}`,
{method: 'POST'});
// Reload file list from device
onShow();
onShow('refresh');
}
async function deleteItems() {
@@ -905,7 +980,7 @@
}
}
// Reload file list from device
onShow();
onShow('refresh');
}
let tree = (() => {
@@ -981,6 +1056,13 @@
});
showScreen("menu");
fetch('/api/v1/info')
.then((resp) => resp.json())
.then((info) => {
const version = document.getElementById('footer-version');
version.innerText = info.version;
});
</script>
</body>

View File

@@ -9,8 +9,8 @@ from utils import TimerManager
Dependencies = namedtuple('Dependencies', ('mp3player', 'nfcreader', 'buttons', 'playlistdb', 'hwconfig', 'leds',
'config'))
# Should be ~ 6dB steps
VOLUME_CURVE = [1, 2, 4, 8, 16, 32, 63, 126, 251]
# Should be ~ 3dB steps
VOLUME_CURVE = [1, 2, 3, 4, 6, 8, 11, 16, 23, 32, 45, 64, 91, 128, 181, 255]
class PlayerApp:
@@ -52,11 +52,19 @@ class PlayerApp:
self.hwconfig = deps.hwconfig(self)
self.leds = deps.leds(self)
self.tag_mode = self.config.get_tagmode()
self.volume_max = self.config.get_volume_max()
self.volume_pos = 3 # fallback if config.get_volume_boot is nonsense
try:
for idx, val in enumerate(VOLUME_CURVE):
if val >= self.config.get_volume_boot():
self.volume_pos = idx
break
except (TypeError, ValueError):
pass
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.paused = False
self.playing = False
self.player.set_volume(VOLUME_CURVE[self.volume_pos])
@@ -74,7 +82,7 @@ class PlayerApp:
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)
self.playing_tag = new_tag
self.playing_tag = new_tag if self.playlist is not None else None
elif self.tag_mode == 'tagstartstop':
print('Tag presented again, stopping playback')
self._unset_playlist()
@@ -91,7 +99,9 @@ class PlayerApp:
def onButtonPressed(self, what):
assert self.buttons is not None
if what == self.buttons.VOLUP:
self.volume_pos = min(self.volume_pos + 1, len(VOLUME_CURVE) - 1)
new_volume = min(self.volume_pos + 1, len(VOLUME_CURVE) - 1)
if VOLUME_CURVE[new_volume] <= self.volume_max:
self.volume_pos = new_volume
self.player.set_volume(VOLUME_CURVE[self.volume_pos])
elif what == self.buttons.VOLDOWN:
self.volume_pos = max(self.volume_pos - 1, 0)

View File

@@ -3,13 +3,12 @@
import aiorepl # type: ignore
import asyncio
from errno import ENOENT
import machine
import micropython
import network
import os
import time
import ubinascii
import sys
# Own modules
import app
@@ -37,14 +36,22 @@ hwconfig.board_init()
machine.mem32[0x40030000 + 0x00] = 0x10
def setup_wifi():
def setup_wifi(ssid='', passphrase='', security=network.WLAN.SEC_WPA_WPA2):
network.hostname("TonberryPico")
if ssid is None or ssid == '':
apname = f"TonberryPicoAP_{machine.unique_id().hex()}"
print(f"Create AP {apname}")
wlan = network.WLAN(network.WLAN.IF_AP)
wlan.config(ssid=f"TonberryPicoAP_{machine.unique_id().hex()}", security=wlan.SEC_OPEN)
wlan.config(ssid=apname, password=passphrase if passphrase is not None else '', security=security)
wlan.active(True)
else:
print(f"Connect to SSID {ssid} with passphrase {passphrase}...")
wlan = network.WLAN()
wlan.active(True)
wlan.connect(ssid, passphrase if passphrase is not None else '', security=security)
# disable power management
wlan.config(pm=network.WLAN.PM_NONE)
# configure power management
wlan.config(pm=network.WLAN.PM_PERFORMANCE)
mac = ubinascii.hexlify(network.WLAN().config('mac'), ':').decode()
print(f" mac: {mac}")
@@ -65,27 +72,37 @@ DB_PATH = '/sd/tonberry.db'
config = Configuration()
# Setup LEDs
np = NeoPixel(hwconfig.LED_DIN, config.get_led_count(), sm=1)
led_max = config.get_led_max()
np.fill((led_max, led_max, 0))
np.write()
def run():
asyncio.new_event_loop()
# Setup LEDs
np = NeoPixel(hwconfig.LED_DIN, config.get_led_count(), sm=1)
# Wifi with default config
setup_wifi()
if machine.Pin(hwconfig.BUTTONS[1], machine.Pin.IN, machine.Pin.PULL_UP).value() == 0:
np.fill((0, 0, led_max))
np.write()
# Force default access point
setup_wifi('', '', network.WLAN.SEC_OPEN)
else:
secstring = config.get_wifi_security()
security = network.WLAN.SEC_WPA_WPA2
if secstring == 'open':
security = network.WLAN.SEC_OPEN
elif secstring == 'wpa_wpa2':
security = network.WLAN.SEC_WPA_WPA2
elif secstring == 'wpa3':
security = network.WLAN.SEC_WPA3
elif secstring == 'wpa2_wpa3':
security = network.WLAN.SEC_WPA2_WPA3
setup_wifi(config.get_wifi_ssid(), config.get_wifi_passphrase(), security)
# Setup MP3 player
with SDContext(mosi=hwconfig.SD_DI, miso=hwconfig.SD_DO, sck=hwconfig.SD_SCK, ss=hwconfig.SD_CS,
baudrate=hwconfig.SD_CLOCKRATE):
# Temporary hack: build database from folders if no database exists
# Can be removed once playlists can be created via API
try:
_ = os.stat(DB_PATH)
except OSError as ex:
if ex.errno == ENOENT:
print("No playlist DB found, trying to build DB from tag dirs")
builddb()
with BTreeFileManager(DB_PATH) as playlistdb, \
AudioContext(hwconfig.I2S_DIN, hwconfig.I2S_DCLK, hwconfig.I2S_LRCLK) as audioctx:
@@ -99,7 +116,7 @@ def run():
buttons=lambda the_app: Buttons(the_app, config, hwconfig),
playlistdb=lambda _: playlistdb,
hwconfig=lambda _: hwconfig,
leds=lambda _: LedManager(np),
leds=lambda _: LedManager(np, config),
config=lambda _: config)
the_app = app.PlayerApp(deps)
@@ -112,25 +129,26 @@ def run():
asyncio.get_event_loop().run_forever()
def builddb():
"""
For testing, build a playlist db based on the previous tag directory format.
Can be removed once uploading files / playlist via the web api is possible.
"""
try:
os.unlink(DB_PATH)
except OSError:
pass
with BTreeFileManager(DB_PATH) as db:
for name, type_, _, _ in os.ilistdir(b'/sd'):
if type_ != 0x4000:
continue
fl = [b'/sd/' + name + b'/' + x for x in os.listdir(b'/sd/' + name) if x.endswith(b'.mp3')]
db.createPlaylistForTag(name, fl)
os.sync()
def error_blink():
while True:
if machine.Pin(hwconfig.BUTTONS[0], machine.Pin.IN, machine.Pin.PULL_UP).value() == 0:
machine.reset()
np.fill((led_max, 0, 0))
np.write()
time.sleep_ms(500)
np.fill((0, 0, 0))
np.write()
time.sleep_ms(500)
if __name__ == '__main__':
time.sleep(1)
if machine.Pin(hwconfig.BUTTONS[0], machine.Pin.IN, machine.Pin.PULL_UP).value() != 0:
try:
run()
except Exception as ex:
sys.print_exception(ex)
error_blink()
else:
np.fill((led_max, 0, 0))
np.write()

View File

@@ -2,6 +2,7 @@
# Copyright (c) 2025 Matthias Blankertz <matthias@blankertz.org>
from utils.helpers import safe_callback
from utils.timer import TimerManager
from utils.buttons import Buttons
from utils.config import Configuration
from utils.leds import LedManager
@@ -9,7 +10,6 @@ from utils.mbrpartition import MBRPartition
from utils.pinindex import get_pin_index
from utils.playlistdb import BTreeDB, BTreeFileManager
from utils.sdcontext import SDContext
from utils.timer import TimerManager
__all__ = ["BTreeDB", "BTreeFileManager", "Buttons", "Configuration", "get_pin_index", "LedManager", "MBRPartition",
"safe_callback", "SDContext", "TimerManager"]

View File

@@ -5,7 +5,7 @@ import asyncio
import machine
import micropython
import time
from utils import safe_callback
from utils import safe_callback, TimerManager
try:
from typing import TYPE_CHECKING # type: ignore
except ImportError:
@@ -32,6 +32,7 @@ class Buttons:
def __init__(self, cb: "ButtonCallback", config, hwconfig):
self.button_map = config.get_button_map()
self.hw_buttons = hwconfig.BUTTONS
self.hwconfig = hwconfig
self.cb = cb
self.buttons = dict()
for key_id, key_name in self.KEYMAP.items():
@@ -42,6 +43,7 @@ class Buttons:
self.int_flag = asyncio.ThreadSafeFlag()
self.pressed: list[int] = []
self.last: dict[int, int] = {}
self.timer_manager = TimerManager()
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())
@@ -69,6 +71,10 @@ class Buttons:
# print(f'B{keycode} {now}')
self.pressed.append(keycode)
self.int_flag.set()
if keycode == self.VOLDOWN:
self.timer_manager.schedule(time.ticks_ms() + 5000, self.long_press_shutdown)
if button.value() == 1 and keycode == self.VOLDOWN:
self.timer_manager.cancel(self.long_press_shutdown)
async def task(self):
while True:
@@ -76,3 +82,9 @@ class Buttons:
while len(self.pressed) > 0:
what = self.pressed.pop()
safe_callback(lambda: self.cb.onButtonPressed(what), "button callback")
def long_press_shutdown(self):
if self.hwconfig.get_on_battery():
self.hwconfig.power_off()
else:
machine.reset()

View File

@@ -22,7 +22,15 @@ class Configuration:
'PREV': None,
'NEXT': 1,
},
'TAGMODE': 'tagremains'
'TAGMODE': 'tagremains',
'WIFI': {
'SSID': '',
'PASSPHRASE': '',
'SECURITY': 'wpa_wpa2',
},
'VOLUME_MAX': 255,
'VOLUME_BOOT': 16,
'LED_MAX': 255,
}
def __init__(self, config_path='/config.json'):
@@ -87,6 +95,24 @@ class Configuration:
def get_tagmode(self) -> str:
return self._get('TAGMODE')
def get_wifi_ssid(self) -> str:
return self._get('WIFI')['SSID']
def get_wifi_passphrase(self) -> str:
return self._get('WIFI')['PASSPHRASE']
def get_wifi_security(self) -> str:
return self._get('WIFI')['SECURITY']
def get_volume_max(self) -> int:
return self._get('VOLUME_MAX')
def get_led_max(self) -> int:
return self._get('LED_MAX')
def get_volume_boot(self) -> int:
return self._get('VOLUME_BOOT')
# For the web API
def get_config(self) -> Mapping[str, Any]:
return self.config
@@ -104,6 +130,9 @@ class Configuration:
self._validate(self.DEFAULT_CONFIG, config)
if 'TAGMODE' in config and config['TAGMODE'] not in ['tagremains', 'tagstartstop']:
raise ValueError("Invalid TAGMODE: Must be 'tagremains' or 'tagstartstop'")
if 'WLAN' in config and 'SECURITY' in config['WLAN'] and \
config['WLAN']['SECURITY'] not in ['open', 'wpa_wpa2', 'wpa3', 'wpa2_wpa3']:
raise ValueError("Invalid WLAN SECURITY: Must be 'open', 'wpa_wpa2', 'wpa3' or 'wpa2_wpa3'")
self._merge_configs(self.config, config)
self.config = config
self._save()

View File

@@ -12,10 +12,10 @@ class LedManager:
PLAYING = const(1)
REBOOTING = const(2)
def __init__(self, np):
def __init__(self, np, config):
self.led_state = LedManager.IDLE
self.brightness = config.get_led_max() / 255
self.np = np
self.brightness = 0.1
self.leds = len(self.np)
asyncio.create_task(self.run())

View File

@@ -33,12 +33,13 @@ class BTreeDB(IPlaylistDB):
PERSIST_OFFSET = b'offset'
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, name):
self.parent = parent
self.tag = tag
self.pos = pos
self.persist = persist
self.shuffle = shuffle
self.name = name
self.length = self.parent._getPlaylistLength(self.tag)
self._shuffle()
@@ -168,6 +169,10 @@ class BTreeDB(IPlaylistDB):
return (b''.join([tag, b'/playlist/']),
b''.join([tag, b'/playlist0']))
@staticmethod
def _keyPlaylistName(tag):
return b''.join([tag, b'/playlistname'])
def _flush(self):
"""
Flush the database and call the flush_func if it was provided.
@@ -222,12 +227,13 @@ class BTreeDB(IPlaylistDB):
raise RuntimeError("Malformed playlist key")
return int(elements[2])+1
def _savePlaylist(self, tag, entries, persist, shuffle, flush=True):
def _savePlaylist(self, tag, entries, persist, shuffle, name, 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
self.db[self._keyPlaylistName(tag)] = name.encode()
if flush:
self._flush()
@@ -240,7 +246,7 @@ class BTreeDB(IPlaylistDB):
pass
for k in (self._keyPlaylistPos(tag), self._keyPlaylistPosOffset(tag),
self._keyPlaylistPersist(tag), self._keyPlaylistShuffle(tag),
self._keyPlaylistShuffleSeed(tag)):
self._keyPlaylistShuffleSeed(tag), self._keyPlaylistName(tag)):
try:
del self.db[k]
except KeyError:
@@ -248,15 +254,18 @@ class BTreeDB(IPlaylistDB):
if flush:
self._flush()
def getPlaylistTags(self):
def getPlaylists(self):
"""
Get a keys-only dict of all defined playlists. Playlists currently do not have names, but are identified by
their tag.
Get a list of all defined playlists with their tag and names.
"""
playlist_tags = set()
for item in self.db:
playlist_tags.add(item.split(b'/')[0])
return playlist_tags
playlists = []
for tag in playlist_tags:
name = self.db.get(self._keyPlaylistName(tag), b'').decode()
playlists.append({'tag': tag, 'name': name})
return playlists
def getPlaylistForTag(self, tag: bytes):
"""
@@ -275,18 +284,19 @@ class BTreeDB(IPlaylistDB):
return None
if self._keyPlaylistEntry(tag, pos) not in self.db:
pos = 0
name = self.db.get(self._keyPlaylistName(tag), b'').decode()
shuffle = self.db.get(self._keyPlaylistShuffle(tag), self.SHUFFLE_NO)
return self.Playlist(self, tag, pos, persist, shuffle)
return self.Playlist(self, tag, pos, persist, shuffle, name)
def createPlaylistForTag(self, tag: bytes, entries: typing.Iterable[bytes], persist=PERSIST_TRACK,
shuffle=SHUFFLE_NO):
shuffle=SHUFFLE_NO, name: str = ''):
"""
Create and save a playlist for 'tag' and return the Playlist object. If a playlist already existed for 'tag' it
is overwritten.
"""
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)
self._savePlaylist(tag, entries, persist, shuffle, name)
return self.getPlaylistForTag(tag)
def deletePlaylistForTag(self, tag: bytes):
@@ -370,6 +380,14 @@ class BTreeDB(IPlaylistDB):
_ = int(val)
except ValueError:
fail(f' Bad playlistposoffset value for {last_tag}: {val!r}')
elif fields[1] == b'playlistname':
val = self.db[k]
try:
name = val.decode()
if dump:
print(f'\tName: {name}')
except UnicodeError:
fail(f' Bad playlistname for {last_tag}: Not valid unicode')
else:
fail(f'Unknown key {k!r}')
return result

View File

@@ -4,11 +4,15 @@ Copyright (c) 2024-2025 Stefan Kratochwil <Kratochwil-LA@gmx.de>
'''
import asyncio
import board
import errno
import hwconfig
import json
import machine
import network
import os
import time
import ubinascii
from array import array
from microdot import Microdot, redirect, send_file, Request
@@ -28,7 +32,7 @@ Request.max_content_length = 128 * 1024 * 1024 # 128MB requests allowed
def start_webserver(config_, app_):
global server, config, app, nfc, playlist_db, leds, timer_manager
server = asyncio.create_task(webapp.start_server(port=80))
server = asyncio.create_task(webapp.start_server(host='::0', port=80))
config = config_
app = app_
nfc = app.get_nfc()
@@ -109,7 +113,7 @@ async def static(request, path):
@webapp.route('/api/v1/playlists', methods=['GET'])
async def playlists_get(request):
return sorted(playlist_db.getPlaylistTags())
return playlist_db.getPlaylists()
def is_hex(s):
@@ -130,10 +134,11 @@ async def playlist_get(request, tag):
return None, 404
return {
'shuffle': playlist.__dict__.get('shuffle'),
'persist': playlist.__dict__.get('persist'),
'shuffle': playlist.shuffle,
'persist': playlist.persist,
'paths': [(p[len(fsroot):] if p.startswith(fsroot) else p).decode()
for p in playlist.getPaths()],
'name': playlist.name
}
@@ -153,7 +158,8 @@ async def playlist_put(request, tag):
playlist_db.createPlaylistForTag(tag.encode(),
(fsroot + path.encode() for path in playlist.get('paths', [])),
playlist.get('persist', 'track').encode(),
playlist.get('shuffle', 'no').encode())
playlist.get('shuffle', 'no').encode(),
playlist.get('name', ''))
return '', 204
@@ -197,6 +203,25 @@ async def audiofiles_get(request):
return directory_iterator(), {'Content-Type': 'application/json; charset=UTF-8'}
async def stream_to_file(stream, file_, length):
data = array('b', range(16384))
bytes_copied = 0
while True:
bytes_read = await stream.readinto(data)
if bytes_read == 0:
# End of body
break
bytes_written = file_.write(data[:bytes_read])
if bytes_written != bytes_read:
# short writes shouldn't happen
raise OSError(errno.EIO, 'unexpected short write')
bytes_copied += bytes_written
if bytes_copied == length:
break
app.reset_idle_timeout()
return bytes_copied
@webapp.route('/api/v1/audiofiles', methods=['POST'])
async def audiofile_upload(request):
if 'type' not in request.args or request.args['type'] not in ['file', 'directory']:
@@ -213,20 +238,13 @@ async def audiofile_upload(request):
os.mkdir(path)
return '', 204
with open(path, 'wb') as newfile:
data = array('b', range(4096))
bytes_copied = 0
while True:
bytes_read = await request.stream.readinto(data)
if bytes_read == 0:
# End of body
break
bytes_written = newfile.write(data[:bytes_read])
if bytes_written != bytes_read:
# short writes shouldn't happen
return 'write failure', 500
bytes_copied += bytes_written
if bytes_copied == length:
break
try:
if length > Request.max_body_length:
bytes_copied = await stream_to_file(request.stream, newfile, length)
else:
bytes_copied = newfile.write(request.body)
except OSError as ex:
return f'error writing data to file: {ex}', 500
if bytes_copied == length:
return '', 204
else:
@@ -258,10 +276,9 @@ async def audiofile_delete(request):
@webapp.route('/api/v1/reboot/<method>', methods=['POST'])
async def reboot(request, method):
if hwconfig.get_on_battery():
return 'not allowed: usb not connected', 403
if method == 'bootloader':
if hwconfig.get_on_battery():
return 'not possible: connect USB first', 403
leds.set_state(LedManager.REBOOTING)
timer_manager.schedule(time.ticks_ms() + 1500, machine.bootloader)
elif method == 'application':
@@ -270,3 +287,10 @@ async def reboot(request, method):
else:
return 'method not supported', 400
return '', 204
@webapp.route('/api/v1/info', methods=['GET'])
async def get_info(request):
mac = ubinascii.hexlify(network.WLAN().config('mac'), ':').decode()
return {'version': board.version,
'mac': mac}

View File

@@ -139,6 +139,12 @@ class FakeConfig:
def get_tagmode(self):
return 'tagremains'
def get_volume_max(self):
return 255
def get_volume_boot(self):
return 16
def fake_open(filename, mode):
return FakeFile(filename, mode)
@@ -167,7 +173,7 @@ def test_construct_app(micropythonify, faketimermanager):
deps = _makedeps(mp3player=fake_mp3)
dut = app.PlayerApp(deps)
fake_mp3 = dut.player
assert fake_mp3.volume is not None
assert fake_mp3.volume is not None and fake_mp3.volume >= 16
def test_load_playlist_on_tag(micropythonify, faketimermanager, monkeypatch):