25 Commits

Author SHA1 Message Date
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
9320a3cff2 fix: Show reboot request response in UI
All checks were successful
Build RPi Pico firmware image / Build-Firmware (push) Successful in 4m40s
Check code formatting / Check-C-Format (push) Successful in 7s
Check code formatting / Check-Python-Flake8 (push) Successful in 9s
Check code formatting / Check-Bash-Shellcheck (push) Successful in 4s
Run unit tests on host / Run-Unit-Tests (push) Successful in 8s
Run pytests / Check-Pytest (push) Successful in 11s
Also make response from api more understandable for non-technical users.

Signed-off-by: Matthias Blankertz <matthias@blankertz.org>
2025-12-21 18:30:45 +01:00
65efebc5c2 feat: allow reboot commands only if usb cable is inserted 2025-12-21 18:30:45 +01:00
040ae4a731 fix: fix flake8 complaint
Signed-off-by: Matthias Blankertz <matthias@blankertz.org>
2025-12-21 18:30:45 +01:00
9cf044bc80 feat: frontend: Add reboot to bootloader button (for updates)
Some checks failed
Build RPi Pico firmware image / Build-Firmware (push) Successful in 4m47s
Check code formatting / Check-C-Format (push) Successful in 6s
Check code formatting / Check-Python-Flake8 (push) Failing after 9s
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-21 17:07:09 +01:00
da9843adb9 feat: /api/v1/reboot/[bootloader|application], confirm with pink LED pattern
Some checks failed
Build RPi Pico firmware image / Build-Firmware (push) Successful in 4m43s
Check code formatting / Check-C-Format (push) Successful in 8s
Check code formatting / Check-Python-Flake8 (push) Failing after 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 11s
2025-12-21 16:57:37 +01:00
02150aec42 fix: frontend: Improve navigation on playlist edit screen
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 9s
Check code formatting / Check-Bash-Shellcheck (push) Successful in 5s
Run unit tests on host / Run-Unit-Tests (push) Successful in 7s
Run pytests / Check-Pytest (push) Successful in 10s
Signed-off-by: Matthias Blankertz <matthias@blankertz.org>
2025-12-21 15:36:53 +01:00
7be038d0d1 feat: Allow deleting files and directories
All checks were successful
Build RPi Pico firmware image / Build-Firmware (push) Successful in 4m40s
Check code formatting / Check-C-Format (push) Successful in 7s
Check code formatting / Check-Python-Flake8 (push) Successful in 10s
Check code formatting / Check-Bash-Shellcheck (push) Successful in 4s
Run unit tests on host / Run-Unit-Tests (push) Successful in 8s
Run pytests / Check-Pytest (push) Successful in 10s
Signed-off-by: Matthias Blankertz <matthias@blankertz.org>
2025-12-21 15:12:31 +01:00
d96350c1a7 feat: frontend: Allow creating directories
All checks were successful
Build RPi Pico firmware image / Build-Firmware (push) Successful in 4m38s
Check code formatting / Check-C-Format (push) Successful in 7s
Check code formatting / Check-Python-Flake8 (push) Successful in 9s
Check code formatting / Check-Bash-Shellcheck (push) Successful in 4s
Run unit tests on host / Run-Unit-Tests (push) Successful in 8s
Run pytests / Check-Pytest (push) Successful in 10s
Signed-off-by: Matthias Blankertz <matthias@blankertz.org>
2025-12-21 14:23:34 +01:00
eec3703b7e feat: Extend audiofiles API
Extend the audiofiles GET API to return both directories and audio
files. Also change the JSON format to include the name and type of all
entries.

Signed-off-by: Matthias Blankertz <matthias@blankertz.org>
2025-12-21 14:22:38 +01:00
25ac3f0687 feat: Add API and frontend to upload files
All checks were successful
Build RPi Pico firmware image / Build-Firmware (push) Successful in 4m40s
Check code formatting / Check-C-Format (push) Successful in 7s
Check code formatting / Check-Python-Flake8 (push) Successful in 9s
Check code formatting / Check-Bash-Shellcheck (push) Successful in 4s
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-12-21 13:29:47 +01:00
3367bba0c5 Merge branch 'misc-fixes' into mbl-next
All checks were successful
Build RPi Pico firmware image / Build-Firmware (push) Successful in 4m38s
Check code formatting / Check-C-Format (push) Successful in 7s
Check code formatting / Check-Python-Flake8 (push) Successful in 10s
Check code formatting / Check-Bash-Shellcheck (push) Successful in 4s
Run unit tests on host / Run-Unit-Tests (push) Successful in 8s
Run pytests / Check-Pytest (push) Successful in 11s
2025-12-21 12:23:23 +01:00
c555ad94f0 fix: Increase watchdog timeout
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 11s
Run unit tests on host / Run-Unit-Tests (push) Successful in 19s
Run pytests / Check-Pytest (push) Successful in 31s
Workaround for #60 until it is fixed.

