7 Commits

Author SHA1 Message Date
d8eb61e967 fix: allow 'reboot' to application when on on battery
All checks were successful
Build RPi Pico firmware image / Build-Firmware (push) Successful in 4m43s
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
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-23 18:23:20 +01:00
a1d71964f7 feat: Allow limiting max. volume
Signed-off-by: Matthias Blankertz <matthias@blankertz.org>
2025-12-23 18:23:20 +01:00
bdde8cb4c2 feat: Add names to playlists
All checks were successful
Build RPi Pico firmware image / Build-Firmware (push) Successful in 4m38s
Check code formatting / Check-C-Format (push) Successful in 7s
Check code formatting / Check-Python-Flake8 (push) Successful in 9s
Check code formatting / Check-Bash-Shellcheck (push) Successful in 4s
Run unit tests on host / Run-Unit-Tests (push) Successful in 7s
Run pytests / Check-Pytest (push) Successful in 10s
Fixes #63.

Signed-off-by: Matthias Blankertz <matthias@blankertz.org>
2025-12-23 13:25:39 +01:00
94a8c3d720 fix: frontend: Replace generic 'Device admin' title
All checks were successful
Build RPi Pico firmware image / Build-Firmware (push) Successful in 4m40s
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 7s
Run pytests / Check-Pytest (push) Successful in 11s
Signed-off-by: Matthias Blankertz <matthias@blankertz.org>
2025-12-23 12:08:36 +01:00
2971df7b68 feat: Enable dualstack IPv4/IPv6 for microdot
All checks were successful
Build RPi Pico firmware image / Build-Firmware (push) Successful in 4m40s
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 7s
Run pytests / Check-Pytest (push) Successful in 10s
Signed-off-by: Matthias Blankertz <matthias@blankertz.org>
2025-12-23 12:00:05 +01:00
18a58992f3 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-23 12:00:05 +01:00
4e0af8e3fc 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-23 12:00:05 +01:00
8 changed files with 84 additions and 168 deletions

View File

@@ -52,23 +52,3 @@ 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
## Notes for UI development with chromium
Features for the web interface are best prototyped in a browser directly. By using the built-in developmer tools and
and their "override" feature, the web contents are replaced by a locally stored copy, which can be used to directly
test the modifications without going all the way through the build and flash process.
However, modern browsers may restrict or even completely forbid the execution of dynamic content like JavaScript, if
the content is stored on the local machine and/or the content is accessed using http. In such a case, chromium issues
an error message similar to the following one:
> Access to fetch at 'http://192.168.4.1/api/v1/audiofiles' from origin 'http://192.168.4.1' has been blocked by CORS
> policy: The request client is not a secure context and the resource is in more-private address space `local`.
To mitigate this, chromium offers two flags that need modification:
- 'chrome://flags/#local-network-access-check' must be `Disabled`
- 'chrome://flags/#unsafely-treat-insecure-origin-as-secure' must be `Enabled`
Note that these settings leave the browser susceptible to security issues and should be returned to
their default values as soon as possible.

View File

