2 Commits

Author SHA1 Message Date
44434683e5 ci: Fix build.yaml for changed output dir
All checks were successful
Build RPi Pico firmware image / Build-Firmware (push) Successful in 4m39s
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 6s
Run unit tests on host / Run-Unit-Tests (push) Successful in 8s
Run pytests / Check-Pytest (push) Successful in 11s
Signed-off-by: Matthias Blankertz <matthias@blankertz.org>
2025-11-28 17:59:13 +01:00
24fa935d6a feat: freeze all python code, restructure build
Add all python code run on the device to the manifest.py to freeze
it. This has two benefits:
- It precompiles the bytecode, so it is smaller and faster
- All code (C and python) is now in the firmware image, leaving the
  littlefs filesystem on the flash free for user settings.

This requires changing the way the hardware variants are handled. There
is now only one _filesystem_ image, but instead there are different
_firmware_ images for each variant.

Signed-off-by: Matthias Blankertz <matthias@blankertz.org>
2025-11-28 17:59:13 +01:00
21 changed files with 108 additions and 1583 deletions

View File

@@ -11,10 +11,8 @@ jobs:
uses: actions/checkout@v4
- name: Initialize submodules
run: cd software && ./update-submodules.sh
- name: Prepare venv
run: python -m venv build-venv && source build-venv/bin/activate && pip install freezefs
- name: Build
run: source build-venv/bin/activate && cd software && ./build.sh
run: cd software && ./build.sh
- name: Upload firmware
uses: actions/upload-artifact@v3
with:

View File

@@ -19,8 +19,5 @@ module("rp2_neopixel.py", "../../modules")
module("main.py", "../../src")
module("app.py", "../../src")
module("mp3player.py", "../../src")
module("webserver.py", "../../src")
package("utils", base_path="../../src")
package("nfc", base_path="../../src")
module("frozen_frontend.py", "../../build")

View File

