From 3eb141e900d07d36b8a3f0ef367615e3653ed325 Mon Sep 17 00:00:00 2001 From: shodan Date: Wed, 7 Oct 2026 23:34:51 +0200 Subject: [PATCH 1/4] feat(http): embed the gzipped web UI in the firmware Vite builds ui/ into one self-contained page (vite-plugin-singlefile), and scripts/embed_ui.py gzips it into a generated header that the firmware serves with Content-Encoding: gzip. The UI always matches the firmware it ships in, so LittleFS only holds audio again: the /ui/* routes, the version stamp and check, and the filesystem-first OTA order are gone. The pre-script skips Node when ui/ is unchanged. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01VUaQRAXtYTdrXQ4C4us2V5 --- .gitignore | 1 - AGENTS.md | 2 +- docs/api.md | 8 ++--- docs/flash.md | 11 +++---- docs/hardware/testing.md | 1 - docs/settings.md | 2 +- partitions.csv | 2 +- platformio.ini | 1 + scripts/audio_pack.py | 6 ++-- scripts/copy_assets.py | 4 --- scripts/embed_ui.py | 58 ++++++++++++++++++++++++++++++++ scripts/firmware_version.py | 2 -- scripts/ota.py | 51 +++++----------------------- scripts/ui_pack.py | 45 ------------------------- src/http/index_page.cpp | 66 ++----------------------------------- src/http/index_page.h | 1 - src/http/json.cpp | 1 + src/http/routes.cpp | 1 - ui/index.html | 5 --- ui/package-lock.json | 23 +++++++++++++ ui/package.json | 1 + ui/vite.config.js | 14 +++----- 22 files changed, 113 insertions(+), 193 deletions(-) create mode 100644 scripts/embed_ui.py delete mode 100644 scripts/ui_pack.py diff --git a/.gitignore b/.gitignore index 04e1d6a6..53b2e49d 100644 --- a/.gitignore +++ b/.gitignore @@ -1,7 +1,6 @@ .DS_Store __pycache__/ data/*.* -data/ui/ artifacts/ # Cursor hook event log diff --git a/AGENTS.md b/AGENTS.md index f74a9183..ae145fa8 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -11,7 +11,7 @@ Tiny Engineer is an open-source ESP32-C3 Wi-Fi desk robot: 3D-printed mechanics, | Path | Role | | --- | --- | | `src/`, `include/`, `lib/`, `data/` | Firmware (PlatformIO) | -| `ui/` | Web control panel (Vite + ES modules); `pio run` builds, minifies and gzips it onto LittleFS | +| `ui/` | Web control panel (Vite + ES modules); `pio run` builds it into one minified page and embeds it gzipped in the firmware | | `packages/` | HTTP / hook CLIs (Cursor, Antigravity, Claude Code, …) — scope `integrations` | | `skills/` | Agent skills shared by Claude Code, Cursor and Antigravity — scope `integrations` | | `3d_models/` | CAD and printables (CERN-OHL-S) | diff --git a/docs/api.md b/docs/api.md index 0a712519..3a5f0716 100644 --- a/docs/api.md +++ b/docs/api.md @@ -6,7 +6,7 @@ Listens on **port 80** after STA Wi-Fi connects, or during setup AP mode at `htt Optional auth: when an `access_token` is configured in settings, all JSON API routes require `Authorization: Bearer `. Empty token (default) means no auth. `GET /auth` is always public and reports whether auth is required. Missing/wrong token → **401** `{"ok":false,"error":"unauthorized"}`. HTML panel routes stay public (the UI prompts for the token). `Content-Type: application/json`. CORS: `Access-Control-Allow-Origin: *`, `Access-Control-Allow-Headers: Authorization`. -When WiFi credentials are not saved yet, control APIs (`/anim`, `/test/*`) return **503** `{"ok":false,"error":"wifi not configured"}`. Setup routes (`/`, `/config`, `/ui/*`, `/auth`, `/health`, `/settings`, `/setup/servo`, `/setup/led`, `/setup/audio`, `/setup/oled`) stay available on the setup AP. +When WiFi credentials are not saved yet, control APIs (`/anim`, `/test/*`) return **503** `{"ok":false,"error":"wifi not configured"}`. Setup routes (`/`, `/config`, `/auth`, `/health`, `/settings`, `/setup/servo`, `/setup/led`, `/setup/audio`, `/setup/oled`) stay available on the setup AP. If boot WiFi credentials are missing, the device opens setup AP mode (`TinyEngineer-XXXX`) and serves a five-step setup wizard at `/config` (servo calibration, OLED orientation, RGB LED, speaker test, then Wi-Fi). If saved credentials fail, it reopens setup AP mode. Hardware tests: [`docs/hardware/testing.md`](hardware/testing.md). How to wire this API into AI tools: [`integration.md`](integration.md). @@ -24,11 +24,7 @@ Open in a browser: curl http://tiny-engineer.local/ ``` -Returns `Content-Type: text/html; charset=utf-8`, gzip-encoded. The page is served from LittleFS (`/ui/index.html.gz`); if the filesystem image is missing, or its `/ui/version.txt` does not match the firmware version, it returns **503** with flashing instructions. - -### `GET /ui/*` - -Web UI assets from LittleFS, gzip-encoded. Bundled files under `/ui/assets/` have content-hashed names and are sent with `Cache-Control: max-age=31536000, immutable`; anything else under `/ui/` is `no-cache`. Always public. Source lives in [`ui/`](../ui/); `pio run` builds it with Vite and gzips the output into `data/ui/`. +Returns `Content-Type: text/html; charset=utf-8`, gzip-encoded. The page is built from [`ui/`](../ui/) with Vite and embedded in the firmware, so it always matches the API the firmware serves. ### `GET /auth` diff --git a/docs/flash.md b/docs/flash.md index 50cb6076..20c71db7 100644 --- a/docs/flash.md +++ b/docs/flash.md @@ -41,7 +41,7 @@ Firmware is Arduino on [PlatformIO](https://platformio.org/) ([pioarduino](https ### Install and connect 1. Install [PlatformIO Core](https://docs.platformio.org/en/latest/core/installation.html) (or the PlatformIO IDE extension). - Also install [Node.js](https://nodejs.org/) 20.19+ on 20.x, or 22.12+: `pio run` builds the web UI in [`ui/`](../ui/) with npm. + Also install [Node.js](https://nodejs.org/) 20.19+ on 20.x, or 22.12+: `pio run` builds the web UI in [`ui/`](../ui/) with npm and embeds it in the firmware. 2. Use a USB-C **data** cable. Charge-only cables fail upload and serial. 3. Plug the data cable into the **main control board USB-C** once the ESP32 is seated on its headers. Advanced breakout build: use the Adafruit 5993, or the C3-Zero onboard USB-C before that breakout is wired — [hardware/wiring.md](hardware/wiring.md). @@ -55,7 +55,7 @@ pio run -t upload # flash firmware + LittleFS pio device monitor # serial (115200) ``` -After firmware upload, a post-script also uploads **LittleFS** ([`scripts/upload_fs_after_upload.py`](../scripts/upload_fs_after_upload.py)) so the web UI and WAV assets (`welcome`, `bell`, and friends) land on the board. If the web page says the UI is missing, or animations move but stay silent, run `pio run -t uploadfs` once. +After firmware upload, a post-script also uploads **LittleFS** ([`scripts/upload_fs_after_upload.py`](../scripts/upload_fs_after_upload.py)) so WAV assets (`welcome`, `bell`, and friends) land on the board. If animations move but stay silent, run `pio run -t uploadfs` once. Several serial ports: @@ -119,16 +119,15 @@ After that, with the robot on your home Wi-Fi: ```bash export TINY_ENGINEER_URL=http://192.168.x.x # default: tiny-engineer.local export TINY_ENGINEER_TOKEN=... # only if access_token is set -pio run -e ota -t ota # LittleFS (web UI, WAV assets), then firmware +pio run -e ota -t ota # firmware +pio run -e ota -t otafs # LittleFS (WAV assets) ``` -Firmware and filesystem are one release: the web UI is stamped with the firmware version it was built with, and the firmware refuses to serve a UI from another build (`/` returns **503** naming both versions). `-t ota` therefore uploads the filesystem first, waits for the robot to reboot, then uploads the firmware. `pio run -e ota -t otafs` uploads the filesystem alone, for recovery. - These are the same variables the agent integrations read ([integration.md](integration.md)). OTA listens on UDP/TCP port 3232 and only runs while connected to home Wi-Fi, not in setup AP mode. When `access_token` is set it is also the OTA password; changing it applies to OTA without a reboot. An update is written to the inactive slot and booted once; it becomes permanent only after it connects to Wi-Fi and starts its OTA listener. Firmware that never gets that far is rolled back to the previous slot on the next reset or power cycle. A firmware that hangs needs that power cycle to recover. -A filesystem update that fails midway reboots the robot; re-run `pio run -e ota -t ota` or fall back to `pio run -e ota -t upload` over USB. +A filesystem update that fails midway reboots the robot; re-run `pio run -e ota -t otafs` or fall back to `pio run -e ota -t uploadfs` over USB. Partition changes, including a switch back to the default build, cannot be applied over the air. Flash those with `pio run -t upload` over USB. diff --git a/docs/hardware/testing.md b/docs/hardware/testing.md index 45a44df0..f529dc73 100644 --- a/docs/hardware/testing.md +++ b/docs/hardware/testing.md @@ -106,7 +106,6 @@ curl -X POST "http://tiny-engineer.local/test/servo?index=0&angle=90" | Method | Path | Body | | --- | --- | --- | | `GET` | `/` | HTML endpoint index | -| `GET` | `/ui/*` | Web UI assets from LittleFS (gzip) | | `GET` | `/auth` | Auth status (`ok`, `required`, `wifi_configured`, `provisioning`) — always public | | `GET` | `/health` | Health JSON — full field list in [api.md](../api.md#get-health) | | `GET` / `POST` | `/anim` | Current animation / start one (`name`, optional `interrupt`) — full params in [api.md](../api.md#post-anim) | diff --git a/docs/settings.md b/docs/settings.md index d45bd031..90a40b6c 100644 --- a/docs/settings.md +++ b/docs/settings.md @@ -91,7 +91,7 @@ Flash only when you want to try it on hardware (`pio run -t upload`). - **JSON buffer:** `sendSettingsJson` uses a fixed `char` buffer — bump size when adding fields. - **Factory reset:** `factoryResetSettings()` in [`reset.cpp`](../src/settings/reset.cpp) clears NVS namespace `te` and writes defaults, including WiFi credentials. Servo min/max (`sranges`), RGB LED mapping (`rgb_ord`), and OLED rotation (`oled_rot`) are written back unchanged. Exposed as `POST /settings/reset`. After reset, power-cycle into setup AP mode to configure WiFi again (WiFi is not editable on the normal Config page). Servo ranges, LED mapping, and screen rotation can be retuned in that wizard. - **Hostname-style settings:** freeze the boot value separately if live change cannot apply (see `settingsBootHostname()` / `reboot_required`). -- **UI size:** the panel ships gzipped on LittleFS next to the WAVs; keep controls compact. +- **UI size:** the panel is embedded gzipped in the firmware; keep controls compact. - **Doc drift:** HTML param tables must match `api.md` exactly. ## Related diff --git a/partitions.csv b/partitions.csv index 283fb68b..fc4dd2aa 100644 --- a/partitions.csv +++ b/partitions.csv @@ -1,6 +1,6 @@ # Name, Type, SubType, Offset, Size, Flags # 4MB: one app slot (USB flash, no OTA). Dual 0x150000 OTA apps overflowed -# the firmware (~1.3MB). LittleFS is ~1.81MB so a mod can replace +# the firmware (~1.38MB HTML UI). LittleFS is ~1.81MB so a mod can replace # some WAVs with longer clips and keep the stock files it does not ship. # NVS stays at 0x9000. spiffs moved; reflash app + filesystem together. nvs, data, nvs, 0x9000, 0x5000, diff --git a/platformio.ini b/platformio.ini index 07a45a2b..1cd572db 100644 --- a/platformio.ini +++ b/platformio.ini @@ -39,6 +39,7 @@ custom_audio_mod = extra_scripts = pre:scripts/firmware_version.py pre:scripts/copy_assets.py + pre:scripts/embed_ui.py post:scripts/upload_fs_after_upload.py ; Host Unity tests — never flash. Run: pio test -e native diff --git a/scripts/audio_pack.py b/scripts/audio_pack.py index be7de3a0..adb92416 100644 --- a/scripts/audio_pack.py +++ b/scripts/audio_pack.py @@ -275,16 +275,16 @@ def pack_audio(assets_dir, dest_dir, partition_bytes, mod_dir=None): total = sum( path.stat().st_size - for path in dest_dir.rglob("*") + for path in dest_dir.iterdir() if path.is_file() ) limit = partition_bytes - HEADROOM_BYTES if total > limit: raise AudioPackError( - f"Filesystem image is {total} bytes; " + f"Audio image is {total} bytes; " f"spiffs allows {limit} after {HEADROOM_BYTES} bytes headroom" ) - print(f"Filesystem image {total} bytes (limit {limit})") + print(f"Audio image {total} bytes (limit {limit})") def _overlay_mod(mod_dir, dest_dir): diff --git a/scripts/copy_assets.py b/scripts/copy_assets.py index 81ab58ba..2c32e283 100644 --- a/scripts/copy_assets.py +++ b/scripts/copy_assets.py @@ -9,7 +9,6 @@ sys.path.insert(0, str(scripts_dir)) from audio_pack import pack_audio, spiffs_size_bytes, validate_mod_name -from ui_pack import build_ui, pack_ui mod_name = env.GetProjectOption("custom_audio_mod", "") mod_name = "" if mod_name is None else str(mod_name).strip() @@ -19,9 +18,6 @@ mod_dir = project_dir / "mods" / mod_name / "assets" print(f"Audio mod overlay: {mod_name}") -# Before pack_audio so its size check covers the UI files too. -pack_ui(build_ui(project_dir / "ui"), project_dir / "data" / "ui", env["TE_FW_VERSION"]) - pack_audio( project_dir / "assets", project_dir / "data", diff --git a/scripts/embed_ui.py b/scripts/embed_ui.py new file mode 100644 index 00000000..5e5cdeae --- /dev/null +++ b/scripts/embed_ui.py @@ -0,0 +1,58 @@ +Import("env") + +import gzip +import shutil +import subprocess +from pathlib import Path + +project_dir = Path(env.subst("$PROJECT_DIR")) +ui_dir = project_dir / "ui" +generated_dir = Path(env.subst("$BUILD_DIR")) / "generated" +header = generated_dir / "web_ui.h" + + +def npm(*args): + executable = shutil.which("npm") + if executable is None: + raise RuntimeError("npm not found; building the web UI needs Node 20.19+ on 20.x, or 22.12+") + subprocess.run([executable, *args], cwd=ui_dir, check=True) + + +def newest_source_mtime(): + sources = [ui_dir / "index.html", ui_dir / "vite.config.js", ui_dir / "package-lock.json"] + sources += [path for path in (ui_dir / "src").rglob("*") if path.is_file()] + return max(path.stat().st_mtime for path in sources) + + +env.Append(CPPPATH=[str(generated_dir)]) + +# pio runs pre-scripts for every target (uploadfs, otafs, ...), so skip Node when ui/ is unchanged. +if header.is_file() and header.stat().st_mtime >= newest_source_mtime(): + Return() + +# npm writes this marker on install; an older one means the lockfile changed since. +installed = ui_dir / "node_modules" / ".package-lock.json" +if not installed.is_file() or installed.stat().st_mtime < (ui_dir / "package-lock.json").stat().st_mtime: + npm("ci") +npm("run", "build") + +page = (ui_dir / "dist" / "index.html").read_bytes() +# mtime=0 keeps the bytes identical for identical sources, so an unchanged UI does not rebuild index_page.cpp. +packed = gzip.compress(page, compresslevel=9, mtime=0) +rows = ",\n".join( + " " + ", ".join(f"0x{byte:02x}" for byte in packed[offset:offset + 16]) + for offset in range(0, len(packed), 16) +) +source = ( + "// Generated by scripts/embed_ui.py from ui/. Do not edit.\n" + "#pragma once\n\n" + "#include \n\n" + f"const uint8_t kWebUiGzip[] PROGMEM = {{\n{rows}\n}};\n" +) + +generated_dir.mkdir(parents=True, exist_ok=True) +if header.is_file() and header.read_text() == source: + header.touch() +else: + header.write_text(source) +print(f"Embedded web UI: {len(page)} -> {len(packed)} bytes gzipped") diff --git a/scripts/firmware_version.py b/scripts/firmware_version.py index 815d9a5c..79347d5c 100644 --- a/scripts/firmware_version.py +++ b/scripts/firmware_version.py @@ -35,5 +35,3 @@ def _sanitize(raw: str) -> str: version = _sanitize(_git_describe(project_dir)) print(f"Firmware version: {version}") env.Append(CPPDEFINES=[("FW_VERSION", '\\"%s\\"' % version)]) -# copy_assets.py stamps the web UI with the same string so the firmware can detect a mismatched filesystem. -env.Replace(TE_FW_VERSION=version) diff --git a/scripts/ota.py b/scripts/ota.py index fd712839..3d1dd87e 100644 --- a/scripts/ota.py +++ b/scripts/ota.py @@ -2,15 +2,10 @@ import os import subprocess -import time -import urllib.error -import urllib.request from pathlib import Path from urllib.parse import urlparse DEFAULT_HOST = "tiny-engineer.local" -REBOOT_TIMEOUT_S = 90 -FIRMWARE_ATTEMPTS = 3 def ota_host(): @@ -20,7 +15,7 @@ def ota_host(): return host or DEFAULT_HOST -def espota(image, filesystem, check=True): +def espota(image, filesystem): command = [ env.subst("$PYTHONEXE"), str(Path(env.PioPlatform().get_package_dir("framework-arduinoespressif32")) / "tools" / "espota.py"), @@ -38,59 +33,29 @@ def espota(image, filesystem, check=True): command.append("--spiffs") print(f"OTA {'filesystem' if filesystem else 'firmware'} -> {ota_host()}") - return subprocess.run(command, check=check).returncode == 0 + subprocess.run(command, check=True) -def wait_for_reboot(): - # The robot restarts after an OTA write; any HTTP answer (even 401) means it is back. - time.sleep(5) - deadline = time.monotonic() + REBOOT_TIMEOUT_S +def upload_firmware(source, target, env): + espota(env.subst("$BUILD_DIR/${PROGNAME}.bin"), filesystem=False) - while time.monotonic() < deadline: - try: - urllib.request.urlopen(f"http://{ota_host()}/health", timeout=3) - return - except urllib.error.HTTPError: - return - except OSError: - time.sleep(2) - raise RuntimeError(f"{ota_host()} did not come back within {REBOOT_TIMEOUT_S} s") - - -def build_filesystem(): +def upload_filesystem(source, target, env): project_dir = env.subst("$PROJECT_DIR") # The platform only wires up the LittleFS image builder when buildfs is a command-line target. subprocess.run( [env.subst("$PYTHONEXE"), "-m", "platformio", "run", "-t", "buildfs", "-e", env.subst("$PIOENV"), "-d", project_dir], check=True, ) - return env.subst("$BUILD_DIR/${ESP32_FS_IMAGE_NAME}.bin") - - -def upload_release(source, target, env): - # Filesystem first: until the firmware follows, the robot keeps its old firmware and the - # web UI refuses to load on the version mismatch instead of calling an older API. - espota(build_filesystem(), filesystem=True) - wait_for_reboot() - - # The OTA listener starts just after the HTTP server, so the first attempt can be early. - for attempt in range(1, FIRMWARE_ATTEMPTS + 1): - if espota(env.subst("$BUILD_DIR/${PROGNAME}.bin"), filesystem=False, check=attempt == FIRMWARE_ATTEMPTS): - return - time.sleep(3) - - -def upload_filesystem(source, target, env): - espota(build_filesystem(), filesystem=True) + espota(env.subst("$BUILD_DIR/${ESP32_FS_IMAGE_NAME}.bin"), filesystem=True) env.AddCustomTarget( name="ota", dependencies="$BUILD_DIR/${PROGNAME}.bin", - actions=upload_release, + actions=upload_firmware, title="Upload OTA", - description="Upload filesystem, then firmware, over Wi-Fi", + description="Upload firmware over Wi-Fi", ) env.AddCustomTarget( diff --git a/scripts/ui_pack.py b/scripts/ui_pack.py deleted file mode 100644 index 3da8d4e0..00000000 --- a/scripts/ui_pack.py +++ /dev/null @@ -1,45 +0,0 @@ -import gzip -import shutil -import subprocess -from pathlib import Path - - -class UiPackError(Exception): - pass - - -def _npm(ui_dir, *args): - npm = shutil.which("npm") - if npm is None: - raise UiPackError("npm not found; building the web UI needs Node 20.19+ on 20.x, or 22.12+") - subprocess.run([npm, *args], cwd=ui_dir, check=True) - - -def build_ui(ui_dir): - ui_dir = Path(ui_dir) - # npm writes this marker on install; an older one means the lockfile changed since. - installed = ui_dir / "node_modules" / ".package-lock.json" - lockfile = ui_dir / "package-lock.json" - if not installed.is_file() or installed.stat().st_mtime < lockfile.stat().st_mtime: - _npm(ui_dir, "ci") - _npm(ui_dir, "run", "build") - return ui_dir / "dist" - - -def pack_ui(dist_dir, dest_dir, version): - dist_dir = Path(dist_dir) - dest_dir = Path(dest_dir) - shutil.rmtree(dest_dir, ignore_errors=True) - - for source in sorted(path for path in dist_dir.rglob("*") if path.is_file()): - name = source.relative_to(dist_dir) - dest = dest_dir / f"{name}.gz" - dest.parent.mkdir(parents=True, exist_ok=True) - # mtime=0 keeps the image byte-identical when sources are unchanged. - packed = gzip.compress(source.read_bytes(), compresslevel=9, mtime=0) - dest.write_bytes(packed) - print(f"Packed {name}: {source.stat().st_size} -> {len(packed)} bytes") - - # Plain text: the firmware reads it to refuse a UI built for another firmware version. - (dest_dir / "version.txt").write_text(version) - print(f"Stamped web UI with {version}") diff --git a/src/http/index_page.cpp b/src/http/index_page.cpp index 41c3daa9..27d90ad6 100644 --- a/src/http/index_page.cpp +++ b/src/http/index_page.cpp @@ -1,69 +1,9 @@ #include "http/index_page.h" -#include - -#include "firmware_version.h" -#include "http/json.h" - -namespace { - -const char kIndexPath[] = "/ui/index.html.gz"; -const char kVersionPath[] = "/ui/version.txt"; - -String uiVersion() { - File file = LittleFS.open(kVersionPath, "r"); - - if (!file) { - return "unknown"; - } - - String version = file.readString(); - file.close(); - version.trim(); - return version; -} - -void sendUiUnavailable(WebServer& server, const String& heading, const String& detail) { - const String html = - String("") + - "Tiny Engineer

" + heading + "

" + detail + - "

Flash firmware and filesystem from the same build: " - "pio run -t upload over USB, or pio run -e ota -t ota over Wi-Fi.

" - ""; - - httpSendHtml(server, 503, html.c_str()); -} - -} // namespace - -void registerUiAssetRoutes(WebServer& server) { - // Bundler output under /ui/assets/ is content-hashed, so it never changes in place. - server.serveStatic("/ui/assets/", LittleFS, "/ui/assets/", "max-age=31536000, immutable"); - server.serveStatic("/ui/", LittleFS, "/ui/", "no-cache"); -} +#include "web_ui.h" void sendIndexPage(WebServer& server) { - File page = LittleFS.open(kIndexPath, "r"); - - if (!page) { - sendUiUnavailable(server, "Web UI missing", "The control panel lives on LittleFS and was not found."); - return; - } - - // A UI from another build may call routes or params this firmware does not have. - const String version = uiVersion(); - - if (version != FW_VERSION) { - page.close(); - sendUiUnavailable( - server, - "Web UI does not match firmware", - String("Firmware ") + FW_VERSION + ", web UI " + version + "." - ); - return; - } - + server.sendHeader("Content-Encoding", "gzip"); server.sendHeader("Cache-Control", "no-cache"); - server.streamFile(page, "text/html; charset=utf-8"); - page.close(); + server.send_P(200, "text/html; charset=utf-8", reinterpret_cast(kWebUiGzip), sizeof(kWebUiGzip)); } diff --git a/src/http/index_page.h b/src/http/index_page.h index 634bff89..2a0bdf56 100644 --- a/src/http/index_page.h +++ b/src/http/index_page.h @@ -2,5 +2,4 @@ #include -void registerUiAssetRoutes(WebServer& server); void sendIndexPage(WebServer& server); diff --git a/src/http/json.cpp b/src/http/json.cpp index dcb021e8..910225e8 100644 --- a/src/http/json.cpp +++ b/src/http/json.cpp @@ -46,6 +46,7 @@ void httpSendJson(WebServer& server, int code, const char* body) { void httpSendHtml(WebServer& server, int code, const char* body) { sendCorsHeaders(server); + // send() copies into Arduino String and fails on the large panel. server.send_P(code, "text/html; charset=utf-8", body); } diff --git a/src/http/routes.cpp b/src/http/routes.cpp index b24ae563..cbf0c409 100644 --- a/src/http/routes.cpp +++ b/src/http/routes.cpp @@ -109,6 +109,5 @@ void registerHttpRoutes() { }); registerHttpTestRoutes(server); registerHttpSetupRoutes(server); - registerUiAssetRoutes(server); server.onNotFound(handleNotFound); } diff --git a/ui/index.html b/ui/index.html index b4b56885..2f587f82 100644 --- a/ui/index.html +++ b/ui/index.html @@ -132,11 +132,6 @@

API reference

/api This API reference - - GET - /ui/* - Web UI assets from LittleFS - GET /auth diff --git a/ui/package-lock.json b/ui/package-lock.json index 7a4d62bb..437cd9f5 100644 --- a/ui/package-lock.json +++ b/ui/package-lock.json @@ -17,6 +17,7 @@ "stylelint": "^17.16.0", "stylelint-config-standard": "^40.0.0", "vite": "^8.3.3", + "vite-plugin-singlefile": "^2.3.3", "vitest": "^5.0.3" }, "engines": { @@ -4065,6 +4066,28 @@ } } }, + "node_modules/vite-plugin-singlefile": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/vite-plugin-singlefile/-/vite-plugin-singlefile-2.3.3.tgz", + "integrity": "sha512-XVnGH0QzbOa8fxRSsHdCarVN1BSBXNi7uLMQYlrGRN5apdHkk62XQWRJhVever0lnfuyBkwn+kvVChdm/OoOUg==", + "dev": true, + "license": "MIT", + "dependencies": { + "micromatch": "^4.0.8" + }, + "engines": { + "node": ">18.0.0" + }, + "peerDependencies": { + "rollup": "^4.59.0", + "vite": "^5.4.21 || ^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "rollup": { + "optional": true + } + } + }, "node_modules/vitest": { "version": "5.0.3", "resolved": "https://registry.npmjs.org/vitest/-/vitest-5.0.3.tgz", diff --git a/ui/package.json b/ui/package.json index bdc1b3b8..20b640d2 100644 --- a/ui/package.json +++ b/ui/package.json @@ -24,6 +24,7 @@ "stylelint": "^17.16.0", "stylelint-config-standard": "^40.0.0", "vite": "^8.3.3", + "vite-plugin-singlefile": "^2.3.3", "vitest": "^5.0.3" } } diff --git a/ui/vite.config.js b/ui/vite.config.js index ffa10060..e3a205b5 100644 --- a/ui/vite.config.js +++ b/ui/vite.config.js @@ -1,5 +1,6 @@ import { minify } from "html-minifier-terser"; import { defineConfig } from "vite"; +import { viteSingleFile } from "vite-plugin-singlefile"; const robot = process.env.TINY_ENGINEER_URL || "http://tiny-engineer.local"; // Anchored so page routes such as /animations and /tests stay on the dev server. @@ -21,14 +22,9 @@ const minifyHtml = { minify(html, { collapseWhitespace: true, removeComments: true, minifyCSS: true }), }; -export default defineConfig(({ command }) => ({ - // The firmware serves the built files from LittleFS under /ui/. - base: command === "build" ? "/ui/" : "/", - plugins: [minifyHtml], - build: { - outDir: "dist", - emptyOutDir: true, - }, +export default defineConfig({ + // One self-contained page: the firmware embeds it gzipped and serves it as-is. + plugins: [viteSingleFile(), minifyHtml], test: { environment: "jsdom", }, @@ -36,4 +32,4 @@ export default defineConfig(({ command }) => ({ host: "127.0.0.1", proxy: Object.fromEntries(apiRoutes.map((route) => [route, robot])), }, -})); +}); From b345f6c9b385dfae45bc9ee2c44ed5d9509571c1 Mon Sep 17 00:00:00 2001 From: shodan Date: Wed, 7 Oct 2026 23:34:51 +0200 Subject: [PATCH 2/4] refactor(http): tie UI state to its views Busy state re-enables only the controls it disabled, so the wizard no longer re-renders to undo it. Servo range changes notify the servo page and the calibration step instead of every caller refreshing both, and the servo_mins/servo_maxs format lives beside the ranges. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01VUaQRAXtYTdrXQ4C4us2V5 --- ui/src/servo-ranges.js | 15 +++++++++++++++ ui/src/servo.js | 22 +++++++++++++++------- ui/src/settings.js | 2 -- ui/src/setup/calibration.js | 11 ++++++++++- ui/src/setup/wizard.js | 33 ++++++++++----------------------- ui/src/shell.js | 2 +- ui/src/status.js | 8 +++++++- 7 files changed, 58 insertions(+), 35 deletions(-) diff --git a/ui/src/servo-ranges.js b/ui/src/servo-ranges.js index c206aae4..811a287d 100644 --- a/ui/src/servo-ranges.js +++ b/ui/src/servo-ranges.js @@ -10,6 +10,17 @@ export const cloneRanges = (ranges) => ranges.map(([min, max]) => [min, max]); export const servoRanges = cloneRanges(DEFAULT_SERVO_RANGES); +const listeners = []; + +export function onServoRangesChange(listener) { + listeners.push(listener); +} + +export const rangesToParams = (ranges) => ({ + servo_mins: ranges.map(([min]) => min).join(","), + servo_maxs: ranges.map(([, max]) => max).join(","), +}); + export function applyServoRanges(settings) { const { servo_mins: mins, servo_maxs: maxs } = settings; @@ -20,4 +31,8 @@ export function applyServoRanges(settings) { mins.forEach((min, index) => { servoRanges[index] = [min, maxs[index]]; }); + + for (const listener of listeners) { + listener(); + } } diff --git a/ui/src/servo.js b/ui/src/servo.js index c02a2820..7a6173ae 100644 --- a/ui/src/servo.js +++ b/ui/src/servo.js @@ -1,5 +1,5 @@ import { $ } from "./dom.js"; -import { servoRanges } from "./servo-ranges.js"; +import { onServoRangesChange, servoRanges } from "./servo-ranges.js"; import { perform, setStatus } from "./status.js"; const servoSelect = $("#servo-index"); @@ -9,6 +9,12 @@ const rangeHint = $("#servo-range-hint"); const currentRange = () => servoRanges[Number(servoSelect.value)] ?? servoRanges[0]; +function inRange(angle) { + const [min, max] = currentRange(); + + return angle >= min && angle <= max; +} + function currentMid() { const [min, max] = currentRange(); @@ -17,13 +23,12 @@ function currentMid() { function updateRangeHint() { const [min, max] = currentRange(); - const angle = parseFloat(angleInput.value); - const inRange = angle >= min && angle <= max; + const safe = inRange(parseFloat(angleInput.value)); - rangeHint.textContent = inRange + rangeHint.textContent = safe ? `Safe range: ${min}–${max}°` : `Outside safe range — firmware clamps to ${min}–${max}°`; - rangeHint.classList.toggle("warn", !inRange); + rangeHint.classList.toggle("warn", !safe); } function setAngle(angle) { @@ -32,7 +37,7 @@ function setAngle(angle) { updateRangeHint(); } -export function refreshServoPage() { +function refreshServoPage() { const [min, max] = currentRange(); for (const input of [slider, angleInput]) { @@ -42,7 +47,7 @@ export function refreshServoPage() { const angle = parseFloat(angleInput.value); - setAngle(angle >= min && angle <= max ? angle : currentMid()); + setAngle(inRange(angle) ? angle : currentMid()); $("#servo-scale-min").textContent = `${min}°`; $("#servo-scale-mid").textContent = `${Math.round(currentMid())}°`; $("#servo-scale-max").textContent = `${max}°`; @@ -80,3 +85,6 @@ $("#servo-form").addEventListener("submit", (event) => { event.preventDefault(); moveServo(); }); + +onServoRangesChange(refreshServoPage); +refreshServoPage(); diff --git a/ui/src/settings.js b/ui/src/settings.js index 80244173..d0c12b56 100644 --- a/ui/src/settings.js +++ b/ui/src/settings.js @@ -1,7 +1,6 @@ import { apiGetJson } from "./api.js"; import { applyConfigSettings } from "./config.js"; import { device } from "./device.js"; -import { refreshServoPage } from "./servo.js"; import { applyServoRanges } from "./servo-ranges.js"; import { applyWizardSettings } from "./setup/wizard.js"; import { setStatus } from "./status.js"; @@ -17,7 +16,6 @@ export async function loadSettings() { device.wifiConfigured = Boolean(settings.wifi_configured); applyConfigSettings(settings); applyServoRanges(settings); - refreshServoPage(); applyWizardSettings(settings); } catch { setStatus("Could not load settings", "err"); diff --git a/ui/src/setup/calibration.js b/ui/src/setup/calibration.js index 81867761..f94f3d40 100644 --- a/ui/src/setup/calibration.js +++ b/ui/src/setup/calibration.js @@ -1,5 +1,10 @@ import { $, $$ } from "../dom.js"; -import { cloneRanges, DEFAULT_SERVO_RANGES, servoRanges } from "../servo-ranges.js"; +import { + cloneRanges, + DEFAULT_SERVO_RANGES, + onServoRangesChange, + servoRanges, +} from "../servo-ranges.js"; import { clearStatus, isBusy, perform, setStatus } from "../status.js"; const JOINT_COPY = [ @@ -120,6 +125,10 @@ export const calibration = { }, }; +onServoRangesChange(() => { + calibration.ranges = cloneRanges(servoRanges); +}); + $("#setup-move-90").addEventListener("click", async () => { const result = await postSetupServo({ all: CENTER }); diff --git a/ui/src/setup/wizard.js b/ui/src/setup/wizard.js index 0effb196..b82612a5 100644 --- a/ui/src/setup/wizard.js +++ b/ui/src/setup/wizard.js @@ -1,8 +1,7 @@ import { postQuietly } from "../api.js"; import { device } from "../device.js"; import { $ } from "../dom.js"; -import { refreshServoPage } from "../servo.js"; -import { applyServoRanges, cloneRanges, servoRanges } from "../servo-ranges.js"; +import { applyServoRanges, rangesToParams } from "../servo-ranges.js"; import { syncSetupUi } from "../shell.js"; import { clearStatus, isBusy, perform, setStatus } from "../status.js"; import { calibration } from "./calibration.js"; @@ -30,6 +29,12 @@ function previewOled() { postQuietly("/setup/oled", { rotate_180: oledRotate180 ? 1 : 0 }); } +function applyOledRotation(settings) { + if (typeof settings.oled_rotate_180 === "boolean") { + oledRotate180 = settings.oled_rotate_180; + } +} + function saveSettings(params, pending) { return perform({ path: "/settings", @@ -87,12 +92,7 @@ export function resetWizard() { export function applyWizardSettings(settings) { $("#config-wifi-hostname").value = settings.hostname || ""; ledMapping.applySavedOrder(settings.rgb_order); - - if (typeof settings.oled_rotate_180 === "boolean") { - oledRotate180 = settings.oled_rotate_180; - } - - calibration.ranges = cloneRanges(servoRanges); + applyOledRotation(settings); if (device.inSetup) { renderWizard(); @@ -105,21 +105,13 @@ const advance = { return; } - const result = await saveSettings( - { - servo_mins: calibration.ranges.map(([min]) => min).join(","), - servo_maxs: calibration.ranges.map(([, max]) => max).join(","), - }, - "Saving servo ranges…", - ); + const result = await saveSettings(rangesToParams(calibration.ranges), "Saving servo ranges…"); if (!result?.ok) { return; } applyServoRanges(result.data); - calibration.ranges = cloneRanges(servoRanges); - refreshServoPage(); goTo("oled"); previewOled(); clearStatus(); @@ -135,9 +127,7 @@ const advance = { return; } - if (typeof result.data.oled_rotate_180 === "boolean") { - oledRotate180 = result.data.oled_rotate_180; - } + applyOledRotation(result.data); goTo("led"); clearStatus(); @@ -193,7 +183,6 @@ nextButton.addEventListener("click", async () => { } await advance[currentStep().id]?.(); - // perform() re-enables every button, so restore this step's Next state. renderWizard(); }); @@ -228,8 +217,6 @@ $("#setup-audio-play").addEventListener("click", async () => { if (result?.ok) { setStatus("Done.", "ok"); } - - renderWizard(); }); $("#config-wifi-password-toggle").addEventListener("click", (event) => { diff --git a/ui/src/shell.js b/ui/src/shell.js index 114919ad..36ffac89 100644 --- a/ui/src/shell.js +++ b/ui/src/shell.js @@ -155,7 +155,7 @@ export async function boot() { let auth; try { - auth = await (await fetch("/auth")).json(); + auth = await apiGetJson("/auth"); } catch { enterApp(); diff --git a/ui/src/status.js b/ui/src/status.js index 80de14cd..92f774d7 100644 --- a/ui/src/status.js +++ b/ui/src/status.js @@ -3,6 +3,7 @@ import { $, $$ } from "./dom.js"; const statusBar = $("#status"); let busy = false; +let lockedControls = []; export const isBusy = () => busy; @@ -16,10 +17,15 @@ export function clearStatus() { statusBar.textContent = ""; } +// Re-enables only what it disabled, so controls that were already disabled for their own reasons stay that way. function setBusy(on) { busy = on; - for (const control of $$(".btn, [type=submit]")) { + if (on) { + lockedControls = [...$$(".btn, [type=submit]")].filter((control) => !control.disabled); + } + + for (const control of lockedControls) { control.disabled = on; } } From 87e975445602738d11e3125dcf1cb98df79be334 Mon Sep 17 00:00:00 2001 From: shodan Date: Sun, 11 Oct 2026 11:27:48 +0200 Subject: [PATCH 3/4] fix(http): rebuild the embedded UI when ui/package.json changes Co-Authored-By: Claude Opus 5.5 (1M context) --- scripts/embed_ui.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/embed_ui.py b/scripts/embed_ui.py index 5e5cdeae..15aa49c3 100644 --- a/scripts/embed_ui.py +++ b/scripts/embed_ui.py @@ -19,7 +19,7 @@ def npm(*args): def newest_source_mtime(): - sources = [ui_dir / "index.html", ui_dir / "vite.config.js", ui_dir / "package-lock.json"] + sources = [ui_dir / "index.html", ui_dir / "vite.config.js", ui_dir / "package.json", ui_dir / "package-lock.json"] sources += [path for path in (ui_dir / "src").rglob("*") if path.is_file()] return max(path.stat().st_mtime for path in sources) From 75f1163f4ace16b19b3df7287c4bdacbc4653b91 Mon Sep 17 00:00:00 2001 From: shodan Date: Sun, 11 Oct 2026 14:13:42 +0200 Subject: [PATCH 4/4] fix(scripts): drop stale directories from the LittleFS data tree Co-Authored-By: Claude Opus 5.5 (1M context) --- .gitignore | 1 + scripts/audio_pack.py | 5 +++++ scripts/test_audio_pack.py | 6 ++++++ 3 files changed, 12 insertions(+) diff --git a/.gitignore b/.gitignore index 53b2e49d..9fce2b4a 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,7 @@ .DS_Store __pycache__/ data/*.* +data/*/ artifacts/ # Cursor hook event log diff --git a/scripts/audio_pack.py b/scripts/audio_pack.py index adb92416..0c4c810b 100644 --- a/scripts/audio_pack.py +++ b/scripts/audio_pack.py @@ -258,6 +258,11 @@ def pack_audio(assets_dir, dest_dir, partition_bytes, mod_dir=None): if leftover.is_file(): leftover.unlink() + # The firmware reads only top-level files, so any directory here is stale build output. + for leftover in dest_dir.iterdir(): + if leftover.is_dir(): + shutil.rmtree(leftover) + for name in STOCK_WAVS: source = assets_dir / name if not source.is_file(): diff --git a/scripts/test_audio_pack.py b/scripts/test_audio_pack.py index 1f2fa289..868885e0 100644 --- a/scripts/test_audio_pack.py +++ b/scripts/test_audio_pack.py @@ -70,6 +70,12 @@ def test_stock_rebuild_drops_old_cue(self): pack_audio(self.assets, self.dest, 2_000_000) self.assertFalse((self.dest / "welcome.cue").exists()) + def test_rebuild_drops_stale_directories(self): + (self.dest / "ui" / "assets").mkdir(parents=True) + (self.dest / "ui" / "assets" / "index.js").write_text("stale") + pack_audio(self.assets, self.dest, 2_000_000) + self.assertFalse((self.dest / "ui").exists()) + def test_stock_copy_has_no_cues(self): pack_audio(self.assets, self.dest, 2_000_000) for name in STOCK_WAVS: