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
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
This commit is contained in:
@@ -11,8 +11,10 @@ jobs:
|
|||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
- name: Initialize submodules
|
- name: Initialize submodules
|
||||||
run: cd software && ./update-submodules.sh
|
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
|
- name: Build
|
||||||
run: cd software && ./build.sh
|
run: source build-venv/bin/activate && cd software && ./build.sh
|
||||||
- name: Upload firmware
|
- name: Upload firmware
|
||||||
uses: actions/upload-artifact@v3
|
uses: actions/upload-artifact@v3
|
||||||
with:
|
with:
|
||||||
|
|||||||
@@ -22,3 +22,5 @@ module("mp3player.py", "../../src")
|
|||||||
module("webserver.py", "../../src")
|
module("webserver.py", "../../src")
|
||||||
package("utils", base_path="../../src")
|
package("utils", base_path="../../src")
|
||||||
package("nfc", base_path="../../src")
|
package("nfc", base_path="../../src")
|
||||||
|
|
||||||
|
module("frozen_frontend.py", "../../build")
|
||||||
|
|||||||
@@ -25,6 +25,11 @@ mkdir "$FS_STAGE_DIR"/fs
|
|||||||
trap 'rm -rf $FS_STAGE_DIR' EXIT
|
trap 'rm -rf $FS_STAGE_DIR' EXIT
|
||||||
tools/mklittlefs/mklittlefs -p 256 -s 868352 -c "$FS_STAGE_DIR"/fs "$FS_STAGE_DIR"/filesystem.bin
|
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
|
for hwconfig in boards/RPI_PICO_W/manifest-*.py; do
|
||||||
hwconfig_base=$(basename "$hwconfig")
|
hwconfig_base=$(basename "$hwconfig")
|
||||||
hwname=${hwconfig_base##manifest-}
|
hwname=${hwconfig_base##manifest-}
|
||||||
|
|||||||
249
software/frontend/index.html
Normal file
249
software/frontend/index.html
Normal file
@@ -0,0 +1,249 @@
|
|||||||
|
<!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;
|
||||||
|
}
|
||||||
|
|
||||||
|
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 serializeConfig(rootObj) {
|
||||||
|
const inputs = document.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;
|
||||||
|
}
|
||||||
|
|
||||||
|
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: " + await saveRes.text());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
alert("Configuration saved successfully!");
|
||||||
|
} catch (err) {
|
||||||
|
alert("Error saving configuration: " + err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Load main menu by default
|
||||||
|
showScreen('menu');
|
||||||
|
</script>
|
||||||
|
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
1
software/requirements.txt
Normal file
1
software/requirements.txt
Normal file
@@ -0,0 +1 @@
|
|||||||
|
freezefs
|
||||||
@@ -14,6 +14,7 @@ import ubinascii
|
|||||||
# Own modules
|
# Own modules
|
||||||
import app
|
import app
|
||||||
from audiocore import AudioContext
|
from audiocore import AudioContext
|
||||||
|
import frozen_frontend # noqa: F401
|
||||||
from mfrc522 import MFRC522
|
from mfrc522 import MFRC522
|
||||||
from mp3player import MP3Player
|
from mp3player import MP3Player
|
||||||
from nfc import Nfc
|
from nfc import Nfc
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ class Configuration:
|
|||||||
try:
|
try:
|
||||||
with open(self.config_path, 'r') as conf_file:
|
with open(self.config_path, 'r') as conf_file:
|
||||||
self.config = json.load(conf_file)
|
self.config = json.load(conf_file)
|
||||||
|
self._merge_configs(self.DEFAULT_CONFIG, self.config)
|
||||||
except OSError as ex:
|
except OSError as ex:
|
||||||
if ex.errno == ENOENT:
|
if ex.errno == ENOENT:
|
||||||
self.config = Configuration.DEFAULT_CONFIG
|
self.config = Configuration.DEFAULT_CONFIG
|
||||||
@@ -51,6 +52,16 @@ class Configuration:
|
|||||||
raise
|
raise
|
||||||
os.sync()
|
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):
|
def _save(self):
|
||||||
with open(self.config_path + '.new', 'w') as conf_file:
|
with open(self.config_path + '.new', 'w') as conf_file:
|
||||||
json.dump(self.config, conf_file)
|
json.dump(self.config, conf_file)
|
||||||
@@ -59,7 +70,7 @@ class Configuration:
|
|||||||
os.sync()
|
os.sync()
|
||||||
|
|
||||||
def _get(self, key):
|
def _get(self, key):
|
||||||
return self.config.get(key, self.DEFAULT_CONFIG[key])
|
return self.config[key]
|
||||||
|
|
||||||
def get_led_count(self) -> int:
|
def get_led_count(self) -> int:
|
||||||
return self._get('LED_COUNT')
|
return self._get('LED_COUNT')
|
||||||
@@ -93,5 +104,6 @@ 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'")
|
||||||
|
self._merge_configs(self.config, config)
|
||||||
self.config = config
|
self.config = config
|
||||||
self._save()
|
self._save()
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ Copyright (c) 2024-2025 Stefan Kratochwil <Kratochwil-LA@gmx.de>
|
|||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
|
|
||||||
from microdot import Microdot
|
from microdot import Microdot, redirect, send_file
|
||||||
|
|
||||||
webapp = Microdot()
|
webapp = Microdot()
|
||||||
server = None
|
server = None
|
||||||
@@ -29,7 +29,7 @@ async def before_request_handler(request):
|
|||||||
app.reset_idle_timeout()
|
app.reset_idle_timeout()
|
||||||
|
|
||||||
|
|
||||||
@webapp.route('/')
|
@webapp.route('/api/v1/hello')
|
||||||
async def index(request):
|
async def index(request):
|
||||||
print("wohoo, a guest :)")
|
print("wohoo, a guest :)")
|
||||||
print(f" app: {request.app}")
|
print(f" app: {request.app}")
|
||||||
@@ -72,3 +72,21 @@ async def config_put(request):
|
|||||||
async def last_tag_uid_get(request):
|
async def last_tag_uid_get(request):
|
||||||
tag, _ = nfc.get_last_uid()
|
tag, _ = nfc.get_last_uid()
|
||||||
return {'tag': tag}
|
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)
|
||||||
|
|||||||
Reference in New Issue
Block a user