Signed-off-by: Matthias Blankertz <matthias@blankertz.org>
2025-12-20 22:31:51 +01:00
10ec080e5f fix: app: Go to idle mode when playlist end is reached
Signed-off-by: Matthias Blankertz <matthias@blankertz.org>
2025-12-20 22:31:51 +01:00
fb01a8aebb micropython: Fix filename unicode issues, enable compile-commands.json
Signed-off-by: Matthias Blankertz <matthias@blankertz.org>
2025-12-20 22:31:51 +01:00
2aa2249238 Merge branch 'more-frontend' into mbl-next 2025-12-20 20:28:56 +01:00
3e275a0aee Merge branch 'mbl/backend-for-frontend' into mbl-next 2025-12-20 20:27:58 +01:00
8070c0e113 feat: frontend: list of playlists screen, playlist screen, add tracks screen
Some checks failed
Build RPi Pico firmware image / Build-Firmware (push) Successful in 4m38s
Check code formatting / Check-C-Format (push) Successful in 7s
Check code formatting / Check-Python-Flake8 (push) Failing after 9s
Check code formatting / Check-Bash-Shellcheck (push) Successful in 4s
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-12-20 20:17:06 +01:00
e31aabbefc Merge branch '30-frontend'
Some checks failed
Build RPi Pico firmware image / Build-Firmware (push) Successful in 4m40s
Check code formatting / Check-C-Format (push) Successful in 7s
Check code formatting / Check-Python-Flake8 (push) Failing after 9s
Check code formatting / Check-Bash-Shellcheck (push) Successful in 4s
Run unit tests on host / Run-Unit-Tests (push) Successful in 8s
Run pytests / Check-Pytest (push) Successful in 10s
2025-12-20 20:15:42 +01:00
059b705a38 fix: webserver: Use streaming response for filesystem listing
All checks were successful
Build RPi Pico firmware image / Build-Firmware (push) Successful in 4m37s
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 8s
Run pytests / Check-Pytest (push) Successful in 10s
Signed-off-by: Matthias Blankertz <matthias@blankertz.org>
2025-12-20 19:26:18 +01:00
3213ec8f66 feat: webserver: set and delete playlists
Signed-off-by: Matthias Blankertz <matthias@blankertz.org>
2025-12-20 19:26:16 +01:00
e2ca9e5139 feat: api endpoint to get playlist properties
Some checks failed
Build RPi Pico firmware image / Build-Firmware (push) Successful in 4m36s
Check code formatting / Check-C-Format (push) Successful in 7s
Check code formatting / Check-Python-Flake8 (push) Failing after 9s
Check code formatting / Check-Bash-Shellcheck (push) Successful in 4s
Run unit tests on host / Run-Unit-Tests (push) Successful in 7s
Run pytests / Check-Pytest (push) Successful in 10s
2025-12-20 16:52:37 +01:00
070cf887ab feat(wip): api endpoint to get playlist properties
Some checks failed
Build RPi Pico firmware image / Build-Firmware (push) Successful in 4m47s
Check code formatting / Check-C-Format (push) Successful in 8s
Check code formatting / Check-Python-Flake8 (push) Failing after 9s
Check code formatting / Check-Bash-Shellcheck (push) Successful in 4s
Run unit tests on host / Run-Unit-Tests (push) Successful in 7s
Run pytests / Check-Pytest (push) Successful in 10s
2025-12-20 15:40:52 +01:00
28846c9274 wip: api endpoint to list all available audio files
Some checks failed
Build RPi Pico firmware image / Build-Firmware (push) Successful in 4m37s
Check code formatting / Check-C-Format (push) Successful in 8s
Check code formatting / Check-Python-Flake8 (push) Failing after 9s
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
Iterative approach. Currently only lists mp3 files.
2025-12-20 14:40:07 +01:00
51cb2c3a68 feat: api endpoint for reading all available playlists
Some checks failed
Build RPi Pico firmware image / Build-Firmware (push) Successful in 4m36s
Check code formatting / Check-C-Format (push) Successful in 7s
Check code formatting / Check-Python-Flake8 (push) Failing after 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
2025-12-19 18:33:42 +01:00
7 changed files with 1107 additions and 168 deletions

View File

