From 936020df580b0ea6f5885b9186fd95177e4dca74 Mon Sep 17 00:00:00 2001 From: Matthias Blankertz Date: Sat, 6 Dec 2025 18:52:07 +0100 Subject: [PATCH 1/6] feat: Add and deploy frontend Signed-off-by: Matthias Blankertz --- .gitea/workflows/build.yaml | 4 +- software/boards/RPI_PICO_W/manifest.py | 2 + software/build.sh | 5 + software/frontend/index.html | 190 +++++++++++++++++++++++++ software/src/main.py | 1 + software/src/webserver.py | 15 +- 6 files changed, 215 insertions(+), 2 deletions(-) create mode 100644 software/frontend/index.html diff --git a/.gitea/workflows/build.yaml b/.gitea/workflows/build.yaml index 2a614db..61f51d0 100644 --- a/.gitea/workflows/build.yaml +++ b/.gitea/workflows/build.yaml @@ -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: diff --git a/software/boards/RPI_PICO_W/manifest.py b/software/boards/RPI_PICO_W/manifest.py index f8cb12a..486963d 100644 --- a/software/boards/RPI_PICO_W/manifest.py +++ b/software/boards/RPI_PICO_W/manifest.py @@ -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") diff --git a/software/build.sh b/software/build.sh index 4b215cc..3f96779 100755 --- a/software/build.sh +++ b/software/build.sh @@ -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-} diff --git a/software/frontend/index.html b/software/frontend/index.html new file mode 100644 index 0000000..b1c2283 --- /dev/null +++ b/software/frontend/index.html @@ -0,0 +1,190 @@ + + + + +Device Admin + + + + +

Device Admin

+ + + + +
+

Main Menu

+

Select a tool:

+ +
    +
  • + +
+
+ + +
+

Configuration Editor

