32 Commits

Author SHA1 Message Date
93e9aea368 wip: async flush
All checks were successful
Build RPi Pico firmware image / Build-Firmware (push) Successful in 4m43s
Check code formatting / Check-C-Format (push) Successful in 7s
Check code formatting / Check-Python-Flake8 (push) Successful in 10s
Check code formatting / Check-Bash-Shellcheck (push) Successful in 5s
Run unit tests on host / Run-Unit-Tests (push) Successful in 8s
Run pytests / Check-Pytest (push) Successful in 11s
2025-12-27 15:46:07 +01:00
cac61f924f Merge branch 'file-upload' into mbl-next
All checks were successful
Build RPi Pico firmware image / Build-Firmware (push) Successful in 4m43s
Check code formatting / Check-C-Format (push) Successful in 7s
Check code formatting / Check-Python-Flake8 (push) Successful in 10s
Check code formatting / Check-Bash-Shellcheck (push) Successful in 5s
Run unit tests on host / Run-Unit-Tests (push) Successful in 9s
Run pytests / Check-Pytest (push) Successful in 11s
2025-12-21 18:31:04 +01:00
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
71949bdd1a Merge branch '30-frontend'
All checks were successful
Build RPi Pico firmware image / Build-Firmware (push) Successful in 4m39s
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 8s
Run pytests / Check-Pytest (push) Successful in 11s
2025-12-20 20:26:33 +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
94aa84879f Merge pull request 'webapi-last-tag-uid' (#58) from webapi-last-tag-uid into main
Some checks failed
Check code formatting / Check-C-Format (push) Has been cancelled
Check code formatting / Check-Python-Flake8 (push) Has been cancelled
Check code formatting / Check-Bash-Shellcheck (push) Has been cancelled
Build RPi Pico firmware image / Build-Firmware (push) Has been cancelled
Run unit tests on host / Run-Unit-Tests (push) Has been cancelled
Run pytests / Check-Pytest (push) Has been cancelled
Reviewed-on: #58
Reviewed-by: Matthias Blankertz <matthias@blankertz.org>
2025-12-20 19:14:20 +00: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
32e996e446 build: add requirements.txt for host python deps
All checks were successful
Build RPi Pico firmware image / Build-Firmware (push) Successful in 4m40s
Check code formatting / Check-C-Format (push) Successful in 7s
Check code formatting / Check-Python-Flake8 (push) Successful in 10s
Check code formatting / Check-Bash-Shellcheck (push) Successful in 5s
Run unit tests on host / Run-Unit-Tests (push) Successful in 8s
Run pytests / Check-Pytest (push) Successful in 11s
Signed-off-by: Matthias Blankertz <matthias@blankertz.org>
2025-12-17 18:37:34 +01:00
49197c8ca4 Merge pull request 'feat: Move tagmode setting to config.json, remove playlistdb settings' (#57) from unify-config into main
All checks were successful
Build RPi Pico firmware image / Build-Firmware (push) Successful in 4m38s
Check code formatting / Check-C-Format (push) Successful in 6s
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 8s
Run pytests / Check-Pytest (push) Successful in 11s
Reviewed-on: #57
Reviewed-by: Stefan Kratochwil <kratochwil-la@gmx.de>
2025-12-16 21:55:28 +00:00
b20a31ccf4 feat: webserver: redirect / to /index.html
All checks were successful
Build RPi Pico firmware image / Build-Firmware (push) Successful in 4m41s
Check code formatting / Check-C-Format (push) Successful in 7s
Check code formatting / Check-Python-Flake8 (push) Successful in 9s
Check code formatting / Check-Bash-Shellcheck (push) Successful in 4s
Run unit tests on host / Run-Unit-Tests (push) Successful in 7s
Run pytests / Check-Pytest (push) Successful in 10s
Signed-off-by: Matthias Blankertz <matthias@blankertz.org>
2025-12-16 21:01:18 +01:00
8a2d621c7d feat: webserver: keep alive; move playback write prot to handler
- Reset the app idle timer when interacting with the webapp, so that the
  device does not turn off while the web ui is used.

- Handle denying put/post while playback is active centrally in the
  before_request handler, so that it does not need to be copy/pasted
  into every request handler.

Signed-off-by: Matthias Blankertz <matthias@blankertz.org>
2025-12-16 20:48:01 +01:00
e447902001 feat: Move tagmode setting to config.json, remove playlistdb settings
All checks were successful
Build RPi Pico firmware image / Build-Firmware (push) Successful in 4m35s
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 6s
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-16 20:41:40 +01:00
93ea5036dc feat: config frontend
Implement nicer configuration UI:

- Show human-readable names for config settings
- Show error message received from server if storing settings fails
- Show appropriate input elements for enum choice and numerical inputs

Signed-off-by: Matthias Blankertz <matthias@blankertz.org>
2025-12-16 20:41:40 +01:00
aee5a48967 fix: config: Merge defaults into config at load time
Merge defaults into config at load time to ensure that all config
options show up in the configuration dialog, even if they were added
after the local configuration was last changed.

Also use the merge method to merge the local config with the new config
in set_config, ensuring the config contains all keys even if the
submitted config leaves some out.

Signed-off-by: Matthias Blankertz <matthias@blankertz.org>
2025-12-16 20:41:40 +01:00
936020df58 feat: Add and deploy frontend
Signed-off-by: Matthias Blankertz <matthias@blankertz.org>
2025-12-16 20:41:40 +01:00
16 changed files with 1272 additions and 68 deletions

View File

@@ -11,8 +11,10 @@ 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: cd software && ./build.sh
run: source build-venv/bin/activate && cd software && ./build.sh
- name: Upload firmware
uses: actions/upload-artifact@v3
with:

View File

@@ -22,3 +22,5 @@ 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

@@ -25,6 +25,11 @@ 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-}

View File

@@ -0,0 +1,987 @@
<!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>
<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="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 };
})();
// 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>
</html>