@@ -45,6 +45,92 @@
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>
@@ -54,6 +140,7 @@
<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 -->
@@ -63,52 +150,178 @@
<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>
<hr>
<button onclick="requestReboot()">Reboot to bootloader</button>
</div>
<!-- CONFIG EDITOR SCREEN -->
<div id="screen-config" class="screen">
<h2>Configuration Editor</h2>
<div id="config-container">Loading…</div>
<button id="save-btn" disabled>Save</button>
<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) {
function showScreen(name, arg=null) {
document.querySelectorAll('.screen').forEach(s => s.classList.remove('active'));
document.getElementById('screen-' + name).classList.add('active');
if (name === "config") {
loadConfig(); // refresh most up-to-date config
}
activeScreen = name;
Screens[name]?.onShow?.(arg);
}
/* -----------------------------
CONFIG EDITOR LOGIC
----------------------------- */
async function loadConfig() {
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('save-btn').disabled = true;
document.getElementById('config-save-btn').disabled = true;
try {
const res = await fetch('/api/v1/config');
const config = await res.json();
renderConfigForm(config);
render(config);
} catch (err) {
container.innerHTML = "Failed to load config: " + err;
}
}
function renderConfigForm(config) {
function render(config) {
const container = document.getElementById('config-container');
container.innerHTML = "";
container.appendChild(renderObject(config, "root"));
document.getElementById('save-btn').disabled = false;
document.getElementById('config-save-btn').disabled = false;
}
const config_names = {
@@ -197,8 +410,8 @@ function renderObject(obj, path) {
return wrapper;
}
function serializeConfig(rootObj) {
const inputs = document.querySelectorAll("input[data-path], select[data-path]");
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"
@@ -218,31 +431,556 @@ function serializeConfig(rootObj) {
return rootObj;
}
document.getElementById('save-btn').addEventListener('click', async () => {
const res = await fetch('/api/v1/config');
const original = await res.json();
const updated = serializeConfig(original);
return { init, onShow };
})();
try {
const saveRes = await fetch('/api/v1/config', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(updated, null, 2)
/* ----------------------------------------
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 config: " + await saveRes.text());
alert("Failed to save playlist: " + await saveRes.text());
return;
}
showScreen('playlist');
}
alert("Configuration saved successfully!");
} catch (err) {
alert("Error saving configuration: " + err);
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]);
}
// Load main menu by default
showScreen('menu');
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 };
})();
// Misc
async function requestReboot() {
const resp = await fetch('/api/v1/reboot/bootloader', {'method': 'POST'});
if (!resp.ok) {
alert('Reboot to bootloader failed: ' + await resp.text());
}
}
// Initialization
Object.values(Screens).forEach(screen => {
screen.init?.();
});
showScreen("menu");
</script>
</body>

View File

@@ -104,9 +104,6 @@ class PlayerApp:
self._pause_toggle()
def onPlaybackDone(self):
assert self.mp3file is not None
self.mp3file.close()
self.mp3file = None
self._play_next()
def onIdleTimeout(self):
@@ -141,9 +138,7 @@ class PlayerApp:
self.playlist = None
def _play_next(self):
if self.playlist is None:
return
filename = self.playlist.getNextPath()
filename = self.playlist.getNextPath() if self.playlist is not None else None
self._play(filename)
if filename is None:
self.playlist = None
@@ -166,7 +161,11 @@ 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.player.play(self.mp3file, offset)
self.paused = False
self._onActive()
@@ -193,3 +192,9 @@ class PlayerApp:
def get_nfc(self):
return self.nfc
def get_playlist_db(self):
return self.playlist_db
def get_leds(self):
return self.leds

View File

@@ -58,7 +58,7 @@ 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(500)
await asyncio.sleep_ms(100)
wdt.feed()
DB_PATH = '/sd/tonberry.db'
@@ -105,7 +105,7 @@ def run():
start_webserver(config, the_app)
# Start
wdt = machine.WDT(timeout=1000)
wdt = machine.WDT(timeout=2000)
asyncio.create_task(aiorepl.task({'timer_manager': TimerManager(),
'app': the_app}))
asyncio.create_task(wdt_task(wdt))

View File

@@ -10,6 +10,7 @@ import time
class LedManager:
IDLE = const(0)
PLAYING = const(1)
REBOOTING = const(2)
def __init__(self, np):
self.led_state = LedManager.IDLE
@@ -19,7 +20,7 @@ class LedManager:
asyncio.create_task(self.run())
def set_state(self, state):
assert state in [LedManager.IDLE, LedManager.PLAYING]
assert state in [LedManager.IDLE, LedManager.PLAYING, LedManager.REBOOTING]
self.led_state = state
def _gamma(self, value, X=2.2):
@@ -50,6 +51,8 @@ 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

@@ -248,6 +248,16 @@ 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
@@ -279,6 +289,9 @@ class BTreeDB(IPlaylistDB):
self._savePlaylist(tag, entries, persist, shuffle)
return self.getPlaylistForTag(tag)
def deletePlaylistForTag(self, tag: bytes):
self._deletePlaylist(tag)
def validate(self, dump=False):
"""
Validate the structure of the playlist database.

View File

@@ -4,27 +4,42 @@ Copyright (c) 2024-2025 Stefan Kratochwil <Kratochwil-LA@gmx.de>
'''
import asyncio
import hwconfig
import json
import machine
import os
import time
from microdot import Microdot, redirect, send_file
from array import array
from microdot import Microdot, redirect, send_file, Request
from utils import TimerManager, LedManager
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
global server, config, app, nfc, playlist_db, leds, timer_manager
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 = TimerManager()
@webapp.before_request
async def before_request_handler(request):
if request.method in ['PUT', 'POST'] and app.is_playing():
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()
@@ -90,3 +105,168 @@ async def static(request, 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: usb not connected', 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