diff --git a/software/frontend/index.html b/software/frontend/index.html
index 39681e9..9ae9291 100644
--- a/software/frontend/index.html
+++ b/software/frontend/index.html
@@ -153,6 +153,8 @@
+
+
@@ -220,6 +222,7 @@
+
@@ -235,42 +238,23 @@
+
+
+
+
+
+
+
@@ -482,7 +466,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");
@@ -558,12 +543,14 @@
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");
}
@@ -597,6 +584,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");
});
@@ -698,6 +686,7 @@
alert("Failed to save playlist: " + await saveRes.text());
return;
}
+ showScreen('playlist');
}
function moveSelectedTracks(up) {
@@ -733,6 +722,15 @@
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();
}
@@ -786,17 +784,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;
}
@@ -822,6 +819,94 @@
}
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;
diff --git a/software/src/app.py b/software/src/app.py
index 344abed..71bb1d4 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()
@@ -191,3 +195,6 @@ class PlayerApp:
def get_playlist_db(self):
return self.playlist_db
+
+ 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 5af66fa..4d118e0 100644
--- a/software/src/webserver.py
+++ b/software/src/webserver.py
@@ -5,9 +5,13 @@ Copyright (c) 2024-2025 Stefan Kratochwil
import asyncio
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
@@ -15,20 +19,26 @@ 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()
@@ -159,6 +169,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()
@@ -167,15 +186,83 @@ 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'}
+
+
+@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/', 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