@@ -17,7 +17,6 @@ set -eu
)
BUILDDIR=lib/micropython/ports/rp2/build-TONBERRY_RPI_PICO_W/
BUILDDIR_UNIX=lib/micropython/ports/unix/build-tonberry_unix/
OUTDIR=$(pwd)/build
mkdir -p "$OUTDIR"
FS_STAGE_DIR=$(mktemp -d)
@@ -25,11 +24,6 @@ mkdir "$FS_STAGE_DIR"/fs
trap 'rm -rf $FS_STAGE_DIR' EXIT
tools/mklittlefs/mklittlefs -p 256 -s 868352 -c "$FS_STAGE_DIR"/fs "$FS_STAGE_DIR"/filesystem.bin
FRONTEND_STAGE_DIR=$(mktemp -d)
trap 'rm -rf $FRONTEND_STAGE_DIR' EXIT
gzip -c frontend/index.html > "$FRONTEND_STAGE_DIR"/index.html.gz
python -m freezefs "$FRONTEND_STAGE_DIR" build/frozen_frontend.py --target=/frontend
for hwconfig in boards/RPI_PICO_W/manifest-*.py; do
hwconfig_base=$(basename "$hwconfig")
hwname=${hwconfig_base##manifest-}
@@ -57,9 +51,5 @@ for hwconfig in boards/RPI_PICO_W/manifest-*.py; do
$PICOTOOL uf2 convert $BUILDDIR/firmware-filesystem.bin "$OUTDIR"/firmware-filesystem-"$hwname".uf2
done
cp "$BUILDDIR_UNIX"/micropython "$OUTDIR"/micropython-tonberry_unix
chmod u+x "$OUTDIR"/micropython-tonberry_unix
echo "Output in" "${OUTDIR}"/firmware-*.uf2
echo "Images with filesystem in" "${OUTDIR}"/firmware-filesystem-*.uf2
echo "Unix build in" "${OUTDIR}"/micropython-tonberry_unix

View File

@@ -2,8 +2,6 @@
set -eu
TOPDIR=$( cd -- "$( dirname -- "${BASH_SOURCE[0]}" )" &> /dev/null && pwd )
check_command()
{
name=$1
@@ -18,7 +16,7 @@ check_command lsusb
check_command picotool
DEVICEPATH=/dev/disk/by-label/RPI-RP2
IMAGEPATH=${TOPDIR}/build
IMAGEPATH=lib/micropython/ports/rp2/build-TONBERRY_RPI_PICO_W/
REVISION=Rev1
flash_via_mountpoint()
@@ -85,7 +83,7 @@ if [ $# -gt 0 ]; then
usage
fi
IMAGEFILE="$IMAGEPATH"/firmware-$REVISION.uf2
IMAGEFILE="$IMAGEPATH"/firmware-filesystem-$REVISION.uf2
if [ "$FLASH_VIA_MOUNTPOINT" -eq 0 ]; then
flash_via_picotool

View File

@@ -1,977 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Device Admin</title>
<style>
body {
font-family: Arial, sans-serif;
padding: 20px;
max-width: 700px;
margin: auto;
}
/* Navigation */
nav button {
margin: 5px;
padding: 10px 16px;
cursor: pointer;
}
/* Screens */
.screen {
display: none;
margin-top: 20px;
}
.screen.active {
display: block;
}
/* Config editor UI */
label {
font-weight: bold;
margin-top: 12px;
display: block;
}
input {
width: 100%;
padding: 6px;
box-sizing: border-box;
margin-top: 4px;
}
.nested {
margin-left: 20px;
border-left: 2px solid #ddd;
padding-left: 10px;
margin-top: 10px;
}
/* Tree view */
.tree ul {
list-style: none;
padding-left: 1rem;
}
.tree li {
margin: 2px 0;
}
.caret {
cursor: pointer;
display: inline-block;
width: 1em;
height: 1em;
user-select: none;
}
.caret::before {
content: "▶";
opacity: 0.6;
}
li.expanded > .caret::before {
content: "▼";
}
li:not(:has(ul)) > .caret::before {
content: "";
}
.node {
cursor: pointer;
padding: 2px 6px;
border-radius: 4px;
user-select: none;
}
.node:hover {
background: #eee;
}
.node.selected {
background: #0078d7;
color: white;
}
.tree ul ul {
display: none;
}
li.expanded > ul {
display: block;
}
.scroll-container {
border: 1px solid #ccc;
border-radius: 4px;
height: 500px;
overflow-y: auto;
overflow-x: hidden;
}
.flex-horizontal {
display:flex;
flex-direction: row;
justify-content: space-between;
align-items: center;
gap: 10px;
margin-bottom: 10px;
}
.flex-vertical {
display:flex;
flex-direction: column;
justify-content: space-between;
align-items: center;
gap: 10px;
}
.list {
list-style: none;
}
</style>
</head>
<body>
<h1>Device Admin</h1>
<nav>
<button onclick="showScreen('menu')">🏠 Main Menu</button>
<button onclick="showScreen('config')">⚙️ Config Editor</button>
<button onclick="showScreen('playlist')">🖹 Playlist Editor</button>
</nav>
<!-- MAIN MENU -->
<div id="screen-menu" class="screen active">
<h2>Main Menu</h2>
<p>Select a tool:</p>
<ul>
<li><button onclick="showScreen('config')">Open Config Editor</button></li>
<li><button onclick="showScreen('playlist')">Open Playlist Editor</button></li>
<!-- More screens can be added later -->
</ul>
</div>
<!-- CONFIG EDITOR SCREEN -->
<div id="screen-config" class="screen">
<h2>Configuration Editor</h2>
<div id="config-container">Loading…</div>
<button id="config-save-btn" disabled>Save</button>
</div>
<!-- PLAYLIST EDITOR SCREEN 1: list of playlists -->
<div id="screen-playlist" class="screen">
<h2>Playlist Editor</h2>
<div id="playlist-container">
<div id="playlist-new">
<label>New playlist</label>
<div class="flex-horizontal">
<input id="playlist-new-tag" type="text" placeholder="Tag ID" pattern="[0-9a-fA-F]+" style="max-width: 70%" />
<button id="playlist-new-button-gettag">Get last Tag</button>
<button id="playlist-new-button-new">New playlist</button>
</div>
</div>
<hr />
<div id="playlist-existing">
<label>Playlists</label>
<div class="scroll-container" style="margin-bottom: 10px">
<ul class="list" id="playlist-exist-list">
<li>Loading...</li>
</ul>
</div>
<div class="flex-horizontal">
<button id="playlist-exist-button-delete" style="background-color: orangered">Delete Playlist</button>
<button id="playlist-exist-button-gettag">Select last Tag</button>
<button id="playlist-exist-button-edit">Edit Playlist</button>
</div>
</div>
</div>
</div>
<!-- PLAYLIST EDITOR SCREEN 2: playlist tracks -->
<div id="screen-playlist_edit" class="screen">
<h2>Playlist Editor</h2>
<div id="playlist-edit-container">
<div id="playlist-edit" style="gap: 10px">
<div>
<label>Playlist type</label>
<select id="playlist-edit-type">
<option value="album">Album (no shuffle, always start at beginning)</option>
<option value="audiobook">Audiobook (no shuffle, start at previous position)</option>
<option value="random">Random Collection (shuffle)</option>
<option value="audioplay">Audioplay (no shuffle, start at previous track)</option>
</select>
</div>
<div class="flex-horizontal">
<div style="flex-grow: 1">
<label>Tracks</label>
<div class="scroll-container">
<ul class="list" id="playlist-edit-list">
<li>Loading...</li>
</ul>
</div>
</div>
<div class="flex-vertical">
<button id="playlist-edit-trackmove-up"></button>
<button id="playlist-edit-trackmove-down"></button>
</div>
</div>
<div class="flex-horizontal">
<button id="playlist-edit-back">Cancel</button>
<button id="playlist-edit-removetrack">Remove track(s)</button>
<button id="playlist-edit-addtrack">Add track(s)</button>
<button id="playlist-edit-save">Save</button>
</div>
</div>
</div>
</div>
<!-- PLAYLIST EDITOR SCREEN 3: file browser -->
<div id="screen-playlist_filebrowser" class="screen">
<h2>Playlist Editor</h2>
<div id="playlist-filebrowser-container">
<div class="scroll-container">
<div class="tree" id="playlist-filebrowser-tree">
<ul><li>Loading...</li></ul>
</div>
</div>
<div class="flex-horizontal">
<button id="playlist-filebrowser-cancel">Cancel</button>
<button id="playlist-filebrowser-delete" style="background-color: orangered">Delete selected</button>
<button id="playlist-filebrowser-addtrack">Add track(s)</button>
</div>
<hr>
<div class="flex-horizontal">
<input type="file" id="playlist-filebrowser-upload-files" multiple accept="audio/mpeg" />
<progress id="playlist-filebrowser-upload-progress" max="100" value="0" style:"flex-grow: 1">0%</progress>
<button id="playlist-filebrowser-upload">Upload</button>
</div>
<div class="flex-horizontal">
<input type="text" id="playlist-filebrowser-mkdir-name" placeholder="Directory Name" />
<button id="playlist-filebrowser-mkdir" style="width: 20%">Create directory</button>
</div>
</div>
</div>
<script>
const Screens = {};
let activeScreen = null;
/* -----------------------------
Screen switching
----------------------------- */
function showScreen(name, arg=null) {
document.querySelectorAll('.screen').forEach(s => s.classList.remove('active'));
document.getElementById('screen-' + name).classList.add('active');
activeScreen = name;
Screens[name]?.onShow?.(arg);
}
/* -----------------------------
CONFIG EDITOR LOGIC
----------------------------- */
Screens.config = (() => {
let screenroot = null;
function init() {
screenroot = document.getElementById('screen-config');
document.getElementById('config-save-btn').addEventListener('click', async () => {
const res = await fetch('/api/v1/config');
const original = await res.json();
const updated = serialize(original);
try {
const saveRes = await fetch('/api/v1/config', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(updated, null, 2)
});
if (!saveRes.ok) {
alert("Failed to save config: " + await saveRes.text());
return;
}
alert("Configuration saved successfully!");
} catch (err) {
alert("Error saving configuration: " + err);
}
});
}
async function onShow() {
const container = document.getElementById('config-container');
container.innerHTML = "Loading…";
document.getElementById('config-save-btn').disabled = true;
try {
const res = await fetch('/api/v1/config');
const config = await res.json();
render(config);
} catch (err) {
container.innerHTML = "Failed to load config: " + err;
}
}
function render(config) {
const container = document.getElementById('config-container');
container.innerHTML = "";
container.appendChild(renderObject(config, "root"));
document.getElementById('config-save-btn').disabled = false;
}
const config_names = {
'root.IDLE_TIMEOUT_SECS': 'Idle Timeout (seconds)',
'root.BUTTON_MAP': 'Button map',
'root.BUTTON_MAP.NEXT': 'Next track',
'root.BUTTON_MAP.PREV': 'Previous track',
'root.BUTTON_MAP.VOLUP': 'Volume up',
'root.BUTTON_MAP.VOLDOWN': 'Volume down',
'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'
};
const config_input_override = {
'root.TAGMODE': {
'element': 'select',
'values': {
'tagremains': 'Play until tag is removed',
'tagstartstop': 'Present tag once to start, present again to stop playback'
}
},
'root.IDLE_TIMEOUT_SECS': {
'input-type': 'number'
},
'root.TAG_TIMEOUT_SECS': {
'input-type': 'number'
},
'root.LED_COUNT': {
'input-type': 'number'
},
};
function renderObject(obj, path) {
const wrapper = document.createElement('div');
Object.entries(obj).forEach(([key, value]) => {
const currentPath = path + '.' + key;
const label = document.createElement('label');
if (currentPath in config_names) {
label.textContent = config_names[currentPath];
} else {
label.textContent = key;
}
if (value !== null && typeof value === 'object') {
wrapper.appendChild(label);
const nested = document.createElement('div');
nested.className = "nested";
nested.appendChild(renderObject(value, currentPath));
wrapper.appendChild(nested);
} else {
wrapper.appendChild(label);
if (currentPath in config_input_override && 'element' in config_input_override[currentPath]) {
const override = config_input_override[currentPath];
if (override['element'] === 'select') {
const input = document.createElement('select');
input.dataset.path = currentPath;
for (const val in override.values) {
const option = document.createElement('option');
option.value = val;
option.textContent = override.values[val];
if (val === value) {
option.selected = true;
}
input.appendChild(option);
}
wrapper.appendChild(input);
}
} else {
const input = document.createElement('input');
if (currentPath in config_input_override && 'input-type' in config_input_override[currentPath]) {
input.type = config_input_override[currentPath]['input-type'];
}
input.value = value === null ? "" : value;
input.dataset.path = currentPath;
wrapper.appendChild(input);
}
}
});
return wrapper;
}
function serialize(rootObj) {
const inputs = screenroot.querySelectorAll("input[data-path], select[data-path]");
inputs.forEach(input => {
const path = input.dataset.path.split('.').slice(1); // remove "root"
let current = rootObj;
for (let i = 0; i < path.length - 1; i++) {
current = current[path[i]];
}
let val = input.value.trim();
if (val === "") val = null;
else if (!isNaN(val)) val = Number(val);
current[path[path.length - 1]] = val;
});
return rootObj;
}
return { init, onShow };
})();
/* ----------------------------------------
PLAYLIST EDITOR LOGIC - SELECTION SCREEN
------------------------------------------- */
Screens.playlist = (() => {
let lastSelected = null;
function init() {
//tree.init();
document.getElementById('playlist-new-button-gettag')
.addEventListener('click', updateLastTag);
document.getElementById('playlist-new-button-new')
.addEventListener('click', (e) => {
const tagid = document.getElementById('playlist-new-tag').value;
if (/^[0-9a-fA-F]+$/.exec(tagid)) {
showScreen("playlist_edit", {create: tagid});
} else {
alert("Invalid tag id");
}
});
document.getElementById('playlist-exist-button-delete')
.addEventListener('click', (e) => {
if (lastSelected === null) return;
const tagid = lastSelected.innerText;
if(confirm(`Really delete playlist ${tagid}?`)) {
fetch(`/api/v1/playlist/${tagid}`, {
method: 'DELETE'})
.then(() => showScreen('playlist'));
}
});
document.getElementById('playlist-exist-button-gettag')
.addEventListener('click', selectLastTag);
document.getElementById('playlist-exist-button-edit').addEventListener("click", (e) => {
if (lastSelected !== null)
showScreen("playlist_edit", {load: lastSelected.innerText});
});
document.getElementById('playlist-exist-list').addEventListener("click", (e) => {
const node = e.target.closest("li");
if (!node) return;
document.getElementById('playlist-exist-list')
.querySelectorAll(".selected")
.forEach(n => n.classList.remove("selected")
);
node.classList.toggle("selected");
lastSelected = node;
});
}
async function onShow() {
const container = document.getElementById('playlist-exist-list');
container.innerHTML = "<li>Loading…</li>";
//document.getElementById('playlist-save-btn').disabled = true;
fetch('/api/v1/playlists')
.then((res) => res.json())
.then((playlists) => renderPlaylistForm(playlists))
.catch((err) => {
container.innerHTML = "<li>Failed to load playlists: " + err + "</li>";
});
fetch('/api/v1/last_tag_uid')
.then((res) => res.json())
.then((tag) => {
if (tag.tag !== null) {
fillLastTag(tag);
selectTag(tag);
}
});
}
async function updateLastTag() {
fetch('/api/v1/last_tag_uid')
.then((res) => res.json())
.then((tag) => fillLastTag(tag));
}
async function selectLastTag() {
fetch('/api/v1/last_tag_uid')
.then((res) => res.json())
.then((tag) => selectTag(tag));
}
function renderPlaylistForm(playlists) {
const container = document.getElementById('playlist-exist-list');
container.innerHTML = "";
for (const playlist of playlists) {
const li = document.createElement("li");
li.innerHTML = playlist;
li.className = "node"
container.appendChild(li)
}
}
function bytesToHex(bytes) {
return bytes
.map(b => b.toString(16).padStart(2, '0'))
.join('');
}
function fillLastTag(tag) {
const container = document.getElementById('playlist-new-tag');
if (tag.tag !== null) {
container.value = bytesToHex(tag.tag);
}
}
function selectTag(tag) {
const container = document.getElementById('playlist-exist-list');
if (tag.tag === null) return;
const tagtext = bytesToHex(tag.tag);
document.getElementById('playlist-exist-list')
.querySelectorAll("li")
.forEach(n => {
if (n.innerText == tagtext) {
n.classList.add("selected");
lastSelected = n;
} else {
n.classList.remove("selected");
}
});
}
return { init, onShow };
})();
/* -----------------------------------
PLAYLIST EDITOR LOGIC - EDIT SCREEN
-------------------------------------- */
Screens.playlist_edit = (() => {
let lastSelected = null;
let playlistId = null;
function init() {
document.getElementById('playlist-edit-list').addEventListener("click", (e) => {
const node = e.target.closest("li");
if (!node) return;
if (e.ctrlKey || e.metaKey) {
node.classList.toggle("selected");
} else {
clearSelection();
node.classList.add("selected");
}
lastSelected = node;
});
document.getElementById('playlist-edit-trackmove-up').addEventListener("click", (e) => moveSelectedTracks(true));
document.getElementById('playlist-edit-trackmove-down').addEventListener("click", (e) => moveSelectedTracks(false));
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("playlist_filebrowser");
});
document.getElementById('playlist-edit-save').addEventListener("click", (e) => save());
}
function clearSelection() {
document.getElementById('playlist-edit-list')
.querySelectorAll(".selected")
.forEach(n => n.classList.remove("selected"));
}
async function onShow(intent) {
const container = document.getElementById('playlist-edit-list');
if ('load' in intent) {
const playlist = intent.load;
document.getElementById('playlist-edit-save').disabled = true;
container.innerHTML = "<li>Loading…</li>";
fetch(`/api/v1/playlist/${playlist}`)
.then((res) => res.json())
.then((playlistRes) => {
playlistId = playlist;
renderPlaylistForm(playlistRes);
document.getElementById('playlist-edit-save').disabled = false;
}).catch((err) => {
container.innerHTML = "<li>Failed to load playlist: " + err + "</li>";
});
} else if ('create' in intent) {
const container = document.getElementById('playlist-edit-list');
container.innerHTML = "";
playlistId = intent.create;
const playlisttype = document.getElementById('playlist-edit-type');
playlisttype.value = "album";
document.getElementById('playlist-edit-save').disabled = false;
} else if ('addtracks' in intent) {
const container = document.getElementById('playlist-edit-list');
intent.addtracks.forEach((track) => {
const li = document.createElement("li");
li.innerHTML = track;
li.className = "node"
container.appendChild(li)
});
}
}
function renderPlaylistForm(playlist) {
const container = document.getElementById('playlist-edit-list');
container.innerHTML = "";
for (const track of playlist.paths) {
const li = document.createElement("li");
li.innerHTML = track;
li.className = "node"
container.appendChild(li)
}
const playlisttype = document.getElementById('playlist-edit-type');
if (playlist.persist === "no" && playlist.shuffle === "no") {
playlisttype.value = "album";
} else if (playlist.persist === "offset" && playlist.shuffle === "no") {
playlisttype.value = "audiobook";
} else if (playlist.persist === "no" && playlist.shuffle === "yes") {
playlisttype.value = "random";
} else if (playlist.persist === "track" && playlist.shuffle === "no") {
playlisttype.value = "audioplay";
}
}
async function save() {
const playlistData = {};
switch(document.getElementById('playlist-edit-type').value) {
case "album":
playlistData.persist = "no";
playlistData.shuffle = "no";
break;
case "audiobook":
playlistData.persist = "offset";
playlistData.shuffle = "no";
break;
case "random":
playlistData.persist = "no";
playlistData.shuffle = "yes";
break;
case "audioplay":
playlistData.persist = "track";
playlistData.shuffle = "no";
break;
}
const container = document.getElementById('playlist-edit-list');
playlistData.paths = [...container.querySelectorAll("li")].map((node) => node.innerText);
const saveRes = await fetch(`/api/v1/playlist/${playlistId}`,
{
method: 'PUT',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(playlistData)});
if (!saveRes.ok) {
alert("Failed to save playlist: " + await saveRes.text());
return;
}
showScreen('playlist');
}
function moveSelectedTracks(up) {
const container = document.getElementById('playlist-edit-list');
let selected_nodes = [...container.querySelectorAll(".selected")];
if (!up) {
selected_nodes = selected_nodes.reverse()
}
for (node of selected_nodes) {
const sibling = up ? node.previousElementSibling : node.nextElementSibling;
if (sibling === null ||
sibling.classList.contains("selected")) continue;
container.insertBefore(node, up ? sibling : sibling.nextElementSibling);
}
}
function deleteSelectedTracks() {
const container = document.getElementById('playlist-edit-list');
container.querySelectorAll(".selected").forEach((node) => node.remove());
}
return { init, onShow };
})();
/* ----------------------------------------
PLAYLIST EDITOR LOGIC - ADD FILES SCREEN
------------------------------------------- */
Screens.playlist_filebrowser = (() => {
function init() {
document.getElementById('playlist-filebrowser-cancel').addEventListener("click", (e) => {
showScreen("playlist_edit", {});
});
document.getElementById('playlist-filebrowser-addtrack').addEventListener("click", (e) => {
returnSelectedTracks();
});
document.getElementById('playlist-filebrowser-upload').addEventListener("click", (e) => {
uploadFiles();
});
document.getElementById('playlist-filebrowser-mkdir').addEventListener("click", (e) => {
createDirectory();
});
document.getElementById('playlist-filebrowser-delete').addEventListener("click", (e) => {
deleteItems();
});
tree.init();
}
async function onShow(intent) {
document.getElementById('playlist-filebrowser-addtrack').disabled = true;
tree = document.getElementById("playlist-filebrowser-tree");
tree.innerHTML = "Loading...";
fetch('/api/v1/audiofiles')
.then((res) => res.json())
.then((files) => renderFilebrowserForm(files))
.catch((err) => {
alert("Failed to load file list: " + err);
});
}
function findChildByName(node, name) {
for (const child of node.querySelectorAll(':scope > li')) {
const childLabel = child.querySelector(':scope > span.node');
if (childLabel.innerText === name)
return child.querySelector(':scope > ul');
}
return null;
}
function addTreeNode(parent, name, type, data) {
const node = document.createElement('li');
const caret = document.createElement('span');
const label = document.createElement('span');
caret.className = 'caret';
label.className = 'node';
label.innerText = name;
node.appendChild(caret);
node.appendChild(label);
if (data !== null)
label.setAttribute('data-path', data);
label.setAttribute('data-type', type);
if (type === 'directory') {
const nested = document.createElement('ul');
node.appendChild(nested);
node.classList.add('expanded');
parent.appendChild(node);
return nested;
}
parent.appendChild(node);
return null;
}
function renderFilebrowserForm(files) {
const tree = document.getElementById("playlist-filebrowser-tree");
tree.innerHTML = "";
const rootnode = document.createElement('ul');
tree.appendChild(rootnode);
files.sort((a, b) => a.name < b.name ? -1 : (a.name > b.name ? 1 : 0));
for (const file of files) {
const path = file.name.split('/').slice(1);
let treeIterator = rootnode;
let curPath = ''
for (const elem of path) {
curPath += '/' + elem;
let node = findChildByName(treeIterator, elem);
if (node === null) {
node = addTreeNode(treeIterator, elem, file.type, curPath);
}
treeIterator = node;
}
}
document.getElementById('playlist-filebrowser-addtrack').disabled = false;
}
function returnSelectedTracks() {
const tree = document.getElementById("playlist-filebrowser-tree");
const selectedNodes = [...tree.querySelectorAll(".selected")];
const tracks = [];
for (const node of selectedNodes) {
if (node.getAttribute('data-type') === "file") {
tracks.push(node.getAttribute('data-path'));
} else {
// recursivly add directory contents
for (const child of node.parentNode.querySelector('ul').querySelectorAll("span.node")) {
if (child.getAttribute('data-type') === "file")
tracks.push(child.getAttribute('data-path'));
}
}
}
showScreen("playlist_edit", {addtracks: tracks});
}
function uploadFiles() {
const tree = document.getElementById("playlist-filebrowser-tree");
const selectedNodes = [...tree.querySelectorAll(".selected")];
const files = [...document.getElementById("playlist-filebrowser-upload-files").files];
if (selectedNodes.length !== 1 ||
selectedNodes[0].getAttribute('data-type') !== "directory") {
alert("Please select a single directory for upload");
return;
}
uploadFileHelper(files.length, 0, selectedNodes[0].getAttribute('data-path'), files);
}
function uploadFileHelper(totalcount, donecount, destdir, files) {
// Upload files sequentially by recursivly calling this function from the 'load' callback when
// the previous upload is completed.
if (files.length === 0)
return;
const reader = new FileReader();
const ctrl = document.getElementById("playlist-filebrowser-upload-progress");
const xhr = new XMLHttpRequest();
const location = destdir + '/' + files[0].name;
xhr.upload.addEventListener("progress", (e) => {
if (e.lengthComputable) {
const percentage = Math.round((e.loaded * 100) / e.total / totalcount + donecount * 100 / totalcount);
ctrl.value = percentage;
}
});
//xhr.upload.addEventListener("load", (e) => {
xhr.onreadystatechange = () => {
if (xhr.readyState === XMLHttpRequest.DONE) {
if (xhr.status !== 204) {
alert(`File upload failed: ${xhr.responseText} (${xhr.status})`);
return;
}
if (donecount + 1 === totalcount) {
// Reload file list from device
onShow();
} else {
uploadFileHelper(totalcount, donecount + 1, destdir, files.slice(1));
}
}
};
xhr.open("POST", `/api/v1/audiofiles?type=file&location=${location}`);
xhr.overrideMimeType("audio/mpeg");
xhr.send(files[0]);
}
async function createDirectory() {
const name = document.getElementById('playlist-filebrowser-mkdir-name');
const selectedNodes = [...tree.querySelectorAll(".selected")];
const files = [...document.getElementById("playlist-filebrowser-upload-files").files];
if (selectedNodes.length > 1 ||
(selectedNodes.length === 1 &&
selectedNodes[0].getAttribute('data-type') !== "directory")) {
alert("Please select a single directory for upload");
return;
}
const location = selectedNodes.length === 1
? selectedNodes[0].getAttribute('data-path') + '/' + name.value
: '/' + name.value;
const saveRes = await fetch(`/api/v1/audiofiles?type=directory&location=${location}`,
{method: 'POST'});
// Reload file list from device
onShow();
}
async function deleteItems() {
const tree = document.getElementById("playlist-filebrowser-tree");
const selectedNodes = [...tree.querySelectorAll(".selected")];
if (selectedNodes.length === 0) {
alert("Please select something to delete");
return;
}
const items = selectedNodes.map(n => n.getAttribute('data-path'));
items.sort();
items.reverse();
for (const item of items) {
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();
}
let tree = (() => {
let tree = null;
function init() {
tree = document.getElementById("playlist-filebrowser-tree");
tree.addEventListener("click", (e) => {
// CARET CLICK → expand/collapse
const caret = e.target.closest(".caret");
if (caret) {
const li = caret.parentElement;
if (li.querySelector("ul")) {
li.classList.toggle("expanded");
}
return; // IMPORTANT: don't affect selection
}
// NODE LABEL CLICK → selection only
const node = e.target.closest(".node");
if (!node) return;
if (e.shiftKey && lastSelected) {
selectRange(lastSelected, node);
} else if (e.ctrlKey || e.metaKey) {
node.classList.toggle("selected");
} else {
clearSelection();
node.classList.add("selected");
}
lastSelected = node;
});
}
function clearSelection() {
tree.querySelectorAll(".selected").forEach(n =>
n.classList.remove("selected")
);
}
function selectRange(start, end) {
const nodes = [...tree.querySelectorAll(".node")];
const startIndex = nodes.indexOf(start);
const endIndex = nodes.indexOf(end);
const [from, to] = startIndex < endIndex
? [startIndex, endIndex]
: [endIndex, startIndex];
clearSelection();
nodes.slice(from, to + 1).forEach(n =>
n.classList.add("selected")
);
}
return { init };
})();
return { init, onShow };
})();
// Initialization
Object.values(Screens).forEach(screen => {
screen.init?.();
});
showScreen("menu");
</script>
</body>
</html>

View File

@@ -1 +0,0 @@
freezefs

View File

@@ -6,8 +6,7 @@ import time
from utils import TimerManager
Dependencies = namedtuple('Dependencies', ('mp3player', 'nfcreader', 'buttons', 'playlistdb', 'hwconfig', 'leds',
'config'))
Dependencies = namedtuple('Dependencies', ('mp3player', 'nfcreader', 'buttons', 'playlistdb', 'hwconfig', 'leds'))
# Should be ~ 6dB steps
VOLUME_CURVE = [1, 2, 4, 8, 16, 32, 63, 126, 251]
@@ -15,12 +14,11 @@ VOLUME_CURVE = [1, 2, 4, 8, 16, 32, 63, 126, 251]
class PlayerApp:
class TagStateMachine:
def __init__(self, parent, timer_manager, timeout=5000):
def __init__(self, parent, timer_manager):
self.parent = parent
self.timer_manager = timer_manager
self.current_tag = None
self.current_tag_time = time.ticks_ms()
self.timeout = timeout
def onTagChange(self, new_tag):
if new_tag is not None:
@@ -33,7 +31,7 @@ class PlayerApp:
self.current_tag = new_tag
self.parent.onNewTag(new_tag)
else:
self.timer_manager.schedule(time.ticks_ms() + self.timeout, self.onTagRemoveDelay)
self.timer_manager.schedule(time.ticks_ms() + 5000, self.onTagRemoveDelay)
def onTagRemoveDelay(self):
if self.current_tag is not None:
@@ -42,23 +40,18 @@ class PlayerApp:
def __init__(self, deps: Dependencies):
self.timer_manager = TimerManager()
self.config = deps.config(self)
self.tag_timeout_ms = self.config.get_tag_timeout() * 1000
self.idle_timeout_ms = self.config.get_idle_timeout() * 1000
self.tag_state_machine = self.TagStateMachine(self, self.timer_manager, self.tag_timeout_ms)
self.tag_state_machine = self.TagStateMachine(self, self.timer_manager)
self.player = deps.mp3player(self)
self.nfc = deps.nfcreader(self.tag_state_machine)
self.playlist_db = deps.playlistdb(self)
self.hwconfig = deps.hwconfig(self)
self.leds = deps.leds(self)
self.tag_mode = self.config.get_tagmode()
self.tag_mode = self.playlist_db.getSetting('tagmode')
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])
self._onIdle()
@@ -98,12 +91,11 @@ class PlayerApp:
self.player.set_volume(VOLUME_CURVE[self.volume_pos])
elif what == self.buttons.NEXT:
self._play_next()
elif what == self.buttons.PREV:
self._play_prev()
elif what == self.buttons.PLAY_PAUSE:
self._pause_toggle()
def onPlaybackDone(self):
assert self.mp3file is not None
self.mp3file.close()
self.mp3file = None
self._play_next()
def onIdleTimeout(self):
@@ -111,14 +103,7 @@ class PlayerApp:
self.hwconfig.power_off()
else:
# Check again in a minute
self.timer_manager.schedule(time.ticks_ms() + self.idle_timeout_ms, self.onIdleTimeout)
def reset_idle_timeout(self):
if not self.playing:
self.timer_manager.schedule(time.ticks_ms() + self.idle_timeout_ms, self.onIdleTimeout)
def is_playing(self) -> bool:
return self.playing
self.timer_manager.schedule(time.ticks_ms() + 60*1000, self.onIdleTimeout)
def _set_playlist(self, tag: bytes):
if self.playlist is not None:
@@ -138,16 +123,9 @@ class PlayerApp:
self.playlist = None
def _play_next(self):
filename = self.playlist.getNextPath() if self.playlist is not None else None
self._play(filename)
if filename is None:
self.playlist = None
self.playing_tag = None
def _play_prev(self):
if self.playlist is None:
return
filename = self.playlist.getPrevPath()
filename = self.playlist.getNextPath()
self._play(filename)
if filename is None:
self.playlist = None
@@ -161,43 +139,14 @@ class PlayerApp:
self._onIdle()
if filename is not None:
print(f'Playing {filename!r}')
try:
self.mp3file = open(filename, 'rb')
except OSError as ex:
print(f"Could not play file {filename}: {ex}")
return
self.mp3file = open(filename, 'rb')
self.player.play(self.mp3file, offset)
self.paused = False
self._onActive()
def _pause_toggle(self):
if self.playlist is None:
return
if self.paused:
self._play(self.playlist.getCurrentPath(), self.pause_offset)
else:
self.pause_offset = self.player.stop()
self.paused = True
self._onIdle()
def _onIdle(self):
self.timer_manager.schedule(time.ticks_ms() + self.idle_timeout_ms, self.onIdleTimeout)
self.timer_manager.schedule(time.ticks_ms() + 60*1000, self.onIdleTimeout)
self.leds.set_state(self.leds.IDLE)
self.playing = False
def _onActive(self):
self.timer_manager.cancel(self.onIdleTimeout)
self.leds.set_state(self.leds.PLAYING)
self.playing = True
def get_nfc(self):
return self.nfc
def get_playlist_db(self):
return self.playlist_db
def get_timer_manager(self):
return self.timer_manager
def get_leds(self):
return self.leds

