Compare commits
22 Commits
file-uploa
...
39a9c68aae
| Author | SHA1 | Date | |
|---|---|---|---|
| 39a9c68aae | |||
| 6dee7fff7e | |||
| 6976aa6963 | |||
| 763305c659 | |||
| 6d18437863 | |||
| 2cf88b26ee | |||
| 73da134a12 | |||
| 5c8a61eb27 | |||
| 4c85683fcb | |||
| 355a8bd345 | |||
| f46c045589 | |||
| fe1c1eadf7 | |||
| 743188e1a4 | |||
| cd5939f4ee | |||
| 02954cd87c | |||
| 58f8526d7e | |||
| d3aef1be32 | |||
| b9baa1c7d5 | |||
| 67d7650923 | |||
| 43fd68779c | |||
| 704951074b | |||
| cac61f924f |
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")
|
add_link_options("-Wl,--print-memory-usage")
|
||||||
set(PICO_USE_FASTEST_SUPPORTED_CLOCK 1)
|
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">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<title>Device Admin</title>
|
<title>TonBERRY pico</title>
|
||||||
<style>
|
<style>
|
||||||
body {
|
body {
|
||||||
font-family: Arial, sans-serif;
|
font-family: Arial, sans-serif;
|
||||||
@@ -131,11 +131,17 @@
|
|||||||
list-style: none;
|
list-style: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.footer {
|
||||||
|
font-size: x-small;
|
||||||
|
color: gray;
|
||||||
|
margin-top: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|
||||||
<h1>Device Admin</h1>
|
<h1>TonBERRY pico</h1>
|
||||||
|
|
||||||
<nav>
|
<nav>
|
||||||
<button onclick="showScreen('menu')">🏠 Main Menu</button>
|
<button onclick="showScreen('menu')">🏠 Main Menu</button>
|
||||||
@@ -161,7 +167,7 @@
|
|||||||
<div id="screen-config" class="screen">
|
<div id="screen-config" class="screen">
|
||||||
<h2>Configuration Editor</h2>
|
<h2>Configuration Editor</h2>
|
||||||
<div id="config-container">Loading…</div>
|
<div id="config-container">Loading…</div>
|
||||||
<button id="config-save-btn" disabled>Save</button>
|
<button id="config-save-btn" disabled>Save & Reboot</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- PLAYLIST EDITOR SCREEN 1: list of playlists -->
|
<!-- PLAYLIST EDITOR SCREEN 1: list of playlists -->
|
||||||
@@ -207,6 +213,10 @@
|
|||||||
<option value="audioplay">Audioplay (no shuffle, start at previous track)</option>
|
<option value="audioplay">Audioplay (no shuffle, start at previous track)</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
<div>
|
||||||
|
<label>Playlist name</label>
|
||||||
|
<input type="text" placeholder="Playlist name" id="playlist-edit-name" />
|
||||||
|
</div>
|
||||||
<div class="flex-horizontal">
|
<div class="flex-horizontal">
|
||||||
<div style="flex-grow: 1">
|
<div style="flex-grow: 1">
|
||||||
<label>Tracks</label>
|
<label>Tracks</label>
|
||||||
@@ -257,7 +267,17 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</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>
|
<script>
|
||||||
const Screens = {};
|
const Screens = {};
|
||||||
let activeScreen = null;
|
let activeScreen = null;
|
||||||
@@ -296,7 +316,9 @@
|
|||||||
return;
|
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) {
|
} catch (err) {
|
||||||
alert("Error saving configuration: " + err);
|
alert("Error saving configuration: " + err);
|
||||||
}
|
}
|
||||||
@@ -334,7 +356,13 @@
|
|||||||
'root.BUTTON_MAP.PLAY_PAUSE': 'Play/Pause',
|
'root.BUTTON_MAP.PLAY_PAUSE': 'Play/Pause',
|
||||||
'root.TAG_TIMEOUT_SECS': 'Tag removal timeout (seconds)',
|
'root.TAG_TIMEOUT_SECS': 'Tag removal timeout (seconds)',
|
||||||
'root.TAGMODE': 'Tag mode',
|
'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)',
|
||||||
|
'root.VOLUME_BOOT': 'Volume at startup (0-255)',
|
||||||
|
'root.LED_MAX': 'Maximum LED brightness (0-255)',
|
||||||
|
'root.WIFI.SSID': 'Network name (SSID) (leave empty for AP mode)',
|
||||||
|
'root.WIFI.PASSPHRASE': 'Password',
|
||||||
|
'root.WIFI.SECURITY': 'Security mode'
|
||||||
};
|
};
|
||||||
const config_input_override = {
|
const config_input_override = {
|
||||||
'root.TAGMODE': {
|
'root.TAGMODE': {
|
||||||
@@ -344,6 +372,21 @@
|
|||||||
'tagstartstop': 'Present tag once to start, present again to stop playback'
|
'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': {
|
'root.IDLE_TIMEOUT_SECS': {
|
||||||
'input-type': 'number'
|
'input-type': 'number'
|
||||||
},
|
},
|
||||||
@@ -353,6 +396,30 @@
|
|||||||
'root.LED_COUNT': {
|
'root.LED_COUNT': {
|
||||||
'input-type': 'number'
|
'input-type': 'number'
|
||||||
},
|
},
|
||||||
|
'root.WIFI.SSID': {
|
||||||
|
'input-type': 'text'
|
||||||
|
},
|
||||||
|
'root.WIFI.PASSPHRASE': {
|
||||||
|
'input-type': 'text'
|
||||||
|
},
|
||||||
|
'root.WIFI.SECURITY': {
|
||||||
|
'element': 'select',
|
||||||
|
'values': {
|
||||||
|
'open': 'Open',
|
||||||
|
'wpa_wpa2': 'WPA/WPA2 (PSK Mixed)',
|
||||||
|
'wpa3': 'WPA3 (SAE AES)',
|
||||||
|
'wpa2_wpa3': 'WPA2/WPA3 (PSK AES)'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
'root.VOLUME_MAX': {
|
||||||
|
'input-type': 'number'
|
||||||
|
},
|
||||||
|
'root.VOLUME_BOOT': {
|
||||||
|
'input-type': 'number'
|
||||||
|
},
|
||||||
|
'root.LED_MAX': {
|
||||||
|
'input-type': 'number'
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
function renderObject(obj, path) {
|
function renderObject(obj, path) {
|
||||||
@@ -423,7 +490,7 @@
|
|||||||
|
|
||||||
let val = input.value.trim();
|
let val = input.value.trim();
|
||||||
if (val === "") val = null;
|
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;
|
current[path[path.length - 1]] = val;
|
||||||
});
|
});
|
||||||
@@ -456,7 +523,7 @@
|
|||||||
document.getElementById('playlist-exist-button-delete')
|
document.getElementById('playlist-exist-button-delete')
|
||||||
.addEventListener('click', (e) => {
|
.addEventListener('click', (e) => {
|
||||||
if (lastSelected === null) return;
|
if (lastSelected === null) return;
|
||||||
const tagid = lastSelected.innerText;
|
const tagid = lastSelected.getAttribute('data-tag');
|
||||||
if(confirm(`Really delete playlist ${tagid}?`)) {
|
if(confirm(`Really delete playlist ${tagid}?`)) {
|
||||||
fetch(`/api/v1/playlist/${tagid}`, {
|
fetch(`/api/v1/playlist/${tagid}`, {
|
||||||
method: 'DELETE'})
|
method: 'DELETE'})
|
||||||
@@ -467,7 +534,7 @@
|
|||||||
.addEventListener('click', selectLastTag);
|
.addEventListener('click', selectLastTag);
|
||||||
document.getElementById('playlist-exist-button-edit').addEventListener("click", (e) => {
|
document.getElementById('playlist-exist-button-edit').addEventListener("click", (e) => {
|
||||||
if (lastSelected !== null)
|
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) => {
|
document.getElementById('playlist-exist-list').addEventListener("click", (e) => {
|
||||||
const node = e.target.closest("li");
|
const node = e.target.closest("li");
|
||||||
@@ -522,8 +589,9 @@
|
|||||||
container.innerHTML = "";
|
container.innerHTML = "";
|
||||||
for (const playlist of playlists) {
|
for (const playlist of playlists) {
|
||||||
const li = document.createElement("li");
|
const li = document.createElement("li");
|
||||||
li.innerHTML = playlist;
|
li.innerHTML = `${playlist.name} (${playlist.tag})`;
|
||||||
li.className = "node"
|
li.className = "node"
|
||||||
|
li.setAttribute('data-tag', playlist.tag);
|
||||||
container.appendChild(li)
|
container.appendChild(li)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -548,7 +616,7 @@
|
|||||||
document.getElementById('playlist-exist-list')
|
document.getElementById('playlist-exist-list')
|
||||||
.querySelectorAll("li")
|
.querySelectorAll("li")
|
||||||
.forEach(n => {
|
.forEach(n => {
|
||||||
if (n.innerText == tagtext) {
|
if (n.getAttribute('data-tag') == tagtext) {
|
||||||
n.classList.add("selected");
|
n.classList.add("selected");
|
||||||
lastSelected = n;
|
lastSelected = n;
|
||||||
} else {
|
} else {
|
||||||
@@ -650,6 +718,8 @@
|
|||||||
} else if (playlist.persist === "track" && playlist.shuffle === "no") {
|
} else if (playlist.persist === "track" && playlist.shuffle === "no") {
|
||||||
playlisttype.value = "audioplay";
|
playlisttype.value = "audioplay";
|
||||||
}
|
}
|
||||||
|
const playlistname = document.getElementById('playlist-edit-name');
|
||||||
|
playlistname.value = playlist.name;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function save() {
|
async function save() {
|
||||||
@@ -672,6 +742,8 @@
|
|||||||
playlistData.shuffle = "no";
|
playlistData.shuffle = "no";
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
const playlistname = document.getElementById('playlist-edit-name');
|
||||||
|
playlistData.name = playlistname.value;
|
||||||
const container = document.getElementById('playlist-edit-list');
|
const container = document.getElementById('playlist-edit-list');
|
||||||
playlistData.paths = [...container.querySelectorAll("li")].map((node) => node.innerText);
|
playlistData.paths = [...container.querySelectorAll("li")].map((node) => node.innerText);
|
||||||
const saveRes = await fetch(`/api/v1/playlist/${playlistId}`,
|
const saveRes = await fetch(`/api/v1/playlist/${playlistId}`,
|
||||||
@@ -736,6 +808,10 @@
|
|||||||
|
|
||||||
async function onShow(intent) {
|
async function onShow(intent) {
|
||||||
document.getElementById('playlist-filebrowser-addtrack').disabled = true;
|
document.getElementById('playlist-filebrowser-addtrack').disabled = true;
|
||||||
|
if (intent !== 'refresh') {
|
||||||
|
document.getElementById('playlist-filebrowser-upload-progress').value = 0;
|
||||||
|
document.getElementById("playlist-filebrowser-upload-files").value = "";
|
||||||
|
}
|
||||||
tree = document.getElementById("playlist-filebrowser-tree");
|
tree = document.getElementById("playlist-filebrowser-tree");
|
||||||
tree.innerHTML = "Loading...";
|
tree.innerHTML = "Loading...";
|
||||||
fetch('/api/v1/audiofiles')
|
fetch('/api/v1/audiofiles')
|
||||||
@@ -770,7 +846,6 @@
|
|||||||
if (type === 'directory') {
|
if (type === 'directory') {
|
||||||
const nested = document.createElement('ul');
|
const nested = document.createElement('ul');
|
||||||
node.appendChild(nested);
|
node.appendChild(nested);
|
||||||
node.classList.add('expanded');
|
|
||||||
parent.appendChild(node);
|
parent.appendChild(node);
|
||||||
return nested;
|
return nested;
|
||||||
}
|
}
|
||||||
@@ -857,7 +932,7 @@
|
|||||||
}
|
}
|
||||||
if (donecount + 1 === totalcount) {
|
if (donecount + 1 === totalcount) {
|
||||||
// Reload file list from device
|
// Reload file list from device
|
||||||
onShow();
|
onShow('refresh');
|
||||||
} else {
|
} else {
|
||||||
uploadFileHelper(totalcount, donecount + 1, destdir, files.slice(1));
|
uploadFileHelper(totalcount, donecount + 1, destdir, files.slice(1));
|
||||||
}
|
}
|
||||||
@@ -884,7 +959,7 @@
|
|||||||
const saveRes = await fetch(`/api/v1/audiofiles?type=directory&location=${location}`,
|
const saveRes = await fetch(`/api/v1/audiofiles?type=directory&location=${location}`,
|
||||||
{method: 'POST'});
|
{method: 'POST'});
|
||||||
// Reload file list from device
|
// Reload file list from device
|
||||||
onShow();
|
onShow('refresh');
|
||||||
}
|
}
|
||||||
|
|
||||||
async function deleteItems() {
|
async function deleteItems() {
|
||||||
@@ -905,7 +980,7 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Reload file list from device
|
// Reload file list from device
|
||||||
onShow();
|
onShow('refresh');
|
||||||
}
|
}
|
||||||
|
|
||||||
let tree = (() => {
|
let tree = (() => {
|
||||||
@@ -981,6 +1056,13 @@
|
|||||||
});
|
});
|
||||||
|
|
||||||
showScreen("menu");
|
showScreen("menu");
|
||||||
|
|
||||||
|
fetch('/api/v1/info')
|
||||||
|
.then((resp) => resp.json())
|
||||||
|
.then((info) => {
|
||||||
|
const version = document.getElementById('footer-version');
|
||||||
|
version.innerText = info.version;
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
Submodule software/lib/microdot updated: d864b81b65...10e740da2b
@@ -9,8 +9,8 @@ from utils import TimerManager
|
|||||||
Dependencies = namedtuple('Dependencies', ('mp3player', 'nfcreader', 'buttons', 'playlistdb', 'hwconfig', 'leds',
|
Dependencies = namedtuple('Dependencies', ('mp3player', 'nfcreader', 'buttons', 'playlistdb', 'hwconfig', 'leds',
|
||||||
'config'))
|
'config'))
|
||||||
|
|
||||||
# Should be ~ 6dB steps
|
# Should be ~ 3dB steps
|
||||||
VOLUME_CURVE = [1, 2, 4, 8, 16, 32, 63, 126, 251]
|
VOLUME_CURVE = [1, 2, 3, 4, 6, 8, 11, 16, 23, 32, 45, 64, 91, 128, 181, 255]
|
||||||
|
|
||||||
|
|
||||||
class PlayerApp:
|
class PlayerApp:
|
||||||
@@ -52,11 +52,19 @@ class PlayerApp:
|
|||||||
self.hwconfig = deps.hwconfig(self)
|
self.hwconfig = deps.hwconfig(self)
|
||||||
self.leds = deps.leds(self)
|
self.leds = deps.leds(self)
|
||||||
self.tag_mode = self.config.get_tagmode()
|
self.tag_mode = self.config.get_tagmode()
|
||||||
|
self.volume_max = self.config.get_volume_max()
|
||||||
|
self.volume_pos = 3 # fallback if config.get_volume_boot is nonsense
|
||||||
|
try:
|
||||||
|
for idx, val in enumerate(VOLUME_CURVE):
|
||||||
|
if val >= self.config.get_volume_boot():
|
||||||
|
self.volume_pos = idx
|
||||||
|
break
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
pass
|
||||||
self.playing_tag = None
|
self.playing_tag = None
|
||||||
self.playlist = None
|
self.playlist = None
|
||||||
self.buttons = deps.buttons(self) if deps.buttons is not None else None
|
self.buttons = deps.buttons(self) if deps.buttons is not None else None
|
||||||
self.mp3file = None
|
self.mp3file = None
|
||||||
self.volume_pos = 3
|
|
||||||
self.paused = False
|
self.paused = False
|
||||||
self.playing = False
|
self.playing = False
|
||||||
self.player.set_volume(VOLUME_CURVE[self.volume_pos])
|
self.player.set_volume(VOLUME_CURVE[self.volume_pos])
|
||||||
@@ -74,7 +82,7 @@ class PlayerApp:
|
|||||||
uid_str = b''.join('{:02x}'.format(x).encode() for x in new_tag)
|
uid_str = b''.join('{:02x}'.format(x).encode() for x in new_tag)
|
||||||
if self.tag_mode == 'tagremains' or (self.tag_mode == 'tagstartstop' and new_tag != self.playing_tag):
|
if self.tag_mode == 'tagremains' or (self.tag_mode == 'tagstartstop' and new_tag != self.playing_tag):
|
||||||
self._set_playlist(uid_str)
|
self._set_playlist(uid_str)
|
||||||
self.playing_tag = new_tag
|
self.playing_tag = new_tag if self.playlist is not None else None
|
||||||
elif self.tag_mode == 'tagstartstop':
|
elif self.tag_mode == 'tagstartstop':
|
||||||
print('Tag presented again, stopping playback')
|
print('Tag presented again, stopping playback')
|
||||||
self._unset_playlist()
|
self._unset_playlist()
|
||||||
@@ -91,8 +99,10 @@ class PlayerApp:
|
|||||||
def onButtonPressed(self, what):
|
def onButtonPressed(self, what):
|
||||||
assert self.buttons is not None
|
assert self.buttons is not None
|
||||||
if what == self.buttons.VOLUP:
|
if what == self.buttons.VOLUP:
|
||||||
self.volume_pos = min(self.volume_pos + 1, len(VOLUME_CURVE) - 1)
|
new_volume = min(self.volume_pos + 1, len(VOLUME_CURVE) - 1)
|
||||||
self.player.set_volume(VOLUME_CURVE[self.volume_pos])
|
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:
|
elif what == self.buttons.VOLDOWN:
|
||||||
self.volume_pos = max(self.volume_pos - 1, 0)
|
self.volume_pos = max(self.volume_pos - 1, 0)
|
||||||
self.player.set_volume(VOLUME_CURVE[self.volume_pos])
|
self.player.set_volume(VOLUME_CURVE[self.volume_pos])
|
||||||
|
|||||||
@@ -3,13 +3,12 @@
|
|||||||
|
|
||||||
import aiorepl # type: ignore
|
import aiorepl # type: ignore
|
||||||
import asyncio
|
import asyncio
|
||||||
from errno import ENOENT
|
|
||||||
import machine
|
import machine
|
||||||
import micropython
|
import micropython
|
||||||
import network
|
import network
|
||||||
import os
|
|
||||||
import time
|
import time
|
||||||
import ubinascii
|
import ubinascii
|
||||||
|
import sys
|
||||||
|
|
||||||
# Own modules
|
# Own modules
|
||||||
import app
|
import app
|
||||||
@@ -37,14 +36,22 @@ hwconfig.board_init()
|
|||||||
machine.mem32[0x40030000 + 0x00] = 0x10
|
machine.mem32[0x40030000 + 0x00] = 0x10
|
||||||
|
|
||||||
|
|
||||||
def setup_wifi():
|
def setup_wifi(ssid='', passphrase='', security=network.WLAN.SEC_WPA_WPA2):
|
||||||
network.hostname("TonberryPico")
|
network.hostname("TonberryPico")
|
||||||
wlan = network.WLAN(network.WLAN.IF_AP)
|
if ssid is None or ssid == '':
|
||||||
wlan.config(ssid=f"TonberryPicoAP_{machine.unique_id().hex()}", security=wlan.SEC_OPEN)
|
apname = f"TonberryPicoAP_{machine.unique_id().hex()}"
|
||||||
wlan.active(True)
|
print(f"Create AP {apname}")
|
||||||
|
wlan = network.WLAN(network.WLAN.IF_AP)
|
||||||
|
wlan.config(ssid=apname, password=passphrase if passphrase is not None else '', security=security)
|
||||||
|
wlan.active(True)
|
||||||
|
else:
|
||||||
|
print(f"Connect to SSID {ssid} with passphrase {passphrase}...")
|
||||||
|
wlan = network.WLAN()
|
||||||
|
wlan.active(True)
|
||||||
|
wlan.connect(ssid, passphrase if passphrase is not None else '', security=security)
|
||||||
|
|
||||||
# disable power management
|
# configure power management
|
||||||
wlan.config(pm=network.WLAN.PM_NONE)
|
wlan.config(pm=network.WLAN.PM_PERFORMANCE)
|
||||||
|
|
||||||
mac = ubinascii.hexlify(network.WLAN().config('mac'), ':').decode()
|
mac = ubinascii.hexlify(network.WLAN().config('mac'), ':').decode()
|
||||||
print(f" mac: {mac}")
|
print(f" mac: {mac}")
|
||||||
@@ -65,27 +72,37 @@ DB_PATH = '/sd/tonberry.db'
|
|||||||
|
|
||||||
config = Configuration()
|
config = Configuration()
|
||||||
|
|
||||||
|
# Setup LEDs
|
||||||
|
np = NeoPixel(hwconfig.LED_DIN, config.get_led_count(), sm=1)
|
||||||
|
led_max = config.get_led_max()
|
||||||
|
np.fill((led_max, led_max, 0))
|
||||||
|
np.write()
|
||||||
|
|
||||||
|
|
||||||
def run():
|
def run():
|
||||||
asyncio.new_event_loop()
|
asyncio.new_event_loop()
|
||||||
# Setup LEDs
|
|
||||||
np = NeoPixel(hwconfig.LED_DIN, config.get_led_count(), sm=1)
|
|
||||||
|
|
||||||
# Wifi with default config
|
if machine.Pin(hwconfig.BUTTONS[1], machine.Pin.IN, machine.Pin.PULL_UP).value() == 0:
|
||||||
setup_wifi()
|
np.fill((0, 0, led_max))
|
||||||
|
np.write()
|
||||||
|
# Force default access point
|
||||||
|
setup_wifi('', '', network.WLAN.SEC_OPEN)
|
||||||
|
else:
|
||||||
|
secstring = config.get_wifi_security()
|
||||||
|
security = network.WLAN.SEC_WPA_WPA2
|
||||||
|
if secstring == 'open':
|
||||||
|
security = network.WLAN.SEC_OPEN
|
||||||
|
elif secstring == 'wpa_wpa2':
|
||||||
|
security = network.WLAN.SEC_WPA_WPA2
|
||||||
|
elif secstring == 'wpa3':
|
||||||
|
security = network.WLAN.SEC_WPA3
|
||||||
|
elif secstring == 'wpa2_wpa3':
|
||||||
|
security = network.WLAN.SEC_WPA2_WPA3
|
||||||
|
setup_wifi(config.get_wifi_ssid(), config.get_wifi_passphrase(), security)
|
||||||
|
|
||||||
# Setup MP3 player
|
# Setup MP3 player
|
||||||
with SDContext(mosi=hwconfig.SD_DI, miso=hwconfig.SD_DO, sck=hwconfig.SD_SCK, ss=hwconfig.SD_CS,
|
with SDContext(mosi=hwconfig.SD_DI, miso=hwconfig.SD_DO, sck=hwconfig.SD_SCK, ss=hwconfig.SD_CS,
|
||||||
baudrate=hwconfig.SD_CLOCKRATE):
|
baudrate=hwconfig.SD_CLOCKRATE):
|
||||||
# Temporary hack: build database from folders if no database exists
|
|
||||||
# Can be removed once playlists can be created via API
|
|
||||||
try:
|
|
||||||
_ = os.stat(DB_PATH)
|
|
||||||
except OSError as ex:
|
|
||||||
if ex.errno == ENOENT:
|
|
||||||
print("No playlist DB found, trying to build DB from tag dirs")
|
|
||||||
builddb()
|
|
||||||
|
|
||||||
with BTreeFileManager(DB_PATH) as playlistdb, \
|
with BTreeFileManager(DB_PATH) as playlistdb, \
|
||||||
AudioContext(hwconfig.I2S_DIN, hwconfig.I2S_DCLK, hwconfig.I2S_LRCLK) as audioctx:
|
AudioContext(hwconfig.I2S_DIN, hwconfig.I2S_DCLK, hwconfig.I2S_LRCLK) as audioctx:
|
||||||
|
|
||||||
@@ -99,7 +116,7 @@ def run():
|
|||||||
buttons=lambda the_app: Buttons(the_app, config, hwconfig),
|
buttons=lambda the_app: Buttons(the_app, config, hwconfig),
|
||||||
playlistdb=lambda _: playlistdb,
|
playlistdb=lambda _: playlistdb,
|
||||||
hwconfig=lambda _: hwconfig,
|
hwconfig=lambda _: hwconfig,
|
||||||
leds=lambda _: LedManager(np),
|
leds=lambda _: LedManager(np, config),
|
||||||
config=lambda _: config)
|
config=lambda _: config)
|
||||||
the_app = app.PlayerApp(deps)
|
the_app = app.PlayerApp(deps)
|
||||||
|
|
||||||
@@ -112,25 +129,26 @@ def run():
|
|||||||
asyncio.get_event_loop().run_forever()
|
asyncio.get_event_loop().run_forever()
|
||||||
|
|
||||||
|
|
||||||
def builddb():
|
def error_blink():
|
||||||
"""
|
while True:
|
||||||
For testing, build a playlist db based on the previous tag directory format.
|
if machine.Pin(hwconfig.BUTTONS[0], machine.Pin.IN, machine.Pin.PULL_UP).value() == 0:
|
||||||
Can be removed once uploading files / playlist via the web api is possible.
|
machine.reset()
|
||||||
"""
|
np.fill((led_max, 0, 0))
|
||||||
try:
|
np.write()
|
||||||
os.unlink(DB_PATH)
|
time.sleep_ms(500)
|
||||||
except OSError:
|
np.fill((0, 0, 0))
|
||||||
pass
|
np.write()
|
||||||
with BTreeFileManager(DB_PATH) as db:
|
time.sleep_ms(500)
|
||||||
for name, type_, _, _ in os.ilistdir(b'/sd'):
|
|
||||||
if type_ != 0x4000:
|
|
||||||
continue
|
|
||||||
fl = [b'/sd/' + name + b'/' + x for x in os.listdir(b'/sd/' + name) if x.endswith(b'.mp3')]
|
|
||||||
db.createPlaylistForTag(name, fl)
|
|
||||||
os.sync()
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == '__main__':
|
if __name__ == '__main__':
|
||||||
time.sleep(1)
|
time.sleep(1)
|
||||||
if machine.Pin(hwconfig.BUTTONS[0], machine.Pin.IN, machine.Pin.PULL_UP).value() != 0:
|
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((led_max, 0, 0))
|
||||||
|
np.write()
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
# Copyright (c) 2025 Matthias Blankertz <matthias@blankertz.org>
|
# Copyright (c) 2025 Matthias Blankertz <matthias@blankertz.org>
|
||||||
|
|
||||||
from utils.helpers import safe_callback
|
from utils.helpers import safe_callback
|
||||||
|
from utils.timer import TimerManager
|
||||||
from utils.buttons import Buttons
|
from utils.buttons import Buttons
|
||||||
from utils.config import Configuration
|
from utils.config import Configuration
|
||||||
from utils.leds import LedManager
|
from utils.leds import LedManager
|
||||||
@@ -9,7 +10,6 @@ from utils.mbrpartition import MBRPartition
|
|||||||
from utils.pinindex import get_pin_index
|
from utils.pinindex import get_pin_index
|
||||||
from utils.playlistdb import BTreeDB, BTreeFileManager
|
from utils.playlistdb import BTreeDB, BTreeFileManager
|
||||||
from utils.sdcontext import SDContext
|
from utils.sdcontext import SDContext
|
||||||
from utils.timer import TimerManager
|
|
||||||
|
|
||||||
__all__ = ["BTreeDB", "BTreeFileManager", "Buttons", "Configuration", "get_pin_index", "LedManager", "MBRPartition",
|
__all__ = ["BTreeDB", "BTreeFileManager", "Buttons", "Configuration", "get_pin_index", "LedManager", "MBRPartition",
|
||||||
"safe_callback", "SDContext", "TimerManager"]
|
"safe_callback", "SDContext", "TimerManager"]
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import asyncio
|
|||||||
import machine
|
import machine
|
||||||
import micropython
|
import micropython
|
||||||
import time
|
import time
|
||||||
from utils import safe_callback
|
from utils import safe_callback, TimerManager
|
||||||
try:
|
try:
|
||||||
from typing import TYPE_CHECKING # type: ignore
|
from typing import TYPE_CHECKING # type: ignore
|
||||||
except ImportError:
|
except ImportError:
|
||||||
@@ -32,6 +32,7 @@ class Buttons:
|
|||||||
def __init__(self, cb: "ButtonCallback", config, hwconfig):
|
def __init__(self, cb: "ButtonCallback", config, hwconfig):
|
||||||
self.button_map = config.get_button_map()
|
self.button_map = config.get_button_map()
|
||||||
self.hw_buttons = hwconfig.BUTTONS
|
self.hw_buttons = hwconfig.BUTTONS
|
||||||
|
self.hwconfig = hwconfig
|
||||||
self.cb = cb
|
self.cb = cb
|
||||||
self.buttons = dict()
|
self.buttons = dict()
|
||||||
for key_id, key_name in self.KEYMAP.items():
|
for key_id, key_name in self.KEYMAP.items():
|
||||||
@@ -42,6 +43,7 @@ class Buttons:
|
|||||||
self.int_flag = asyncio.ThreadSafeFlag()
|
self.int_flag = asyncio.ThreadSafeFlag()
|
||||||
self.pressed: list[int] = []
|
self.pressed: list[int] = []
|
||||||
self.last: dict[int, int] = {}
|
self.last: dict[int, int] = {}
|
||||||
|
self.timer_manager = TimerManager()
|
||||||
for button in self.buttons.keys():
|
for button in self.buttons.keys():
|
||||||
button.irq(handler=self._interrupt, trigger=machine.Pin.IRQ_FALLING | machine.Pin.IRQ_RISING)
|
button.irq(handler=self._interrupt, trigger=machine.Pin.IRQ_FALLING | machine.Pin.IRQ_RISING)
|
||||||
asyncio.create_task(self.task())
|
asyncio.create_task(self.task())
|
||||||
@@ -69,6 +71,10 @@ class Buttons:
|
|||||||
# print(f'B{keycode} {now}')
|
# print(f'B{keycode} {now}')
|
||||||
self.pressed.append(keycode)
|
self.pressed.append(keycode)
|
||||||
self.int_flag.set()
|
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):
|
async def task(self):
|
||||||
while True:
|
while True:
|
||||||
@@ -76,3 +82,9 @@ class Buttons:
|
|||||||
while len(self.pressed) > 0:
|
while len(self.pressed) > 0:
|
||||||
what = self.pressed.pop()
|
what = self.pressed.pop()
|
||||||
safe_callback(lambda: self.cb.onButtonPressed(what), "button callback")
|
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,15 @@ class Configuration:
|
|||||||
'PREV': None,
|
'PREV': None,
|
||||||
'NEXT': 1,
|
'NEXT': 1,
|
||||||
},
|
},
|
||||||
'TAGMODE': 'tagremains'
|
'TAGMODE': 'tagremains',
|
||||||
|
'WIFI': {
|
||||||
|
'SSID': '',
|
||||||
|
'PASSPHRASE': '',
|
||||||
|
'SECURITY': 'wpa_wpa2',
|
||||||
|
},
|
||||||
|
'VOLUME_MAX': 255,
|
||||||
|
'VOLUME_BOOT': 16,
|
||||||
|
'LED_MAX': 255,
|
||||||
}
|
}
|
||||||
|
|
||||||
def __init__(self, config_path='/config.json'):
|
def __init__(self, config_path='/config.json'):
|
||||||
@@ -87,6 +95,24 @@ class Configuration:
|
|||||||
def get_tagmode(self) -> str:
|
def get_tagmode(self) -> str:
|
||||||
return self._get('TAGMODE')
|
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_wifi_security(self) -> str:
|
||||||
|
return self._get('WIFI')['SECURITY']
|
||||||
|
|
||||||
|
def get_volume_max(self) -> int:
|
||||||
|
return self._get('VOLUME_MAX')
|
||||||
|
|
||||||
|
def get_led_max(self) -> int:
|
||||||
|
return self._get('LED_MAX')
|
||||||
|
|
||||||
|
def get_volume_boot(self) -> int:
|
||||||
|
return self._get('VOLUME_BOOT')
|
||||||
|
|
||||||
# For the web API
|
# For the web API
|
||||||
def get_config(self) -> Mapping[str, Any]:
|
def get_config(self) -> Mapping[str, Any]:
|
||||||
return self.config
|
return self.config
|
||||||
@@ -104,6 +130,9 @@ class Configuration:
|
|||||||
self._validate(self.DEFAULT_CONFIG, config)
|
self._validate(self.DEFAULT_CONFIG, config)
|
||||||
if 'TAGMODE' in config and config['TAGMODE'] not in ['tagremains', 'tagstartstop']:
|
if 'TAGMODE' in config and config['TAGMODE'] not in ['tagremains', 'tagstartstop']:
|
||||||
raise ValueError("Invalid TAGMODE: Must be 'tagremains' or 'tagstartstop'")
|
raise ValueError("Invalid TAGMODE: Must be 'tagremains' or 'tagstartstop'")
|
||||||
|
if 'WLAN' in config and 'SECURITY' in config['WLAN'] and \
|
||||||
|
config['WLAN']['SECURITY'] not in ['open', 'wpa_wpa2', 'wpa3', 'wpa2_wpa3']:
|
||||||
|
raise ValueError("Invalid WLAN SECURITY: Must be 'open', 'wpa_wpa2', 'wpa3' or 'wpa2_wpa3'")
|
||||||
self._merge_configs(self.config, config)
|
self._merge_configs(self.config, config)
|
||||||
self.config = config
|
self.config = config
|
||||||
self._save()
|
self._save()
|
||||||
|
|||||||
@@ -12,10 +12,10 @@ class LedManager:
|
|||||||
PLAYING = const(1)
|
PLAYING = const(1)
|
||||||
REBOOTING = const(2)
|
REBOOTING = const(2)
|
||||||
|
|
||||||
def __init__(self, np):
|
def __init__(self, np, config):
|
||||||
self.led_state = LedManager.IDLE
|
self.led_state = LedManager.IDLE
|
||||||
|
self.brightness = config.get_led_max() / 255
|
||||||
self.np = np
|
self.np = np
|
||||||
self.brightness = 0.1
|
|
||||||
self.leds = len(self.np)
|
self.leds = len(self.np)
|
||||||
asyncio.create_task(self.run())
|
asyncio.create_task(self.run())
|
||||||
|
|
||||||
|
|||||||
@@ -33,12 +33,13 @@ class BTreeDB(IPlaylistDB):
|
|||||||
PERSIST_OFFSET = b'offset'
|
PERSIST_OFFSET = b'offset'
|
||||||
|
|
||||||
class Playlist(IPlaylist):
|
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.parent = parent
|
||||||
self.tag = tag
|
self.tag = tag
|
||||||
self.pos = pos
|
self.pos = pos
|
||||||
self.persist = persist
|
self.persist = persist
|
||||||
self.shuffle = shuffle
|
self.shuffle = shuffle
|
||||||
|
self.name = name
|
||||||
self.length = self.parent._getPlaylistLength(self.tag)
|
self.length = self.parent._getPlaylistLength(self.tag)
|
||||||
self._shuffle()
|
self._shuffle()
|
||||||
|
|
||||||
@@ -168,6 +169,10 @@ class BTreeDB(IPlaylistDB):
|
|||||||
return (b''.join([tag, b'/playlist/']),
|
return (b''.join([tag, b'/playlist/']),
|
||||||
b''.join([tag, b'/playlist0']))
|
b''.join([tag, b'/playlist0']))
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _keyPlaylistName(tag):
|
||||||
|
return b''.join([tag, b'/playlistname'])
|
||||||
|
|
||||||
def _flush(self):
|
def _flush(self):
|
||||||
"""
|
"""
|
||||||
Flush the database and call the flush_func if it was provided.
|
Flush the database and call the flush_func if it was provided.
|
||||||
@@ -222,12 +227,13 @@ class BTreeDB(IPlaylistDB):
|
|||||||
raise RuntimeError("Malformed playlist key")
|
raise RuntimeError("Malformed playlist key")
|
||||||
return int(elements[2])+1
|
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)
|
self._deletePlaylist(tag, False)
|
||||||
for idx, entry in enumerate(entries):
|
for idx, entry in enumerate(entries):
|
||||||
self.db[self._keyPlaylistEntry(tag, idx)] = entry
|
self.db[self._keyPlaylistEntry(tag, idx)] = entry
|
||||||
self.db[self._keyPlaylistPersist(tag)] = persist
|
self.db[self._keyPlaylistPersist(tag)] = persist
|
||||||
self.db[self._keyPlaylistShuffle(tag)] = shuffle
|
self.db[self._keyPlaylistShuffle(tag)] = shuffle
|
||||||
|
self.db[self._keyPlaylistName(tag)] = name.encode()
|
||||||
if flush:
|
if flush:
|
||||||
self._flush()
|
self._flush()
|
||||||
|
|
||||||
@@ -240,7 +246,7 @@ class BTreeDB(IPlaylistDB):
|
|||||||
pass
|
pass
|
||||||
for k in (self._keyPlaylistPos(tag), self._keyPlaylistPosOffset(tag),
|
for k in (self._keyPlaylistPos(tag), self._keyPlaylistPosOffset(tag),
|
||||||
self._keyPlaylistPersist(tag), self._keyPlaylistShuffle(tag),
|
self._keyPlaylistPersist(tag), self._keyPlaylistShuffle(tag),
|
||||||
self._keyPlaylistShuffleSeed(tag)):
|
self._keyPlaylistShuffleSeed(tag), self._keyPlaylistName(tag)):
|
||||||
try:
|
try:
|
||||||
del self.db[k]
|
del self.db[k]
|
||||||
except KeyError:
|
except KeyError:
|
||||||
@@ -248,15 +254,18 @@ class BTreeDB(IPlaylistDB):
|
|||||||
if flush:
|
if flush:
|
||||||
self._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
|
Get a list of all defined playlists with their tag and names.
|
||||||
their tag.
|
|
||||||
"""
|
"""
|
||||||
playlist_tags = set()
|
playlist_tags = set()
|
||||||
for item in self.db:
|
for item in self.db:
|
||||||
playlist_tags.add(item.split(b'/')[0])
|
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):
|
def getPlaylistForTag(self, tag: bytes):
|
||||||
"""
|
"""
|
||||||
@@ -275,18 +284,19 @@ class BTreeDB(IPlaylistDB):
|
|||||||
return None
|
return None
|
||||||
if self._keyPlaylistEntry(tag, pos) not in self.db:
|
if self._keyPlaylistEntry(tag, pos) not in self.db:
|
||||||
pos = 0
|
pos = 0
|
||||||
|
name = self.db.get(self._keyPlaylistName(tag), b'').decode()
|
||||||
shuffle = self.db.get(self._keyPlaylistShuffle(tag), self.SHUFFLE_NO)
|
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,
|
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
|
Create and save a playlist for 'tag' and return the Playlist object. If a playlist already existed for 'tag' it
|
||||||
is overwritten.
|
is overwritten.
|
||||||
"""
|
"""
|
||||||
assert persist in (self.PERSIST_NO, self.PERSIST_TRACK, self.PERSIST_OFFSET)
|
assert persist in (self.PERSIST_NO, self.PERSIST_TRACK, self.PERSIST_OFFSET)
|
||||||
assert shuffle in (self.SHUFFLE_NO, self.SHUFFLE_YES)
|
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)
|
return self.getPlaylistForTag(tag)
|
||||||
|
|
||||||
def deletePlaylistForTag(self, tag: bytes):
|
def deletePlaylistForTag(self, tag: bytes):
|
||||||
@@ -370,6 +380,14 @@ class BTreeDB(IPlaylistDB):
|
|||||||
_ = int(val)
|
_ = int(val)
|
||||||
except ValueError:
|
except ValueError:
|
||||||
fail(f' Bad playlistposoffset value for {last_tag}: {val!r}')
|
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:
|
else:
|
||||||
fail(f'Unknown key {k!r}')
|
fail(f'Unknown key {k!r}')
|
||||||
return result
|
return result
|
||||||
|
|||||||
@@ -4,11 +4,15 @@ Copyright (c) 2024-2025 Stefan Kratochwil <Kratochwil-LA@gmx.de>
|
|||||||
'''
|
'''
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
|
import board
|
||||||
|
import errno
|
||||||
import hwconfig
|
import hwconfig
|
||||||
import json
|
import json
|
||||||
import machine
|
import machine
|
||||||
|
import network
|
||||||
import os
|
import os
|
||||||
import time
|
import time
|
||||||
|
import ubinascii
|
||||||
|
|
||||||
from array import array
|
from array import array
|
||||||
from microdot import Microdot, redirect, send_file, Request
|
from microdot import Microdot, redirect, send_file, Request
|
||||||
@@ -28,7 +32,7 @@ Request.max_content_length = 128 * 1024 * 1024 # 128MB requests allowed
|
|||||||
|
|
||||||
def start_webserver(config_, app_):
|
def start_webserver(config_, app_):
|
||||||
global server, config, app, nfc, playlist_db, leds, timer_manager
|
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_
|
config = config_
|
||||||
app = app_
|
app = app_
|
||||||
nfc = app.get_nfc()
|
nfc = app.get_nfc()
|
||||||
@@ -109,7 +113,7 @@ async def static(request, path):
|
|||||||
|
|
||||||
@webapp.route('/api/v1/playlists', methods=['GET'])
|
@webapp.route('/api/v1/playlists', methods=['GET'])
|
||||||
async def playlists_get(request):
|
async def playlists_get(request):
|
||||||
return sorted(playlist_db.getPlaylistTags())
|
return playlist_db.getPlaylists()
|
||||||
|
|
||||||
|
|
||||||
def is_hex(s):
|
def is_hex(s):
|
||||||
@@ -130,10 +134,11 @@ async def playlist_get(request, tag):
|
|||||||
return None, 404
|
return None, 404
|
||||||
|
|
||||||
return {
|
return {
|
||||||
'shuffle': playlist.__dict__.get('shuffle'),
|
'shuffle': playlist.shuffle,
|
||||||
'persist': playlist.__dict__.get('persist'),
|
'persist': playlist.persist,
|
||||||
'paths': [(p[len(fsroot):] if p.startswith(fsroot) else p).decode()
|
'paths': [(p[len(fsroot):] if p.startswith(fsroot) else p).decode()
|
||||||
for p in playlist.getPaths()],
|
for p in playlist.getPaths()],
|
||||||
|
'name': playlist.name
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -153,7 +158,8 @@ async def playlist_put(request, tag):
|
|||||||
playlist_db.createPlaylistForTag(tag.encode(),
|
playlist_db.createPlaylistForTag(tag.encode(),
|
||||||
(fsroot + path.encode() for path in playlist.get('paths', [])),
|
(fsroot + path.encode() for path in playlist.get('paths', [])),
|
||||||
playlist.get('persist', 'track').encode(),
|
playlist.get('persist', 'track').encode(),
|
||||||
playlist.get('shuffle', 'no').encode())
|
playlist.get('shuffle', 'no').encode(),
|
||||||
|
playlist.get('name', ''))
|
||||||
return '', 204
|
return '', 204
|
||||||
|
|
||||||
|
|
||||||
@@ -197,6 +203,25 @@ async def audiofiles_get(request):
|
|||||||
return directory_iterator(), {'Content-Type': 'application/json; charset=UTF-8'}
|
return directory_iterator(), {'Content-Type': 'application/json; charset=UTF-8'}
|
||||||
|
|
||||||
|
|
||||||
|
async def stream_to_file(stream, file_, length):
|
||||||
|
data = array('b', range(16384))
|
||||||
|
bytes_copied = 0
|
||||||
|
while True:
|
||||||
|
bytes_read = await stream.readinto(data)
|
||||||
|
if bytes_read == 0:
|
||||||
|
# End of body
|
||||||
|
break
|
||||||
|
bytes_written = file_.write(data[:bytes_read])
|
||||||
|
if bytes_written != bytes_read:
|
||||||
|
# short writes shouldn't happen
|
||||||
|
raise OSError(errno.EIO, 'unexpected short write')
|
||||||
|
bytes_copied += bytes_written
|
||||||
|
if bytes_copied == length:
|
||||||
|
break
|
||||||
|
app.reset_idle_timeout()
|
||||||
|
return bytes_copied
|
||||||
|
|
||||||
|
|
||||||
@webapp.route('/api/v1/audiofiles', methods=['POST'])
|
@webapp.route('/api/v1/audiofiles', methods=['POST'])
|
||||||
async def audiofile_upload(request):
|
async def audiofile_upload(request):
|
||||||
if 'type' not in request.args or request.args['type'] not in ['file', 'directory']:
|
if 'type' not in request.args or request.args['type'] not in ['file', 'directory']:
|
||||||
@@ -213,20 +238,13 @@ async def audiofile_upload(request):
|
|||||||
os.mkdir(path)
|
os.mkdir(path)
|
||||||
return '', 204
|
return '', 204
|
||||||
with open(path, 'wb') as newfile:
|
with open(path, 'wb') as newfile:
|
||||||
data = array('b', range(4096))
|
try:
|
||||||
bytes_copied = 0
|
if length > Request.max_body_length:
|
||||||
while True:
|
bytes_copied = await stream_to_file(request.stream, newfile, length)
|
||||||
bytes_read = await request.stream.readinto(data)
|
else:
|
||||||
if bytes_read == 0:
|
bytes_copied = newfile.write(request.body)
|
||||||
# End of body
|
except OSError as ex:
|
||||||
break
|
return f'error writing data to file: {ex}', 500
|
||||||
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:
|
if bytes_copied == length:
|
||||||
return '', 204
|
return '', 204
|
||||||
else:
|
else:
|
||||||
@@ -258,10 +276,9 @@ async def audiofile_delete(request):
|
|||||||
|
|
||||||
@webapp.route('/api/v1/reboot/<method>', methods=['POST'])
|
@webapp.route('/api/v1/reboot/<method>', methods=['POST'])
|
||||||
async def reboot(request, method):
|
async def reboot(request, method):
|
||||||
if hwconfig.get_on_battery():
|
|
||||||
return 'not allowed: usb not connected', 403
|
|
||||||
|
|
||||||
if method == 'bootloader':
|
if method == 'bootloader':
|
||||||
|
if hwconfig.get_on_battery():
|
||||||
|
return 'not possible: connect USB first', 403
|
||||||
leds.set_state(LedManager.REBOOTING)
|
leds.set_state(LedManager.REBOOTING)
|
||||||
timer_manager.schedule(time.ticks_ms() + 1500, machine.bootloader)
|
timer_manager.schedule(time.ticks_ms() + 1500, machine.bootloader)
|
||||||
elif method == 'application':
|
elif method == 'application':
|
||||||
@@ -270,3 +287,10 @@ async def reboot(request, method):
|
|||||||
else:
|
else:
|
||||||
return 'method not supported', 400
|
return 'method not supported', 400
|
||||||
return '', 204
|
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,12 @@ class FakeConfig:
|
|||||||
def get_tagmode(self):
|
def get_tagmode(self):
|
||||||
return 'tagremains'
|
return 'tagremains'
|
||||||
|
|
||||||
|
def get_volume_max(self):
|
||||||
|
return 255
|
||||||
|
|
||||||
|
def get_volume_boot(self):
|
||||||
|
return 16
|
||||||
|
|
||||||
|
|
||||||
def fake_open(filename, mode):
|
def fake_open(filename, mode):
|
||||||
return FakeFile(filename, mode)
|
return FakeFile(filename, mode)
|
||||||
@@ -167,7 +173,7 @@ def test_construct_app(micropythonify, faketimermanager):
|
|||||||
deps = _makedeps(mp3player=fake_mp3)
|
deps = _makedeps(mp3player=fake_mp3)
|
||||||
dut = app.PlayerApp(deps)
|
dut = app.PlayerApp(deps)
|
||||||
fake_mp3 = dut.player
|
fake_mp3 = dut.player
|
||||||
assert fake_mp3.volume is not None
|
assert fake_mp3.volume is not None and fake_mp3.volume >= 16
|
||||||
|
|
||||||
|
|
||||||
def test_load_playlist_on_tag(micropythonify, faketimermanager, monkeypatch):
|
def test_load_playlist_on_tag(micropythonify, faketimermanager, monkeypatch):
|
||||||
|
|||||||
Reference in New Issue
Block a user