7 Commits

Author SHA1 Message Date
e5a2f7bc78 wip: frontend
All checks were successful
Build RPi Pico firmware image / Build-Firmware (push) Successful in 5m5s
Check code formatting / Check-C-Format (push) Successful in 7s
Check code formatting / Check-Python-Flake8 (push) Successful in 10s
Check code formatting / Check-Bash-Shellcheck (push) Successful in 5s
Run unit tests on host / Run-Unit-Tests (push) Successful in 7s
Run pytests / Check-Pytest (push) Successful in 10s
2025-12-17 21:38:05 +01:00
735167db22 Merge branch '30-frontend' into mbl-next
All checks were successful
Build RPi Pico firmware image / Build-Firmware (push) Successful in 4m38s
Check code formatting / Check-C-Format (push) Successful in 7s
Check code formatting / Check-Python-Flake8 (push) Successful in 10s
Check code formatting / Check-Bash-Shellcheck (push) Successful in 4s
Run unit tests on host / Run-Unit-Tests (push) Successful in 7s
Run pytests / Check-Pytest (push) Successful in 10s
2025-12-17 19:11:10 +01:00
11e31aabc2 Merge remote-tracking branch 'origin/webapi-last-tag-uid' into mbl-next 2025-12-17 18:39:26 +01:00
32bf6a8d68 always return content type application/json
All checks were successful
Build RPi Pico firmware image / Build-Firmware (push) Successful in 4m36s
Check code formatting / Check-C-Format (push) Successful in 7s
Check code formatting / Check-Python-Flake8 (push) Successful in 10s
Check code formatting / Check-Bash-Shellcheck (push) Successful in 5s
Run unit tests on host / Run-Unit-Tests (push) Successful in 8s
Run pytests / Check-Pytest (push) Successful in 10s
2025-12-16 23:13:04 +01:00
49197c8ca4 Merge pull request 'feat: Move tagmode setting to config.json, remove playlistdb settings' (#57) from unify-config into main
All checks were successful
Build RPi Pico firmware image / Build-Firmware (push) Successful in 4m38s
Check code formatting / Check-C-Format (push) Successful in 6s
Check code formatting / Check-Python-Flake8 (push) Successful in 9s
Check code formatting / Check-Bash-Shellcheck (push) Successful in 5s
Run unit tests on host / Run-Unit-Tests (push) Successful in 8s
Run pytests / Check-Pytest (push) Successful in 11s
Reviewed-on: #57
Reviewed-by: Stefan Kratochwil <kratochwil-la@gmx.de>
2025-12-16 21:55:28 +00:00
e2f9287ebd feat: last connected tag uid available at /api/v1/last_tag_uid
All checks were successful
Build RPi Pico firmware image / Build-Firmware (push) Successful in 4m39s
Check code formatting / Check-C-Format (push) Successful in 8s
Check code formatting / Check-Python-Flake8 (push) Successful in 10s
Check code formatting / Check-Bash-Shellcheck (push) Successful in 5s
Run unit tests on host / Run-Unit-Tests (push) Successful in 9s
Run pytests / Check-Pytest (push) Successful in 11s
2025-12-16 22:41:55 +01:00
19dff763bd feat: webserver: keep alive; move playback write prot to handler
- Reset the app idle timer when interacting with the webapp, so that the
  device does not turn off while the web ui is used.

- Handle denying put/post while playback is active centrally in the
  before_request handler, so that it does not need to be copy/pasted
  into every request handler.

Signed-off-by: Matthias Blankertz <matthias@blankertz.org>
2025-12-16 22:34:06 +01:00
3 changed files with 374 additions and 162 deletions

View File

@@ -45,6 +45,71 @@
padding-left: 10px; padding-left: 10px;
margin-top: 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-tree-container {
border: 1px solid #ccc;
border-radius: 4px;
height: 300px;
overflow-y: auto;
overflow-x: hidden;
}
</style> </style>
</head> </head>
<body> <body>
@@ -54,6 +119,7 @@
<nav> <nav>
<button onclick="showScreen('menu')">🏠 Main Menu</button> <button onclick="showScreen('menu')">🏠 Main Menu</button>
<button onclick="showScreen('config')">⚙️ Config Editor</button> <button onclick="showScreen('config')">⚙️ Config Editor</button>
<button onclick="showScreen('playlist')">🖹 Playlist Editor</button>
</nav> </nav>
<!-- MAIN MENU --> <!-- MAIN MENU -->
@@ -63,6 +129,7 @@
<ul> <ul>
<li><button onclick="showScreen('config')">Open Config Editor</button></li> <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 --> <!-- More screens can be added later -->
</ul> </ul>
</div> </div>
@@ -71,10 +138,53 @@
<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="save-btn" disabled>Save</button> <button id="config-save-btn" disabled>Save</button>
</div>
<!-- PLAYLIST EDITOR SCREEN -->
<div id="screen-playlist" class="screen">
<h2>Playlist Editor</h2>
<div id="playlist-container">Loading…</div>
<div class="scroll-tree-container">
<div class="tree" id="tree">
<ul>
<li>
<span class="caret"></span>
<span class="node">Fruits</span>
<ul>
<li>
<span class="caret"></span>
<span class="node">Apple</span>
</li>
<li>
<span class="caret"></span>
<span class="node">Citrus</span>
<ul>
<li>
<span class="caret"></span>
<span class="node">Orange</span>
</li>
<li>
<span class="caret"></span>
<span class="node">Lemon</span>
</li>
</ul>
</li>
<li>
<span class="caret"></span>
<span class="node">Strawberry</span>
</li>
</ul>
</li>
</ul>
</div>
</div>
<button id="playlist-save-btn" disabled>Save</button>
</div> </div>
<script> <script>
const Screens = {};
let activeScreen = null;
/* ----------------------------- /* -----------------------------
Screen switching Screen switching
----------------------------- */ ----------------------------- */
@@ -82,33 +192,60 @@ function showScreen(name) {
document.querySelectorAll('.screen').forEach(s => s.classList.remove('active')); document.querySelectorAll('.screen').forEach(s => s.classList.remove('active'));
document.getElementById('screen-' + name).classList.add('active'); document.getElementById('screen-' + name).classList.add('active');
if (name === "config") { activeScreen = name;
loadConfig(); // refresh most up-to-date config Screens[name]?.onShow?.();
}
} }
/* ----------------------------- /* -----------------------------
CONFIG EDITOR LOGIC CONFIG EDITOR LOGIC
----------------------------- */ ----------------------------- */
async function loadConfig() { 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'); const container = document.getElementById('config-container');
container.innerHTML = "Loading…"; container.innerHTML = "Loading…";
document.getElementById('save-btn').disabled = true; document.getElementById('config-save-btn').disabled = true;
try { try {
const res = await fetch('/api/v1/config'); const res = await fetch('/api/v1/config');
const config = await res.json(); const config = await res.json();
renderConfigForm(config); render(config);
} catch (err) { } catch (err) {
container.innerHTML = "Failed to load config: " + err; container.innerHTML = "Failed to load config: " + err;
} }
} }
function renderConfigForm(config) { function render(config) {
const container = document.getElementById('config-container'); const container = document.getElementById('config-container');
container.innerHTML = ""; container.innerHTML = "";
container.appendChild(renderObject(config, "root")); container.appendChild(renderObject(config, "root"));
document.getElementById('save-btn').disabled = false; document.getElementById('config-save-btn').disabled = false;
} }
const config_names = { const config_names = {
@@ -197,8 +334,8 @@ function renderObject(obj, path) {
return wrapper; return wrapper;
} }
function serializeConfig(rootObj) { function serialize(rootObj) {
const inputs = document.querySelectorAll("input[data-path], select[data-path]"); const inputs = screenroot.querySelectorAll("input[data-path], select[data-path]");
inputs.forEach(input => { inputs.forEach(input => {
const path = input.dataset.path.split('.').slice(1); // remove "root" const path = input.dataset.path.split('.').slice(1); // remove "root"
@@ -218,31 +355,95 @@ function serializeConfig(rootObj) {
return rootObj; return rootObj;
} }
document.getElementById('save-btn').addEventListener('click', async () => { return { init, onShow };
const res = await fetch('/api/v1/config'); })();
const original = await res.json();
const updated = serializeConfig(original); /* -----------------------------
PLAYLIST EDITOR LOGIC
----------------------------- */
Screens.playlist = (() => {
let lastSelected = null;
function init() {
document.getElementById("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;
});
}
async function onShow() {
const container = document.getElementById('playlist-container');
container.innerHTML = "Loading…";
document.getElementById('playlist-save-btn').disabled = true;
try { try {
const saveRes = await fetch('/api/v1/config', { const res = await fetch('/api/v1/playlist');
method: 'PUT', const config = await res.json();
headers: { 'Content-Type': 'application/json' }, renderPlaylistForm(config);
body: JSON.stringify(updated, null, 2)
});
if (!saveRes.ok) {
alert("Failed to save config: " + await saveRes.text());
return;
}
alert("Configuration saved successfully!");
} catch (err) { } catch (err) {
alert("Error saving configuration: " + err); container.innerHTML = "Failed to load config: " + err;
} }
}
function renderPlaylistForm(config) {
const container = document.getElementById('playlist-container');
container.innerHTML = "";
container.appendChild(renderPlaylistObject(config, "root"));
document.getElementById('playlist-save-btn').disabled = false;
}
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, onShow };
})();
// Initialization
Object.values(Screens).forEach(screen => {
screen.init?.();
}); });
// Load main menu by default showScreen("menu");
showScreen('menu');
</script> </script>
</body> </body>

View File

@@ -190,3 +190,6 @@ class PlayerApp:
self.timer_manager.cancel(self.onIdleTimeout) self.timer_manager.cancel(self.onIdleTimeout)
self.leds.set_state(self.leds.PLAYING) self.leds.set_state(self.leds.PLAYING)
self.playing = True self.playing = True
def get_nfc(self):
return self.nfc

View File

@@ -11,13 +11,15 @@ webapp = Microdot()
server = None server = None
config = None config = None
app = None app = None
nfc = None
def start_webserver(config_, app_): def start_webserver(config_, app_):
global server, config, app global server, config, app, nfc
server = asyncio.create_task(webapp.start_server(port=80)) server = asyncio.create_task(webapp.start_server(port=80))
config = config_ config = config_
app = app_ app = app_
nfc = app.get_nfc()
@webapp.before_request @webapp.before_request
@@ -66,6 +68,12 @@ async def config_put(request):
return '', 204 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']) @webapp.route('/', methods=['GET'])
async def root_get(request): async def root_get(request):
return redirect('/index.html') return redirect('/index.html')