View File

@@ -28,14 +28,13 @@ RC522_SS = Pin.board.GP13
# WS2812
LED_DIN = Pin.board.GP16
LED_COUNT = 1
# Buttons
BUTTONS = [Pin.board.GP17,
Pin.board.GP18,
Pin.board.GP19,
Pin.board.GP20,
Pin.board.GP21,
]
BUTTON_VOLUP = Pin.board.GP17
BUTTON_VOLDOWN = Pin.board.GP19
BUTTON_NEXT = Pin.board.GP18
BUTTON_POWER = Pin.board.GP21
# Power
POWER_EN = Pin.board.GP22

View File

@@ -27,12 +27,13 @@ RC522_SS = Pin.board.GP13
# WS2812
LED_DIN = Pin.board.GP16
LED_COUNT = 1
# Buttons
BUTTONS = [Pin.board.GP17,
Pin.board.GP18,
Pin.board.GP19,
]
BUTTON_VOLUP = Pin.board.GP17
BUTTON_VOLDOWN = Pin.board.GP19
BUTTON_NEXT = Pin.board.GP18
BUTTON_POWER = None
# Power
POWER_EN = None

View File

@@ -14,12 +14,11 @@ import ubinascii
# Own modules
import app
from audiocore import AudioContext
import frozen_frontend # noqa: F401
from mfrc522 import MFRC522
from mp3player import MP3Player
from nfc import Nfc
from rp2_neopixel import NeoPixel
from utils import BTreeFileManager, Buttons, SDContext, TimerManager, LedManager, Configuration
from utils import BTreeFileManager, Buttons, SDContext, TimerManager, LedManager
from webserver import start_webserver
try:
@@ -54,25 +53,17 @@ def setup_wifi():
print(f"ifconfig: {wlan.ifconfig()}")
async def wdt_task(wdt):
# TODO: more checking of app health
# Right now this only protects against the asyncio executor crashing completely
while True:
await asyncio.sleep_ms(100)
wdt.feed()
DB_PATH = '/sd/tonberry.db'
config = Configuration()
def run():
asyncio.new_event_loop()
# Setup LEDs
np = NeoPixel(hwconfig.LED_DIN, config.get_led_count(), sm=1)
np = NeoPixel(hwconfig.LED_DIN, hwconfig.LED_COUNT, sm=1)
# Wifi with default config
setup_wifi()
start_webserver()
# Setup MP3 player
with SDContext(mosi=hwconfig.SD_DI, miso=hwconfig.SD_DO, sck=hwconfig.SD_SCK, ss=hwconfig.SD_CS,
@@ -96,19 +87,17 @@ def run():
# Setup app
deps = app.Dependencies(mp3player=lambda the_app: MP3Player(audioctx, the_app),
nfcreader=lambda the_app: Nfc(reader, the_app),
buttons=lambda the_app: Buttons(the_app, config, hwconfig),
buttons=lambda the_app: Buttons(the_app, pin_volup=hwconfig.BUTTON_VOLUP,
pin_voldown=hwconfig.BUTTON_VOLDOWN,
pin_next=hwconfig.BUTTON_NEXT),
playlistdb=lambda _: playlistdb,
hwconfig=lambda _: hwconfig,
leds=lambda _: LedManager(np),
config=lambda _: config)
leds=lambda _: LedManager(np))
the_app = app.PlayerApp(deps)
start_webserver(config, the_app)
# Start
wdt = machine.WDT(timeout=2000)
asyncio.create_task(aiorepl.task({'timer_manager': TimerManager(),
'app': the_app}))
asyncio.create_task(wdt_task(wdt))
asyncio.get_event_loop().run_forever()
@@ -132,5 +121,5 @@ def builddb():
if __name__ == '__main__':
time.sleep(1)
if machine.Pin(hwconfig.BUTTONS[0], machine.Pin.IN, machine.Pin.PULL_UP).value() != 0:
if machine.Pin(hwconfig.BUTTON_VOLUP, machine.Pin.IN, machine.Pin.PULL_UP).value() != 0:
run()

