11 Commits

Author SHA1 Message Date
2cf88b26ee fix: button 0 to shutdown device when in error state
All checks were successful
Build RPi Pico firmware image / Build-Firmware (push) Successful in 4m42s
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 5s
Run unit tests on host / Run-Unit-Tests (push) Successful in 8s
Run pytests / Check-Pytest (push) Successful in 10s
Signed-off-by: Matthias Blankertz <matthias@blankertz.org>
2025-12-27 15:23:48 +01:00
73da134a12 fix: webserver: file uploading
- Fix upload of files <= Request.max_body_length
  File uploads smaller than the limit were not given to the handler as a
  stream by microdot, instead the content is directly stored in the
  request.body.

- Refactor stream to file copy into a helper method

- Increase the copy buffer size to 16k

- Call app.reset_idle_timeout() periodically during file uploads to
  avoid the device turning off when uploading large files while on
  battery.

Signed-off-by: Matthias Blankertz <matthias@blankertz.org>
2025-12-27 15:23:48 +01:00
5c8a61eb27 feat: Allow configuring volume set at startup
Signed-off-by: Matthias Blankertz <matthias@blankertz.org>
2025-12-27 15:23:48 +01:00
4c85683fcb feat: Allow limiting LED brightness
Signed-off-by: Matthias Blankertz <matthias@blankertz.org>
2025-12-27 15:23:48 +01:00
355a8bd345 fix: allow 'reboot' to application when on on battery
Having to press the power button again to wake up the device is less
annoying to the user than not being able to apply settings when the
device is on battery.

Signed-off-by: Matthias Blankertz <matthias@blankertz.org>
2025-12-27 15:23:48 +01:00
f46c045589 feat: Allow limiting max. volume
Signed-off-by: Matthias Blankertz <matthias@blankertz.org>
2025-12-27 15:23:48 +01:00
fe1c1eadf7 feat: Add names to playlists
Fixes #63.

Signed-off-by: Matthias Blankertz <matthias@blankertz.org>
2025-12-27 15:23:48 +01:00
743188e1a4 fix: frontend: Replace generic 'Device admin' title
Signed-off-by: Matthias Blankertz <matthias@blankertz.org>
2025-12-27 15:23:48 +01:00
cd5939f4ee feat: Enable dualstack IPv4/IPv6 for microdot
Signed-off-by: Matthias Blankertz <matthias@blankertz.org>
2025-12-27 15:23:48 +01:00
02954cd87c feat: Visual feedback on LEDs during startup
Set the LEDs to a fixed color during bootup to show the different modes:
- Orange when the device is booting
- Red when button 0 is held and the device goes to a shell
- Blue when button 1 is held and the device goes to AP mode instead of
  joining the configured WiFi network
- Red blinking when the run() method throws an exception for any reason

Signed-off-by: Matthias Blankertz <matthias@blankertz.org>
2025-12-27 15:23:48 +01:00
58f8526d7e feat: Join an existing WiFi network
Add ssid and passphrase to config to configure the network to join. If
empty SSID is configured, it will create the "TonberryPicoAP..." network
in AP mode as before.

Holding the button 1 during startup will revert to AP mode regardless of
the current configuration.

Signed-off-by: Matthias Blankertz <matthias@blankertz.org>
2025-12-27 15:23:48 +01:00
8 changed files with 198 additions and 68 deletions

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;
@@ -141,7 +141,7 @@
</head>
<body>
<h1>Device Admin</h1>
<h1>TonBERRY pico</h1>
<nav>
<button onclick="showScreen('menu')">🏠 Main Menu</button>
@@ -167,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 -->
@@ -213,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>
@@ -312,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);
}
@@ -350,7 +356,10 @@
'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)'
};
const config_input_override = {
'root.TAGMODE': {
@@ -384,6 +393,21 @@
'root.LED_COUNT': {
'input-type': 'number'
},
'root.WLAN.SSID': {
'input-type': 'text'
},
'root.WLAN.PASSPHRASE': {
'input-type': 'text'
},
'root.VOLUME_MAX': {
'input-type': 'number'
},
'root.VOLUME_BOOT': {
'input-type': 'number'
},
'root.LED_MAX': {
'input-type': 'number'
}
};
function renderObject(obj, path) {
@@ -487,7 +511,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'})
@@ -498,7 +522,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");
@@ -553,8 +577,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)
}
}
@@ -579,7 +604,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 {
@@ -681,6 +706,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() {
@@ -703,6 +730,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}`,

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])
@@ -91,8 +99,10 @@ 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)
self.player.set_volume(VOLUME_CURVE[self.volume_pos])
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)
self.player.set_volume(VOLUME_CURVE[self.volume_pos])

View File

@@ -10,6 +10,7 @@ import network
import os
import time
import ubinascii
import sys
# Own modules
import app
@@ -37,14 +38,22 @@ hwconfig.board_init()
machine.mem32[0x40030000 + 0x00] = 0x10
def setup_wifi():
def setup_wifi(ssid='', passphrase='', sec=network.WLAN.SEC_WPA2_WPA3):
network.hostname("TonberryPico")
wlan = network.WLAN(network.WLAN.IF_AP)
wlan.config(ssid=f"TonberryPicoAP_{machine.unique_id().hex()}", security=wlan.SEC_OPEN)
wlan.active(True)
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=apname, security=wlan.SEC_OPEN)
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=sec)
# 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,14 +74,23 @@ 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('', '')
else:
setup_wifi(config.get_wifi_ssid(), config.get_wifi_passphrase())
# Setup MP3 player
with SDContext(mosi=hwconfig.SD_DI, miso=hwconfig.SD_DO, sck=hwconfig.SD_SCK, ss=hwconfig.SD_CS,
@@ -99,7 +117,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)
@@ -130,7 +148,26 @@ def builddb():
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:
run()
try:
run()
except Exception as ex:
sys.print_exception(ex)
error_blink()
else:
np.fill((led_max, 0, 0))
np.write()

View File

@@ -22,7 +22,14 @@ class Configuration:
'PREV': None,
'NEXT': 1,
},
'TAGMODE': 'tagremains'
'TAGMODE': 'tagremains',
'WIFI': {
'SSID': '',
'PASSPHRASE': '',
},
'VOLUME_MAX': 255,
'VOLUME_BOOT': 16,
'LED_MAX': 255,
}
def __init__(self, config_path='/config.json'):
@@ -87,6 +94,21 @@ 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_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

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

@@ -5,6 +5,7 @@ Copyright (c) 2024-2025 Stefan Kratochwil <Kratochwil-LA@gmx.de>
import asyncio
import board
import errno
import hwconfig
import json
import machine
@@ -31,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()
@@ -112,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):
@@ -133,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
}
@@ -156,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
@@ -200,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']:
@@ -216,26 +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:
try:
bytes_read = await request.stream.readinto(data)
except OSError as ex:
return f'read error: {ex}', 500
if bytes_read == 0:
# End of body
break
try:
bytes_written = newfile.write(data[:bytes_read])
except OSError as ex:
return f'write error: {ex}', 500
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:
@@ -267,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':

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):