From 25ac3f06879e4dd5ec6e32b2c64e86110f40d07b Mon Sep 17 00:00:00 2001 From: Matthias Blankertz Date: Sun, 21 Dec 2025 13:23:23 +0100 Subject: [PATCH 1/8] feat: Add API and frontend to upload files Signed-off-by: Matthias Blankertz --- software/frontend/index.html | 61 +++++++++++++++++++++++++++++++++++- software/src/webserver.py | 43 +++++++++++++++++++++++-- 2 files changed, 101 insertions(+), 3 deletions(-) diff --git a/software/frontend/index.html b/software/frontend/index.html index 39681e9..d8a9103 100644 --- a/software/frontend/index.html +++ b/software/frontend/index.html @@ -271,6 +271,12 @@ + +
+ + + 0% +
@@ -482,7 +488,8 @@ document.getElementById('playlist-exist-button-gettag') .addEventListener('click', selectLastTag); document.getElementById('playlist-exist-button-edit').addEventListener("click", (e) => { - showScreen("playlist_edit", {load: lastSelected.innerText}); + if (lastSelected !== null) + showScreen("playlist_edit", {load: lastSelected.innerText}); }); document.getElementById('playlist-exist-list').addEventListener("click", (e) => { const node = e.target.closest("li"); @@ -564,6 +571,7 @@ .forEach(n => { if (n.innerText == tagtext) { n.classList.add("selected"); + lastSelected = n; } else { n.classList.remove("selected"); } @@ -733,6 +741,9 @@ document.getElementById('playlist-filebrowser-addtrack').addEventListener("click", (e) => { returnSelectedTracks(); }); + document.getElementById('playlist-filebrowser-upload').addEventListener("click", (e) => { + uploadFiles(); + }); tree.init(); } @@ -822,6 +833,54 @@ } 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]); + } let tree = (() => { let tree = null; diff --git a/software/src/webserver.py b/software/src/webserver.py index 5af66fa..85f4917 100644 --- a/software/src/webserver.py +++ b/software/src/webserver.py @@ -7,7 +7,8 @@ import asyncio import json import os -from microdot import Microdot, redirect, send_file +from array import array +from microdot import Microdot, redirect, send_file, Request webapp = Microdot() server = None @@ -16,6 +17,8 @@ app = None nfc = None playlist_db = None +Request.max_content_length = 128 * 1024 * 1024 # 128MB requests allowed + def start_webserver(config_, app_): global server, config, app, nfc, playlist_db @@ -28,7 +31,7 @@ def start_webserver(config_, app_): @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() @@ -179,3 +182,39 @@ async def audiofiles_get(request): 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 From eec3703b7eaab6a4cf96b0f26b87f65f4c121747 Mon Sep 17 00:00:00 2001 From: Matthias Blankertz Date: Sun, 21 Dec 2025 14:22:33 +0100 Subject: [PATCH 2/8] 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 --- software/frontend/index.html | 8 ++++---- software/src/webserver.py | 17 +++++++++++------ 2 files changed, 15 insertions(+), 10 deletions(-) diff --git a/software/frontend/index.html b/software/frontend/index.html index d8a9103..29576cf 100644 --- a/software/frontend/index.html +++ b/software/frontend/index.html @@ -565,6 +565,7 @@ 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") @@ -797,17 +798,16 @@ const rootnode = document.createElement('ul'); tree.appendChild(rootnode); - files.sort(); + files.sort((a, b) => a.name < b.name ? -1 : (a.name > b.name ? 1 : 0)); for (const file of files) { - const path = file.split('/').slice(1); + 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) { - const isFile = path.slice(-1)[0] === elem; - node = addTreeNode(treeIterator, elem, isFile ? 'file':'directory', curPath); + node = addTreeNode(treeIterator, elem, file.type, curPath); } treeIterator = node; } diff --git a/software/src/webserver.py b/software/src/webserver.py index 85f4917..c56fad2 100644 --- a/software/src/webserver.py +++ b/software/src/webserver.py @@ -162,6 +162,15 @@ 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() @@ -170,15 +179,11 @@ async def audiofiles_get(request): 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'): - jsonpath = json.dumps(current_path[len(fsroot):]) - if not first: - yield ','+jsonpath - else: - yield jsonpath - first = False + yield make_json_str({'name': current_path[len(fsroot):], 'type': 'file'}) yield ']' return directory_iterator(), {'Content-Type': 'application/json; charset=UTF-8'} From d96350c1a7ba3b020b6d68697cb03d45e4bc4199 Mon Sep 17 00:00:00 2001 From: Matthias Blankertz Date: Sun, 21 Dec 2025 14:23:34 +0100 Subject: [PATCH 3/8] feat: frontend: Allow creating directories Signed-off-by: Matthias Blankertz --- software/frontend/index.html | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/software/frontend/index.html b/software/frontend/index.html index 29576cf..533c725 100644 --- a/software/frontend/index.html +++ b/software/frontend/index.html @@ -277,6 +277,10 @@ 0% +
+ + +
@@ -745,6 +749,9 @@ document.getElementById('playlist-filebrowser-upload').addEventListener("click", (e) => { uploadFiles(); }); + document.getElementById('playlist-filebrowser-mkdir').addEventListener("click", (e) => { + createDirectory(); + }); tree.init(); } @@ -881,6 +888,25 @@ 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(); + } let tree = (() => { let tree = null; From 7be038d0d1060f68a806ca54d3439943f253e10e Mon Sep 17 00:00:00 2001 From: Matthias Blankertz Date: Sun, 21 Dec 2025 15:12:31 +0100 Subject: [PATCH 4/8] feat: Allow deleting files and directories Signed-off-by: Matthias Blankertz --- software/frontend/index.html | 63 +++++++++++++++++------------------- software/src/app.py | 6 +++- software/src/webserver.py | 23 +++++++++++++ 3 files changed, 57 insertions(+), 35 deletions(-) diff --git a/software/frontend/index.html b/software/frontend/index.html index 533c725..c812b44 100644 --- a/software/frontend/index.html +++ b/software/frontend/index.html @@ -235,51 +235,22 @@
  • Loading...
-
+
- +
+ 0% - 0%
- - + +
@@ -752,6 +723,9 @@ document.getElementById('playlist-filebrowser-mkdir').addEventListener("click", (e) => { createDirectory(); }); + document.getElementById('playlist-filebrowser-delete').addEventListener("click", (e) => { + deleteItems(); + }); tree.init(); } @@ -907,6 +881,27 @@ // 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; diff --git a/software/src/app.py b/software/src/app.py index 344abed..fc29d88 100644 --- a/software/src/app.py +++ b/software/src/app.py @@ -161,7 +161,11 @@ class PlayerApp: self._onIdle() if filename is not None: print(f'Playing {filename!r}') - self.mp3file = open(filename, 'rb') + 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() diff --git a/software/src/webserver.py b/software/src/webserver.py index c56fad2..2347a7b 100644 --- a/software/src/webserver.py +++ b/software/src/webserver.py @@ -223,3 +223,26 @@ async def audiofile_upload(request): 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 From 02150aec42855c87f49928240299da4225eac51f Mon Sep 17 00:00:00 2001 From: Matthias Blankertz Date: Sun, 21 Dec 2025 15:36:53 +0100 Subject: [PATCH 5/8] fix: frontend: Improve navigation on playlist edit screen Signed-off-by: Matthias Blankertz --- software/frontend/index.html | 3 +++ 1 file changed, 3 insertions(+) diff --git a/software/frontend/index.html b/software/frontend/index.html index c812b44..78a39b5 100644 --- a/software/frontend/index.html +++ b/software/frontend/index.html @@ -220,6 +220,7 @@
+ @@ -581,6 +582,7 @@ 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"); }); @@ -682,6 +684,7 @@ alert("Failed to save playlist: " + await saveRes.text()); return; } + showScreen('playlist'); } function moveSelectedTracks(up) { From da9843adb94f6a5bc33541043d72f25a267a1280 Mon Sep 17 00:00:00 2001 From: Stefan Kratochwil Date: Sun, 21 Dec 2025 16:57:37 +0100 Subject: [PATCH 6/8] feat: /api/v1/reboot/[bootloader|application], confirm with pink LED pattern --- software/src/app.py | 6 ++++++ software/src/utils/leds.py | 5 ++++- software/src/webserver.py | 22 +++++++++++++++++++++- 3 files changed, 31 insertions(+), 2 deletions(-) diff --git a/software/src/app.py b/software/src/app.py index fc29d88..35abf48 100644 --- a/software/src/app.py +++ b/software/src/app.py @@ -195,3 +195,9 @@ class PlayerApp: def get_playlist_db(self): return self.playlist_db + + def get_timer_manager(self): + return self.timer_manager + + def get_leds(self): + return self.leds diff --git a/software/src/utils/leds.py b/software/src/utils/leds.py index 3598c6d..621c1f4 100644 --- a/software/src/utils/leds.py +++ b/software/src/utils/leds.py @@ -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() diff --git a/software/src/webserver.py b/software/src/webserver.py index 2347a7b..c8df31f 100644 --- a/software/src/webserver.py +++ b/software/src/webserver.py @@ -5,10 +5,13 @@ Copyright (c) 2024-2025 Stefan Kratochwil import asyncio import json +import machine import os +import time from array import array from microdot import Microdot, redirect, send_file, Request +from utils import TimerManager, LedManager webapp = Microdot() server = None @@ -16,17 +19,21 @@ 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 = app.get_timer_manager() @webapp.before_request @@ -246,3 +253,16 @@ async def audiofile_delete(request): path = fsroot + '/' + request.args['location'] recursive_delete(path) return '', 204 + + +@webapp.route('/api/v1/reboot/', methods=['POST']) +async def reboot(request, method): + 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 From 9cf044bc80bbdfa8f45a0e641e8907e764d27ba7 Mon Sep 17 00:00:00 2001 From: Matthias Blankertz Date: Sun, 21 Dec 2025 17:07:09 +0100 Subject: [PATCH 7/8] feat: frontend: Add reboot to bootloader button (for updates) Signed-off-by: Matthias Blankertz --- software/frontend/index.html | 2 ++ 1 file changed, 2 insertions(+) diff --git a/software/frontend/index.html b/software/frontend/index.html index 78a39b5..9ae9291 100644 --- a/software/frontend/index.html +++ b/software/frontend/index.html @@ -153,6 +153,8 @@
  • +
    +
    From 17ccefd922d1bc38da55c4e95bfd07528216c97d Mon Sep 17 00:00:00 2001 From: Matthias Blankertz Date: Sun, 21 Dec 2025 17:37:00 +0100 Subject: [PATCH 8/8] fix: fix flake8 complaint Signed-off-by: Matthias Blankertz --- software/src/app.py | 3 --- software/src/webserver.py | 2 +- 2 files changed, 1 insertion(+), 4 deletions(-) diff --git a/software/src/app.py b/software/src/app.py index 35abf48..71bb1d4 100644 --- a/software/src/app.py +++ b/software/src/app.py @@ -196,8 +196,5 @@ class PlayerApp: def get_playlist_db(self): return self.playlist_db - def get_timer_manager(self): - return self.timer_manager - def get_leds(self): return self.leds diff --git a/software/src/webserver.py b/software/src/webserver.py index c8df31f..4d118e0 100644 --- a/software/src/webserver.py +++ b/software/src/webserver.py @@ -33,7 +33,7 @@ def start_webserver(config_, app_): nfc = app.get_nfc() playlist_db = app.get_playlist_db() leds = app.get_leds() - timer_manager = app.get_timer_manager() + timer_manager = TimerManager() @webapp.before_request