View File

@@ -6,7 +6,6 @@ Copyright (c) 2025 Matthias Blankertz <matthias@blankertz.org>
import asyncio
import time
from utils import safe_callback
from mfrc522 import MFRC522
try:
@@ -75,7 +74,7 @@ class Nfc:
self.last_uid = uid
self.last_uid_timestamp = time.ticks_us()
if self.cb is not None and last_callback_uid != uid:
safe_callback(lambda: self.cb.onTagChange(uid), "nfc tag change")
self.cb.onTagChange(uid)
last_callback_uid = uid
await asyncio.sleep_ms(poll_interval_ms)

View File

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

View File

@@ -5,7 +5,6 @@ import asyncio
import machine
import micropython
import time
from utils import safe_callback
try:
from typing import TYPE_CHECKING # type: ignore
except ImportError:
@@ -18,27 +17,14 @@ if TYPE_CHECKING:
class Buttons:
VOLUP = micropython.const(1)
VOLDOWN = micropython.const(2)
NEXT = micropython.const(3)
PREV = micropython.const(4)
PLAY_PAUSE = micropython.const(5)
KEYMAP = {VOLUP: 'VOLUP',
VOLDOWN: 'VOLDOWN',
NEXT: 'NEXT',
PREV: 'PREV',
PLAY_PAUSE: 'PLAY_PAUSE'}
def __init__(self, cb: "ButtonCallback", config, hwconfig):
self.button_map = config.get_button_map()
self.hw_buttons = hwconfig.BUTTONS
def __init__(self, cb: "ButtonCallback", pin_volup=17, pin_voldown=19, pin_next=18):
self.VOLUP = micropython.const(1)
self.VOLDOWN = micropython.const(2)
self.NEXT = micropython.const(3)
self.cb = cb
self.buttons = dict()
for key_id, key_name in self.KEYMAP.items():
pin = self._get_pin(key_name)
if pin is None:
continue
self.buttons[pin] = key_id
self.buttons = {machine.Pin(pin_volup, machine.Pin.IN, machine.Pin.PULL_UP): self.VOLUP,
machine.Pin(pin_voldown, machine.Pin.IN, machine.Pin.PULL_UP): self.VOLDOWN,
machine.Pin(pin_next, machine.Pin.IN, machine.Pin.PULL_UP): self.NEXT}
self.int_flag = asyncio.ThreadSafeFlag()
self.pressed: list[int] = []
self.last: dict[int, int] = {}
@@ -46,17 +32,6 @@ class Buttons:
button.irq(handler=self._interrupt, trigger=machine.Pin.IRQ_FALLING | machine.Pin.IRQ_RISING)
asyncio.create_task(self.task())
def _get_pin(self, key):
key_id = self.button_map.get(key, None)
if key_id is None:
return None
if key_id < 0 or key_id >= len(self.hw_buttons):
return None
pin = self.hw_buttons[key_id]
if pin is not None:
pin.init(machine.Pin.IN, machine.Pin.PULL_UP)
return pin
def _interrupt(self, button):
keycode = self.buttons[button]
last = self.last.get(keycode, 0)
@@ -75,4 +50,4 @@ class Buttons:
await self.int_flag.wait()
while len(self.pressed) > 0:
what = self.pressed.pop()
safe_callback(lambda: self.cb.onButtonPressed(what), "button callback")
self.cb.onButtonPressed(what)

