diff --git a/software/frontend/index.html b/software/frontend/index.html index 39681e9..0184d89 100644 --- a/software/frontend/index.html +++ b/software/frontend/index.html @@ -153,6 +153,8 @@
  • +
    + @@ -220,6 +222,7 @@
    + @@ -235,42 +238,23 @@
    • Loading...
    -
    +
    +
    +
    + + 0% + +
    +
    + + +
    @@ -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; @@ -881,6 +966,14 @@ 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 => { 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..ea7f3cb 100644 --- a/software/src/webserver.py +++ b/software/src/webserver.py @@ -4,10 +4,15 @@ Copyright (c) 2024-2025 Stefan Kratochwil ''' 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 @@ -15,20 +20,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 +170,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 +187,86 @@ 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 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