22 Commits

Author SHA1 Message Date
39a9c68aae 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>
2026-01-13 21:31:18 +00:00
6dee7fff7e feat: Allow configuring WiFi security
All checks were successful
Build RPi Pico firmware image / Build-Firmware (push) Successful in 4m44s
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 9s
Run pytests / Check-Pytest (push) Successful in 11s
Allow choosing between the three security modes exposed by the
micropython cyw43 wifi driver. Also allow setting up security in AP
mode.

Improve the WiFi section of the configuration UI.

Signed-off-by: Matthias Blankertz <matthias@blankertz.org>
2026-01-06 14:59:12 +01:00
6976aa6963 fix[player]: Don't latch tag if no playlist exists
When the device is in 'tagstartstop' tag mode, and the user presents a
new tag to get the serial number and create a playlist using the web UI,
the playerapp still remembered the tag as the currently playing tag even
though no playlist was found and no playback is running. After the user
saves the playlist in the UI and puts the tag on the device again, they
expect the playback to start with the new playlist. Instead, nothing
happens, because this is counted as the 'stop' event of the tagstartstop
mode. The user would have to remove the tag and present it again (after
waiting for the tagtimeout) to play the new playlist.

Fix this unexpected behaviour by not storing the current tag into the
playing_tag field if no playlist existed for the tag.

Signed-off-by: Matthias Blankertz <matthias@blankertz.org>
2026-01-06 14:59:12 +01:00
763305c659 fix[frontend]: Reset upload UI elements, don't expand tree view
- Make sure the upload progess bar and file choser are reset when
  loading the file browser screen.
- Don't expand the directories in the tree view by default.

Signed-off-by: Matthias Blankertz <matthias@blankertz.org>
2026-01-06 14:59:12 +01:00
6d18437863 fix: Remove directory based db creation
Previously, if no tonberry.db existed on the SD card, the database was
initialized with a playlist for each directory containing mp3 files,
with the tag serial number matching the directory name. This was used
during development before the web UI to edit the playlist db existed.
It is no longer necessary, and confusing to the user when unusable
playlists are created when albums are preloaded onto the SD card before
putting it in the tonberry device.

Signed-off-by: Matthias Blankertz <matthias@blankertz.org>
2026-01-06 14:59:12 +01:00
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
d3aef1be32 fix: frontend: don't convert text that looks like an integer to integers
Signed-off-by: Matthias Blankertz <matthias@blankertz.org>
2025-12-23 12:00:05 +01:00
b9baa1c7d5 microdot: Update to v2.5.1
The important fix is an urldecode bug causing umlauts to be decoded
incorrectly from url arguments. This was fixed in v2.2.0, but let's just
update to the latest version.