@@ -147,7 +147,6 @@
<button onclick="showScreen('menu')">🏠 Main Menu</button>
<button onclick="showScreen('config')">⚙️ Config Editor</button>
<button onclick="showScreen('playlist')">🖹 Playlist Editor</button>
<button onclick="showScreen('filebrowser', 'filesystem')">📂 Filesystem</button>
</nav>
<!-- MAIN MENU -->
@@ -243,10 +242,10 @@
</div>
<!-- PLAYLIST EDITOR SCREEN 3: file browser -->
<div id="screen-filebrowser" class="screen">
<h2 id="playlist-filebrowser-title">Playlist Editor</h2>
<div id="screen-playlist_filebrowser" class="screen">
<h2>Playlist Editor</h2>
<div id="playlist-filebrowser-container">
<div class="scroll-container" style="margin-bottom: 10px">
<div class="scroll-container">
<div class="tree" id="playlist-filebrowser-tree">
<ul><li>Loading...</li></ul>
</div>
@@ -358,12 +357,7 @@
'root.TAG_TIMEOUT_SECS': 'Tag removal timeout (seconds)',
'root.TAGMODE': 'Tag mode',
'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'
'root.VOLUME_MAX': 'Maximum volume (0-255)'
};
const config_input_override = {
'root.TAGMODE': {
@@ -397,29 +391,14 @@
'root.LED_COUNT': {
'input-type': 'number'
},
'root.WIFI.SSID': {
'root.WLAN.SSID': {
'input-type': 'text'
},
'root.WIFI.PASSPHRASE': {
'root.WLAN.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'
}
};
@@ -655,7 +634,7 @@
document.getElementById('playlist-edit-removetrack').addEventListener("click", (e) => deleteSelectedTracks());
document.getElementById('playlist-edit-back').addEventListener("click", (e) => showScreen('playlist'));
document.getElementById('playlist-edit-addtrack').addEventListener("click", (e) => {
showScreen("filebrowser", "playlist");
showScreen("playlist_filebrowser");
});
document.getElementById('playlist-edit-save').addEventListener("click", (e) => save());
}
@@ -787,9 +766,7 @@
/* ----------------------------------------
PLAYLIST EDITOR LOGIC - ADD FILES SCREEN
------------------------------------------- */
Screens.filebrowser = (() => {
let isFilesystemMode = false;
Screens.playlist_filebrowser = (() => {
function init() {
document.getElementById('playlist-filebrowser-cancel').addEventListener("click", (e) => {
showScreen("playlist_edit", {});
@@ -808,32 +785,9 @@
});
tree.init();
}
async function onShow(intent) {
console.log(intent)
document.getElementById('playlist-filebrowser-addtrack').disabled = true;
const title = document.getElementById('playlist-filebrowser-title');
const cancelButton = document.getElementById('playlist-filebrowser-cancel');
const addTracksButton = document.getElementById('playlist-filebrowser-addtrack');
if (intent !== 'refresh') {
isFilesystemMode = (intent === 'filesystem');
document.getElementById('playlist-filebrowser-upload-progress').value = 0;
document.getElementById("playlist-filebrowser-upload-files").value = "";
}
if (isFilesystemMode) {
title.innerText = "Filesystem";
cancelButton.style.display = 'none';
addTracksButton.style.display = 'none';
} else {
title.innerText = "Playlist Editor";
cancelButton.style.display = ''
addTracksButton.style.display = ''
}
tree = document.getElementById("playlist-filebrowser-tree");
tree.innerHTML = "Loading...";
fetch('/api/v1/audiofiles')
@@ -868,6 +822,7 @@
if (type === 'directory') {
const nested = document.createElement('ul');
node.appendChild(nested);
node.classList.add('expanded');
parent.appendChild(node);
return nested;
}
@@ -954,13 +909,13 @@
}
if (donecount + 1 === totalcount) {
// Reload file list from device
onShow('refresh');
onShow();
} else {
uploadFileHelper(totalcount, donecount + 1, destdir, files.slice(1));
}
}
};
xhr.open("POST", `/api/v1/audiofiles?type=file&location=${encodeURIComponent(location)}`);
xhr.open("POST", `/api/v1/audiofiles?type=file&location=${location}`);
xhr.overrideMimeType("audio/mpeg");
xhr.send(files[0]);
}
@@ -978,10 +933,10 @@
const location = selectedNodes.length === 1
? selectedNodes[0].getAttribute('data-path') + '/' + name.value
: '/' + name.value;
const saveRes = await fetch(`/api/v1/audiofiles?type=directory&location=${encodeURIComponent(location)}`,
const saveRes = await fetch(`/api/v1/audiofiles?type=directory&location=${location}`,
{method: 'POST'});
// Reload file list from device
onShow('refresh');
onShow();
}
async function deleteItems() {
@@ -995,14 +950,14 @@
items.sort();
items.reverse();
for (const item of items) {
const saveRes = await fetch(`/api/v1/audiofiles?location=${encodeURIComponent(item)}`,
const saveRes = await fetch(`/api/v1/audiofiles?location=${item}`,
{method: 'DELETE'});
if (!saveRes.ok) {
alert(`Failed to delete item ${item}: ${await saveRes.text()}`);
}
}
// Reload file list from device
onShow('refresh');
onShow();
}
let tree = (() => {

View File

@@ -53,18 +53,11 @@ class PlayerApp:
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])
@@ -82,7 +75,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 if self.playlist is not None else None
self.playing_tag = new_tag
elif self.tag_mode == 'tagstartstop':
print('Tag presented again, stopping playback')
self._unset_playlist()

View File

@@ -3,9 +3,11 @@
import aiorepl # type: ignore
import asyncio
from errno import ENOENT
import machine
import micropython
import network
import os
import time
import ubinascii
import sys
@@ -36,19 +38,19 @@ hwconfig.board_init()
machine.mem32[0x40030000 + 0x00] = 0x10
def setup_wifi(ssid='', passphrase='', security=network.WLAN.SEC_WPA_WPA2):
def setup_wifi(ssid='', passphrase='', sec=network.WLAN.SEC_WPA2_WPA3):
network.hostname("TonberryPico")
if ssid is None or ssid == '':
if ssid == '':
apname = f"TonberryPicoAP_{machine.unique_id().hex()}"
print(f"Create AP {apname}")
wlan = network.WLAN(network.WLAN.IF_AP)
wlan.config(ssid=apname, password=passphrase if passphrase is not None else '', security=security)
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=security)
wlan.connect(ssid, passphrase, security=sec)
# configure power management
wlan.config(pm=network.WLAN.PM_PERFORMANCE)
@@ -74,8 +76,7 @@ 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.fill((32, 32, 0))
np.write()
@@ -83,26 +84,25 @@ def run():
asyncio.new_event_loop()
if machine.Pin(hwconfig.BUTTONS[1], machine.Pin.IN, machine.Pin.PULL_UP).value() == 0:
np.fill((0, 0, led_max))
np.fill((0, 0, 32))
np.write()
# Force default access point
setup_wifi('', '', network.WLAN.SEC_OPEN)
setup_wifi('', '')
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_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,
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:
@@ -116,7 +116,7 @@ def run():
buttons=lambda the_app: Buttons(the_app, config, hwconfig),
playlistdb=lambda _: playlistdb,
hwconfig=lambda _: hwconfig,
leds=lambda _: LedManager(np, config),
leds=lambda _: LedManager(np),
config=lambda _: config)
the_app = app.PlayerApp(deps)
@@ -129,11 +129,27 @@ 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.fill((32, 0, 0))
np.write()
time.sleep_ms(500)
np.fill((0, 0, 0))
@@ -150,5 +166,5 @@ if __name__ == '__main__':
sys.print_exception(ex)
error_blink()
else:
np.fill((led_max, 0, 0))
np.fill((32, 0, 0))
np.write()

View File

@@ -26,11 +26,8 @@ class Configuration:
'WIFI': {
'SSID': '',
'PASSPHRASE': '',
'SECURITY': 'wpa_wpa2',
},
'VOLUME_MAX': 255,
'VOLUME_BOOT': 16,
'LED_MAX': 255,
'VOLUME_MAX': 255
}
def __init__(self, config_path='/config.json'):
@@ -101,18 +98,9 @@ class Configuration:
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
@@ -130,9 +118,6 @@ 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, config):
def __init__(self, np):
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

@@ -5,7 +5,6 @@ Copyright (c) 2024-2025 Stefan Kratochwil <Kratochwil-LA@gmx.de>
import asyncio
import board
import errno
import hwconfig
import json
import machine
@@ -203,25 +202,6 @@ 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(4096))
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']:
@@ -235,19 +215,29 @@ async def audiofile_upload(request):
if type_ == 'directory':
if length != 0:
return 'directory request may not have content', 400
try:
os.mkdir(path)
except OSError as ex:
return f'error creating directory: {ex}', 500
os.mkdir(path)
return '', 204
try:
with open(path, 'wb') as newfile:
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
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
if bytes_copied == length:
return '', 204
else:

View File

@@ -142,9 +142,6 @@ class FakeConfig:
def get_volume_max(self):
return 255
def get_volume_boot(self):
return 16
def fake_open(filename, mode):
return FakeFile(filename, mode)
@@ -173,7 +170,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 and fake_mp3.volume >= 16
assert fake_mp3.volume is not None
def test_load_playlist_on_tag(micropythonify, faketimermanager, monkeypatch):