View File

@@ -1,109 +0,0 @@
# SPDX-License-Identifier: MIT
# Copyright (c) 2025 Matthias Blankertz <matthias@blankertz.org>
from errno import ENOENT
import json
import os
try:
from typing import TYPE_CHECKING, Mapping, Any
except ImportError:
TYPE_CHECKING = False
class Configuration:
DEFAULT_CONFIG = {
'LED_COUNT': 1,
'IDLE_TIMEOUT_SECS': 60,
'TAG_TIMEOUT_SECS': 5,
'BUTTON_MAP': {
'PLAY_PAUSE': 4,
'VOLUP': 0,
'VOLDOWN': 2,
'PREV': None,
'NEXT': 1,
},
'TAGMODE': 'tagremains'
}
def __init__(self, config_path='/config.json'):
self.config_path = config_path
try:
with open(self.config_path, 'r') as conf_file:
self.config = json.load(conf_file)
self._merge_configs(self.DEFAULT_CONFIG, self.config)
except OSError as ex:
if ex.errno == ENOENT:
self.config = Configuration.DEFAULT_CONFIG
self._save()
else:
raise
except ValueError as ex:
print(f"Warning: Could not load configuration {self.config_path}:\n{ex}")
self._move_config_to_backup()
self.config = Configuration.DEFAULT_CONFIG
def _move_config_to_backup(self):
# Remove old backup
try:
os.remove(self.config_path + '.bup')
os.rename(self.config_path, self.config_path + '.bup')
except OSError as ex:
if ex.errno != ENOENT:
raise
os.sync()
def _merge_configs(self, default, config):
for k in default.keys():
if k not in config:
if isinstance(default[k], dict):
config[k] = default[k].copy()
else:
config[k] = default[k]
elif isinstance(default[k], dict):
self._merge_configs(default[k], config[k])
def _save(self):
with open(self.config_path + '.new', 'w') as conf_file:
json.dump(self.config, conf_file)
self._move_config_to_backup()
os.rename(self.config_path + '.new', self.config_path)
os.sync()
def _get(self, key):
return self.config[key]
def get_led_count(self) -> int:
return self._get('LED_COUNT')
def get_idle_timeout(self) -> int:
return self._get('IDLE_TIMEOUT_SECS')
def get_tag_timeout(self) -> int:
return self._get('TAG_TIMEOUT_SECS')
def get_button_map(self) -> Mapping[str, int | None]:
return self._get('BUTTON_MAP')
def get_tagmode(self) -> str:
return self._get('TAGMODE')
# For the web API
def get_config(self) -> Mapping[str, Any]:
return self.config
def _validate(self, default, config, path=''):
for k in config.keys():
if k not in default:
raise ValueError(f'Invalid config key {path}/{k}')
if isinstance(default[k], dict):
if not isinstance(config[k], dict):
raise ValueError(f'Invalid config: Value of {path}/{k} must be mapping')
self._validate(default[k], config[k], f'{path}/{k}')
def set_config(self, config):
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'")
self._merge_configs(self.config, config)
self.config = config
self._save()

