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

View File

@@ -357,9 +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.VOLUME_MAX': 'Maximum volume (0-255)'
};
const config_input_override = {
'root.TAGMODE': {
@@ -401,12 +399,6 @@
},
'root.VOLUME_MAX': {
'input-type': 'number'
},
'root.VOLUME_BOOT': {
'input-type': 'number'
},
'root.LED_MAX': {
'input-type': 'number'
}
};

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

View File

@@ -40,7 +40,7 @@ machine.mem32[0x40030000 + 0x00] = 0x10
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)
@@ -50,7 +50,7 @@ def setup_wifi(ssid='', passphrase='', sec=network.WLAN.SEC_WPA2_WPA3):
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)
wlan.connect(ssid, passphrase, security=sec)
# configure power management
wlan.config(pm=network.WLAN.PM_PERFORMANCE)
@@ -76,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()
@@ -85,7 +84,7 @@ 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('', '')
@@ -117,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)
@@ -150,9 +149,7 @@ def builddb():
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))
@@ -169,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

@@ -27,9 +27,7 @@ class Configuration:
'SSID': '',
'PASSPHRASE': '',
},
'VOLUME_MAX': 255,
'VOLUME_BOOT': 16,
'LED_MAX': 255,
'VOLUME_MAX': 255
}
def __init__(self, config_path='/config.json'):
@@ -103,12 +101,6 @@ class Configuration:
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, 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(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']:
@@ -238,13 +218,26 @@ async def audiofile_upload(request):
os.mkdir(path)
return '', 204
with open(path, 'wb') as newfile:
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
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):