Compare commits
1 Commits
feat/api_e
...
b69f4bd87d
| Author | SHA1 | Date | |
|---|---|---|---|
| b69f4bd87d |
@@ -11,10 +11,8 @@ jobs:
|
||||
uses: actions/checkout@v4
|
||||
- name: Initialize submodules
|
||||
run: cd software && ./update-submodules.sh
|
||||
- name: Prepare venv
|
||||
run: python -m venv build-venv && source build-venv/bin/activate && pip install freezefs
|
||||
- name: Build
|
||||
run: source build-venv/bin/activate && cd software && ./build.sh
|
||||
run: cd software && ./build.sh
|
||||
- name: Upload firmware
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
|
||||
@@ -22,5 +22,3 @@ module("mp3player.py", "../../src")
|
||||
module("webserver.py", "../../src")
|
||||
package("utils", base_path="../../src")
|
||||
package("nfc", base_path="../../src")
|
||||
|
||||
module("frozen_frontend.py", "../../build")
|
||||
|
||||
@@ -25,11 +25,6 @@ mkdir "$FS_STAGE_DIR"/fs
|
||||
trap 'rm -rf $FS_STAGE_DIR' EXIT
|
||||
tools/mklittlefs/mklittlefs -p 256 -s 868352 -c "$FS_STAGE_DIR"/fs "$FS_STAGE_DIR"/filesystem.bin
|
||||
|
||||
FRONTEND_STAGE_DIR=$(mktemp -d)
|
||||
trap 'rm -rf $FRONTEND_STAGE_DIR' EXIT
|
||||
gzip -c frontend/index.html > "$FRONTEND_STAGE_DIR"/index.html.gz
|
||||
python -m freezefs "$FRONTEND_STAGE_DIR" build/frozen_frontend.py --target=/frontend
|
||||
|
||||
for hwconfig in boards/RPI_PICO_W/manifest-*.py; do
|
||||
hwconfig_base=$(basename "$hwconfig")
|
||||
hwname=${hwconfig_base##manifest-}
|
||||
|
||||
@@ -1,977 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>Device Admin</title>
|
||||
<style>
|
||||
body {
|
||||
font-family: Arial, sans-serif;
|
||||
padding: 20px;
|
||||
max-width: 700px;
|
||||
margin: auto;
|
||||
}
|
||||
|
||||
/* Navigation */
|
||||
nav button {
|
||||
margin: 5px;
|
||||
padding: 10px 16px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* Screens */
|
||||
.screen {
|
||||
display: none;
|
||||
margin-top: 20px;
|
||||
}
|
||||
.screen.active {
|
||||
display: block;
|
||||
}
|
||||
|
||||
/* Config editor UI */
|
||||
label {
|
||||
font-weight: bold;
|
||||
margin-top: 12px;
|
||||
display: block;
|
||||
}
|
||||
input {
|
||||
width: 100%;
|
||||
padding: 6px;
|
||||
box-sizing: border-box;
|
||||
margin-top: 4px;
|
||||
}
|
||||
.nested {
|
||||
margin-left: 20px;
|
||||
border-left: 2px solid #ddd;
|
||||
padding-left: 10px;
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
/* Tree view */
|
||||
.tree ul {
|
||||
list-style: none;
|
||||
padding-left: 1rem;
|
||||
}
|
||||
|
||||
.tree li {
|
||||
margin: 2px 0;
|
||||
}
|
||||
|
||||
.caret {
|
||||
cursor: pointer;
|
||||
display: inline-block;
|
||||
width: 1em;
|
||||
height: 1em;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.caret::before {
|
||||
content: "▶";
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
li.expanded > .caret::before {
|
||||
content: "▼";
|
||||
}
|
||||
|
||||
li:not(:has(ul)) > .caret::before {
|
||||
content: "";
|
||||
}
|
||||
|
||||
.node {
|
||||
cursor: pointer;
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.node:hover {
|
||||
background: #eee;
|
||||
}
|
||||
|
||||
.node.selected {
|
||||
background: #0078d7;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.tree ul ul {
|
||||
display: none;
|
||||
}
|
||||
|
||||
li.expanded > ul {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.scroll-container {
|
||||
border: 1px solid #ccc;
|
||||
border-radius: 4px;
|
||||
|
||||
height: 500px;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
.flex-horizontal {
|
||||
display:flex;
|
||||
flex-direction: row;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.flex-vertical {
|
||||
display:flex;
|
||||
flex-direction: column;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.list {
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<h1>Device Admin</h1>
|
||||
|
||||
<nav>
|
||||
<button onclick="showScreen('menu')">🏠 Main Menu</button>
|
||||
<button onclick="showScreen('config')">⚙️ Config Editor</button>
|
||||
<button onclick="showScreen('playlist')">🖹 Playlist Editor</button>
|
||||
</nav>
|
||||
|
||||
<!-- MAIN MENU -->
|
||||
<div id="screen-menu" class="screen active">
|
||||
<h2>Main Menu</h2>
|
||||
<p>Select a tool:</p>
|
||||
|
||||
<ul>
|
||||
<li><button onclick="showScreen('config')">Open Config Editor</button></li>
|
||||
<li><button onclick="showScreen('playlist')">Open Playlist Editor</button></li>
|
||||
<!-- More screens can be added later -->
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<!-- CONFIG EDITOR SCREEN -->
|
||||
<div id="screen-config" class="screen">
|
||||
<h2>Configuration Editor</h2>
|
||||
<div id="config-container">Loading…</div>
|
||||
<button id="config-save-btn" disabled>Save</button>
|
||||
</div>
|
||||
|
||||
<!-- PLAYLIST EDITOR SCREEN 1: list of playlists -->
|
||||
<div id="screen-playlist" class="screen">
|
||||
<h2>Playlist Editor</h2>
|
||||
<div id="playlist-container">
|
||||
<div id="playlist-new">
|
||||
<label>New playlist</label>
|
||||
<div class="flex-horizontal">
|
||||
<input id="playlist-new-tag" type="text" placeholder="Tag ID" pattern="[0-9a-fA-F]+" style="max-width: 70%" />
|
||||
<button id="playlist-new-button-gettag">Get last Tag</button>
|
||||
<button id="playlist-new-button-new">New playlist</button>
|
||||
</div>
|
||||
</div>
|
||||
<hr />
|
||||
<div id="playlist-existing">
|
||||
<label>Playlists</label>
|
||||
<div class="scroll-container" style="margin-bottom: 10px">
|
||||
<ul class="list" id="playlist-exist-list">
|
||||
<li>Loading...</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="flex-horizontal">
|
||||
<button id="playlist-exist-button-delete" style="background-color: orangered">Delete Playlist</button>
|
||||
<button id="playlist-exist-button-gettag">Select last Tag</button>
|
||||
<button id="playlist-exist-button-edit">Edit Playlist</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- PLAYLIST EDITOR SCREEN 2: playlist tracks -->
|
||||
<div id="screen-playlist_edit" class="screen">
|
||||
<h2>Playlist Editor</h2>
|
||||
<div id="playlist-edit-container">
|
||||
<div id="playlist-edit" style="gap: 10px">
|
||||
<div>
|
||||
<label>Playlist type</label>
|
||||
<select id="playlist-edit-type">
|
||||
<option value="album">Album (no shuffle, always start at beginning)</option>
|
||||
<option value="audiobook">Audiobook (no shuffle, start at previous position)</option>
|
||||
<option value="random">Random Collection (shuffle)</option>
|
||||
<option value="audioplay">Audioplay (no shuffle, start at previous track)</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="flex-horizontal">
|
||||
<div style="flex-grow: 1">
|
||||
<label>Tracks</label>
|
||||
<div class="scroll-container">
|
||||
<ul class="list" id="playlist-edit-list">
|
||||
<li>Loading...</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex-vertical">
|
||||
<button id="playlist-edit-trackmove-up">↑</button>
|
||||
<button id="playlist-edit-trackmove-down">↓</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex-horizontal">
|
||||
<button id="playlist-edit-back">Cancel</button>
|
||||
<button id="playlist-edit-removetrack">Remove track(s)</button>
|
||||
<button id="playlist-edit-addtrack">Add track(s)</button>
|
||||
<button id="playlist-edit-save">Save</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- PLAYLIST EDITOR SCREEN 3: file browser -->
|
||||
<div id="screen-playlist_filebrowser" class="screen">
|
||||
<h2>Playlist Editor</h2>
|
||||
<div id="playlist-filebrowser-container">
|
||||
<div class="scroll-container">
|
||||
<div class="tree" id="playlist-filebrowser-tree">
|
||||
<ul><li>Loading...</li></ul>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex-horizontal">
|
||||
<button id="playlist-filebrowser-cancel">Cancel</button>
|
||||
<button id="playlist-filebrowser-delete" style="background-color: orangered">Delete selected</button>
|
||||
<button id="playlist-filebrowser-addtrack">Add track(s)</button>
|
||||
</div>
|
||||
<hr>
|
||||
<div class="flex-horizontal">
|
||||
<input type="file" id="playlist-filebrowser-upload-files" multiple accept="audio/mpeg" />
|
||||
<progress id="playlist-filebrowser-upload-progress" max="100" value="0" style:"flex-grow: 1">0%</progress>
|
||||
<button id="playlist-filebrowser-upload">Upload</button>
|
||||
</div>
|
||||
<div class="flex-horizontal">
|
||||
<input type="text" id="playlist-filebrowser-mkdir-name" placeholder="Directory Name" />
|
||||
<button id="playlist-filebrowser-mkdir" style="width: 20%">Create directory</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const Screens = {};
|
||||
let activeScreen = null;
|
||||
/* -----------------------------
|
||||
Screen switching
|
||||
----------------------------- */
|
||||
function showScreen(name, arg=null) {
|
||||
document.querySelectorAll('.screen').forEach(s => s.classList.remove('active'));
|
||||
document.getElementById('screen-' + name).classList.add('active');
|
||||
|
||||
activeScreen = name;
|
||||
Screens[name]?.onShow?.(arg);
|
||||
}
|
||||
|
||||
/* -----------------------------
|
||||
CONFIG EDITOR LOGIC
|
||||
----------------------------- */
|
||||
Screens.config = (() => {
|
||||
let screenroot = null;
|
||||
function init() {
|
||||
screenroot = document.getElementById('screen-config');
|
||||
document.getElementById('config-save-btn').addEventListener('click', async () => {
|
||||
const res = await fetch('/api/v1/config');
|
||||
const original = await res.json();
|
||||
const updated = serialize(original);
|
||||
|
||||
try {
|
||||
const saveRes = await fetch('/api/v1/config', {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(updated, null, 2)
|
||||
});
|
||||
|
||||
if (!saveRes.ok) {
|
||||
alert("Failed to save config: " + await saveRes.text());
|
||||
return;
|
||||
}
|
||||
|
||||
alert("Configuration saved successfully!");
|
||||
} catch (err) {
|
||||
alert("Error saving configuration: " + err);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function onShow() {
|
||||
const container = document.getElementById('config-container');
|
||||
container.innerHTML = "Loading…";
|
||||
document.getElementById('config-save-btn').disabled = true;
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/v1/config');
|
||||
const config = await res.json();
|
||||
render(config);
|
||||
} catch (err) {
|
||||
container.innerHTML = "Failed to load config: " + err;
|
||||
}
|
||||
}
|
||||
|
||||
function render(config) {
|
||||
const container = document.getElementById('config-container');
|
||||
container.innerHTML = "";
|
||||
container.appendChild(renderObject(config, "root"));
|
||||
document.getElementById('config-save-btn').disabled = false;
|
||||
}
|
||||
|
||||
const config_names = {
|
||||
'root.IDLE_TIMEOUT_SECS': 'Idle Timeout (seconds)',
|
||||
'root.BUTTON_MAP': 'Button map',
|
||||
'root.BUTTON_MAP.NEXT': 'Next track',
|
||||
'root.BUTTON_MAP.PREV': 'Previous track',
|
||||
'root.BUTTON_MAP.VOLUP': 'Volume up',
|
||||
'root.BUTTON_MAP.VOLDOWN': 'Volume down',
|
||||
'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'
|
||||
};
|
||||
const config_input_override = {
|
||||
'root.TAGMODE': {
|
||||
'element': 'select',
|
||||
'values': {
|
||||
'tagremains': 'Play until tag is removed',
|
||||
'tagstartstop': 'Present tag once to start, present again to stop playback'
|
||||
}
|
||||
},
|
||||
'root.IDLE_TIMEOUT_SECS': {
|
||||
'input-type': 'number'
|
||||
},
|
||||
'root.TAG_TIMEOUT_SECS': {
|
||||
'input-type': 'number'
|
||||
},
|
||||
'root.LED_COUNT': {
|
||||
'input-type': 'number'
|
||||
},
|
||||
};
|
||||
|
||||
function renderObject(obj, path) {
|
||||
const wrapper = document.createElement('div');
|
||||
|
||||
Object.entries(obj).forEach(([key, value]) => {
|
||||
const currentPath = path + '.' + key;
|
||||
const label = document.createElement('label');
|
||||
if (currentPath in config_names) {
|
||||
label.textContent = config_names[currentPath];
|
||||
} else {
|
||||
label.textContent = key;
|
||||
}
|
||||
|
||||
if (value !== null && typeof value === 'object') {
|
||||
wrapper.appendChild(label);
|
||||
const nested = document.createElement('div');
|
||||
nested.className = "nested";
|
||||
nested.appendChild(renderObject(value, currentPath));
|
||||
wrapper.appendChild(nested);
|
||||
} else {
|
||||
wrapper.appendChild(label);
|
||||
if (currentPath in config_input_override && 'element' in config_input_override[currentPath]) {
|
||||
const override = config_input_override[currentPath];
|
||||
if (override['element'] === 'select') {
|
||||
const input = document.createElement('select');
|
||||
input.dataset.path = currentPath;
|
||||
|
||||
for (const val in override.values) {
|
||||
const option = document.createElement('option');
|
||||
option.value = val;
|
||||
option.textContent = override.values[val];
|
||||
if (val === value) {
|
||||
option.selected = true;
|
||||
}
|
||||
|
||||
input.appendChild(option);
|
||||
}
|
||||
|
||||
wrapper.appendChild(input);
|
||||
}
|
||||
} else {
|
||||
const input = document.createElement('input');
|
||||
if (currentPath in config_input_override && 'input-type' in config_input_override[currentPath]) {
|
||||
input.type = config_input_override[currentPath]['input-type'];
|
||||
}
|
||||
input.value = value === null ? "" : value;
|
||||
input.dataset.path = currentPath;
|
||||
|
||||
wrapper.appendChild(input);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return wrapper;
|
||||
}
|
||||
|
||||
function serialize(rootObj) {
|
||||
const inputs = screenroot.querySelectorAll("input[data-path], select[data-path]");
|
||||
|
||||
inputs.forEach(input => {
|
||||
const path = input.dataset.path.split('.').slice(1); // remove "root"
|
||||
let current = rootObj;
|
||||
|
||||
for (let i = 0; i < path.length - 1; i++) {
|
||||
current = current[path[i]];
|
||||
}
|
||||
|
||||
let val = input.value.trim();
|
||||
if (val === "") val = null;
|
||||
else if (!isNaN(val)) val = Number(val);
|
||||
|
||||
current[path[path.length - 1]] = val;
|
||||
});
|
||||
|
||||
return rootObj;
|
||||
}
|
||||
|
||||
return { init, onShow };
|
||||
})();
|
||||
|
||||
/* ----------------------------------------
|
||||
PLAYLIST EDITOR LOGIC - SELECTION SCREEN
|
||||
------------------------------------------- */
|
||||
Screens.playlist = (() => {
|
||||
let lastSelected = null;
|
||||
|
||||
function init() {
|
||||
//tree.init();
|
||||
document.getElementById('playlist-new-button-gettag')
|
||||
.addEventListener('click', updateLastTag);
|
||||
document.getElementById('playlist-new-button-new')
|
||||
.addEventListener('click', (e) => {
|
||||
const tagid = document.getElementById('playlist-new-tag').value;
|
||||
if (/^[0-9a-fA-F]+$/.exec(tagid)) {
|
||||
showScreen("playlist_edit", {create: tagid});
|
||||
} else {
|
||||
alert("Invalid tag id");
|
||||
}
|
||||
});
|
||||
document.getElementById('playlist-exist-button-delete')
|
||||
.addEventListener('click', (e) => {
|
||||
if (lastSelected === null) return;
|
||||
const tagid = lastSelected.innerText;
|
||||
if(confirm(`Really delete playlist ${tagid}?`)) {
|
||||
fetch(`/api/v1/playlist/${tagid}`, {
|
||||
method: 'DELETE'})
|
||||
.then(() => showScreen('playlist'));
|
||||
}
|
||||
});
|
||||
document.getElementById('playlist-exist-button-gettag')
|
||||
.addEventListener('click', selectLastTag);
|
||||
document.getElementById('playlist-exist-button-edit').addEventListener("click", (e) => {
|
||||
if (lastSelected !== null)
|
||||
showScreen("playlist_edit", {load: lastSelected.innerText});
|
||||
});
|
||||
document.getElementById('playlist-exist-list').addEventListener("click", (e) => {
|
||||
const node = e.target.closest("li");
|
||||
if (!node) return;
|
||||
|
||||
document.getElementById('playlist-exist-list')
|
||||
.querySelectorAll(".selected")
|
||||
.forEach(n => n.classList.remove("selected")
|
||||
);
|
||||
|
||||
node.classList.toggle("selected");
|
||||
|
||||
lastSelected = node;
|
||||
});
|
||||
}
|
||||
|
||||
async function onShow() {
|
||||
const container = document.getElementById('playlist-exist-list');
|
||||
container.innerHTML = "<li>Loading…</li>";
|
||||
//document.getElementById('playlist-save-btn').disabled = true;
|
||||
|
||||
fetch('/api/v1/playlists')
|
||||
.then((res) => res.json())
|
||||
.then((playlists) => renderPlaylistForm(playlists))
|
||||
.catch((err) => {
|
||||
container.innerHTML = "<li>Failed to load playlists: " + err + "</li>";
|
||||
});
|
||||
fetch('/api/v1/last_tag_uid')
|
||||
.then((res) => res.json())
|
||||
.then((tag) => {
|
||||
if (tag.tag !== null) {
|
||||
fillLastTag(tag);
|
||||
selectTag(tag);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function updateLastTag() {
|
||||
fetch('/api/v1/last_tag_uid')
|
||||
.then((res) => res.json())
|
||||
.then((tag) => fillLastTag(tag));
|
||||
}
|
||||
|
||||
async function selectLastTag() {
|
||||
fetch('/api/v1/last_tag_uid')
|
||||
.then((res) => res.json())
|
||||
.then((tag) => selectTag(tag));
|
||||
}
|
||||
|
||||
function renderPlaylistForm(playlists) {
|
||||
const container = document.getElementById('playlist-exist-list');
|
||||
container.innerHTML = "";
|
||||
for (const playlist of playlists) {
|
||||
const li = document.createElement("li");
|
||||
li.innerHTML = playlist;
|
||||
li.className = "node"
|
||||
container.appendChild(li)
|
||||
}
|
||||
}
|
||||
|
||||
function bytesToHex(bytes) {
|
||||
return bytes
|
||||
.map(b => b.toString(16).padStart(2, '0'))
|
||||
.join('');
|
||||
}
|
||||
|
||||
function fillLastTag(tag) {
|
||||
const container = document.getElementById('playlist-new-tag');
|
||||
if (tag.tag !== null) {
|
||||
container.value = bytesToHex(tag.tag);
|
||||
}
|
||||
}
|
||||
|
||||
function selectTag(tag) {
|
||||
const container = document.getElementById('playlist-exist-list');
|
||||
if (tag.tag === null) return;
|
||||
const tagtext = bytesToHex(tag.tag);
|
||||
document.getElementById('playlist-exist-list')
|
||||
.querySelectorAll("li")
|
||||
.forEach(n => {
|
||||
if (n.innerText == tagtext) {
|
||||
n.classList.add("selected");
|
||||
lastSelected = n;
|
||||
} else {
|
||||
n.classList.remove("selected");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return { init, onShow };
|
||||
})();
|
||||
|
||||
/* -----------------------------------
|
||||
PLAYLIST EDITOR LOGIC - EDIT SCREEN
|
||||
-------------------------------------- */
|
||||
Screens.playlist_edit = (() => {
|
||||
let lastSelected = null;
|
||||
let playlistId = null;
|
||||
|
||||
function init() {
|
||||
document.getElementById('playlist-edit-list').addEventListener("click", (e) => {
|
||||
const node = e.target.closest("li");
|
||||
if (!node) return;
|
||||
|
||||
if (e.ctrlKey || e.metaKey) {
|
||||
node.classList.toggle("selected");
|
||||
} else {
|
||||
clearSelection();
|
||||
node.classList.add("selected");
|
||||
}
|
||||
|
||||
lastSelected = node;
|
||||
});
|
||||
document.getElementById('playlist-edit-trackmove-up').addEventListener("click", (e) => moveSelectedTracks(true));
|
||||
document.getElementById('playlist-edit-trackmove-down').addEventListener("click", (e) => moveSelectedTracks(false));
|
||||
document.getElementById('playlist-edit-removetrack').addEventListener("click", (e) => deleteSelectedTracks());
|
||||
document.getElementById('playlist-edit-back').addEventListener("click", (e) => showScreen('playlist'));
|
||||
document.getElementById('playlist-edit-addtrack').addEventListener("click", (e) => {
|
||||
showScreen("playlist_filebrowser");
|
||||
});
|
||||
document.getElementById('playlist-edit-save').addEventListener("click", (e) => save());
|
||||
}
|
||||
|
||||
function clearSelection() {
|
||||
document.getElementById('playlist-edit-list')
|
||||
.querySelectorAll(".selected")
|
||||
.forEach(n => n.classList.remove("selected"));
|
||||
}
|
||||
|
||||
async function onShow(intent) {
|
||||
const container = document.getElementById('playlist-edit-list');
|
||||
if ('load' in intent) {
|
||||
const playlist = intent.load;
|
||||
document.getElementById('playlist-edit-save').disabled = true;
|
||||
container.innerHTML = "<li>Loading…</li>";
|
||||
|
||||
fetch(`/api/v1/playlist/${playlist}`)
|
||||
.then((res) => res.json())
|
||||
.then((playlistRes) => {
|
||||
playlistId = playlist;
|
||||
renderPlaylistForm(playlistRes);
|
||||
document.getElementById('playlist-edit-save').disabled = false;
|
||||
}).catch((err) => {
|
||||
container.innerHTML = "<li>Failed to load playlist: " + err + "</li>";
|
||||
});
|
||||
} else if ('create' in intent) {
|
||||
const container = document.getElementById('playlist-edit-list');
|
||||
container.innerHTML = "";
|
||||
playlistId = intent.create;
|
||||
const playlisttype = document.getElementById('playlist-edit-type');
|
||||
playlisttype.value = "album";
|
||||
document.getElementById('playlist-edit-save').disabled = false;
|
||||
} else if ('addtracks' in intent) {
|
||||
const container = document.getElementById('playlist-edit-list');
|
||||
intent.addtracks.forEach((track) => {
|
||||
const li = document.createElement("li");
|
||||
li.innerHTML = track;
|
||||
li.className = "node"
|
||||
container.appendChild(li)
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function renderPlaylistForm(playlist) {
|
||||
const container = document.getElementById('playlist-edit-list');
|
||||
container.innerHTML = "";
|
||||
for (const track of playlist.paths) {
|
||||
const li = document.createElement("li");
|
||||
li.innerHTML = track;
|
||||
li.className = "node"
|
||||
container.appendChild(li)
|
||||
}
|
||||
const playlisttype = document.getElementById('playlist-edit-type');
|
||||
if (playlist.persist === "no" && playlist.shuffle === "no") {
|
||||
playlisttype.value = "album";
|
||||
} else if (playlist.persist === "offset" && playlist.shuffle === "no") {
|
||||
playlisttype.value = "audiobook";
|
||||
} else if (playlist.persist === "no" && playlist.shuffle === "yes") {
|
||||
playlisttype.value = "random";
|
||||
} else if (playlist.persist === "track" && playlist.shuffle === "no") {
|
||||
playlisttype.value = "audioplay";
|
||||
}
|
||||
}
|
||||
|
||||
async function save() {
|
||||
const playlistData = {};
|
||||
switch(document.getElementById('playlist-edit-type').value) {
|
||||
case "album":
|
||||
playlistData.persist = "no";
|
||||
playlistData.shuffle = "no";
|
||||
break;
|
||||
case "audiobook":
|
||||
playlistData.persist = "offset";
|
||||
playlistData.shuffle = "no";
|
||||
break;
|
||||
case "random":
|
||||
playlistData.persist = "no";
|
||||
playlistData.shuffle = "yes";
|
||||
break;
|
||||
case "audioplay":
|
||||
playlistData.persist = "track";
|
||||
playlistData.shuffle = "no";
|
||||
break;
|
||||
}
|
||||
const container = document.getElementById('playlist-edit-list');
|
||||
playlistData.paths = [...container.querySelectorAll("li")].map((node) => node.innerText);
|
||||
const saveRes = await fetch(`/api/v1/playlist/${playlistId}`,
|
||||
{
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify(playlistData)});
|
||||
|
||||
if (!saveRes.ok) {
|
||||
alert("Failed to save playlist: " + await saveRes.text());
|
||||
return;
|
||||
}
|
||||
showScreen('playlist');
|
||||
}
|
||||
|
||||
function moveSelectedTracks(up) {
|
||||
const container = document.getElementById('playlist-edit-list');
|
||||
let selected_nodes = [...container.querySelectorAll(".selected")];
|
||||
if (!up) {
|
||||
selected_nodes = selected_nodes.reverse()
|
||||
}
|
||||
for (node of selected_nodes) {
|
||||
const sibling = up ? node.previousElementSibling : node.nextElementSibling;
|
||||
if (sibling === null ||
|
||||
sibling.classList.contains("selected")) continue;
|
||||
container.insertBefore(node, up ? sibling : sibling.nextElementSibling);
|
||||
}
|
||||
}
|
||||
|
||||
function deleteSelectedTracks() {
|
||||
const container = document.getElementById('playlist-edit-list');
|
||||
container.querySelectorAll(".selected").forEach((node) => node.remove());
|
||||
}
|
||||
|
||||
return { init, onShow };
|
||||
})();
|
||||
|
||||
/* ----------------------------------------
|
||||
PLAYLIST EDITOR LOGIC - ADD FILES SCREEN
|
||||
------------------------------------------- */
|
||||
Screens.playlist_filebrowser = (() => {
|
||||
function init() {
|
||||
document.getElementById('playlist-filebrowser-cancel').addEventListener("click", (e) => {
|
||||
showScreen("playlist_edit", {});
|
||||
});
|
||||
document.getElementById('playlist-filebrowser-addtrack').addEventListener("click", (e) => {
|
||||
returnSelectedTracks();
|
||||
});
|
||||
document.getElementById('playlist-filebrowser-upload').addEventListener("click", (e) => {
|
||||
uploadFiles();
|
||||
});
|
||||
document.getElementById('playlist-filebrowser-mkdir').addEventListener("click", (e) => {
|
||||
createDirectory();
|
||||
});
|
||||
document.getElementById('playlist-filebrowser-delete').addEventListener("click", (e) => {
|
||||
deleteItems();
|
||||
});
|
||||
tree.init();
|
||||
}
|
||||
|
||||
async function onShow(intent) {
|
||||
document.getElementById('playlist-filebrowser-addtrack').disabled = true;
|
||||
tree = document.getElementById("playlist-filebrowser-tree");
|
||||
tree.innerHTML = "Loading...";
|
||||
fetch('/api/v1/audiofiles')
|
||||
.then((res) => res.json())
|
||||
.then((files) => renderFilebrowserForm(files))
|
||||
.catch((err) => {
|
||||
alert("Failed to load file list: " + err);
|
||||
});
|
||||
}
|
||||
|
||||
function findChildByName(node, name) {
|
||||
for (const child of node.querySelectorAll(':scope > li')) {
|
||||
const childLabel = child.querySelector(':scope > span.node');
|
||||
if (childLabel.innerText === name)
|
||||
return child.querySelector(':scope > ul');
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function addTreeNode(parent, name, type, data) {
|
||||
const node = document.createElement('li');
|
||||
const caret = document.createElement('span');
|
||||
const label = document.createElement('span');
|
||||
caret.className = 'caret';
|
||||
label.className = 'node';
|
||||
label.innerText = name;
|
||||
node.appendChild(caret);
|
||||
node.appendChild(label);
|
||||
if (data !== null)
|
||||
label.setAttribute('data-path', data);
|
||||
label.setAttribute('data-type', type);
|
||||
if (type === 'directory') {
|
||||
const nested = document.createElement('ul');
|
||||
node.appendChild(nested);
|
||||
node.classList.add('expanded');
|
||||
parent.appendChild(node);
|
||||
return nested;
|
||||
}
|
||||
parent.appendChild(node);
|
||||
return null;
|
||||
}
|
||||
|
||||
function renderFilebrowserForm(files) {
|
||||
const tree = document.getElementById("playlist-filebrowser-tree");
|
||||
tree.innerHTML = "";
|
||||
const rootnode = document.createElement('ul');
|
||||
tree.appendChild(rootnode);
|
||||
|
||||
files.sort((a, b) => a.name < b.name ? -1 : (a.name > b.name ? 1 : 0));
|
||||
for (const file of files) {
|
||||
const path = file.name.split('/').slice(1);
|
||||
let treeIterator = rootnode;
|
||||
let curPath = ''
|
||||
for (const elem of path) {
|
||||
curPath += '/' + elem;
|
||||
let node = findChildByName(treeIterator, elem);
|
||||
if (node === null) {
|
||||
node = addTreeNode(treeIterator, elem, file.type, curPath);
|
||||
}
|
||||
treeIterator = node;
|
||||
}
|
||||
}
|
||||
|
||||
document.getElementById('playlist-filebrowser-addtrack').disabled = false;
|
||||
}
|
||||
|
||||
function returnSelectedTracks() {
|
||||
const tree = document.getElementById("playlist-filebrowser-tree");
|
||||
const selectedNodes = [...tree.querySelectorAll(".selected")];
|
||||
const tracks = [];
|
||||
for (const node of selectedNodes) {
|
||||
if (node.getAttribute('data-type') === "file") {
|
||||
tracks.push(node.getAttribute('data-path'));
|
||||
} else {
|
||||
// recursivly add directory contents
|
||||
for (const child of node.parentNode.querySelector('ul').querySelectorAll("span.node")) {
|
||||
if (child.getAttribute('data-type') === "file")
|
||||
tracks.push(child.getAttribute('data-path'));
|
||||
}
|
||||
}
|
||||
}
|
||||
showScreen("playlist_edit", {addtracks: tracks});
|
||||
}
|
||||
|
||||
function uploadFiles() {
|
||||
const tree = document.getElementById("playlist-filebrowser-tree");
|
||||
const selectedNodes = [...tree.querySelectorAll(".selected")];
|
||||
const files = [...document.getElementById("playlist-filebrowser-upload-files").files];
|
||||
if (selectedNodes.length !== 1 ||
|
||||
selectedNodes[0].getAttribute('data-type') !== "directory") {
|
||||
alert("Please select a single directory for upload");
|
||||
return;
|
||||
}
|
||||
uploadFileHelper(files.length, 0, selectedNodes[0].getAttribute('data-path'), files);
|
||||
}
|
||||
|
||||
function uploadFileHelper(totalcount, donecount, destdir, files) {
|
||||
// Upload files sequentially by recursivly calling this function from the 'load' callback when
|
||||
// the previous upload is completed.
|
||||
if (files.length === 0)
|
||||
return;
|
||||
const reader = new FileReader();
|
||||
const ctrl = document.getElementById("playlist-filebrowser-upload-progress");
|
||||
const xhr = new XMLHttpRequest();
|
||||
const location = destdir + '/' + files[0].name;
|
||||
|
||||
xhr.upload.addEventListener("progress", (e) => {
|
||||
if (e.lengthComputable) {
|
||||
const percentage = Math.round((e.loaded * 100) / e.total / totalcount + donecount * 100 / totalcount);
|
||||
ctrl.value = percentage;
|
||||
}
|
||||
});
|
||||
//xhr.upload.addEventListener("load", (e) => {
|
||||
xhr.onreadystatechange = () => {
|
||||
if (xhr.readyState === XMLHttpRequest.DONE) {
|
||||
if (xhr.status !== 204) {
|
||||
alert(`File upload failed: ${xhr.responseText} (${xhr.status})`);
|
||||
return;
|
||||
}
|
||||
if (donecount + 1 === totalcount) {
|
||||
// Reload file list from device
|
||||
onShow();
|
||||
} else {
|
||||
uploadFileHelper(totalcount, donecount + 1, destdir, files.slice(1));
|
||||
}
|
||||
}
|
||||
};
|
||||
xhr.open("POST", `/api/v1/audiofiles?type=file&location=${location}`);
|
||||
xhr.overrideMimeType("audio/mpeg");
|
||||
xhr.send(files[0]);
|
||||
}
|
||||
|
||||
async function createDirectory() {
|
||||
const name = document.getElementById('playlist-filebrowser-mkdir-name');
|
||||
const selectedNodes = [...tree.querySelectorAll(".selected")];
|
||||
const files = [...document.getElementById("playlist-filebrowser-upload-files").files];
|
||||
if (selectedNodes.length > 1 ||
|
||||
(selectedNodes.length === 1 &&
|
||||
selectedNodes[0].getAttribute('data-type') !== "directory")) {
|
||||
alert("Please select a single directory for upload");
|
||||
return;
|
||||
}
|
||||
const location = selectedNodes.length === 1
|
||||
? selectedNodes[0].getAttribute('data-path') + '/' + name.value
|
||||
: '/' + name.value;
|
||||
const saveRes = await fetch(`/api/v1/audiofiles?type=directory&location=${location}`,
|
||||
{method: 'POST'});
|
||||
// Reload file list from device
|
||||
onShow();
|
||||
}
|
||||
|
||||
async function deleteItems() {
|
||||
const tree = document.getElementById("playlist-filebrowser-tree");
|
||||
const selectedNodes = [...tree.querySelectorAll(".selected")];
|
||||
if (selectedNodes.length === 0) {
|
||||
alert("Please select something to delete");
|
||||
return;
|
||||
}
|
||||
const items = selectedNodes.map(n => n.getAttribute('data-path'));
|
||||
items.sort();
|
||||
items.reverse();
|
||||
for (const item of items) {
|
||||
const saveRes = await fetch(`/api/v1/audiofiles?location=${item}`,
|
||||
{method: 'DELETE'});
|
||||
if (!saveRes.ok) {
|
||||
alert(`Failed to delete item ${item}: ${await saveRes.text()}`);
|
||||
}
|
||||
}
|
||||
// Reload file list from device
|
||||
onShow();
|
||||
}
|
||||
|
||||
let tree = (() => {
|
||||
let tree = null;
|
||||
function init() {
|
||||
tree = document.getElementById("playlist-filebrowser-tree");
|
||||
tree.addEventListener("click", (e) => {
|
||||
// CARET CLICK → expand/collapse
|
||||
const caret = e.target.closest(".caret");
|
||||
if (caret) {
|
||||
const li = caret.parentElement;
|
||||
if (li.querySelector("ul")) {
|
||||
li.classList.toggle("expanded");
|
||||
}
|
||||
return; // IMPORTANT: don't affect selection
|
||||
}
|
||||
|
||||
// NODE LABEL CLICK → selection only
|
||||
const node = e.target.closest(".node");
|
||||
if (!node) return;
|
||||
|
||||
if (e.shiftKey && lastSelected) {
|
||||
selectRange(lastSelected, node);
|
||||
} else if (e.ctrlKey || e.metaKey) {
|
||||
node.classList.toggle("selected");
|
||||
} else {
|
||||
clearSelection();
|
||||
node.classList.add("selected");
|
||||
}
|
||||
|
||||
lastSelected = node;
|
||||
});
|
||||
}
|
||||
|
||||
function clearSelection() {
|
||||
tree.querySelectorAll(".selected").forEach(n =>
|
||||
n.classList.remove("selected")
|
||||
);
|
||||
}
|
||||
|
||||
function selectRange(start, end) {
|
||||
const nodes = [...tree.querySelectorAll(".node")];
|
||||
const startIndex = nodes.indexOf(start);
|
||||
const endIndex = nodes.indexOf(end);
|
||||
|
||||
const [from, to] = startIndex < endIndex
|
||||
? [startIndex, endIndex]
|
||||
: [endIndex, startIndex];
|
||||
|
||||
clearSelection();
|
||||
nodes.slice(from, to + 1).forEach(n =>
|
||||
n.classList.add("selected")
|
||||
);
|
||||
}
|
||||
|
||||
return { init };
|
||||
})();
|
||||
|
||||
return { init, onShow };
|
||||
})();
|
||||
|
||||
// Initialization
|
||||
Object.values(Screens).forEach(screen => {
|
||||
screen.init?.();
|
||||
});
|
||||
|
||||
showScreen("menu");
|
||||
</script>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
Submodule software/lib/micropython updated: 6fdbf1d339...4ecb4099cf
@@ -1 +0,0 @@
|
||||
freezefs
|
||||
@@ -51,14 +51,13 @@ class PlayerApp:
|
||||
self.playlist_db = deps.playlistdb(self)
|
||||
self.hwconfig = deps.hwconfig(self)
|
||||
self.leds = deps.leds(self)
|
||||
self.tag_mode = self.config.get_tagmode()
|
||||
self.tag_mode = self.playlist_db.getSetting('tagmode')
|
||||
self.playing_tag = None
|
||||
self.playlist = None
|
||||
self.buttons = deps.buttons(self) if deps.buttons is not None else None
|
||||
self.mp3file = None
|
||||
self.volume_pos = 3
|
||||
self.paused = False
|
||||
self.playing = False
|
||||
self.player.set_volume(VOLUME_CURVE[self.volume_pos])
|
||||
self._onIdle()
|
||||
|
||||
@@ -104,6 +103,9 @@ 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):
|
||||
@@ -113,13 +115,6 @@ class PlayerApp:
|
||||
# Check again in a minute
|
||||
self.timer_manager.schedule(time.ticks_ms() + self.idle_timeout_ms, self.onIdleTimeout)
|
||||
|
||||
def reset_idle_timeout(self):
|
||||
if not self.playing:
|
||||
self.timer_manager.schedule(time.ticks_ms() + self.idle_timeout_ms, self.onIdleTimeout)
|
||||
|
||||
def is_playing(self) -> bool:
|
||||
return self.playing
|
||||
|
||||
def _set_playlist(self, tag: bytes):
|
||||
if self.playlist is not None:
|
||||
pos = self.player.stop()
|
||||
@@ -138,7 +133,9 @@ class PlayerApp:
|
||||
self.playlist = None
|
||||
|
||||
def _play_next(self):
|
||||
filename = self.playlist.getNextPath() if self.playlist is not None else None
|
||||
if self.playlist is None:
|
||||
return
|
||||
filename = self.playlist.getNextPath()
|
||||
self._play(filename)
|
||||
if filename is None:
|
||||
self.playlist = None
|
||||
@@ -161,11 +158,7 @@ class PlayerApp:
|
||||
self._onIdle()
|
||||
if filename is not None:
|
||||
print(f'Playing {filename!r}')
|
||||
try:
|
||||
self.mp3file = open(filename, 'rb')
|
||||
except OSError as ex:
|
||||
print(f"Could not play file {filename}: {ex}")
|
||||
return
|
||||
self.mp3file = open(filename, 'rb')
|
||||
self.player.play(self.mp3file, offset)
|
||||
self.paused = False
|
||||
self._onActive()
|
||||
@@ -183,21 +176,7 @@ class PlayerApp:
|
||||
def _onIdle(self):
|
||||
self.timer_manager.schedule(time.ticks_ms() + self.idle_timeout_ms, self.onIdleTimeout)
|
||||
self.leds.set_state(self.leds.IDLE)
|
||||
self.playing = False
|
||||
|
||||
def _onActive(self):
|
||||
self.timer_manager.cancel(self.onIdleTimeout)
|
||||
self.leds.set_state(self.leds.PLAYING)
|
||||
self.playing = True
|
||||
|
||||
def get_nfc(self):
|
||||
return self.nfc
|
||||
|
||||
def get_playlist_db(self):
|
||||
return self.playlist_db
|
||||
|
||||
def get_timer_manager(self):
|
||||
return self.timer_manager
|
||||
|
||||
def get_leds(self):
|
||||
return self.leds
|
||||
|
||||
@@ -14,7 +14,6 @@ import ubinascii
|
||||
# Own modules
|
||||
import app
|
||||
from audiocore import AudioContext
|
||||
import frozen_frontend # noqa: F401
|
||||
from mfrc522 import MFRC522
|
||||
from mp3player import MP3Player
|
||||
from nfc import Nfc
|
||||
@@ -54,13 +53,6 @@ def setup_wifi():
|
||||
print(f"ifconfig: {wlan.ifconfig()}")
|
||||
|
||||
|
||||
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(100)
|
||||
wdt.feed()
|
||||
|
||||
DB_PATH = '/sd/tonberry.db'
|
||||
|
||||
config = Configuration()
|
||||
@@ -73,6 +65,7 @@ def run():
|
||||
|
||||
# Wifi with default config
|
||||
setup_wifi()
|
||||
start_webserver(config)
|
||||
|
||||
# Setup MP3 player
|
||||
with SDContext(mosi=hwconfig.SD_DI, miso=hwconfig.SD_DO, sck=hwconfig.SD_SCK, ss=hwconfig.SD_CS,
|
||||
@@ -103,12 +96,9 @@ def run():
|
||||
config=lambda _: config)
|
||||
the_app = app.PlayerApp(deps)
|
||||
|
||||
start_webserver(config, the_app)
|
||||
# Start
|
||||
wdt = machine.WDT(timeout=2000)
|
||||
asyncio.create_task(aiorepl.task({'timer_manager': TimerManager(),
|
||||
'app': the_app}))
|
||||
asyncio.create_task(wdt_task(wdt))
|
||||
asyncio.get_event_loop().run_forever()
|
||||
|
||||
|
||||
|
||||
@@ -6,7 +6,6 @@ Copyright (c) 2025 Matthias Blankertz <matthias@blankertz.org>
|
||||
|
||||
import asyncio
|
||||
import time
|
||||
from utils import safe_callback
|
||||
|
||||
from mfrc522 import MFRC522
|
||||
try:
|
||||
@@ -75,7 +74,7 @@ class Nfc:
|
||||
self.last_uid = uid
|
||||
self.last_uid_timestamp = time.ticks_us()
|
||||
if self.cb is not None and last_callback_uid != uid:
|
||||
safe_callback(lambda: self.cb.onTagChange(uid), "nfc tag change")
|
||||
self.cb.onTagChange(uid)
|
||||
last_callback_uid = uid
|
||||
|
||||
await asyncio.sleep_ms(poll_interval_ms)
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
# SPDX-License-Identifier: MIT
|
||||
# Copyright (c) 2025 Matthias Blankertz <matthias@blankertz.org>
|
||||
|
||||
from utils.helpers import safe_callback
|
||||
from utils.buttons import Buttons
|
||||
from utils.config import Configuration
|
||||
from utils.leds import LedManager
|
||||
@@ -12,4 +11,4 @@ from utils.sdcontext import SDContext
|
||||
from utils.timer import TimerManager
|
||||
|
||||
__all__ = ["BTreeDB", "BTreeFileManager", "Buttons", "Configuration", "get_pin_index", "LedManager", "MBRPartition",
|
||||
"safe_callback", "SDContext", "TimerManager"]
|
||||
"SDContext", "TimerManager"]
|
||||
|
||||
@@ -5,7 +5,6 @@ import asyncio
|
||||
import machine
|
||||
import micropython
|
||||
import time
|
||||
from utils import safe_callback
|
||||
try:
|
||||
from typing import TYPE_CHECKING # type: ignore
|
||||
except ImportError:
|
||||
@@ -75,4 +74,4 @@ class Buttons:
|
||||
await self.int_flag.wait()
|
||||
while len(self.pressed) > 0:
|
||||
what = self.pressed.pop()
|
||||
safe_callback(lambda: self.cb.onButtonPressed(what), "button callback")
|
||||
self.cb.onButtonPressed(what)
|
||||
|
||||
@@ -21,8 +21,7 @@ class Configuration:
|
||||
'VOLDOWN': 2,
|
||||
'PREV': None,
|
||||
'NEXT': 1,
|
||||
},
|
||||
'TAGMODE': 'tagremains'
|
||||
}
|
||||
}
|
||||
|
||||
def __init__(self, config_path='/config.json'):
|
||||
@@ -30,7 +29,6 @@ class Configuration:
|
||||
try:
|
||||
with open(self.config_path, 'r') as conf_file:
|
||||
self.config = json.load(conf_file)
|
||||
self._merge_configs(self.DEFAULT_CONFIG, self.config)
|
||||
except OSError as ex:
|
||||
if ex.errno == ENOENT:
|
||||
self.config = Configuration.DEFAULT_CONFIG
|
||||
@@ -52,16 +50,6 @@ class Configuration:
|
||||
raise
|
||||
os.sync()
|
||||
|
||||
def _merge_configs(self, default, config):
|
||||
for k in default.keys():
|
||||
if k not in config:
|
||||
if isinstance(default[k], dict):
|
||||
config[k] = default[k].copy()
|
||||
else:
|
||||
config[k] = default[k]
|
||||
elif isinstance(default[k], dict):
|
||||
self._merge_configs(default[k], config[k])
|
||||
|
||||
def _save(self):
|
||||
with open(self.config_path + '.new', 'w') as conf_file:
|
||||
json.dump(self.config, conf_file)
|
||||
@@ -70,7 +58,7 @@ class Configuration:
|
||||
os.sync()
|
||||
|
||||
def _get(self, key):
|
||||
return self.config[key]
|
||||
return self.config.get(key, self.DEFAULT_CONFIG[key])
|
||||
|
||||
def get_led_count(self) -> int:
|
||||
return self._get('LED_COUNT')
|
||||
@@ -84,9 +72,6 @@ class Configuration:
|
||||
def get_button_map(self) -> Mapping[str, int | None]:
|
||||
return self._get('BUTTON_MAP')
|
||||
|
||||
def get_tagmode(self) -> str:
|
||||
return self._get('TAGMODE')
|
||||
|
||||
# For the web API
|
||||
def get_config(self) -> Mapping[str, Any]:
|
||||
return self.config
|
||||
@@ -102,8 +87,5 @@ class Configuration:
|
||||
|
||||
def set_config(self, config):
|
||||
self._validate(self.DEFAULT_CONFIG, config)
|
||||
if 'TAGMODE' in config and config['TAGMODE'] not in ['tagremains', 'tagstartstop']:
|
||||
raise ValueError("Invalid TAGMODE: Must be 'tagremains' or 'tagstartstop'")
|
||||
self._merge_configs(self.config, config)
|
||||
self.config = config
|
||||
self._save()
|
||||
|
||||
@@ -1,12 +0,0 @@
|
||||
# SPDX-License-Identifier: MIT
|
||||
# Copyright (c) 2025 Matthias Blankertz <matthias@blankertz.org>
|
||||
|
||||
import sys
|
||||
|
||||
|
||||
def safe_callback(func, name="callback"):
|
||||
try:
|
||||
func()
|
||||
except Exception as ex:
|
||||
print(f"Uncaught exception in {name}")
|
||||
sys.print_exception(ex)
|
||||
@@ -10,7 +10,6 @@ import time
|
||||
class LedManager:
|
||||
IDLE = const(0)
|
||||
PLAYING = const(1)
|
||||
REBOOTING = const(2)
|
||||
|
||||
def __init__(self, np):
|
||||
self.led_state = LedManager.IDLE
|
||||
@@ -20,7 +19,7 @@ class LedManager:
|
||||
asyncio.create_task(self.run())
|
||||
|
||||
def set_state(self, state):
|
||||
assert state in [LedManager.IDLE, LedManager.PLAYING, LedManager.REBOOTING]
|
||||
assert state in [LedManager.IDLE, LedManager.PLAYING]
|
||||
self.led_state = state
|
||||
|
||||
def _gamma(self, value, X=2.2):
|
||||
@@ -51,8 +50,6 @@ 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()
|
||||
|
||||
@@ -31,6 +31,9 @@ class BTreeDB(IPlaylistDB):
|
||||
PERSIST_NO = b'no'
|
||||
PERSIST_TRACK = b'track'
|
||||
PERSIST_OFFSET = b'offset'
|
||||
DEFAULT_SETTINGS = {
|
||||
b'tagmode': b'tagremains'
|
||||
}
|
||||
|
||||
class Playlist(IPlaylist):
|
||||
def __init__(self, parent: "BTreeDB", tag: bytes, pos: int, persist, shuffle):
|
||||
@@ -248,16 +251,6 @@ 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
|
||||
@@ -289,8 +282,10 @@ class BTreeDB(IPlaylistDB):
|
||||
self._savePlaylist(tag, entries, persist, shuffle)
|
||||
return self.getPlaylistForTag(tag)
|
||||
|
||||
def deletePlaylistForTag(self, tag: bytes):
|
||||
self._deletePlaylist(tag)
|
||||
def getSetting(self, key: bytes | str) -> str:
|
||||
if type(key) is str:
|
||||
key = key.encode()
|
||||
return self.db.get(b'settings/' + key, self.DEFAULT_SETTINGS[key]).decode()
|
||||
|
||||
def validate(self, dump=False):
|
||||
"""
|
||||
@@ -311,7 +306,8 @@ class BTreeDB(IPlaylistDB):
|
||||
fail(f'Malformed key {k!r}')
|
||||
continue
|
||||
if fields[0] == b'settings':
|
||||
# Legacy, not used any more
|
||||
val = self.db[k].decode()
|
||||
print(f'Setting {fields[1].decode()} = {val}')
|
||||
continue
|
||||
if last_tag != fields[0]:
|
||||
last_tag = fields[0]
|
||||
|
||||
@@ -4,7 +4,6 @@
|
||||
import asyncio
|
||||
import heapq
|
||||
import time
|
||||
from utils import safe_callback
|
||||
|
||||
TIMER_DEBUG = True
|
||||
|
||||
@@ -50,36 +49,28 @@ class TimerManager(object):
|
||||
heapq.heapify(self.timers)
|
||||
return i
|
||||
|
||||
def _next_timeout(self):
|
||||
if len(self.timers) == 0:
|
||||
if self.timer_debug:
|
||||
print("timer: worker: queue empty")
|
||||
return None
|
||||
cur_nearest = self.timers[0][0]
|
||||
next_timeout = cur_nearest - time.ticks_ms()
|
||||
if self.timer_debug:
|
||||
if next_timeout > 0:
|
||||
print(f"timer: worker: next is {self.timers[0]}, sleep {next_timeout} ms")
|
||||
else:
|
||||
print(f"timer: worker: {self.timers[0]} elapsed @{cur_nearest}, delay {-next_timeout} ms")
|
||||
return next_timeout
|
||||
|
||||
async def _wait(self, timeout):
|
||||
try:
|
||||
await asyncio.wait_for_ms(self.worker_event.wait(), timeout)
|
||||
if self.timer_debug:
|
||||
print("timer: worker: event")
|
||||
# got woken up due to event
|
||||
self.worker_event.clear()
|
||||
return True
|
||||
except asyncio.TimeoutError:
|
||||
return False
|
||||
|
||||
async def _timer_worker(self):
|
||||
while True:
|
||||
next_timeout = self._next_timeout()
|
||||
if next_timeout is None or next_timeout > 0:
|
||||
await self._wait(next_timeout)
|
||||
else:
|
||||
_, callback = heapq.heappop(self.timers)
|
||||
safe_callback(callback, "timer callback")
|
||||
if len(self.timers) == 0:
|
||||
# Nothing to do
|
||||
await self.worker_event.wait()
|
||||
if self.timer_debug:
|
||||
print("_timer_worker: event 0")
|
||||
self.worker_event.clear()
|
||||
continue
|
||||
cur_nearest = self.timers[0][0]
|
||||
wait_time = cur_nearest - time.ticks_ms()
|
||||
if wait_time > 0:
|
||||
if self.timer_debug:
|
||||
print(f"_timer_worker: next is {self.timers[0]}, sleep {wait_time} ms")
|
||||
try:
|
||||
await asyncio.wait_for_ms(self.worker_event.wait(), wait_time)
|
||||
if self.timer_debug:
|
||||
print("_timer_worker: event 1")
|
||||
# got woken up due to event
|
||||
self.worker_event.clear()
|
||||
continue
|
||||
except asyncio.TimeoutError:
|
||||
pass
|
||||
_, callback = heapq.heappop(self.timers)
|
||||
callback()
|
||||
|
||||
@@ -4,47 +4,21 @@ Copyright (c) 2024-2025 Stefan Kratochwil <Kratochwil-LA@gmx.de>
|
||||
'''
|
||||
|
||||
import asyncio
|
||||
import hwconfig
|
||||
import json
|
||||
import machine
|
||||
import os
|
||||
import time
|
||||
|
||||
from array import array
|
||||
from microdot import Microdot, redirect, send_file, Request
|
||||
from utils import TimerManager, LedManager
|
||||
from microdot import Microdot
|
||||
|
||||
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, playlist_db, leds, timer_manager
|
||||
def start_webserver(config_):
|
||||
global server, config
|
||||
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 = app.get_timer_manager()
|
||||
|
||||
|
||||
@webapp.before_request
|
||||
async def before_request_handler(request):
|
||||
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()
|
||||
|
||||
|
||||
@webapp.route('/api/v1/hello')
|
||||
@webapp.route('/')
|
||||
async def index(request):
|
||||
print("wohoo, a guest :)")
|
||||
print(f" app: {request.app}")
|
||||
@@ -81,192 +55,3 @@ async def config_put(request):
|
||||
except ValueError as ex:
|
||||
return str(ex), 400
|
||||
return '', 204
|
||||
|
||||
|
||||
@webapp.route('/api/v1/last_tag_uid', methods=['GET'])
|
||||
async def last_tag_uid_get(request):
|
||||
tag, _ = nfc.get_last_uid()
|
||||
return {'tag': tag}
|
||||
|
||||
|
||||
@webapp.route('/', methods=['GET'])
|
||||
async def root_get(request):
|
||||
return redirect('/index.html')
|
||||
|
||||
|
||||
@webapp.route('/index.html', methods=['GET'])
|
||||
async def index_get(request):
|
||||
return send_file('/frontend/index.html.gz', content_type='text/html', compressed='gzip')
|
||||
|
||||
|
||||
@webapp.route('/static/<path:path>', methods=['GET'])
|
||||
async def static(request, path):
|
||||
if '..' in 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: no vbus', 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
|
||||
|
||||
@@ -136,9 +136,6 @@ class FakeConfig:
|
||||
def get_tag_timeout(self):
|
||||
return 5
|
||||
|
||||
def get_tagmode(self):
|
||||
return 'tagremains'
|
||||
|
||||
|
||||
def fake_open(filename, mode):
|
||||
return FakeFile(filename, mode)
|
||||
@@ -229,16 +226,18 @@ def test_playlist_unknown_tag(micropythonify, faketimermanager, monkeypatch):
|
||||
|
||||
|
||||
def test_tagmode_startstop(micropythonify, faketimermanager, monkeypatch):
|
||||
class FakeStartStopConfig(FakeConfig):
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
class MyFakePlaylistDb(FakePlaylistDb):
|
||||
def __init__(self, tracklist=[b'test/path.mp3']):
|
||||
super().__init__(tracklist)
|
||||
|
||||
def get_tagmode(self):
|
||||
return 'tagstartstop'
|
||||
def getSetting(self, key: bytes | str):
|
||||
if key == 'tagmode':
|
||||
return 'tagstartstop'
|
||||
return None
|
||||
|
||||
fake_db = FakePlaylistDb([b'test/path.mp3'])
|
||||
fake_db = MyFakePlaylistDb()
|
||||
fake_mp3 = FakeMp3Player()
|
||||
deps = _makedeps(mp3player=fake_mp3, playlistdb=fake_db, config=FakeStartStopConfig)
|
||||
deps = _makedeps(mp3player=fake_mp3, playlistdb=fake_db)
|
||||
app.PlayerApp(deps)
|
||||
with monkeypatch.context() as m:
|
||||
m.setattr(builtins, 'open', fake_open)
|
||||
@@ -265,7 +264,16 @@ def test_tagmode_startstop(micropythonify, faketimermanager, monkeypatch):
|
||||
|
||||
|
||||
def test_tagmode_remains(micropythonify, faketimermanager, monkeypatch):
|
||||
fake_db = FakePlaylistDb([b'test/path.mp3'])
|
||||
class MyFakePlaylistDb(FakePlaylistDb):
|
||||
def __init__(self, tracklist=[b'test/path.mp3']):
|
||||
super().__init__(tracklist)
|
||||
|
||||
def getSetting(self, key: bytes | str):
|
||||
if key == 'tagmode':
|
||||
return 'tagremains'
|
||||
return None
|
||||
|
||||
fake_db = MyFakePlaylistDb()
|
||||
fake_mp3 = FakeMp3Player()
|
||||
deps = _makedeps(mp3player=fake_mp3, playlistdb=fake_db)
|
||||
app.PlayerApp(deps)
|
||||
|
||||
Reference in New Issue
Block a user