View File

@@ -1,12 +0,0 @@
# SPDX-License-Identifier: MIT
# Copyright (c) 2025 Matthias Blankertz <matthias@blankertz.org>
import sys
def safe_callback(func, name="callback"):
try:
func()
except Exception as ex:
print(f"Uncaught exception in {name}")
sys.print_exception(ex)

View File

@@ -10,7 +10,6 @@ import time
class LedManager:
IDLE = const(0)
PLAYING = const(1)
REBOOTING = const(2)
def __init__(self, np):
self.led_state = LedManager.IDLE
@@ -20,7 +19,7 @@ class LedManager:
asyncio.create_task(self.run())
def set_state(self, state):
assert state in [LedManager.IDLE, LedManager.PLAYING, LedManager.REBOOTING]
assert state in [LedManager.IDLE, LedManager.PLAYING]
self.led_state = state
def _gamma(self, value, X=2.2):
@@ -51,8 +50,6 @@ class LedManager:
self._pulse(time_, (0, 1, 0), 3)
elif self.led_state == LedManager.PLAYING:
self._rainbow(time_)
elif self.led_state == LedManager.REBOOTING:
self._pulse(time_, (1, 0, 1), 0.2)
time_ += 0.02
before = time.ticks_ms()
await self.np.async_write()