+
Loading…
+ +
+ + + + + diff --git a/software/src/main.py b/software/src/main.py index 613ff97..09afe34 100644 --- a/software/src/main.py +++ b/software/src/main.py @@ -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 diff --git a/software/src/webserver.py b/software/src/webserver.py index 95cacc5..841935c 100644 --- a/software/src/webserver.py +++ b/software/src/webserver.py @@ -5,7 +5,7 @@ Copyright (c) 2024-2025 Stefan Kratochwil 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/') +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) From aee5a489679504a58a15ef4ccbdc88af7bdc6734 Mon Sep 17 00:00:00 2001 From: Matthias Blankertz Date: Sun, 14 Dec 2025 12:26:27 +0100 Subject: [PATCH 2/6] fix: config: Merge defaults into config at load time Merge defaults into config at load time to ensure that all config options show up in the configuration dialog, even if they were added after the local configuration was last changed. Also use the merge method to merge the local config with the new config in set_config, ensuring the config contains all keys even if the submitted config leaves some out. Signed-off-by: Matthias Blankertz --- software/src/utils/config.py | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/software/src/utils/config.py b/software/src/utils/config.py index e2677f7..0ab4ad2 100644 --- a/software/src/utils/config.py +++ b/software/src/utils/config.py @@ -30,6 +30,7 @@ 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 @@ -51,6 +52,16 @@ 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) @@ -59,7 +70,7 @@ class Configuration: os.sync() def _get(self, key): - return self.config.get(key, self.DEFAULT_CONFIG[key]) + return self.config[key] def get_led_count(self) -> int: return self._get('LED_COUNT') @@ -93,5 +104,6 @@ class Configuration: 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() From 93ea5036dc757468e9b6910ee8bf8abf0db558d3 Mon Sep 17 00:00:00 2001 From: Matthias Blankertz Date: Sun, 14 Dec 2025 14:01:35 +0100 Subject: [PATCH 3/6] feat: config frontend Implement nicer configuration UI: - Show human-readable names for config settings - Show error message received from server if storing settings fails - Show appropriate input elements for enum choice and numerical inputs Signed-off-by: Matthias Blankertz --- software/frontend/index.html | 75 ++++++++++++++++++++++++++++++++---- 1 file changed, 67 insertions(+), 8 deletions(-) diff --git a/software/frontend/index.html b/software/frontend/index.html index b1c2283..48d6548 100644 --- a/software/frontend/index.html +++ b/software/frontend/index.html @@ -111,13 +111,48 @@ function renderConfigForm(config) { 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'); - label.textContent = key; + if (currentPath in config_names) { + label.textContent = config_names[currentPath]; + } else { + label.textContent = key; + } if (value !== null && typeof value === 'object') { wrapper.appendChild(label); @@ -126,12 +161,36 @@ function renderObject(obj, path) { 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); + 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); + } } }); @@ -139,7 +198,7 @@ function renderObject(obj, path) { } function serializeConfig(rootObj) { - const inputs = document.querySelectorAll("input[data-path]"); + const inputs = document.querySelectorAll("input[data-path], select[data-path]"); inputs.forEach(input => { const path = input.dataset.path.split('.').slice(1); // remove "root" @@ -172,7 +231,7 @@ document.getElementById('save-btn').addEventListener('click', async () => { }); if (!saveRes.ok) { - alert("Failed to save config!"); + alert("Failed to save config: " + await saveRes.text()); return; } From 8a2d621c7dc596ddc3ad2236113736ab97834d12 Mon Sep 17 00:00:00 2001 From: Matthias Blankertz Date: Tue, 16 Dec 2025 20:48:01 +0100 Subject: [PATCH 4/6] 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 --- software/src/app.py | 4 ++++ software/src/webserver.py | 9 +++++++-- 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/software/src/app.py b/software/src/app.py index 48c3a61..c848757 100644 --- a/software/src/app.py +++ b/software/src/app.py @@ -116,6 +116,10 @@ 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 diff --git a/software/src/webserver.py b/software/src/webserver.py index 841935c..8e02052 100644 --- a/software/src/webserver.py +++ b/software/src/webserver.py @@ -20,6 +20,13 @@ def start_webserver(config_, app_): app = app_ +@webapp.before_request +async def before_request_handler(request): + if request.method in ['PUT', 'POST'] and app.is_playing(): + return "Cannot write to device while playback is active", 503 + app.reset_idle_timeout() + + @webapp.route('/') async def index(request): print("wohoo, a guest :)") @@ -52,8 +59,6 @@ async def config_get(request): @webapp.route('/api/v1/config', methods=['PUT']) async def config_put(request): - if app.is_playing(): - return 503 try: config.set_config(request.json) except ValueError as ex: From b20a31ccf41762ef164dc9444eb1562438838188 Mon Sep 17 00:00:00 2001 From: Matthias Blankertz Date: Tue, 16 Dec 2025 21:01:18 +0100 Subject: [PATCH 5/6] feat: webserver: redirect / to /index.html Signed-off-by: Matthias Blankertz --- software/src/webserver.py | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/software/src/webserver.py b/software/src/webserver.py index 8e02052..6919a80 100644 --- a/software/src/webserver.py +++ b/software/src/webserver.py @@ -5,7 +5,7 @@ Copyright (c) 2024-2025 Stefan Kratochwil import asyncio -from microdot import Microdot, send_file +from microdot import Microdot, redirect, send_file webapp = Microdot() server = None @@ -27,7 +27,7 @@ async def before_request_handler(request): app.reset_idle_timeout() -@webapp.route('/') +@webapp.route('/api/v1/hello') async def index(request): print("wohoo, a guest :)") print(f" app: {request.app}") @@ -66,12 +66,17 @@ async def config_put(request): return '', 204 +@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/') +@webapp.route('/static/', methods=['GET']) async def static(request, path): if '..' in path: # directory traversal is not allowed From 32e996e446fdeb8017261ba59e83190c2bb736be Mon Sep 17 00:00:00 2001 From: Matthias Blankertz Date: Wed, 17 Dec 2025 18:37:34 +0100 Subject: [PATCH 6/6] build: add requirements.txt for host python deps Signed-off-by: Matthias Blankertz --- software/requirements.txt | 1 + 1 file changed, 1 insertion(+) create mode 100644 software/requirements.txt diff --git a/software/requirements.txt b/software/requirements.txt new file mode 100644 index 0000000..067d919 --- /dev/null +++ b/software/requirements.txt @@ -0,0 +1 @@ +freezefs