Skip to content

Commit 8bf93da

Browse files
fhirschmannclaude
andcommitted
feat: device-free Web-UI demo on GitHub Pages
Build a self-contained static demo of the management interface and publish it to GitHub Pages so the UI can be tried in the browser without any hardware. - tools/build_demo.py turns html/ into demo_dist/: rewrites absolute asset paths to be document-relative (works from the /leoino/ sub-path), disables the service worker, points the cover image at a bundled SVG, injects the mock loader + a demo banner, emits index.html + .nojekyll. - tools/demo/demo-mock.js fakes the WebSocket and every REST endpoint (settings, trackinfo, cover, explorer tree, RFID list, version, info, trackprogress, ...). Write actions are no-ops. Content is copyright-free (public-domain Grimm/Andersen fairy tales + classical works). - .github/workflows/demo-pages.yml rebuilds and redeploys on every push so the demo never goes stale. - README: live-demo link + local build instructions. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent f14f08f commit 8bf93da

7 files changed

Lines changed: 614 additions & 1 deletion

File tree

.github/workflows/demo-pages.yml

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
name: Deploy Web-UI demo to GitHub Pages
2+
3+
# Builds a self-contained, device-free demo of the management web interface
4+
# (mock WebSocket + REST, copyright-free content) and publishes it to GitHub
5+
# Pages so the UI can be tried out in the browser without any hardware.
6+
7+
on:
8+
push:
9+
branches: [master]
10+
paths:
11+
- "html/**"
12+
- "tools/demo/**"
13+
- "tools/build_demo.py"
14+
- ".github/workflows/demo-pages.yml"
15+
workflow_dispatch:
16+
17+
permissions:
18+
contents: read
19+
pages: write
20+
id-token: write
21+
22+
# Allow only one concurrent deployment, don't cancel in-progress runs.
23+
concurrency:
24+
group: pages
25+
cancel-in-progress: false
26+
27+
jobs:
28+
build:
29+
runs-on: ubuntu-latest
30+
steps:
31+
- uses: actions/checkout@v4
32+
- uses: actions/setup-python@v5
33+
with:
34+
python-version: "3.x"
35+
- name: Build demo
36+
run: python3 tools/build_demo.py demo_dist
37+
- uses: actions/configure-pages@v5
38+
- uses: actions/upload-pages-artifact@v3
39+
with:
40+
path: demo_dist
41+
42+
deploy:
43+
needs: build
44+
runs-on: ubuntu-latest
45+
environment:
46+
name: github-pages
47+
url: ${{ steps.deployment.outputs.page_url }}
48+
steps:
49+
- id: deployment
50+
uses: actions/deploy-pages@v4

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,4 +21,5 @@ html/locales/*.min.json
2121
html/REST_API.yaml
2222
html/js/*.min.min.js
2323
html/js/swaggerInitializer.min.js
24+
demo_dist/
2425
managed_components/

README.md

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,28 @@ fork-specific features. The full interface (default RFID tab shown below, live f
8686

8787
</div>
8888

89+
### // Live demo
90+
91+
Try the web interface in your browser — **no hardware required**:
92+
93+
<div align="center">
94+
95+
**[fhirschmann.github.io/leoino](https://fhirschmann.github.io/leoino/)**
96+
97+
</div>
98+
99+
It is the real management page with a small mock layer that fakes the WebSocket and every
100+
REST endpoint, filled with copyright-free content (public-domain fairy tales and classical
101+
works). Every screen is clickable; write actions (save, restart, …) are no-ops. The demo is
102+
rebuilt and redeployed automatically on every push by
103+
[`build_demo.py`](tools/build_demo.py) → GitHub Pages, so it never goes stale. To build it
104+
locally:
105+
106+
```bash
107+
python3 tools/build_demo.py demo_dist
108+
python3 -m http.server --directory demo_dist 8000 # open http://localhost:8000/
109+
```
110+
89111
## // Differences to upstream
90112

91113
All changes compared to upstream/`dev`, each with a reference to its commit.
@@ -124,7 +146,7 @@ neon logo that doubles as the SVG favicon ([`7be5254`](../../commit/7be5254)):
124146
| **Listening statistics**: per-day listening time (today / yesterday / 7 d / 30 d) in a 365-day NVS ring buffer, plus a most-played-cards top list — shown in the info dialog (`GET /info`, `GET /topcards`). The info dialog also draws a **30-day bar chart** (inline SVG, themed, no chart lib) and offers a **CSV export** of the full daily series (`GET /stats.csv`, `date,seconds`) | [`af6c8d3`](../../commit/af6c8d3) · [`c2e7d44`](../../commit/c2e7d44) |
125147
| **Full backup**: export/import all settings + RFID assignments + per-path EQ rules + listening stats as one JSON file; passwords are only included when explicitly ticked, so a shared backup doesn't leak credentials | [`4c90ff4`](../../commit/4c90ff4) · [`646ec5c`](../../commit/646ec5c) |
126148
| **Home Assistant MQTT discovery**: auto-registers all entities under one HA device — track/status/firmware/WiFi/battery sensors, volume & LED-brightness numbers, lock & ambient-light switches, equalizer select, transport/update/shutdown buttons | [`db73db1`](../../commit/db73db1) |
127-
| **Apple HomeKit + Siri** (`HOMEKIT_ENABLE`, via [HomeSpan](https://github.com/HomeSpan/HomeSpan)): pair the player straight into the Home app as a bridge with named tiles — **Playback** (play/pause + battery), **Volume** (brightness dimmer) and **Button-lock** — all controllable by Siri and usable in automations. State is mirrored back, so changes from buttons/RFID show up on the iPhone. Each device generates its **own random pairing code** on first boot (persisted in NVS) instead of a shared hard-coded one, so multiple ESPuinos — and everyone running this firmware — stay distinct and private. A dedicated **HomeKit settings section** shows the scannable pairing **QR code** + that setup code and a "reset pairing" button. Pairs over the existing WiFi (no MFi chip needed); the HAP server runs on its own port and the poll task is pinned to core 0 so it never disturbs the audio pipeline on core 1 | [`6387563`](../../commit/6387563) |
149+
| **Apple HomeKit + Siri** (`HOMEKIT_ENABLE`, via [HomeSpan](https://github.com/HomeSpan/HomeSpan)): pair the player straight into the Home app as a bridge with named tiles — **Playback** (play/pause + battery), **Volume** (brightness dimmer) and **Button-lock** — all controllable by Siri and usable in automations. State is mirrored back, so changes from buttons/RFID show up on the iPhone. Each device generates its **own random pairing code** on first boot (persisted in NVS) instead of a shared hard-coded one, so multiple ESPuinos — and everyone running this firmware — stay distinct and private. A dedicated **HomeKit settings section** shows the scannable pairing **QR code** + that setup code, a "reset pairing" button and an **on/off switch** to disable HomeKit entirely (HomeSpan then never starts, so it costs nothing at boot). HomeSpan now also comes up **lazily after boot** instead of inside `setup()`, so its multi-second start-up no longer stalls the boot LED. Pairs over the existing WiFi (no MFi chip needed); the HAP server runs on its own port and the poll task is pinned to core 0 so it never disturbs the audio pipeline on core 1 | [`6387563`](../../commit/6387563) |
128150
| **Control tab**: Repeat / Sleep-Timer (live countdown) / Night-Mode / button-lock / FTP start-stop and a Bluetooth-mode picker (Normal / Speaker / Headphones) right in the control tab; mobile-optimised (full-width louder/quieter buttons instead of the fiddly slider) | [`88a742c`](../../commit/88a742c) · [`520815a`](../../commit/520815a) · [`d57f24a`](../../commit/d57f24a) · [`aef1765`](../../commit/aef1765) |
129151
| **Battery-backed RTC (DS3231)**: optional real-time clock on the external I²C bus so the time stays correct without WiFi/NTP (seeds the clock at boot, NTP disciplines it); time + die-temperature in the info dialog and via MQTT/HA | [`bf90f66`](../../commit/bf90f66) |
130152
| **State-driven control LEDs**: the optional control LEDs can mirror a runtime state (key-lock / repeat mode / Bluetooth / battery level) instead of a static colour, per-slot configurable, with a master mute (command **121** / MQTT `control_leds`) | [`d12a496`](../../commit/d12a496) |

tools/build_demo.py

Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,120 @@
1+
#!/usr/bin/env python3
2+
"""Build a self-contained ESPuino Web-UI demo for GitHub Pages.
3+
4+
Takes the regular firmware UI in ``html/`` and turns it into a static
5+
site that needs no ESP32: a mock layer (``tools/demo/demo-mock.js``)
6+
fakes the WebSocket and every REST endpoint. The result is written to an
7+
output directory (default ``demo_dist/``) that can be published as the
8+
Pages artifact.
9+
10+
Transformations applied to ``management.html`` -> ``index.html``:
11+
* absolute asset paths (``/css``, ``/js``, ``/logo.svg`` ...) are made
12+
document-relative so the site works from a project sub-path
13+
(``user.github.io/leoino/``);
14+
* the service worker is disabled (it caches device endpoints);
15+
* the cover-image URL is pointed at the bundled demo SVG;
16+
* the mock script is injected first in <head>;
17+
* a "demo" banner is added.
18+
19+
Usage: python3 tools/build_demo.py [output_dir]
20+
"""
21+
import re
22+
import shutil
23+
import sys
24+
from pathlib import Path
25+
26+
ROOT = Path(__file__).resolve().parent.parent
27+
HTML = ROOT / "html"
28+
DEMO = ROOT / "tools" / "demo"
29+
30+
# Static asset trees/files copied verbatim from html/ into the demo.
31+
COPY_ITEMS = [
32+
"css", "js", "fonts", "webfonts", "jstree", "locales",
33+
"logo.svg", "appicon.svg", "manifest.json",
34+
]
35+
36+
# Extra demo-only assets.
37+
DEMO_ASSETS = ["demo-mock.js", "demo-cover.svg", "demo.css"]
38+
39+
40+
def transform_html(src: str) -> str:
41+
out = src
42+
43+
# 1) make head asset references document-relative
44+
replacements = {
45+
'href="/manifest.json"': 'href="manifest.json"',
46+
'href="/logo.svg"': 'href="logo.svg"',
47+
'href="/appicon.svg"': 'href="appicon.svg"',
48+
'href="/favicon.ico"': 'href="favicon.ico"',
49+
'href="/css/vendor.min.css"': 'href="css/vendor.min.css"',
50+
'src="/js/vendor.min.js"': 'src="js/vendor.min.js"',
51+
'src="/logo"': 'src="logo.svg"',
52+
}
53+
for a, b in replacements.items():
54+
out = out.replace(a, b)
55+
56+
# 2) disable the service worker (it would cache device-only endpoints)
57+
out = out.replace("if ('serviceWorker' in navigator) {", "if (false) {")
58+
59+
# 3) cover image -> bundled demo SVG (avoids http/mixed-content + 404)
60+
out = out.replace('"http://" + host + "/cover?"', '"demo-cover.svg?"')
61+
62+
# 4) inject the mock loader as the very first <head> child, plus demo CSS
63+
head_inject = (
64+
'<head>\n'
65+
'\t<!-- ESPuino demo: mock layer must load before any app code -->\n'
66+
'\t<script src="demo-mock.js"></script>\n'
67+
'\t<link rel="stylesheet" href="demo.css">\n'
68+
)
69+
out = out.replace("<head>\n", head_inject, 1)
70+
71+
# 5) demo banner right after <body>
72+
banner = (
73+
'\n\t<div id="demoBanner">\n'
74+
'\t\t<span class="demo-pill">DEMO</span>\n'
75+
'\t\t<span>Statische Vorschau des ESPuino-Webinterface &ndash; kein Geraet verbunden, Aktionen ohne Wirkung.</span>\n'
76+
'\t\t<a href="https://github.com/fhirschmann/leoino" target="_blank" rel="noopener">Projekt auf GitHub</a>\n'
77+
'\t</div>\n'
78+
)
79+
out = re.sub(r"(<body[^>]*>)", lambda m: m.group(1) + banner, out, count=1)
80+
81+
return out
82+
83+
84+
def main() -> int:
85+
out_dir = Path(sys.argv[1]) if len(sys.argv) > 1 else ROOT / "demo_dist"
86+
out_dir = out_dir.resolve()
87+
88+
if out_dir.exists():
89+
shutil.rmtree(out_dir)
90+
out_dir.mkdir(parents=True)
91+
92+
# copy static assets
93+
for item in COPY_ITEMS:
94+
src = HTML / item
95+
if not src.exists():
96+
print(f" skip (missing): {item}")
97+
continue
98+
dst = out_dir / item
99+
if src.is_dir():
100+
shutil.copytree(src, dst)
101+
else:
102+
shutil.copy2(src, dst)
103+
104+
# demo-only assets
105+
for item in DEMO_ASSETS:
106+
shutil.copy2(DEMO / item, out_dir / item)
107+
108+
# transformed entry point
109+
html = (HTML / "management.html").read_text(encoding="utf-8")
110+
(out_dir / "index.html").write_text(transform_html(html), encoding="utf-8")
111+
112+
# disable Jekyll so files/dirs starting with "_" are served untouched
113+
(out_dir / ".nojekyll").write_text("", encoding="utf-8")
114+
115+
print(f"Demo built -> {out_dir}")
116+
return 0
117+
118+
119+
if __name__ == "__main__":
120+
raise SystemExit(main())

tools/demo/demo-cover.svg

Lines changed: 16 additions & 0 deletions
Loading

0 commit comments

Comments
 (0)