View File

@@ -2,7 +2,8 @@
# Copyright (c) 2025 Matthias Blankertz <matthias@blankertz.org>
import _audiocore
from asyncio import ThreadSafeFlag
import asyncio
from asyncio import Lock, ThreadSafeFlag
from utils import get_pin_index
@@ -11,20 +12,35 @@ class Audiocore:
# PIO requires sideset pins to be adjacent
assert get_pin_index(lrclk) == get_pin_index(dclk)+1 or get_pin_index(lrclk) == get_pin_index(dclk)-1
self.notify = ThreadSafeFlag()
self.audiocore_lock = Lock()
self._audiocore = _audiocore.Audiocore(din, dclk, lrclk, self._interrupt)
def deinit(self):
assert not self.audiocore_lock.locked()
self._audiocore.deinit()
def _interrupt(self, _):
self.notify.set()
def flush(self):
assert not self.audiocore_lock.locked()
self._audiocore.flush()
def set_volume(self, volume):
async def async_flush(self):
async with self.audiocore_lock:
self._audiocore.flush(False)
while True:
if self._audiocore.get_async_result() is not None:
return
await self.notify.wait()
async def async_set_volume(self, volume):
async with self.audiocore_lock:
self._audiocore.set_volume(volume)
def set_volume(self, volume):
asyncio.create_task(self.async_set_volume(volume))
def put(self, buffer, blocking=False):
pos = 0
while True:

View File

