Compare commits
26 Commits
e31aabbefc
...
experiment
| Author | SHA1 | Date | |
|---|---|---|---|
| 93e9aea368 | |||
| cac61f924f | |||
| 9320a3cff2 | |||
| 65efebc5c2 | |||
| 040ae4a731 | |||
| 9cf044bc80 | |||
| da9843adb9 | |||
| 02150aec42 | |||
| 7be038d0d1 | |||
| d96350c1a7 | |||
| eec3703b7e | |||
| 25ac3f0687 | |||
| 3367bba0c5 | |||
| c555ad94f0 | |||
| 10ec080e5f | |||
| fb01a8aebb | |||
| 2aa2249238 | |||
| 3e275a0aee | |||
| 71949bdd1a | |||
| 8070c0e113 | |||
| 059b705a38 | |||
| 3213ec8f66 | |||
| e2ca9e5139 | |||
| 070cf887ab | |||
| 28846c9274 | |||
| 51cb2c3a68 |
File diff suppressed because it is too large
Load Diff
Submodule software/lib/micropython updated: 4ecb4099cf...6fdbf1d339
@@ -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,19 +12,34 @@ 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()
|
||||
|
||||
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):
|
||||
self._audiocore.set_volume(volume)
|
||||
asyncio.create_task(self.async_set_volume(volume))
|
||||
|
||||
def put(self, buffer, blocking=False):
|
||||
pos = 0
|
||||
|
||||
@@ -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();
|
||||
get_fifo_read_value_blocking(self);
|
||||
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);
|
||||
|
||||
@@ -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}')
|
||||
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()
|
||||
@@ -193,3 +192,9 @@ class PlayerApp:
|
||||
|
||||
def get_nfc(self):
|
||||
return self.nfc
|
||||
|
||||
def get_playlist_db(self):
|
||||
return self.playlist_db
|
||||
|
||||
def get_leds(self):
|
||||
return self.leds
|
||||
|
||||
@@ -58,7 +58,7 @@ async def wdt_task(wdt):
|
||||
# TODO: more checking of app health
|
||||
# Right now this only protects against the asyncio executor crashing completely
|
||||
while True:
|
||||
await asyncio.sleep_ms(500)
|
||||
await asyncio.sleep_ms(100)
|
||||
wdt.feed()
|
||||
|
||||
DB_PATH = '/sd/tonberry.db'
|
||||
@@ -105,7 +105,7 @@ def run():
|
||||
|
||||
start_webserver(config, the_app)
|
||||
# Start
|
||||
wdt = machine.WDT(timeout=1000)
|
||||
wdt = machine.WDT(timeout=2000)
|
||||
asyncio.create_task(aiorepl.task({'timer_manager': TimerManager(),
|
||||
'app': the_app}))
|
||||
asyncio.create_task(wdt_task(wdt))
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -248,6 +248,16 @@ class BTreeDB(IPlaylistDB):
|
||||
if flush:
|
||||
self._flush()
|
||||
|
||||
def getPlaylistTags(self):
|
||||
"""
|
||||
Get a keys-only dict of all defined playlists. Playlists currently do not have names, but are identified by
|
||||
their tag.
|
||||
"""
|
||||
playlist_tags = set()
|
||||
for item in self.db:
|
||||
playlist_tags.add(item.split(b'/')[0])
|
||||
return playlist_tags
|
||||
|
||||
def getPlaylistForTag(self, tag: bytes):
|
||||
"""
|
||||
Lookup the playlist for 'tag' and return the Playlist object. Return None if no playlist exists for the given
|
||||
@@ -279,6 +289,9 @@ class BTreeDB(IPlaylistDB):
|
||||
self._savePlaylist(tag, entries, persist, shuffle)
|
||||
return self.getPlaylistForTag(tag)
|
||||
|
||||
def deletePlaylistForTag(self, tag: bytes):
|
||||
self._deletePlaylist(tag)
|
||||
|
||||
def validate(self, dump=False):
|
||||
"""
|
||||
Validate the structure of the playlist database.
|
||||
|
||||
@@ -4,34 +4,42 @@ Copyright (c) 2024-2025 Stefan Kratochwil <Kratochwil-LA@gmx.de>
|
||||
'''
|
||||
|
||||
import asyncio
|
||||
import hwconfig
|
||||
import json
|
||||
import machine
|
||||
import os
|
||||
import time
|
||||
|
||||
from microdot import Microdot, redirect, send_file
|
||||
from array import array
|
||||
from microdot import Microdot, redirect, send_file, Request
|
||||
from utils import TimerManager, LedManager
|
||||
|
||||
webapp = Microdot()
|
||||
server = None
|
||||
config = None
|
||||
app = None
|
||||
nfc = None
|
||||
playlist_db = None
|
||||
leds = None
|
||||
timer_manager = None
|
||||
|
||||
Request.max_content_length = 128 * 1024 * 1024 # 128MB requests allowed
|
||||
|
||||
|
||||
def start_webserver(config_, app_):
|
||||
global server, config, app, nfc
|
||||
global server, config, app, nfc, playlist_db, leds, timer_manager
|
||||
server = asyncio.create_task(webapp.start_server(port=80))
|
||||
config = config_
|
||||
app = app_
|
||||
nfc = app.get_nfc()
|
||||
playlist_db = app.get_playlist_db()
|
||||
leds = app.get_leds()
|
||||
timer_manager = TimerManager()
|
||||
|
||||
|
||||
@webapp.before_request
|
||||
async def before_request_handler(request):
|
||||
if request.method in ['PUT', 'POST'] and app.is_playing():
|
||||
return "Cannot write to device while playback is active", 503
|
||||
app.reset_idle_timeout()
|
||||
|
||||
|
||||
@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()
|
||||
|
||||
@@ -97,3 +105,168 @@ async def static(request, path):
|
||||
# directory traversal is not allowed
|
||||
return 'Not found', 404
|
||||
return send_file('/frontend/static/' + path, max_age=86400)
|
||||
|
||||
|
||||
@webapp.route('/api/v1/playlists', methods=['GET'])
|
||||
async def playlists_get(request):
|
||||
return sorted(playlist_db.getPlaylistTags())
|
||||
|
||||
|
||||
def is_hex(s):
|
||||
hex_chars = '0123456789abcdef'
|
||||
return all(c in hex_chars for c in s)
|
||||
|
||||
|
||||
fsroot = b'/sd'
|
||||
|
||||
|
||||
@webapp.route('/api/v1/playlist/<tag>', methods=['GET'])
|
||||
async def playlist_get(request, tag):
|
||||
if not is_hex(tag):
|
||||
return 'invalid tag', 400
|
||||
|
||||
playlist = playlist_db.getPlaylistForTag(tag.encode())
|
||||
if playlist is None:
|
||||
return None, 404
|
||||
|
||||
return {
|
||||
'shuffle': playlist.__dict__.get('shuffle'),
|
||||
'persist': playlist.__dict__.get('persist'),
|
||||
'paths': [(p[len(fsroot):] if p.startswith(fsroot) else p).decode()
|
||||
for p in playlist.getPaths()],
|
||||
}
|
||||
|
||||
|
||||
@webapp.route('/api/v1/playlist/<tag>', methods=['PUT'])
|
||||
async def playlist_put(request, tag):
|
||||
if not is_hex(tag):
|
||||
return 'invalid tag', 400
|
||||
|
||||
playlist = request.json
|
||||
if 'persist' in playlist and \
|
||||
playlist['persist'] not in ['no', 'track', 'offset']:
|
||||
return "Invalid 'persist' setting", 400
|
||||
if 'shuffle' in playlist and \
|
||||
playlist['shuffle'] not in ['no', 'yes']:
|
||||
return "Invalid 'shuffle' setting", 400
|
||||
|
||||
playlist_db.createPlaylistForTag(tag.encode(),
|
||||
(fsroot + path.encode() for path in playlist.get('paths', [])),
|
||||
playlist.get('persist', 'track').encode(),
|
||||
playlist.get('shuffle', 'no').encode())
|
||||
return '', 204
|
||||
|
||||
|
||||
@webapp.route('/api/v1/playlist/<tag>', methods=['DELETE'])
|
||||
async def playlist_delete(request, tag):
|
||||
if not is_hex(tag):
|
||||
return 'invalid tag', 400
|
||||
playlist_db.deletePlaylistForTag(tag.encode())
|
||||
return '', 204
|
||||
|
||||
|
||||
@webapp.route('/api/v1/audiofiles', methods=['GET'])
|
||||
async def audiofiles_get(request):
|
||||
def directory_iterator():
|
||||
yield '['
|
||||
first = True
|
||||
|
||||
def make_json_str(obj):
|
||||
nonlocal first
|
||||
jsonpath = json.dumps(obj)
|
||||
if not first:
|
||||
jsonpath = ',' + jsonpath
|
||||
first = False
|
||||
return jsonpath
|
||||
|
||||
dirstack = [fsroot]
|
||||
while dirstack:
|
||||
current_dir = dirstack.pop()
|
||||
for entry in os.ilistdir(current_dir):
|
||||
name = entry[0]
|
||||
type_ = entry[1]
|
||||
current_path = current_dir + b'/' + name
|
||||
if type_ == 0x4000:
|
||||
yield make_json_str({'name': current_path[len(fsroot):], 'type': 'directory'})
|
||||
dirstack.append(current_path)
|
||||
elif type_ == 0x8000:
|
||||
if name.lower().endswith('.mp3'):
|
||||
yield make_json_str({'name': current_path[len(fsroot):], 'type': 'file'})
|
||||
yield ']'
|
||||
|
||||
return directory_iterator(), {'Content-Type': 'application/json; charset=UTF-8'}
|
||||
|
||||
|
||||
@webapp.route('/api/v1/audiofiles', methods=['POST'])
|
||||
async def audiofile_upload(request):
|
||||
if 'type' not in request.args or request.args['type'] not in ['file', 'directory']:
|
||||
return 'invalid or missing type', 400
|
||||
if 'location' not in request.args:
|
||||
return 'missing location', 400
|
||||
path = fsroot + '/' + request.args['location']
|
||||
type_ = request.args['type']
|
||||
length = request.content_length
|
||||
print(f'Got upload request of type {type_} to {path} with length {length}')
|
||||
if type_ == 'directory':
|
||||
if length != 0:
|
||||
return 'directory request may not have content', 400
|
||||
os.mkdir(path)
|
||||
return '', 204
|
||||
with open(path, 'wb') as newfile:
|
||||
data = array('b', range(4096))
|
||||
bytes_copied = 0
|
||||
while True:
|
||||
bytes_read = await request.stream.readinto(data)
|
||||
if bytes_read == 0:
|
||||
# End of body
|
||||
break
|
||||
bytes_written = newfile.write(data[:bytes_read])
|
||||
if bytes_written != bytes_read:
|
||||
# short writes shouldn't happen
|
||||
return 'write failure', 500
|
||||
bytes_copied += bytes_written
|
||||
if bytes_copied == length:
|
||||
break
|
||||
if bytes_copied == length:
|
||||
return '', 204
|
||||
else:
|
||||
return 'size mismatch', 500
|
||||
|
||||
|
||||
def recursive_delete(path):
|
||||
stat = os.stat(path)
|
||||
if stat[0] == 0x8000:
|
||||
os.remove(path)
|
||||
elif stat[0] == 0x4000:
|
||||
for entry in os.ilistdir(path):
|
||||
entry_path = path + '/' + entry[0]
|
||||
recursive_delete(entry_path)
|
||||
os.rmdir(path)
|
||||
|
||||
|
||||
@webapp.route('/api/v1/audiofiles', methods=['DELETE'])
|
||||
async def audiofile_delete(request):
|
||||
if 'location' not in request.args:
|
||||
return 'missing location', 400
|
||||
location = request.args['location']
|
||||
if '..' in location or len(location) == 0:
|
||||
return 'bad location', 400
|
||||
path = fsroot + '/' + request.args['location']
|
||||
recursive_delete(path)
|
||||
return '', 204
|
||||
|
||||
|
||||
@webapp.route('/api/v1/reboot/<method>', methods=['POST'])
|
||||
async def reboot(request, method):
|
||||
if hwconfig.get_on_battery():
|
||||
return 'not allowed: usb not connected', 403
|
||||
|
||||
if method == 'bootloader':
|
||||
leds.set_state(LedManager.REBOOTING)
|
||||
timer_manager.schedule(time.ticks_ms() + 1500, machine.bootloader)
|
||||
elif method == 'application':
|
||||
leds.set_state(LedManager.REBOOTING)
|
||||
timer_manager.schedule(time.ticks_ms() + 1500, machine.reset)
|
||||
else:
|
||||
return 'method not supported', 400
|
||||
return '', 204
|
||||
|
||||
Reference in New Issue
Block a user