View File

@@ -31,6 +31,9 @@ class BTreeDB(IPlaylistDB):
PERSIST_NO = b'no'
PERSIST_TRACK = b'track'
PERSIST_OFFSET = b'offset'
DEFAULT_SETTINGS = {
b'tagmode': b'tagremains'
}
class Playlist(IPlaylist):
def __init__(self, parent: "BTreeDB", tag: bytes, pos: int, persist, shuffle):
@@ -104,17 +107,6 @@ class BTreeDB(IPlaylistDB):
self.setPlaybackOffset(0)
return self.getCurrentPath()
def getPrevPath(self):
"""
Select prev track and return path.
"""
if self.pos > 0:
self.pos -= 1
if self.persist != BTreeDB.PERSIST_NO:
self.parent._setPlaylistPos(self.tag, self.pos)
self.setPlaybackOffset(0)
return self.getCurrentPath()
def setPlaybackOffset(self, offset):
"""
Store the current position in the track for PERSIST_OFFSET mode
@@ -248,16 +240,6 @@ class BTreeDB(IPlaylistDB):
if flush:
self._flush()
def getPlaylistTags(self):
"""
Get a keys-only dict of all defined playlists. Playlists currently do not have names, but are identified by
their tag.
"""
playlist_tags = set()
for item in self.db:
playlist_tags.add(item.split(b'/')[0])
return playlist_tags
def getPlaylistForTag(self, tag: bytes):
"""
Lookup the playlist for 'tag' and return the Playlist object. Return None if no playlist exists for the given
@@ -289,8 +271,10 @@ class BTreeDB(IPlaylistDB):
self._savePlaylist(tag, entries, persist, shuffle)
return self.getPlaylistForTag(tag)
def deletePlaylistForTag(self, tag: bytes):
self._deletePlaylist(tag)
def getSetting(self, key: bytes | str) -> str:
if type(key) is str:
key = key.encode()
return self.db.get(b'settings/' + key, self.DEFAULT_SETTINGS[key]).decode()
def validate(self, dump=False):
"""
@@ -311,7 +295,8 @@ class BTreeDB(IPlaylistDB):
fail(f'Malformed key {k!r}')
continue
if fields[0] == b'settings':
# Legacy, not used any more
val = self.db[k].decode()
print(f'Setting {fields[1].decode()} = {val}')
continue
if last_tag != fields[0]:
last_tag = fields[0]

View File

@@ -4,7 +4,6 @@
import asyncio
import heapq
import time
from utils import safe_callback
TIMER_DEBUG = True
@@ -23,7 +22,6 @@ class TimerManager(object):
def schedule(self, when, what):
cur_nearest = self.timers[0][0] if len(self.timers) > 0 else None
self._remove_timer(what) # Ensure timer is not already scheduled
heapq.heappush(self.timers, (when, what))
if cur_nearest is None or cur_nearest > self.timers[0][0]:
# New timer is closer than previous closest timer
@@ -33,53 +31,41 @@ class TimerManager(object):
self.worker_event.set()
def cancel(self, what):
remove_idx = self._remove_timer(what)
if remove_idx == 0:
# Cancel timer was closest timer
if self.timer_debug:
print("cancel: wake")
self.worker_event.set()
return True
def _remove_timer(self, what):
try:
(when, _), i = next(filter(lambda item: item[0][1] == what, zip(self.timers, range(len(self.timers)))))
except StopIteration:
return False
del self.timers[i]
heapq.heapify(self.timers)
return i
def _next_timeout(self):
if len(self.timers) == 0:
if i == 0:
# Cancel timer was closest timer
if self.timer_debug:
print("timer: worker: queue empty")
return None
cur_nearest = self.timers[0][0]
next_timeout = cur_nearest - time.ticks_ms()
if self.timer_debug:
if next_timeout > 0:
print(f"timer: worker: next is {self.timers[0]}, sleep {next_timeout} ms")
else:
print(f"timer: worker: {self.timers[0]} elapsed @{cur_nearest}, delay {-next_timeout} ms")
return next_timeout
async def _wait(self, timeout):
try:
await asyncio.wait_for_ms(self.worker_event.wait(), timeout)
if self.timer_debug:
print("timer: worker: event")
# got woken up due to event
self.worker_event.clear()
return True
except asyncio.TimeoutError:
return False
print("cancel: wake")
self.worker_event.set()
return True
async def _timer_worker(self):
while True:
next_timeout = self._next_timeout()
if next_timeout is None or next_timeout > 0:
await self._wait(next_timeout)
else:
_, callback = heapq.heappop(self.timers)
safe_callback(callback, "timer callback")
if len(self.timers) == 0:
# Nothing to do
await self.worker_event.wait()
if self.timer_debug:
print("_timer_worker: event 0")
self.worker_event.clear()
continue
cur_nearest = self.timers[0][0]
wait_time = cur_nearest - time.ticks_ms()
if wait_time > 0:
if self.timer_debug:
print(f"_timer_worker: next is {self.timers[0]}, sleep {wait_time} ms")
try:
await asyncio.wait_for_ms(self.worker_event.wait(), wait_time)
if self.timer_debug:
print("_timer_worker: event 1")
# got woken up due to event
self.worker_event.clear()
continue
except asyncio.TimeoutError:
pass
_, callback = heapq.heappop(self.timers)
callback()

View File

@@ -4,47 +4,19 @@ Copyright (c) 2024-2025 Stefan Kratochwil <Kratochwil-LA@gmx.de>
'''
import asyncio
import hwconfig
import json
import machine
import os
import time
from array import array
from microdot import Microdot, redirect, send_file, Request
from utils import TimerManager, LedManager
from microdot import Microdot
webapp = Microdot()
server = None
config = None
app = None
nfc = None
playlist_db = None
leds = None
timer_manager = None
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
def start_webserver():
global server
server = asyncio.create_task(webapp.start_server(port=80))
config = config_
app = app_
nfc = app.get_nfc()
playlist_db = app.get_playlist_db()
leds = app.get_leds()
timer_manager = app.get_timer_manager()
@webapp.before_request
async def before_request_handler(request):
if request.method in ['PUT', 'POST', 'DELETE'] and app.is_playing():
return "Cannot write to device while playback is active", 503
app.reset_idle_timeout()
@webapp.route('/api/v1/hello')
@webapp.route('/')
async def index(request):
print("wohoo, a guest :)")
print(f" app: {request.app}")
@@ -67,206 +39,3 @@ async def filesystem_post(request):
async def playlist_post(request):
print(request)
return {'success': False}
@webapp.route('/api/v1/config', methods=['GET'])
async def config_get(request):
return config.get_config()
@webapp.route('/api/v1/config', methods=['PUT'])
async def config_put(request):
try:
config.set_config(request.json)
except ValueError as ex:
return str(ex), 400
return '', 204
@webapp.route('/api/v1/last_tag_uid', methods=['GET'])
async def last_tag_uid_get(request):
tag, _ = nfc.get_last_uid()
return {'tag': tag}
@webapp.route('/', methods=['GET'])
async def root_get(request):
return redirect('/index.html')
@webapp.route('/index.html', methods=['GET'])
async def index_get(request):
return send_file('/frontend/index.html.gz', content_type='text/html', compressed='gzip')
@webapp.route('/static/<path:path>', methods=['GET'])
async def static(request, path):
if '..' in path:
# directory traversal is not allowed
return 'Not found', 404
return send_file('/frontend/static/' + path, max_age=86400)
@webapp.route('/api/v1/playlists', methods=['GET'])
async def playlists_get(request):
return sorted(playlist_db.getPlaylistTags())
def is_hex(s):
hex_chars = '0123456789abcdef'
return all(c in hex_chars for c in s)
fsroot = b'/sd'
@webapp.route('/api/v1/playlist/<tag>', methods=['GET'])
async def playlist_get(request, tag):
if not is_hex(tag):
return 'invalid tag', 400
playlist = playlist_db.getPlaylistForTag(tag.encode())
if playlist is None:
return None, 404
return {
'shuffle': playlist.__dict__.get('shuffle'),
'persist': playlist.__dict__.get('persist'),
'paths': [(p[len(fsroot):] if p.startswith(fsroot) else p).decode()
for p in playlist.getPaths()],
}
@webapp.route('/api/v1/playlist/<tag>', methods=['PUT'])
async def playlist_put(request, tag):
if not is_hex(tag):
return 'invalid tag', 400
playlist = request.json
if 'persist' in playlist and \
playlist['persist'] not in ['no', 'track', 'offset']:
return "Invalid 'persist' setting", 400
if 'shuffle' in playlist and \
playlist['shuffle'] not in ['no', 'yes']:
return "Invalid 'shuffle' setting", 400
playlist_db.createPlaylistForTag(tag.encode(),
(fsroot + path.encode() for path in playlist.get('paths', [])),
playlist.get('persist', 'track').encode(),
playlist.get('shuffle', 'no').encode())
return '', 204
@webapp.route('/api/v1/playlist/<tag>', methods=['DELETE'])
async def playlist_delete(request, tag):
if not is_hex(tag):
return 'invalid tag', 400
playlist_db.deletePlaylistForTag(tag.encode())
return '', 204
@webapp.route('/api/v1/audiofiles', methods=['GET'])
async def audiofiles_get(request):
def directory_iterator():
yield '['
first = True
def make_json_str(obj):
nonlocal first
jsonpath = json.dumps(obj)
if not first:
jsonpath = ',' + jsonpath
first = False
return jsonpath
dirstack = [fsroot]
while dirstack:
current_dir = dirstack.pop()
for entry in os.ilistdir(current_dir):
name = entry[0]
type_ = entry[1]
current_path = current_dir + b'/' + name
if type_ == 0x4000:
yield make_json_str({'name': current_path[len(fsroot):], 'type': 'directory'})
dirstack.append(current_path)
elif type_ == 0x8000:
if name.lower().endswith('.mp3'):
yield make_json_str({'name': current_path[len(fsroot):], 'type': 'file'})
yield ']'
return directory_iterator(), {'Content-Type': 'application/json; charset=UTF-8'}
@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']:
return 'invalid or missing type', 400
if 'location' not in request.args:
return 'missing location', 400
path = fsroot + '/' + request.args['location']
type_ = request.args['type']
length = request.content_length
print(f'Got upload request of type {type_} to {path} with length {length}')
if type_ == 'directory':
if length != 0:
return 'directory request may not have content', 400
os.mkdir(path)
return '', 204
with open(path, 'wb') as newfile:
data = array('b', range(4096))
bytes_copied = 0
while True:
bytes_read = await request.stream.readinto(data)
if bytes_read == 0:
# End of body
break
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:
return '', 204
else:
return 'size mismatch', 500
def recursive_delete(path):
stat = os.stat(path)
if stat[0] == 0x8000:
os.remove(path)
elif stat[0] == 0x4000:
for entry in os.ilistdir(path):
entry_path = path + '/' + entry[0]
recursive_delete(entry_path)
os.rmdir(path)
@webapp.route('/api/v1/audiofiles', methods=['DELETE'])
async def audiofile_delete(request):
if 'location' not in request.args:
return 'missing location', 400
location = request.args['location']
if '..' in location or len(location) == 0:
return 'bad location', 400
path = fsroot + '/' + request.args['location']
recursive_delete(path)
return '', 204
@webapp.route('/api/v1/reboot/<method>', methods=['POST'])
async def reboot(request, method):
if hwconfig.get_on_battery():
return 'not allowed: no vbus', 403
if method == 'bootloader':
leds.set_state(LedManager.REBOOTING)
timer_manager.schedule(time.ticks_ms() + 1500, machine.bootloader)
elif method =='application':
leds.set_state(LedManager.REBOOTING)
timer_manager.schedule(time.ticks_ms() + 1500, machine.reset)
else:
return 'method not supported', 400
return '', 204

View File

@@ -124,22 +124,6 @@ class FakeLeds:
self.state = state
class FakeConfig:
def __init__(self): pass
def get_led_count(self):
return 1
def get_idle_timeout(self):
return 60
def get_tag_timeout(self):
return 5
def get_tagmode(self):
return 'tagremains'
def fake_open(filename, mode):
return FakeFile(filename, mode)
@@ -152,14 +136,13 @@ def faketimermanager(monkeypatch):
def _makedeps(mp3player=FakeMp3Player, nfcreader=FakeNfcReader, buttons=FakeButtons,
playlistdb=FakePlaylistDb, hwconfig=FakeHwconfig, leds=FakeLeds, config=FakeConfig):
playlistdb=FakePlaylistDb, hwconfig=FakeHwconfig, leds=FakeLeds):
return app.Dependencies(mp3player=lambda _: mp3player() if callable(mp3player) else mp3player,
nfcreader=lambda x: nfcreader(x) if callable(nfcreader) else nfcreader,
buttons=lambda _: buttons() if callable(buttons) else buttons,
playlistdb=lambda _: playlistdb() if callable(playlistdb) else playlistdb,
hwconfig=lambda _: hwconfig() if callable(hwconfig) else hwconfig,
leds=lambda _: leds() if callable(leds) else leds,
config=lambda _: config() if callable(config) else config)
leds=lambda _: leds() if callable(leds) else leds)
def test_construct_app(micropythonify, faketimermanager):
@@ -229,16 +212,18 @@ def test_playlist_unknown_tag(micropythonify, faketimermanager, monkeypatch):
def test_tagmode_startstop(micropythonify, faketimermanager, monkeypatch):
class FakeStartStopConfig(FakeConfig):
def __init__(self):
super().__init__()
class MyFakePlaylistDb(FakePlaylistDb):
def __init__(self, tracklist=[b'test/path.mp3']):
super().__init__(tracklist)
def get_tagmode(self):
return 'tagstartstop'
def getSetting(self, key: bytes | str):
if key == 'tagmode':
return 'tagstartstop'
return None
fake_db = FakePlaylistDb([b'test/path.mp3'])
fake_db = MyFakePlaylistDb()
fake_mp3 = FakeMp3Player()
deps = _makedeps(mp3player=fake_mp3, playlistdb=fake_db, config=FakeStartStopConfig)
deps = _makedeps(mp3player=fake_mp3, playlistdb=fake_db)
app.PlayerApp(deps)
with monkeypatch.context() as m:
m.setattr(builtins, 'open', fake_open)
@@ -265,7 +250,16 @@ def test_tagmode_startstop(micropythonify, faketimermanager, monkeypatch):
def test_tagmode_remains(micropythonify, faketimermanager, monkeypatch):
fake_db = FakePlaylistDb([b'test/path.mp3'])
class MyFakePlaylistDb(FakePlaylistDb):
def __init__(self, tracklist=[b'test/path.mp3']):
super().__init__(tracklist)
def getSetting(self, key: bytes | str):
if key == 'tagmode':
return 'tagremains'
return None
fake_db = MyFakePlaylistDb()
fake_mp3 = FakeMp3Player()
deps = _makedeps(mp3player=fake_mp3, playlistdb=fake_db)
app.PlayerApp(deps)