Signed-off-by: Matthias Blankertz <matthias@blankertz.org>
2025-12-23 12:00:05 +01:00
67d7650923 fix: webserver: Catch and report IO errors on upload
Signed-off-by: Matthias Blankertz <matthias@blankertz.org>
2025-12-23 12:00:05 +01:00
43fd68779c feat: store git version in fw and show in web ui
Signed-off-by: Matthias Blankertz <matthias@blankertz.org>
2025-12-23 12:00:05 +01:00
704951074b feat: Long press VOL_DOWN button to shutdown/reset device
Signed-off-by: Matthias Blankertz <matthias@blankertz.org>
2025-12-21 18:31:13 +01:00
cac61f924f Merge branch 'file-upload' into mbl-next
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 9s
Run pytests / Check-Pytest (push) Successful in 11s
2025-12-21 18:31:04 +01: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") add_link_options("-Wl,--print-memory-usage")
set(PICO_USE_FASTEST_SUPPORTED_CLOCK 1) 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"> <html lang="en">
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<title>Device Admin</title> <title>TonBERRY pico</title>
<style> <style>
body { body {
font-family: Arial, sans-serif; font-family: Arial, sans-serif;
@@ -131,11 +131,17 @@
list-style: none; list-style: none;
} }
.footer {
font-size: x-small;
color: gray;
margin-top: 20px;
}
</style> </style>
</head> </head>
<body> <body>
<h1>Device Admin</h1> <h1>TonBERRY pico</h1>
<nav> <nav>
<button onclick="showScreen('menu')">🏠 Main Menu</button> <button onclick="showScreen('menu')">🏠 Main Menu</button>
@@ -161,7 +167,7 @@
<div id="screen-config" class="screen"> <div id="screen-config" class="screen">
<h2>Configuration Editor</h2> <h2>Configuration Editor</h2>
<div id="config-container">Loading…</div> <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> </div>
<!-- PLAYLIST EDITOR SCREEN 1: list of playlists --> <!-- PLAYLIST EDITOR SCREEN 1: list of playlists -->
@@ -207,6 +213,10 @@
<option value="audioplay">Audioplay (no shuffle, start at previous track)</option> <option value="audioplay">Audioplay (no shuffle, start at previous track)</option>
</select> </select>
</div> </div>
<div>
<label>Playlist name</label>
<input type="text" placeholder="Playlist name" id="playlist-edit-name" />
</div>
<div class="flex-horizontal"> <div class="flex-horizontal">
<div style="flex-grow: 1"> <div style="flex-grow: 1">
<label>Tracks</label> <label>Tracks</label>
@@ -257,7 +267,17 @@
</div> </div>
</div> </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> <script>
const Screens = {}; const Screens = {};
let activeScreen = null; let activeScreen = null;
@@ -296,7 +316,9 @@
return; 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) { } catch (err) {
alert("Error saving configuration: " + err); alert("Error saving configuration: " + err);
} }
@@ -334,7 +356,13 @@
'root.BUTTON_MAP.PLAY_PAUSE': 'Play/Pause', 'root.BUTTON_MAP.PLAY_PAUSE': 'Play/Pause',
'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_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 = { const config_input_override = {
'root.TAGMODE': { 'root.TAGMODE': {
@@ -344,6 +372,21 @@
'tagstartstop': 'Present tag once to start, present again to stop playback' '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': { 'root.IDLE_TIMEOUT_SECS': {
'input-type': 'number' 'input-type': 'number'
}, },
@@ -353,6 +396,30 @@
'root.LED_COUNT': { 'root.LED_COUNT': {
'input-type': 'number' '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) { function renderObject(obj, path) {
@@ -423,7 +490,7 @@
let val = input.value.trim(); let val = input.value.trim();
if (val === "") val = null; 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; current[path[path.length - 1]] = val;
}); });
@@ -456,7 +523,7 @@
document.getElementById('playlist-exist-button-delete') document.getElementById('playlist-exist-button-delete')
.addEventListener('click', (e) => { .addEventListener('click', (e) => {
if (lastSelected === null) return; if (lastSelected === null) return;
const tagid = lastSelected.innerText; const tagid = lastSelected.getAttribute('data-tag');
if(confirm(`Really delete playlist ${tagid}?`)) { if(confirm(`Really delete playlist ${tagid}?`)) {
fetch(`/api/v1/playlist/${tagid}`, { fetch(`/api/v1/playlist/${tagid}`, {
method: 'DELETE'}) method: 'DELETE'})
@@ -467,7 +534,7 @@
.addEventListener('click', selectLastTag); .addEventListener('click', selectLastTag);
document.getElementById('playlist-exist-button-edit').addEventListener("click", (e) => { document.getElementById('playlist-exist-button-edit').addEventListener("click", (e) => {
if (lastSelected !== null) 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) => { document.getElementById('playlist-exist-list').addEventListener("click", (e) => {
const node = e.target.closest("li"); const node = e.target.closest("li");
@@ -522,8 +589,9 @@
container.innerHTML = ""; container.innerHTML = "";
for (const playlist of playlists) { for (const playlist of playlists) {
const li = document.createElement("li"); const li = document.createElement("li");
li.innerHTML = playlist; li.innerHTML = `${playlist.name} (${playlist.tag})`;
li.className = "node" li.className = "node"
li.setAttribute('data-tag', playlist.tag);
container.appendChild(li) container.appendChild(li)
} }
} }
@@ -548,7 +616,7 @@
document.getElementById('playlist-exist-list') document.getElementById('playlist-exist-list')
.querySelectorAll("li") .querySelectorAll("li")
.forEach(n => { .forEach(n => {
if (n.innerText == tagtext) { if (n.getAttribute('data-tag') == tagtext) {
n.classList.add("selected"); n.classList.add("selected");
lastSelected = n; lastSelected = n;
} else { } else {
@@ -650,6 +718,8 @@
} else if (playlist.persist === "track" && playlist.shuffle === "no") { } else if (playlist.persist === "track" && playlist.shuffle === "no") {
playlisttype.value = "audioplay"; playlisttype.value = "audioplay";
} }
const playlistname = document.getElementById('playlist-edit-name');
playlistname.value = playlist.name;
} }
async function save() { async function save() {
@@ -672,6 +742,8 @@
playlistData.shuffle = "no"; playlistData.shuffle = "no";
break; break;
} }
const playlistname = document.getElementById('playlist-edit-name');
playlistData.name = playlistname.value;
const container = document.getElementById('playlist-edit-list'); const container = document.getElementById('playlist-edit-list');
playlistData.paths = [...container.querySelectorAll("li")].map((node) => node.innerText); playlistData.paths = [...container.querySelectorAll("li")].map((node) => node.innerText);
const saveRes = await fetch(`/api/v1/playlist/${playlistId}`, const saveRes = await fetch(`/api/v1/playlist/${playlistId}`,
@@ -736,6 +808,10 @@
async function onShow(intent) { async function onShow(intent) {
document.getElementById('playlist-filebrowser-addtrack').disabled = true; 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 = document.getElementById("playlist-filebrowser-tree");
tree.innerHTML = "Loading..."; tree.innerHTML = "Loading...";
fetch('/api/v1/audiofiles') fetch('/api/v1/audiofiles')
@@ -770,7 +846,6 @@
if (type === 'directory') { if (type === 'directory') {
const nested = document.createElement('ul'); const nested = document.createElement('ul');
node.appendChild(nested); node.appendChild(nested);
node.classList.add('expanded');
parent.appendChild(node); parent.appendChild(node);
return nested; return nested;
} }
@@ -857,7 +932,7 @@
} }
if (donecount + 1 === totalcount) { if (donecount + 1 === totalcount) {
// Reload file list from device // Reload file list from device
onShow(); onShow('refresh');
} else { } else {
uploadFileHelper(totalcount, donecount + 1, destdir, files.slice(1)); uploadFileHelper(totalcount, donecount + 1, destdir, files.slice(1));
} }
@@ -884,7 +959,7 @@
const saveRes = await fetch(`/api/v1/audiofiles?type=directory&location=${location}`, const saveRes = await fetch(`/api/v1/audiofiles?type=directory&location=${location}`,
{method: 'POST'}); {method: 'POST'});
// Reload file list from device // Reload file list from device
onShow(); onShow('refresh');
} }
async function deleteItems() { async function deleteItems() {
@@ -905,7 +980,7 @@
} }
} }
// Reload file list from device // Reload file list from device
onShow(); onShow('refresh');
} }
let tree = (() => { let tree = (() => {
@@ -981,6 +1056,13 @@
}); });
showScreen("menu"); showScreen("menu");
fetch('/api/v1/info')
.then((resp) => resp.json())
.then((info) => {
const version = document.getElementById('footer-version');
version.innerText = info.version;
});
</script> </script>
</body> </body>

View File

@@ -9,8 +9,8 @@ from utils import TimerManager
Dependencies = namedtuple('Dependencies', ('mp3player', 'nfcreader', 'buttons', 'playlistdb', 'hwconfig', 'leds', Dependencies = namedtuple('Dependencies', ('mp3player', 'nfcreader', 'buttons', 'playlistdb', 'hwconfig', 'leds',
'config')) 'config'))
# Should be ~ 6dB steps # Should be ~ 3dB steps
VOLUME_CURVE = [1, 2, 4, 8, 16, 32, 63, 126, 251] VOLUME_CURVE = [1, 2, 3, 4, 6, 8, 11, 16, 23, 32, 45, 64, 91, 128, 181, 255]
class PlayerApp: class PlayerApp:
@@ -52,11 +52,19 @@ class PlayerApp:
self.hwconfig = deps.hwconfig(self) self.hwconfig = deps.hwconfig(self)
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_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])
@@ -74,7 +82,7 @@ class PlayerApp:
uid_str = b''.join('{:02x}'.format(x).encode() for x in new_tag) 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): if self.tag_mode == 'tagremains' or (self.tag_mode == 'tagstartstop' and new_tag != self.playing_tag):
self._set_playlist(uid_str) 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': elif self.tag_mode == 'tagstartstop':
print('Tag presented again, stopping playback') print('Tag presented again, stopping playback')
self._unset_playlist() self._unset_playlist()
@@ -91,8 +99,10 @@ class PlayerApp:
def onButtonPressed(self, what): def onButtonPressed(self, what):
assert self.buttons is not None assert self.buttons is not None
if what == self.buttons.VOLUP: 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)
self.player.set_volume(VOLUME_CURVE[self.volume_pos]) 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: elif what == self.buttons.VOLDOWN:
self.volume_pos = max(self.volume_pos - 1, 0) self.volume_pos = max(self.volume_pos - 1, 0)
self.player.set_volume(VOLUME_CURVE[self.volume_pos]) self.player.set_volume(VOLUME_CURVE[self.volume_pos])

View File

@@ -3,13 +3,12 @@
import aiorepl # type: ignore import aiorepl # type: ignore
import asyncio import asyncio
from errno import ENOENT
import machine import machine
import micropython import micropython
import network import network
import os
import time import time
import ubinascii import ubinascii
import sys
# Own modules # Own modules
import app import app
@@ -37,14 +36,22 @@ hwconfig.board_init()
machine.mem32[0x40030000 + 0x00] = 0x10 machine.mem32[0x40030000 + 0x00] = 0x10
def setup_wifi(): def setup_wifi(ssid='', passphrase='', security=network.WLAN.SEC_WPA_WPA2):
network.hostname("TonberryPico") network.hostname("TonberryPico")
wlan = network.WLAN(network.WLAN.IF_AP) if ssid is None or ssid == '':
wlan.config(ssid=f"TonberryPicoAP_{machine.unique_id().hex()}", security=wlan.SEC_OPEN) apname = f"TonberryPicoAP_{machine.unique_id().hex()}"
wlan.active(True) 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.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 # configure power management
wlan.config(pm=network.WLAN.PM_NONE) wlan.config(pm=network.WLAN.PM_PERFORMANCE)
mac = ubinascii.hexlify(network.WLAN().config('mac'), ':').decode() mac = ubinascii.hexlify(network.WLAN().config('mac'), ':').decode()
print(f" mac: {mac}") print(f" mac: {mac}")
@@ -65,27 +72,37 @@ DB_PATH = '/sd/tonberry.db'
config = Configuration() 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(): def run():
asyncio.new_event_loop() asyncio.new_event_loop()
# Setup LEDs
np = NeoPixel(hwconfig.LED_DIN, config.get_led_count(), sm=1)
# Wifi with default config if machine.Pin(hwconfig.BUTTONS[1], machine.Pin.IN, machine.Pin.PULL_UP).value() == 0:
setup_wifi() 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 # Setup MP3 player
with SDContext(mosi=hwconfig.SD_DI, miso=hwconfig.SD_DO, sck=hwconfig.SD_SCK, ss=hwconfig.SD_CS, with SDContext(mosi=hwconfig.SD_DI, miso=hwconfig.SD_DO, sck=hwconfig.SD_SCK, ss=hwconfig.SD_CS,
baudrate=hwconfig.SD_CLOCKRATE): 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, \ with BTreeFileManager(DB_PATH) as playlistdb, \
AudioContext(hwconfig.I2S_DIN, hwconfig.I2S_DCLK, hwconfig.I2S_LRCLK) as audioctx: 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), 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)
@@ -112,25 +129,26 @@ def run():
asyncio.get_event_loop().run_forever() asyncio.get_event_loop().run_forever()
def builddb(): def error_blink():
""" while True:
For testing, build a playlist db based on the previous tag directory format. if machine.Pin(hwconfig.BUTTONS[0], machine.Pin.IN, machine.Pin.PULL_UP).value() == 0:
Can be removed once uploading files / playlist via the web api is possible. machine.reset()
""" np.fill((led_max, 0, 0))
try: np.write()
os.unlink(DB_PATH) time.sleep_ms(500)
except OSError: np.fill((0, 0, 0))
pass np.write()
with BTreeFileManager(DB_PATH) as db: time.sleep_ms(500)
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()
if __name__ == '__main__': if __name__ == '__main__':
time.sleep(1) time.sleep(1)
if machine.Pin(hwconfig.BUTTONS[0], machine.Pin.IN, machine.Pin.PULL_UP).value() != 0: 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

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

View File

@@ -5,7 +5,7 @@ import asyncio
import machine import machine
import micropython import micropython
import time import time
from utils import safe_callback from utils import safe_callback, TimerManager
try: try:
from typing import TYPE_CHECKING # type: ignore from typing import TYPE_CHECKING # type: ignore
except ImportError: except ImportError:
@@ -32,6 +32,7 @@ class Buttons:
def __init__(self, cb: "ButtonCallback", config, hwconfig): def __init__(self, cb: "ButtonCallback", config, hwconfig):
self.button_map = config.get_button_map() self.button_map = config.get_button_map()
self.hw_buttons = hwconfig.BUTTONS self.hw_buttons = hwconfig.BUTTONS
self.hwconfig = hwconfig
self.cb = cb self.cb = cb
self.buttons = dict() self.buttons = dict()
for key_id, key_name in self.KEYMAP.items(): for key_id, key_name in self.KEYMAP.items():
@@ -42,6 +43,7 @@ class Buttons:
self.int_flag = asyncio.ThreadSafeFlag() self.int_flag = asyncio.ThreadSafeFlag()
self.pressed: list[int] = [] self.pressed: list[int] = []
self.last: dict[int, int] = {} self.last: dict[int, int] = {}
self.timer_manager = TimerManager()
for button in self.buttons.keys(): for button in self.buttons.keys():
button.irq(handler=self._interrupt, trigger=machine.Pin.IRQ_FALLING | machine.Pin.IRQ_RISING) button.irq(handler=self._interrupt, trigger=machine.Pin.IRQ_FALLING | machine.Pin.IRQ_RISING)
asyncio.create_task(self.task()) asyncio.create_task(self.task())
@@ -69,6 +71,10 @@ class Buttons:
# print(f'B{keycode} {now}') # print(f'B{keycode} {now}')
self.pressed.append(keycode) self.pressed.append(keycode)
self.int_flag.set() 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): async def task(self):
while True: while True:
@@ -76,3 +82,9 @@ class Buttons:
while len(self.pressed) > 0: while len(self.pressed) > 0:
what = self.pressed.pop() what = self.pressed.pop()
safe_callback(lambda: self.cb.onButtonPressed(what), "button callback") 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, 'PREV': None,
'NEXT': 1, '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'): def __init__(self, config_path='/config.json'):
@@ -87,6 +95,24 @@ class Configuration:
def get_tagmode(self) -> str: def get_tagmode(self) -> str:
return self._get('TAGMODE') 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 # For the web API
def get_config(self) -> Mapping[str, Any]: def get_config(self) -> Mapping[str, Any]:
return self.config return self.config
@@ -104,6 +130,9 @@ class Configuration:
self._validate(self.DEFAULT_CONFIG, config) self._validate(self.DEFAULT_CONFIG, config)
if 'TAGMODE' in config and config['TAGMODE'] not in ['tagremains', 'tagstartstop']: if 'TAGMODE' in config and config['TAGMODE'] not in ['tagremains', 'tagstartstop']:
raise ValueError("Invalid TAGMODE: Must be 'tagremains' or '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._merge_configs(self.config, config)
self.config = config self.config = config
self._save() self._save()

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

@@ -33,12 +33,13 @@ class BTreeDB(IPlaylistDB):
PERSIST_OFFSET = b'offset' PERSIST_OFFSET = b'offset'
class Playlist(IPlaylist): 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.parent = parent
self.tag = tag self.tag = tag
self.pos = pos self.pos = pos
self.persist = persist self.persist = persist
self.shuffle = shuffle self.shuffle = shuffle
self.name = name
self.length = self.parent._getPlaylistLength(self.tag) self.length = self.parent._getPlaylistLength(self.tag)
self._shuffle() self._shuffle()
@@ -168,6 +169,10 @@ class BTreeDB(IPlaylistDB):
return (b''.join([tag, b'/playlist/']), return (b''.join([tag, b'/playlist/']),
b''.join([tag, b'/playlist0'])) b''.join([tag, b'/playlist0']))
@staticmethod
def _keyPlaylistName(tag):
return b''.join([tag, b'/playlistname'])
def _flush(self): def _flush(self):
""" """
Flush the database and call the flush_func if it was provided. Flush the database and call the flush_func if it was provided.
@@ -222,12 +227,13 @@ class BTreeDB(IPlaylistDB):
raise RuntimeError("Malformed playlist key") raise RuntimeError("Malformed playlist key")
return int(elements[2])+1 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) self._deletePlaylist(tag, False)
for idx, entry in enumerate(entries): for idx, entry in enumerate(entries):
self.db[self._keyPlaylistEntry(tag, idx)] = entry self.db[self._keyPlaylistEntry(tag, idx)] = entry
self.db[self._keyPlaylistPersist(tag)] = persist self.db[self._keyPlaylistPersist(tag)] = persist
self.db[self._keyPlaylistShuffle(tag)] = shuffle self.db[self._keyPlaylistShuffle(tag)] = shuffle
self.db[self._keyPlaylistName(tag)] = name.encode()
if flush: if flush:
self._flush() self._flush()
@@ -240,7 +246,7 @@ class BTreeDB(IPlaylistDB):
pass pass
for k in (self._keyPlaylistPos(tag), self._keyPlaylistPosOffset(tag), for k in (self._keyPlaylistPos(tag), self._keyPlaylistPosOffset(tag),
self._keyPlaylistPersist(tag), self._keyPlaylistShuffle(tag), self._keyPlaylistPersist(tag), self._keyPlaylistShuffle(tag),
self._keyPlaylistShuffleSeed(tag)): self._keyPlaylistShuffleSeed(tag), self._keyPlaylistName(tag)):
try: try:
del self.db[k] del self.db[k]
except KeyError: except KeyError:
@@ -248,15 +254,18 @@ class BTreeDB(IPlaylistDB):
if flush: if flush:
self._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 Get a list of all defined playlists with their tag and names.
their tag.
""" """
playlist_tags = set() playlist_tags = set()
for item in self.db: for item in self.db:
playlist_tags.add(item.split(b'/')[0]) 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): def getPlaylistForTag(self, tag: bytes):
""" """
@@ -275,18 +284,19 @@ class BTreeDB(IPlaylistDB):
return None return None
if self._keyPlaylistEntry(tag, pos) not in self.db: if self._keyPlaylistEntry(tag, pos) not in self.db:
pos = 0 pos = 0
name = self.db.get(self._keyPlaylistName(tag), b'').decode()
shuffle = self.db.get(self._keyPlaylistShuffle(tag), self.SHUFFLE_NO) 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, 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 Create and save a playlist for 'tag' and return the Playlist object. If a playlist already existed for 'tag' it
is overwritten. is overwritten.
""" """
assert persist in (self.PERSIST_NO, self.PERSIST_TRACK, self.PERSIST_OFFSET) assert persist in (self.PERSIST_NO, self.PERSIST_TRACK, self.PERSIST_OFFSET)
assert shuffle in (self.SHUFFLE_NO, self.SHUFFLE_YES) 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) return self.getPlaylistForTag(tag)
def deletePlaylistForTag(self, tag: bytes): def deletePlaylistForTag(self, tag: bytes):
@@ -370,6 +380,14 @@ class BTreeDB(IPlaylistDB):
_ = int(val) _ = int(val)
except ValueError: except ValueError:
fail(f' Bad playlistposoffset value for {last_tag}: {val!r}') 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: else:
fail(f'Unknown key {k!r}') fail(f'Unknown key {k!r}')
return result return result

