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
7 changed files with 69 additions and 33 deletions

View File

@@ -357,7 +357,9 @@
'root.TAG_TIMEOUT_SECS': 'Tag removal timeout (seconds)', 'root.TAG_TIMEOUT_SECS': 'Tag removal timeout (seconds)',
'root.TAGMODE': 'Tag mode', '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_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 = { const config_input_override = {
'root.TAGMODE': { 'root.TAGMODE': {
@@ -399,6 +401,12 @@
}, },
'root.VOLUME_MAX': { 'root.VOLUME_MAX': {
'input-type': 'number' 'input-type': 'number'
},
'root.VOLUME_BOOT': {
'input-type': 'number'
},
'root.LED_MAX': {
'input-type': 'number'
} }
}; };

View File

@@ -53,11 +53,18 @@ class PlayerApp:
self.leds = deps.leds(self) self.leds = deps.leds(self)
self.tag_mode = self.config.get_tagmode() self.tag_mode = self.config.get_tagmode()
self.volume_max = self.config.get_volume_max() 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.playing_tag = None
self.playlist = None self.playlist = None
self.buttons = deps.buttons(self) if deps.buttons is not None else None self.buttons = deps.buttons(self) if deps.buttons is not None else None
self.mp3file = None self.mp3file = None
self.volume_pos = 3
self.paused = False self.paused = False
self.playing = False self.playing = False
self.player.set_volume(VOLUME_CURVE[self.volume_pos]) self.player.set_volume(VOLUME_CURVE[self.volume_pos])

View File

@@ -40,7 +40,7 @@ machine.mem32[0x40030000 + 0x00] = 0x10
def setup_wifi(ssid='', passphrase='', sec=network.WLAN.SEC_WPA2_WPA3): def setup_wifi(ssid='', passphrase='', sec=network.WLAN.SEC_WPA2_WPA3):
network.hostname("TonberryPico") network.hostname("TonberryPico")
if ssid == '': if ssid is None or ssid == '':
apname = f"TonberryPicoAP_{machine.unique_id().hex()}" apname = f"TonberryPicoAP_{machine.unique_id().hex()}"
print(f"Create AP {apname}") print(f"Create AP {apname}")
wlan = network.WLAN(network.WLAN.IF_AP) wlan = network.WLAN(network.WLAN.IF_AP)
@@ -50,7 +50,7 @@ def setup_wifi(ssid='', passphrase='', sec=network.WLAN.SEC_WPA2_WPA3):
print(f"Connect to SSID {ssid} with passphrase {passphrase}...") print(f"Connect to SSID {ssid} with passphrase {passphrase}...")
wlan = network.WLAN() wlan = network.WLAN()
wlan.active(True) wlan.active(True)
wlan.connect(ssid, passphrase, security=sec) wlan.connect(ssid, passphrase if passphrase is not None else '', security=sec)
# configure power management # configure power management
wlan.config(pm=network.WLAN.PM_PERFORMANCE) wlan.config(pm=network.WLAN.PM_PERFORMANCE)
@@ -76,7 +76,8 @@ config = Configuration()
# Setup LEDs # Setup LEDs
np = NeoPixel(hwconfig.LED_DIN, config.get_led_count(), sm=1) np = NeoPixel(hwconfig.LED_DIN, config.get_led_count(), sm=1)
np.fill((32, 32, 0)) led_max = config.get_led_max()
np.fill((led_max, led_max, 0))
np.write() np.write()
@@ -84,7 +85,7 @@ def run():
asyncio.new_event_loop() asyncio.new_event_loop()
if machine.Pin(hwconfig.BUTTONS[1], machine.Pin.IN, machine.Pin.PULL_UP).value() == 0: if machine.Pin(hwconfig.BUTTONS[1], machine.Pin.IN, machine.Pin.PULL_UP).value() == 0:
np.fill((0, 0, 32)) np.fill((0, 0, led_max))
np.write() np.write()
# Force default access point # Force default access point
setup_wifi('', '') setup_wifi('', '')
@@ -116,7 +117,7 @@ def run():
buttons=lambda the_app: Buttons(the_app, config, hwconfig), buttons=lambda the_app: Buttons(the_app, config, hwconfig),
playlistdb=lambda _: playlistdb, playlistdb=lambda _: playlistdb,
hwconfig=lambda _: hwconfig, hwconfig=lambda _: hwconfig,
leds=lambda _: LedManager(np), leds=lambda _: LedManager(np, config),
config=lambda _: config) config=lambda _: config)
the_app = app.PlayerApp(deps) the_app = app.PlayerApp(deps)
@@ -149,7 +150,9 @@ def builddb():
def error_blink(): def error_blink():
while True: while True:
np.fill((32, 0, 0)) 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() np.write()
time.sleep_ms(500) time.sleep_ms(500)
np.fill((0, 0, 0)) np.fill((0, 0, 0))
@@ -166,5 +169,5 @@ if __name__ == '__main__':
sys.print_exception(ex) sys.print_exception(ex)
error_blink() error_blink()
else: else:
np.fill((32, 0, 0)) np.fill((led_max, 0, 0))
np.write() np.write()

