Compare commits
12 Commits
experiment
...
d8eb61e967
| Author | SHA1 | Date | |
|---|---|---|---|
| d8eb61e967 | |||
| a1d71964f7 | |||
| bdde8cb4c2 | |||
| 94a8c3d720 | |||
| 2971df7b68 | |||
| 18a58992f3 | |||
| 4e0af8e3fc | |||
| d3aef1be32 | |||
| b9baa1c7d5 | |||
| 67d7650923 | |||
| 43fd68779c | |||
| 704951074b |
29
software/boards/RPI_PICO_W/board.c
Normal file
29
software/boards/RPI_PICO_W/board.c
Normal file
@@ -0,0 +1,29 @@
|
||||
// SPDX-License-Identifier: MIT
|
||||
// Copyright (c) 2025 Matthias Blankertz <matthias@blankertz.org>
|
||||
|
||||
#include "py/obj.h"
|
||||
#include "py/runtime.h"
|
||||
#include "py/objstr.h"
|
||||
|
||||
#ifndef TONBERRY_GIT_REVISION
|
||||
#define TONBERRY_GIT_REVISION "unknown"
|
||||
#endif
|
||||
#ifndef TONBERRY_VERSION
|
||||
#define TONBERRY_VERSION "unknown"
|
||||
#endif
|
||||
|
||||
static const MP_DEFINE_STR_OBJ(tonberry_git_revision_obj, TONBERRY_GIT_REVISION);
|
||||
static const MP_DEFINE_STR_OBJ(tonberry_version_obj, TONBERRY_VERSION);
|
||||
|
||||
static const mp_rom_map_elem_t board_module_globals_table[] = {
|
||||
{MP_ROM_QSTR(MP_QSTR___name__), MP_ROM_QSTR(MP_QSTR_board)},
|
||||
{MP_ROM_QSTR(MP_QSTR_revision), MP_ROM_PTR(&tonberry_git_revision_obj)},
|
||||
{MP_ROM_QSTR(MP_QSTR_version), MP_ROM_PTR(&tonberry_version_obj)},
|
||||
};
|
||||
static MP_DEFINE_CONST_DICT(board_module_globals, board_module_globals_table);
|
||||
|
||||
const mp_obj_module_t board_cmodule = {
|
||||
.base = {&mp_type_module},
|
||||
.globals = (mp_obj_dict_t *)&board_module_globals,
|
||||
};
|
||||
MP_REGISTER_MODULE(MP_QSTR_board, board_cmodule);
|
||||
@@ -20,3 +20,22 @@ set(GEN_PINS_CSV_ARG --board-csv "${GEN_PINS_BOARD_CSV}")
|
||||
|
||||
add_link_options("-Wl,--print-memory-usage")
|
||||
set(PICO_USE_FASTEST_SUPPORTED_CLOCK 1)
|
||||
|
||||
find_program(GIT git)
|
||||
|
||||
execute_process(COMMAND ${GIT} rev-parse HEAD
|
||||
WORKING_DIRECTORY "${CMAKE_CURRENT_LIST_DIR}"
|
||||
OUTPUT_VARIABLE TONBERRY_GIT_REVISION
|
||||
OUTPUT_STRIP_TRAILING_WHITESPACE
|
||||
)
|
||||
|
||||
execute_process(COMMAND ${GIT} describe --match 'v*' --always --dirty
|
||||
WORKING_DIRECTORY "${CMAKE_CURRENT_LIST_DIR}"
|
||||
OUTPUT_VARIABLE TONBERRY_VERSION
|
||||
OUTPUT_STRIP_TRAILING_WHITESPACE
|
||||
)
|
||||
|
||||
set(MICROPY_SOURCE_BOARD "${CMAKE_CURRENT_LIST_DIR}/board.c")
|
||||
set(MICROPY_DEF_BOARD
|
||||
TONBERRY_GIT_REVISION="${TONBERRY_GIT_REVISION}"
|
||||
TONBERRY_VERSION="${TONBERRY_VERSION}")
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>Device Admin</title>
|
||||
<title>TonBERRY pico</title>
|
||||
<style>
|
||||
body {
|
||||
font-family: Arial, sans-serif;
|
||||
@@ -131,11 +131,17 @@
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.footer {
|
||||
font-size: x-small;
|
||||
color: gray;
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<h1>Device Admin</h1>
|
||||
<h1>TonBERRY pico</h1>
|
||||
|
||||
<nav>
|
||||
<button onclick="showScreen('menu')">🏠 Main Menu</button>
|
||||
@@ -161,7 +167,7 @@
|
||||
<div id="screen-config" class="screen">
|
||||
<h2>Configuration Editor</h2>
|
||||
<div id="config-container">Loading…</div>
|
||||
<button id="config-save-btn" disabled>Save</button>
|
||||
<button id="config-save-btn" disabled>Save & Reboot</button>
|
||||
</div>
|
||||
|
||||
<!-- PLAYLIST EDITOR SCREEN 1: list of playlists -->
|
||||
@@ -207,6 +213,10 @@
|
||||
<option value="audioplay">Audioplay (no shuffle, start at previous track)</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label>Playlist name</label>
|
||||
<input type="text" placeholder="Playlist name" id="playlist-edit-name" />
|
||||
</div>
|
||||
<div class="flex-horizontal">
|
||||
<div style="flex-grow: 1">
|
||||
<label>Tracks</label>
|
||||
@@ -257,7 +267,17 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="footer">
|
||||
<hr>
|
||||
<div class="flex-horizontal">
|
||||
<div>
|
||||
<a href="https://git.ka.blankertz.org/TonBERRY/tonberry-pico">TonBERRY pico</a>
|
||||
</div>
|
||||
<div>
|
||||
Version: <span id="footer-version">unknown</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<script>
|
||||
const Screens = {};
|
||||
let activeScreen = null;
|
||||
@@ -296,7 +316,9 @@
|
||||
return;
|
||||
}
|
||||
|
||||
alert("Configuration saved successfully!");
|
||||
alert("Configuration saved successfully, device will now reboot/shutdown! " +
|
||||
"On battery, press Power button after shutdown to restart.");
|
||||
await fetch('/api/v1/reboot/application', {'method': 'POST'});
|
||||
} catch (err) {
|
||||
alert("Error saving configuration: " + err);
|
||||
}
|
||||
@@ -334,7 +356,8 @@
|
||||
'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'
|
||||
'root.LED_COUNT': 'Length of WS2182 (Neopixel) LED chain',
|
||||
'root.VOLUME_MAX': 'Maximum volume (0-255)'
|
||||
};
|
||||
const config_input_override = {
|
||||
'root.TAGMODE': {
|
||||
@@ -344,6 +367,21 @@
|
||||
'tagstartstop': 'Present tag once to start, present again to stop playback'
|
||||
}
|
||||
},
|
||||
'root.BUTTON_MAP.NEXT': {
|
||||
'input-type': 'number'
|
||||
},
|
||||
'root.BUTTON_MAP.PREV': {
|
||||
'input-type': 'number'
|
||||
},
|
||||
'root.BUTTON_MAP.VOLUP': {
|
||||
'input-type': 'number'
|
||||
},
|
||||
'root.BUTTON_MAP.VOLDOWN': {
|
||||
'input-type': 'number'
|
||||
},
|
||||
'root.BUTTON_MAP.PLAY_PAUSE': {
|
||||
'input-type': 'number'
|
||||
},
|
||||
'root.IDLE_TIMEOUT_SECS': {
|
||||
'input-type': 'number'
|
||||
},
|
||||
@@ -353,6 +391,15 @@
|
||||
'root.LED_COUNT': {
|
||||
'input-type': 'number'
|
||||
},
|
||||
'root.WLAN.SSID': {
|
||||
'input-type': 'text'
|
||||
},
|
||||
'root.WLAN.PASSPHRASE': {
|
||||
'input-type': 'text'
|
||||
},
|
||||
'root.VOLUME_MAX': {
|
||||
'input-type': 'number'
|
||||
}
|
||||
};
|
||||
|
||||
function renderObject(obj, path) {
|
||||
@@ -423,7 +470,7 @@
|
||||
|
||||
let val = input.value.trim();
|
||||
if (val === "") val = null;
|
||||
else if (!isNaN(val)) val = Number(val);
|
||||
else if (!isNaN(val) && input.type != 'text') val = Number(val);
|
||||
|
||||
current[path[path.length - 1]] = val;
|
||||
});
|
||||
@@ -456,7 +503,7 @@
|
||||
document.getElementById('playlist-exist-button-delete')
|
||||
.addEventListener('click', (e) => {
|
||||
if (lastSelected === null) return;
|
||||
const tagid = lastSelected.innerText;
|
||||
const tagid = lastSelected.getAttribute('data-tag');
|
||||
if(confirm(`Really delete playlist ${tagid}?`)) {
|
||||
fetch(`/api/v1/playlist/${tagid}`, {
|
||||
method: 'DELETE'})
|
||||
@@ -467,7 +514,7 @@
|
||||
.addEventListener('click', selectLastTag);
|
||||
document.getElementById('playlist-exist-button-edit').addEventListener("click", (e) => {
|
||||
if (lastSelected !== null)
|
||||
showScreen("playlist_edit", {load: lastSelected.innerText});
|
||||
showScreen("playlist_edit", {load: lastSelected.getAttribute('data-tag')});
|
||||
});
|
||||
document.getElementById('playlist-exist-list').addEventListener("click", (e) => {
|
||||
const node = e.target.closest("li");
|
||||
@@ -522,8 +569,9 @@
|
||||
container.innerHTML = "";
|
||||
for (const playlist of playlists) {
|
||||
const li = document.createElement("li");
|
||||
li.innerHTML = playlist;
|
||||
li.innerHTML = `${playlist.name} (${playlist.tag})`;
|
||||
li.className = "node"
|
||||
li.setAttribute('data-tag', playlist.tag);
|
||||
container.appendChild(li)
|
||||
}
|
||||
}
|
||||
@@ -548,7 +596,7 @@
|
||||
document.getElementById('playlist-exist-list')
|
||||
.querySelectorAll("li")
|
||||
.forEach(n => {
|
||||
if (n.innerText == tagtext) {
|
||||
if (n.getAttribute('data-tag') == tagtext) {
|
||||
n.classList.add("selected");
|
||||
lastSelected = n;
|
||||
} else {
|
||||
@@ -650,6 +698,8 @@
|
||||
} else if (playlist.persist === "track" && playlist.shuffle === "no") {
|
||||
playlisttype.value = "audioplay";
|
||||
}
|
||||
const playlistname = document.getElementById('playlist-edit-name');
|
||||
playlistname.value = playlist.name;
|
||||
}
|
||||
|
||||
async function save() {
|
||||
@@ -672,6 +722,8 @@
|
||||
playlistData.shuffle = "no";
|
||||
break;
|
||||
}
|
||||
const playlistname = document.getElementById('playlist-edit-name');
|
||||
playlistData.name = playlistname.value;
|
||||
const container = document.getElementById('playlist-edit-list');
|
||||
playlistData.paths = [...container.querySelectorAll("li")].map((node) => node.innerText);
|
||||
const saveRes = await fetch(`/api/v1/playlist/${playlistId}`,
|
||||
@@ -981,6 +1033,13 @@
|
||||
});
|
||||
|
||||
showScreen("menu");
|
||||
|
||||
fetch('/api/v1/info')
|
||||
.then((resp) => resp.json())
|
||||
.then((info) => {
|
||||
const version = document.getElementById('footer-version');
|
||||
version.innerText = info.version;
|
||||
});
|
||||
</script>
|
||||
|
||||
</body>
|
||||
|
||||
Submodule software/lib/microdot updated: d864b81b65...10e740da2b
@@ -2,8 +2,7 @@
|
||||
# Copyright (c) 2025 Matthias Blankertz <matthias@blankertz.org>
|
||||
|
||||
import _audiocore
|
||||
import asyncio
|
||||
from asyncio import Lock, ThreadSafeFlag
|
||||
from asyncio import ThreadSafeFlag
|
||||
from utils import get_pin_index
|
||||
|
||||
|
||||
@@ -12,34 +11,19 @@ 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):
|
||||
asyncio.create_task(self.async_set_volume(volume))
|
||||
self._audiocore.set_volume(volume)
|
||||
|
||||
def put(self, buffer, blocking=False):
|
||||
pos = 0
|
||||
|
||||
@@ -5,8 +5,6 @@
|
||||
|
||||
// Include MicroPython API.
|
||||
#include "py/mperrno.h"
|
||||
#include "py/obj.h"
|
||||
#include "py/persistentcode.h"
|
||||
#include "py/runtime.h"
|
||||
#include "shared/runtime/mpirq.h"
|
||||
|
||||
@@ -63,20 +61,6 @@ 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)
|
||||
*
|
||||
@@ -141,31 +125,15 @@ 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(size_t n_args, const mp_obj_t *args)
|
||||
static mp_obj_t audiocore_flush(mp_obj_t 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];
|
||||
}
|
||||
struct audiocore_obj *self = MP_OBJ_TO_PTR(self_in);
|
||||
multicore_fifo_push_blocking(AUDIOCORE_CMD_FLUSH);
|
||||
wake_core1();
|
||||
if (mp_obj_is_true(blocking))
|
||||
get_fifo_read_value_blocking(self);
|
||||
get_fifo_read_value_blocking(self);
|
||||
return mp_const_none;
|
||||
}
|
||||
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);
|
||||
static MP_DEFINE_CONST_FUN_OBJ_1(audiocore_flush_obj, audiocore_flush);
|
||||
|
||||
/*
|
||||
* audiocore.set_volume(self, volume)
|
||||
@@ -272,7 +240,6 @@ 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);
|
||||
|
||||
@@ -9,8 +9,8 @@ from utils import TimerManager
|
||||
Dependencies = namedtuple('Dependencies', ('mp3player', 'nfcreader', 'buttons', 'playlistdb', 'hwconfig', 'leds',
|
||||
'config'))
|
||||
|
||||
# Should be ~ 6dB steps
|
||||
VOLUME_CURVE = [1, 2, 4, 8, 16, 32, 63, 126, 251]
|
||||
# Should be ~ 3dB steps
|
||||
VOLUME_CURVE = [1, 2, 3, 4, 6, 8, 11, 16, 23, 32, 45, 64, 91, 128, 181, 255]
|
||||
|
||||
|
||||
class PlayerApp:
|
||||
@@ -52,6 +52,7 @@ class PlayerApp:
|
||||
self.hwconfig = deps.hwconfig(self)
|
||||
self.leds = deps.leds(self)
|
||||
self.tag_mode = self.config.get_tagmode()
|
||||
self.volume_max = self.config.get_volume_max()
|
||||
self.playing_tag = None
|
||||
self.playlist = None
|
||||
self.buttons = deps.buttons(self) if deps.buttons is not None else None
|
||||
@@ -91,8 +92,10 @@ class PlayerApp:
|
||||
def onButtonPressed(self, what):
|
||||
assert self.buttons is not None
|
||||
if what == self.buttons.VOLUP:
|
||||
self.volume_pos = min(self.volume_pos + 1, len(VOLUME_CURVE) - 1)
|
||||
self.player.set_volume(VOLUME_CURVE[self.volume_pos])
|
||||
new_volume = min(self.volume_pos + 1, len(VOLUME_CURVE) - 1)
|
||||
if VOLUME_CURVE[new_volume] <= self.volume_max:
|
||||
self.volume_pos = new_volume
|
||||
self.player.set_volume(VOLUME_CURVE[self.volume_pos])
|
||||
elif what == self.buttons.VOLDOWN:
|
||||
self.volume_pos = max(self.volume_pos - 1, 0)
|
||||
self.player.set_volume(VOLUME_CURVE[self.volume_pos])
|
||||
|
||||
@@ -10,6 +10,7 @@ import network
|
||||
import os
|
||||
import time
|
||||
import ubinascii
|
||||
import sys
|
||||
|
||||
# Own modules
|
||||
import app
|
||||
@@ -37,14 +38,22 @@ hwconfig.board_init()
|
||||
machine.mem32[0x40030000 + 0x00] = 0x10
|
||||
|
||||
|
||||
def setup_wifi():
|
||||
def setup_wifi(ssid='', passphrase='', sec=network.WLAN.SEC_WPA2_WPA3):
|
||||
network.hostname("TonberryPico")
|
||||
wlan = network.WLAN(network.WLAN.IF_AP)
|
||||
wlan.config(ssid=f"TonberryPicoAP_{machine.unique_id().hex()}", security=wlan.SEC_OPEN)
|
||||
wlan.active(True)
|
||||
if ssid == '':
|
||||
apname = f"TonberryPicoAP_{machine.unique_id().hex()}"
|
||||
print(f"Create AP {apname}")
|
||||
wlan = network.WLAN(network.WLAN.IF_AP)
|
||||
wlan.config(ssid=apname, security=wlan.SEC_OPEN)
|
||||
wlan.active(True)
|
||||
else:
|
||||
print(f"Connect to SSID {ssid} with passphrase {passphrase}...")
|
||||
wlan = network.WLAN()
|
||||
wlan.active(True)
|
||||
wlan.connect(ssid, passphrase, security=sec)
|
||||
|
||||
# disable power management
|
||||
wlan.config(pm=network.WLAN.PM_NONE)
|
||||
# configure power management
|
||||
wlan.config(pm=network.WLAN.PM_PERFORMANCE)
|
||||
|
||||
mac = ubinascii.hexlify(network.WLAN().config('mac'), ':').decode()
|
||||
print(f" mac: {mac}")
|
||||
@@ -65,14 +74,22 @@ DB_PATH = '/sd/tonberry.db'
|
||||
|
||||
config = Configuration()
|
||||
|
||||
# Setup LEDs
|
||||
np = NeoPixel(hwconfig.LED_DIN, config.get_led_count(), sm=1)
|
||||
np.fill((32, 32, 0))
|
||||
np.write()
|
||||
|
||||
|
||||
def run():
|
||||
asyncio.new_event_loop()
|
||||
# Setup LEDs
|
||||
np = NeoPixel(hwconfig.LED_DIN, config.get_led_count(), sm=1)
|
||||
|
||||
# Wifi with default config
|
||||
setup_wifi()
|
||||
if machine.Pin(hwconfig.BUTTONS[1], machine.Pin.IN, machine.Pin.PULL_UP).value() == 0:
|
||||
np.fill((0, 0, 32))
|
||||
np.write()
|
||||
# Force default access point
|
||||
setup_wifi('', '')
|
||||
else:
|
||||
setup_wifi(config.get_wifi_ssid(), config.get_wifi_passphrase())
|
||||
|
||||
# Setup MP3 player
|
||||
with SDContext(mosi=hwconfig.SD_DI, miso=hwconfig.SD_DO, sck=hwconfig.SD_SCK, ss=hwconfig.SD_CS,
|
||||
@@ -130,7 +147,24 @@ def builddb():
|
||||
os.sync()
|
||||
|
||||
|
||||
def error_blink():
|
||||
while True:
|
||||
np.fill((32, 0, 0))
|
||||
np.write()
|
||||
time.sleep_ms(500)
|
||||
np.fill((0, 0, 0))
|
||||
np.write()
|
||||
time.sleep_ms(500)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
time.sleep(1)
|
||||
if machine.Pin(hwconfig.BUTTONS[0], machine.Pin.IN, machine.Pin.PULL_UP).value() != 0:
|
||||
run()
|
||||
try:
|
||||
run()
|
||||
except Exception as ex:
|
||||
sys.print_exception(ex)
|
||||
error_blink()
|
||||
else:
|
||||
np.fill((32, 0, 0))
|
||||
np.write()
|
||||
|
||||
@@ -74,7 +74,7 @@ class MP3Player:
|
||||
# Call onPlaybackDone after flush
|
||||
send_done = True
|
||||
finally:
|
||||
await self.audiocore.async_flush()
|
||||
self.audiocore.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
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
# Copyright (c) 2025 Matthias Blankertz <matthias@blankertz.org>
|
||||
|
||||
from utils.helpers import safe_callback
|
||||
from utils.timer import TimerManager
|
||||
from utils.buttons import Buttons
|
||||
from utils.config import Configuration
|
||||
from utils.leds import LedManager
|
||||
@@ -9,7 +10,6 @@ from utils.mbrpartition import MBRPartition
|
||||
from utils.pinindex import get_pin_index
|
||||
from utils.playlistdb import BTreeDB, BTreeFileManager
|
||||
from utils.sdcontext import SDContext
|
||||
from utils.timer import TimerManager
|
||||
|
||||
__all__ = ["BTreeDB", "BTreeFileManager", "Buttons", "Configuration", "get_pin_index", "LedManager", "MBRPartition",
|
||||
"safe_callback", "SDContext", "TimerManager"]
|
||||
|
||||
@@ -5,7 +5,7 @@ import asyncio
|
||||
import machine
|
||||
import micropython
|
||||
import time
|
||||
from utils import safe_callback
|
||||
from utils import safe_callback, TimerManager
|
||||
try:
|
||||
from typing import TYPE_CHECKING # type: ignore
|
||||
except ImportError:
|
||||
@@ -32,6 +32,7 @@ class Buttons:
|
||||
def __init__(self, cb: "ButtonCallback", config, hwconfig):
|
||||
self.button_map = config.get_button_map()
|
||||
self.hw_buttons = hwconfig.BUTTONS
|
||||
self.hwconfig = hwconfig
|
||||
self.cb = cb
|
||||
self.buttons = dict()
|
||||
for key_id, key_name in self.KEYMAP.items():
|
||||
@@ -42,6 +43,7 @@ class Buttons:
|
||||
self.int_flag = asyncio.ThreadSafeFlag()
|
||||
self.pressed: list[int] = []
|
||||
self.last: dict[int, int] = {}
|
||||
self.timer_manager = TimerManager()
|
||||
for button in self.buttons.keys():
|
||||
button.irq(handler=self._interrupt, trigger=machine.Pin.IRQ_FALLING | machine.Pin.IRQ_RISING)
|
||||
asyncio.create_task(self.task())
|
||||
@@ -69,6 +71,10 @@ class Buttons:
|
||||
# print(f'B{keycode} {now}')
|
||||
self.pressed.append(keycode)
|
||||
self.int_flag.set()
|
||||
if keycode == self.VOLDOWN:
|
||||
self.timer_manager.schedule(time.ticks_ms() + 5000, self.long_press_shutdown)
|
||||
if button.value() == 1 and keycode == self.VOLDOWN:
|
||||
self.timer_manager.cancel(self.long_press_shutdown)
|
||||
|
||||
async def task(self):
|
||||
while True:
|
||||
@@ -76,3 +82,9 @@ class Buttons:
|
||||
while len(self.pressed) > 0:
|
||||
what = self.pressed.pop()
|
||||
safe_callback(lambda: self.cb.onButtonPressed(what), "button callback")
|
||||
|
||||
def long_press_shutdown(self):
|
||||
if self.hwconfig.get_on_battery():
|
||||
self.hwconfig.power_off()
|
||||
else:
|
||||
machine.reset()
|
||||
|
||||
@@ -22,7 +22,12 @@ class Configuration:
|
||||
'PREV': None,
|
||||
'NEXT': 1,
|
||||
},
|
||||
'TAGMODE': 'tagremains'
|
||||
'TAGMODE': 'tagremains',
|
||||
'WIFI': {
|
||||
'SSID': '',
|
||||
'PASSPHRASE': '',
|
||||
},
|
||||
'VOLUME_MAX': 255
|
||||
}
|
||||
|
||||
def __init__(self, config_path='/config.json'):
|
||||
@@ -87,6 +92,15 @@ class Configuration:
|
||||
def get_tagmode(self) -> str:
|
||||
return self._get('TAGMODE')
|
||||
|
||||
def get_wifi_ssid(self) -> str:
|
||||
return self._get('WIFI')['SSID']
|
||||
|
||||
def get_wifi_passphrase(self) -> str:
|
||||
return self._get('WIFI')['PASSPHRASE']
|
||||
|
||||
def get_volume_max(self) -> int:
|
||||
return self._get('VOLUME_MAX')
|
||||
|
||||
# For the web API
|
||||
def get_config(self) -> Mapping[str, Any]:
|
||||
return self.config
|
||||
|
||||
@@ -33,12 +33,13 @@ class BTreeDB(IPlaylistDB):
|
||||
PERSIST_OFFSET = b'offset'
|
||||
|
||||
class Playlist(IPlaylist):
|
||||
def __init__(self, parent: "BTreeDB", tag: bytes, pos: int, persist, shuffle):
|
||||
def __init__(self, parent: "BTreeDB", tag: bytes, pos: int, persist, shuffle, name):
|
||||
self.parent = parent
|
||||
self.tag = tag
|
||||
self.pos = pos
|
||||
self.persist = persist
|
||||
self.shuffle = shuffle
|
||||
self.name = name
|
||||
self.length = self.parent._getPlaylistLength(self.tag)
|
||||
self._shuffle()
|
||||
|
||||
@@ -168,6 +169,10 @@ class BTreeDB(IPlaylistDB):
|
||||
return (b''.join([tag, b'/playlist/']),
|
||||
b''.join([tag, b'/playlist0']))
|
||||
|
||||
@staticmethod
|
||||
def _keyPlaylistName(tag):
|
||||
return b''.join([tag, b'/playlistname'])
|
||||
|
||||
def _flush(self):
|
||||
"""
|
||||
Flush the database and call the flush_func if it was provided.
|
||||
@@ -222,12 +227,13 @@ class BTreeDB(IPlaylistDB):
|
||||
raise RuntimeError("Malformed playlist key")
|
||||
return int(elements[2])+1
|
||||
|
||||
def _savePlaylist(self, tag, entries, persist, shuffle, flush=True):
|
||||
def _savePlaylist(self, tag, entries, persist, shuffle, name, flush=True):
|
||||
self._deletePlaylist(tag, False)
|
||||
for idx, entry in enumerate(entries):
|
||||
self.db[self._keyPlaylistEntry(tag, idx)] = entry
|
||||
self.db[self._keyPlaylistPersist(tag)] = persist
|
||||
self.db[self._keyPlaylistShuffle(tag)] = shuffle
|
||||
self.db[self._keyPlaylistName(tag)] = name.encode()
|
||||
if flush:
|
||||
self._flush()
|
||||
|
||||
@@ -240,7 +246,7 @@ class BTreeDB(IPlaylistDB):
|
||||
pass
|
||||
for k in (self._keyPlaylistPos(tag), self._keyPlaylistPosOffset(tag),
|
||||
self._keyPlaylistPersist(tag), self._keyPlaylistShuffle(tag),
|
||||
self._keyPlaylistShuffleSeed(tag)):
|
||||
self._keyPlaylistShuffleSeed(tag), self._keyPlaylistName(tag)):
|
||||
try:
|
||||
del self.db[k]
|
||||
except KeyError:
|
||||
@@ -248,15 +254,18 @@ class BTreeDB(IPlaylistDB):
|
||||
if flush:
|
||||
self._flush()
|
||||
|
||||
def getPlaylistTags(self):
|
||||
def getPlaylists(self):
|
||||
"""
|
||||
Get a keys-only dict of all defined playlists. Playlists currently do not have names, but are identified by
|
||||
their tag.
|
||||
Get a list of all defined playlists with their tag and names.
|
||||
"""
|
||||
playlist_tags = set()
|
||||
for item in self.db:
|
||||
playlist_tags.add(item.split(b'/')[0])
|
||||
return playlist_tags
|
||||
playlists = []
|
||||
for tag in playlist_tags:
|
||||
name = self.db.get(self._keyPlaylistName(tag), b'').decode()
|
||||
playlists.append({'tag': tag, 'name': name})
|
||||
return playlists
|
||||
|
||||
def getPlaylistForTag(self, tag: bytes):
|
||||
"""
|
||||
@@ -275,18 +284,19 @@ class BTreeDB(IPlaylistDB):
|
||||
return None
|
||||
if self._keyPlaylistEntry(tag, pos) not in self.db:
|
||||
pos = 0
|
||||
name = self.db.get(self._keyPlaylistName(tag), b'').decode()
|
||||
shuffle = self.db.get(self._keyPlaylistShuffle(tag), self.SHUFFLE_NO)
|
||||
return self.Playlist(self, tag, pos, persist, shuffle)
|
||||
return self.Playlist(self, tag, pos, persist, shuffle, name)
|
||||
|
||||
def createPlaylistForTag(self, tag: bytes, entries: typing.Iterable[bytes], persist=PERSIST_TRACK,
|
||||
shuffle=SHUFFLE_NO):
|
||||
shuffle=SHUFFLE_NO, name: str = ''):
|
||||
"""
|
||||
Create and save a playlist for 'tag' and return the Playlist object. If a playlist already existed for 'tag' it
|
||||
is overwritten.
|
||||
"""
|
||||
assert persist in (self.PERSIST_NO, self.PERSIST_TRACK, self.PERSIST_OFFSET)
|
||||
assert shuffle in (self.SHUFFLE_NO, self.SHUFFLE_YES)
|
||||
self._savePlaylist(tag, entries, persist, shuffle)
|
||||
self._savePlaylist(tag, entries, persist, shuffle, name)
|
||||
return self.getPlaylistForTag(tag)
|
||||
|
||||
def deletePlaylistForTag(self, tag: bytes):
|
||||
@@ -370,6 +380,14 @@ class BTreeDB(IPlaylistDB):
|
||||
_ = int(val)
|
||||
except ValueError:
|
||||
fail(f' Bad playlistposoffset value for {last_tag}: {val!r}')
|
||||
elif fields[1] == b'playlistname':
|
||||
val = self.db[k]
|
||||
try:
|
||||
name = val.decode()
|
||||
if dump:
|
||||
print(f'\tName: {name}')
|
||||
except UnicodeError:
|
||||
fail(f' Bad playlistname for {last_tag}: Not valid unicode')
|
||||
else:
|
||||
fail(f'Unknown key {k!r}')
|
||||
return result
|
||||
|
||||
@@ -4,11 +4,14 @@ Copyright (c) 2024-2025 Stefan Kratochwil <Kratochwil-LA@gmx.de>
|
||||
'''
|
||||
|
||||
import asyncio
|
||||
import board
|
||||
import hwconfig
|
||||
import json
|
||||
import machine
|
||||
import network
|
||||
import os
|
||||
import time
|
||||
import ubinascii
|
||||
|
||||
from array import array
|
||||
from microdot import Microdot, redirect, send_file, Request
|
||||
@@ -28,7 +31,7 @@ Request.max_content_length = 128 * 1024 * 1024 # 128MB requests allowed
|
||||
|
||||
def start_webserver(config_, app_):
|
||||
global server, config, app, nfc, playlist_db, leds, timer_manager
|
||||
server = asyncio.create_task(webapp.start_server(port=80))
|
||||
server = asyncio.create_task(webapp.start_server(host='::0', port=80))
|
||||
config = config_
|
||||
app = app_
|
||||
nfc = app.get_nfc()
|
||||
@@ -109,7 +112,7 @@ async def static(request, path):
|
||||
|
||||
@webapp.route('/api/v1/playlists', methods=['GET'])
|
||||
async def playlists_get(request):
|
||||
return sorted(playlist_db.getPlaylistTags())
|
||||
return playlist_db.getPlaylists()
|
||||
|
||||
|
||||
def is_hex(s):
|
||||
@@ -130,10 +133,11 @@ async def playlist_get(request, tag):
|
||||
return None, 404
|
||||
|
||||
return {
|
||||
'shuffle': playlist.__dict__.get('shuffle'),
|
||||
'persist': playlist.__dict__.get('persist'),
|
||||
'shuffle': playlist.shuffle,
|
||||
'persist': playlist.persist,
|
||||
'paths': [(p[len(fsroot):] if p.startswith(fsroot) else p).decode()
|
||||
for p in playlist.getPaths()],
|
||||
'name': playlist.name
|
||||
}
|
||||
|
||||
|
||||
@@ -153,7 +157,8 @@ async def playlist_put(request, tag):
|
||||
playlist_db.createPlaylistForTag(tag.encode(),
|
||||
(fsroot + path.encode() for path in playlist.get('paths', [])),
|
||||
playlist.get('persist', 'track').encode(),
|
||||
playlist.get('shuffle', 'no').encode())
|
||||
playlist.get('shuffle', 'no').encode(),
|
||||
playlist.get('name', ''))
|
||||
return '', 204
|
||||
|
||||
|
||||
@@ -216,11 +221,17 @@ async def audiofile_upload(request):
|
||||
data = array('b', range(4096))
|
||||
bytes_copied = 0
|
||||
while True:
|
||||
bytes_read = await request.stream.readinto(data)
|
||||
try:
|
||||
bytes_read = await request.stream.readinto(data)
|
||||
except OSError as ex:
|
||||
return f'read error: {ex}', 500
|
||||
if bytes_read == 0:
|
||||
# End of body
|
||||
break
|
||||
bytes_written = newfile.write(data[:bytes_read])
|
||||
try:
|
||||
bytes_written = newfile.write(data[:bytes_read])
|
||||
except OSError as ex:
|
||||
return f'write error: {ex}', 500
|
||||
if bytes_written != bytes_read:
|
||||
# short writes shouldn't happen
|
||||
return 'write failure', 500
|
||||
@@ -258,10 +269,9 @@ async def audiofile_delete(request):
|
||||
|
||||
@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':
|
||||
if hwconfig.get_on_battery():
|
||||
return 'not possible: connect USB first', 403
|
||||
leds.set_state(LedManager.REBOOTING)
|
||||
timer_manager.schedule(time.ticks_ms() + 1500, machine.bootloader)
|
||||
elif method == 'application':
|
||||
@@ -270,3 +280,10 @@ async def reboot(request, method):
|
||||
else:
|
||||
return 'method not supported', 400
|
||||
return '', 204
|
||||
|
||||
|
||||
@webapp.route('/api/v1/info', methods=['GET'])
|
||||
async def get_info(request):
|
||||
mac = ubinascii.hexlify(network.WLAN().config('mac'), ':').decode()
|
||||
return {'version': board.version,
|
||||
'mac': mac}
|
||||
|
||||
@@ -139,6 +139,9 @@ class FakeConfig:
|
||||
def get_tagmode(self):
|
||||
return 'tagremains'
|
||||
|
||||
def get_volume_max(self):
|
||||
return 255
|
||||
|
||||
|
||||
def fake_open(filename, mode):
|
||||
return FakeFile(filename, mode)
|
||||
|
||||
Reference in New Issue
Block a user