feat: Add and deploy frontend
All checks were successful
Build RPi Pico firmware image / Build-Firmware (push) Successful in 4m48s
Check code formatting / Check-C-Format (push) Successful in 8s
Check code formatting / Check-Python-Flake8 (push) Successful in 11s
Check code formatting / Check-Bash-Shellcheck (push) Successful in 6s
Run unit tests on host / Run-Unit-Tests (push) Successful in 11s
Run pytests / Check-Pytest (push) Successful in 13s

Signed-off-by: Matthias Blankertz <matthias@blankertz.org>
This commit is contained in:
2025-12-06 18:52:07 +01:00
parent c687e3a977
commit d350a07301
6 changed files with 215 additions and 2 deletions

View File

@@ -11,8 +11,10 @@ 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: cd software && ./build.sh
run: source build-venv/bin/activate && cd software && ./build.sh
- name: Upload firmware
uses: actions/upload-artifact@v3
with:

View File

@@ -22,3 +22,5 @@ module("mp3player.py", "../../src")
module("webserver.py", "../../src")
package("utils", base_path="../../src")
package("nfc", base_path="../../src")
module("frozen_frontend.py", "../../build")

View File

@@ -25,6 +25,11 @@ 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-}

View File

@@ -0,0 +1,190 @@
<!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;
}
</style>
</head>
<body>
<h1>Device Admin</h1>
<nav>
<button onclick="showScreen('menu')">🏠 Main Menu</button>
<button onclick="showScreen('config')">⚙️ Config 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>
<!-- 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="save-btn" disabled>Save</button>
</div>
<script>
/* -----------------------------
Screen switching
----------------------------- */
function showScreen(name) {
document.querySelectorAll('.screen').forEach(s => s.classList.remove('active'));
document.getElementById('screen-' + name).classList.add('active');
if (name === "config") {
loadConfig(); // refresh most up-to-date config
}
}
/* -----------------------------
CONFIG EDITOR LOGIC
----------------------------- */
async function loadConfig() {
const container = document.getElementById('config-container');
container.innerHTML = "Loading…";
document.getElementById('save-btn').disabled = true;
try {
const res = await fetch('/api/v1/config');
const config = await res.json();
renderConfigForm(config);
} catch (err) {
container.innerHTML = "Failed to load config: " + err;
}
}
function renderConfigForm(config) {
const container = document.getElementById('config-container');
container.innerHTML = "";
container.appendChild(renderObject(config, "root"));
document.getElementById('save-btn').disabled = false;
}
function renderObject(obj, path) {
const wrapper = document.createElement('div');
Object.entries(obj).forEach(([key, value]) => {
const currentPath = path + '.' + key;
const label = document.createElement('label');
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 {
const input = document.createElement('input');
input.value = value === null ? "" : value;
input.dataset.path = currentPath;
wrapper.appendChild(label);
wrapper.appendChild(input);
}
});
return wrapper;
}
function serializeConfig(rootObj) {
const inputs = document.querySelectorAll("input[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;
}
document.getElementById('save-btn').addEventListener('click', async () => {
const res = await fetch('/api/v1/config');
const original = await res.json();
const updated = serializeConfig(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!");
return;
}
alert("Configuration saved successfully!");
} catch (err) {
alert("Error saving configuration: " + err);
}
});
// Load main menu by default
showScreen('menu');
</script>
</body>
</html>

View File

@@ -14,6 +14,7 @@ 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

View File

@@ -5,7 +5,7 @@ Copyright (c) 2024-2025 Stefan Kratochwil <Kratochwil-LA@gmx.de>
import asyncio
from microdot import Microdot
from microdot import Microdot, send_file
webapp = Microdot()
server = None
@@ -59,3 +59,16 @@ async def config_put(request):
except ValueError as ex:
return str(ex), 400
return '', 204
@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>')
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)