View File

@@ -4,11 +4,15 @@ Copyright (c) 2024-2025 Stefan Kratochwil <Kratochwil-LA@gmx.de>
''' '''
import asyncio import asyncio
import board
import errno
import hwconfig import hwconfig
import json import json
import machine import machine
import network
import os import os
import time import time
import ubinascii
from array import array from array import array
from microdot import Microdot, redirect, send_file, Request 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_): def start_webserver(config_, app_):
global server, config, app, nfc, playlist_db, leds, timer_manager 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_ config = config_
app = app_ app = app_
nfc = app.get_nfc() nfc = app.get_nfc()
@@ -109,7 +113,7 @@ async def static(request, path):
@webapp.route('/api/v1/playlists', methods=['GET']) @webapp.route('/api/v1/playlists', methods=['GET'])
async def playlists_get(request): async def playlists_get(request):
return sorted(playlist_db.getPlaylistTags()) return playlist_db.getPlaylists()
def is_hex(s): def is_hex(s):
@@ -130,10 +134,11 @@ async def playlist_get(request, tag):
return None, 404 return None, 404
return { return {
'shuffle': playlist.__dict__.get('shuffle'), 'shuffle': playlist.shuffle,
'persist': playlist.__dict__.get('persist'), 'persist': playlist.persist,
'paths': [(p[len(fsroot):] if p.startswith(fsroot) else p).decode() 'paths': [(p[len(fsroot):] if p.startswith(fsroot) else p).decode()
for p in playlist.getPaths()], for p in playlist.getPaths()],
'name': playlist.name
} }
@@ -153,7 +158,8 @@ async def playlist_put(request, tag):
playlist_db.createPlaylistForTag(tag.encode(), playlist_db.createPlaylistForTag(tag.encode(),
(fsroot + path.encode() for path in playlist.get('paths', [])), (fsroot + path.encode() for path in playlist.get('paths', [])),
playlist.get('persist', 'track').encode(), playlist.get('persist', 'track').encode(),
playlist.get('shuffle', 'no').encode()) playlist.get('shuffle', 'no').encode(),
playlist.get('name', ''))
return '', 204 return '', 204
@@ -197,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']:
@@ -213,20 +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)
bytes_read = await request.stream.readinto(data) else:
if bytes_read == 0: bytes_copied = newfile.write(request.body)
# End of body except OSError as ex:
break return f'error writing data to file: {ex}', 500
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
if bytes_copied == length: if bytes_copied == length:
return '', 204 return '', 204
else: else:
@@ -258,10 +276,9 @@ async def audiofile_delete(request):
@webapp.route('/api/v1/reboot/<method>', methods=['POST']) @webapp.route('/api/v1/reboot/<method>', methods=['POST'])
async def reboot(request, method): async def reboot(request, method):
if hwconfig.get_on_battery():
return 'not allowed: usb not connected', 403
if method == 'bootloader': if method == 'bootloader':
if hwconfig.get_on_battery():
return 'not possible: connect USB first', 403
leds.set_state(LedManager.REBOOTING) leds.set_state(LedManager.REBOOTING)
timer_manager.schedule(time.ticks_ms() + 1500, machine.bootloader) timer_manager.schedule(time.ticks_ms() + 1500, machine.bootloader)
elif method == 'application': elif method == 'application':
@@ -270,3 +287,10 @@ async def reboot(request, method):
else: else:
return 'method not supported', 400 return 'method not supported', 400
return '', 204 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): def get_tagmode(self):
return 'tagremains' return 'tagremains'
def get_volume_max(self):
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)
@@ -167,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):