@@ -5,6 +5,8 @@
// Include MicroPython API.
#include "py/mperrno.h"
#include "py/obj.h"
#include "py/persistentcode.h"
#include "py/runtime.h"
#include "shared/runtime/mpirq.h"
@@ -61,6 +63,20 @@ static uint32_t get_fifo_read_value_blocking(struct audiocore_obj *obj)
}
}
static bool get_fifo_read_value(struct audiocore_obj *obj, uint32_t *result)
{
const long flags = save_and_disable_interrupts();
const uint32_t value = obj->fifo_read_value;
obj->fifo_read_value = 0;
if (value & AUDIOCORE_FIFO_DATA_FLAG) {
restore_interrupts(flags);
*result = value & ~AUDIOCORE_FIFO_DATA_FLAG;
return true;
}
restore_interrupts(flags);
return false;
}
/*
* audiocore.Context.deinit(self)
*
@@ -125,15 +141,31 @@ static MP_DEFINE_CONST_FUN_OBJ_2(audiocore_put_obj, audiocore_put);
* Tells the audiocore to stop playback as soon as all MP3 frames still in the buffer have been decoded.
* This function blocks until playback has ended.
*/
static mp_obj_t audiocore_flush(mp_obj_t self_in)
static mp_obj_t audiocore_flush(size_t n_args, const mp_obj_t *args)
{
struct audiocore_obj *self = MP_OBJ_TO_PTR(self_in);
struct audiocore_obj *self = MP_OBJ_TO_PTR(args[0]);
mp_obj_t blocking = MP_ROM_TRUE;
if (n_args == 2) {
blocking = args[1];
}
multicore_fifo_push_blocking(AUDIOCORE_CMD_FLUSH);
wake_core1();
if (mp_obj_is_true(blocking))
get_fifo_read_value_blocking(self);
return mp_const_none;
}
static MP_DEFINE_CONST_FUN_OBJ_1(audiocore_flush_obj, audiocore_flush);
static MP_DEFINE_CONST_FUN_OBJ_VAR_BETWEEN(audiocore_flush_obj, 1, 2, audiocore_flush);
static mp_obj_t audiocore_get_async_result(mp_obj_t self_in)
{
uint32_t result;
struct audiocore_obj *self = MP_OBJ_TO_PTR(self_in);
if (get_fifo_read_value(self, &result)) {
return mp_obj_new_int(result);
}
return mp_const_none;
}
static MP_DEFINE_CONST_FUN_OBJ_1(audiocore_get_async_result_obj, audiocore_get_async_result);
/*
* audiocore.set_volume(self, volume)
@@ -240,6 +272,7 @@ static const mp_rom_map_elem_t audiocore_locals_dict_table[] = {
{MP_ROM_QSTR(MP_QSTR_deinit), MP_ROM_PTR(&audiocore_deinit_obj)},
{MP_ROM_QSTR(MP_QSTR_put), MP_ROM_PTR(&audiocore_put_obj)},
{MP_ROM_QSTR(MP_QSTR_flush), MP_ROM_PTR(&audiocore_flush_obj)},
{MP_ROM_QSTR(MP_QSTR_get_async_result), MP_ROM_PTR(&audiocore_get_async_result_obj)},
{MP_ROM_QSTR(MP_QSTR_set_volume), MP_ROM_PTR(&audiocore_set_volume_obj)},
};
static MP_DEFINE_CONST_DICT(audiocore_locals_dict, audiocore_locals_dict_table);

View File

@@ -0,0 +1 @@
freezefs

View File

@@ -51,7 +51,7 @@ class PlayerApp:
self.playlist_db = deps.playlistdb(self)
self.hwconfig = deps.hwconfig(self)
self.leds = deps.leds(self)
self.tag_mode = self.playlist_db.getSetting('tagmode')
self.tag_mode = self.config.get_tagmode()
self.playing_tag = None
self.playlist = None
self.buttons = deps.buttons(self) if deps.buttons is not None else None
@@ -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()
@@ -196,3 +195,6 @@ class PlayerApp:
def get_playlist_db(self):
return self.playlist_db
def get_leds(self):
return self.leds

View File

@@ -14,6 +14,7 @@ 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
@@ -57,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'
@@ -104,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

@@ -74,7 +74,7 @@ class MP3Player:
# Call onPlaybackDone after flush
send_done = True
finally:
self.audiocore.flush()
await self.audiocore.async_flush()
if send_done:
# Only call onPlaybackDone if exit due to end of stream
# Use timer with time 0 to call callback "immediately" but from a different task

View File

@@ -21,7 +21,8 @@ class Configuration:
'VOLDOWN': 2,
'PREV': None,
'NEXT': 1,
}
},
'TAGMODE': 'tagremains'
}
def __init__(self, config_path='/config.json'):
@@ -29,6 +30,7 @@ class Configuration:
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
@@ -50,6 +52,16 @@ class Configuration:
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)
@@ -58,7 +70,7 @@ class Configuration:
os.sync()
def _get(self, key):
return self.config.get(key, self.DEFAULT_CONFIG[key])
return self.config[key]
def get_led_count(self) -> int:
return self._get('LED_COUNT')
@@ -72,6 +84,9 @@ class Configuration:
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
@@ -87,5 +102,8 @@ class Configuration:
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

@@ -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

@@ -31,9 +31,6 @@ 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):
@@ -292,10 +289,8 @@ class BTreeDB(IPlaylistDB):
self._savePlaylist(tag, entries, persist, shuffle)
return self.getPlaylistForTag(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 deletePlaylistForTag(self, tag: bytes):
self._deletePlaylist(tag)
def validate(self, dump=False):
"""
@@ -316,8 +311,7 @@ class BTreeDB(IPlaylistDB):
fail(f'Malformed key {k!r}')
continue
if fields[0] == b'settings':
val = self.db[k].decode()
print(f'Setting {fields[1].decode()} = {val}')
# Legacy, not used any more
continue
if last_tag != fields[0]:
last_tag = fields[0]

View File

@@ -4,10 +4,15 @@ 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
from array import array
from microdot import Microdot, redirect, send_file, Request
from utils import TimerManager, LedManager
webapp = Microdot()
server = None
@@ -15,24 +20,31 @@ 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
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()
@webapp.route('/')
@webapp.route('/api/v1/hello')
async def index(request):
print("wohoo, a guest :)")
print(f" app: {request.app}")
@@ -77,6 +89,24 @@ async def last_tag_uid_get(request):
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())
@@ -96,6 +126,8 @@ async def playlist_get(request, tag):
return 'invalid tag', 400
playlist = playlist_db.getPlaylistForTag(tag.encode())
if playlist is None:
return None, 404
return {
'shuffle': playlist.__dict__.get('shuffle'),
@@ -104,21 +136,137 @@ async def playlist_get(request, tag):
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):
audiofiles = set()
dirstack = [fsroot]
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 + '/' + name
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'):
audiofiles.add(current_path[len(fsroot):])
yield make_json_str({'name': current_path[len(fsroot):], 'type': 'file'})
yield ']'
return sorted(audiofiles)
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

View File

@@ -136,6 +136,9 @@ class FakeConfig:
def get_tag_timeout(self):
return 5
def get_tagmode(self):
return 'tagremains'
def fake_open(filename, mode):
return FakeFile(filename, mode)
@@ -226,18 +229,16 @@ def test_playlist_unknown_tag(micropythonify, faketimermanager, monkeypatch):
def test_tagmode_startstop(micropythonify, faketimermanager, monkeypatch):
class MyFakePlaylistDb(FakePlaylistDb):
def __init__(self, tracklist=[b'test/path.mp3']):
super().__init__(tracklist)
class FakeStartStopConfig(FakeConfig):
def __init__(self):
super().__init__()
def getSetting(self, key: bytes | str):
if key == 'tagmode':
def get_tagmode(self):
return 'tagstartstop'
return None
fake_db = MyFakePlaylistDb()
fake_db = FakePlaylistDb([b'test/path.mp3'])
fake_mp3 = FakeMp3Player()
deps = _makedeps(mp3player=fake_mp3, playlistdb=fake_db)
deps = _makedeps(mp3player=fake_mp3, playlistdb=fake_db, config=FakeStartStopConfig)
app.PlayerApp(deps)
with monkeypatch.context() as m:
m.setattr(builtins, 'open', fake_open)
@@ -264,16 +265,7 @@ def test_tagmode_startstop(micropythonify, faketimermanager, monkeypatch):
def test_tagmode_remains(micropythonify, faketimermanager, monkeypatch):
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_db = FakePlaylistDb([b'test/path.mp3'])
fake_mp3 = FakeMp3Player()
deps = _makedeps(mp3player=fake_mp3, playlistdb=fake_db)
app.PlayerApp(deps)