View File

@@ -27,7 +27,9 @@ class Configuration:
'SSID': '', 'SSID': '',
'PASSPHRASE': '', 'PASSPHRASE': '',
}, },
'VOLUME_MAX': 255 'VOLUME_MAX': 255,
'VOLUME_BOOT': 16,
'LED_MAX': 255,
} }
def __init__(self, config_path='/config.json'): def __init__(self, config_path='/config.json'):
@@ -101,6 +103,12 @@ class Configuration:
def get_volume_max(self) -> int: def get_volume_max(self) -> int:
return self._get('VOLUME_MAX') 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 # For the web API
def get_config(self) -> Mapping[str, Any]: def get_config(self) -> Mapping[str, Any]:
return self.config return self.config

View File

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

View File

@@ -5,6 +5,7 @@ Copyright (c) 2024-2025 Stefan Kratochwil <Kratochwil-LA@gmx.de>
import asyncio import asyncio
import board import board
import errno
import hwconfig import hwconfig
import json import json
import machine import machine
@@ -202,6 +203,25 @@ async def audiofiles_get(request):
return directory_iterator(), {'Content-Type': 'application/json; charset=UTF-8'} 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']) @webapp.route('/api/v1/audiofiles', methods=['POST'])
async def audiofile_upload(request): async def audiofile_upload(request):
if 'type' not in request.args or request.args['type'] not in ['file', 'directory']: if 'type' not in request.args or request.args['type'] not in ['file', 'directory']:
@@ -218,26 +238,13 @@ async def audiofile_upload(request):
os.mkdir(path) os.mkdir(path)
return '', 204 return '', 204
with open(path, 'wb') as newfile: with open(path, 'wb') as newfile:
data = array('b', range(4096)) try:
bytes_copied = 0 if length > Request.max_body_length:
while True: bytes_copied = await stream_to_file(request.stream, newfile, length)
try: else:
bytes_read = await request.stream.readinto(data) bytes_copied = newfile.write(request.body)
except OSError as ex: except OSError as ex:
return f'read error: {ex}', 500 return f'error writing data to file: {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: if bytes_copied == length:
return '', 204 return '', 204
else: else:

View File

@@ -142,6 +142,9 @@ class FakeConfig:
def get_volume_max(self): def get_volume_max(self):
return 255 return 255
def get_volume_boot(self):
return 16
def fake_open(filename, mode): def fake_open(filename, mode):
return FakeFile(filename, mode) return FakeFile(filename, mode)
@@ -170,7 +173,7 @@ def test_construct_app(micropythonify, faketimermanager):
deps = _makedeps(mp3player=fake_mp3) deps = _makedeps(mp3player=fake_mp3)
dut = app.PlayerApp(deps) dut = app.PlayerApp(deps)
fake_mp3 = dut.player 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): def test_load_playlist_on_tag(micropythonify, faketimermanager, monkeypatch):