Skip to content

PR-10: Frontend served from littlefs#17

Merged
cryptotomte merged 7 commits into
mainfrom
010-frontend-littlefs-assets
Jul 6, 2026
Merged

PR-10: Frontend served from littlefs#17
cryptotomte merged 7 commits into
mainfrom
010-frontend-littlefs-assets

Conversation

@cryptotomte

Copy link
Copy Markdown
Owner

Feature 010 — serve the web dashboard from the littlefs storage partition (gzipped) over the same esp_http_server that exposes /api/v1/, minimally adapted to the frozen contract. This completes the phase-3 web exit criteria and gives the API a real end-to-end browser test client. No redesign, no framework (a separate PRD).

What's here

  • firmware/web/ — adapted COPIES of the frozen data/ frontend (index.html/script.js/styles.css/favicon); data/ untouched, wifi_setup.html dropped (no v1 equivalent). Vendored libs for offline operation (clarify decision): Chart.js 4.4.3, chartjs-adapter-date-fns 3.0.0, and a purged Tailwind 3.4.17 CSS — the greenhouse client renders fully with no internet, only LAN access to the device.
  • Build pipelinetools/gzip_assets.py (deterministic, mtime=0) + a CMake staging step gzip the web tree into the littlefs image (merged with the storage_image seed). No committed .gz, no node/Tailwind toolchain in CI — only Python gzip (Constitution III). The build fails loudly if it compresses zero assets.
  • ApiStatic (pure, host-tested) — path sanitization (rejects ../NUL/backslash/absolute-escape, maps /→index.html) + content-type mapping.
  • GET /* static handler in ApiServer — registered LAST so exact /api/v1/ routes win; serves <base>/<path>.gz with Content-Encoding: gzip + content-type + cache; JSON 404 on miss; detects a truncated read; GET-only; file I/O only, off the watering buses.
  • script.js adapted to /api/v1/ — sensors read .valid (null-safe; soil valid:false until PR-11 no longer breaks the page); status/pumps/config split; JSON POST bodies with 409/501 handling; reservoir reduced to manual start/stop + level display (no v1 enable/auto-level endpoint) and hidden on single-pump rev2; history by ?metric=&range=; OTA placeholder against the /ota 501 stub (PR-13).

Verification

  • Host: 316 / 0 (23 new ApiStatic cases incl. traversal/security).
  • rev1 + rev2 build green; littlefs image packs ~106 KB gz assets (well within the 960 KiB partition).
  • Full spec-kit flow: spec → clarify (vendor-offline) → plan → tasks → implement → CP3 review (3 agents) → fixes → verifying re-review (approved, no regressions). Record: docs/checkpoints/cp3-010-frontend-littlefs-assets.md.

HIL

Deferred to the rev1 bench rig (specs/010-frontend-littlefs-assets/checklists/hil.md) — includes the offline-render check (LAN, no client internet) that justifies vendoring. The merge gate is CI (host tests + both board builds + the littlefs image).

Safety / scope

Frozen data/ and the frozen /api/v1/ contract are unchanged (any genuine contract gap is a PR-09 amendment, not patched here). The static server is isolated (httpd task, not watchdog-subscribed, no watering mutex). Merge with a merge commit (never squash — spec-kit chaining).

🤖 Generated with Claude Code

cryptotomte and others added 7 commits July 6, 2026 11:15
Serve the existing frontend from littlefs (gzipped) via the existing
esp_http_server, minimally adapted to the frozen /api/v1/ contract. Clarify
decision: vendor Tailwind + Chart.js locally (offline-robust greenhouse client).
Frozen data/ + the frozen contract untouched; adapted copies live in firmware/web/.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Adds firmware/web/ as the source of truth for the device dashboard: copies of
the frozen data/ frontend (index.html/script.js/styles.css/favicon.ico),
adapted where noted, plus vendored libs for offline operation (Chart.js 4.4.3,
chartjs-adapter-date-fns 3.0.0, a purged Tailwind 3.4.17 CSS). data/ is
untouched; wifi_setup.html is not carried over.

index.html: the three CDN references (Tailwind runtime+config, Chart.js,
adapter) are replaced with local vendor/ paths (styles.css still loads after
tailwind.css so custom overrides win). script.js API adaptation to /api/v1/
lands in the next commit.

Build pipeline: tools/gzip_assets.py deterministically (mtime=0, level 9)
gzips firmware/web/ into a build staging dir merged with the storage_image
seed; main/CMakeLists.txt stages it and points littlefs_create_partition_image
at it. No committed .gz, no node/Tailwind toolchain in CI — only Python gzip
(Constitution III). README.md is excluded from the served set.

rev1 build green; the littlefs image packs all assets (index 4.1K, script.js
11K, tailwind 3.5K, chart.js 69.6K, adapter 11.7K gz) — well within the 960K
partition.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Host-testable helpers for the static file server (feature 010):
- sanitizeAssetPath(uri): strip query, map "/" -> index.html, strip one
  leading slash, reject NUL / backslash / absolute-escape / any ".." path
  segment; returns the safe relative path (no ".gz" suffix).
- contentTypeForPath(path): content type by extension (html/js/css/ico/json/
  svg), application/octet-stream default.

Pure (no IDF), built on host + both targets. 18 host tests (293 -> 311).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Adds a GET /* catch-all handler to the existing ApiServer, registered LAST so
the exact /api/v1/ routes match first and any other GET falls through to the
static server. It sanitizes the path (ApiStatic), opens
<StorageMount base>/<path>.gz, and streams it with Content-Encoding: gzip +
content-type + Cache-Control; a rejected path or missing file returns the JSON
404 envelope (reuses sendJson). GET-only, so the POST API routes are
unaffected. File I/O only, off the watering buses (isolation class of
history/events); no second server/port/task.

Target-only (in ApiServer.cpp); api target build now PRIV_REQUIRES storage for
StorageMount. rev1 + rev2 build green; host unchanged (311/0).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
ENDPOINT -> /api/v1. Read paths: sensors read environmental/soil .valid (was
.success) and render every nullable field null-safe (soil valid=false until
PR-11 no longer breaks the page); status remapped to the v1 shape (wifi not
network, storage in bytes, mode string) with pump running state now from
GET /api/v1/pumps and config values from GET /api/v1/config (both removed from
/status). Write paths (JSON, not urlencoded): mode toggle -> POST /config
{wateringEnabled}; config save -> POST /config with wateringDurationS/
minWateringIntervalS (seconds); plant + reservoir run/stop -> POST
/pumps/{name} {action,durationS} with 409 handling; errors read {error}.

Reservoir UI reduced to manual start/stop + level display (from sensors
data.level); the enable/disable + auto-level-control controls are hidden (no
v1 endpoint) and the whole section is hidden on single-pump (rev2) boards when
/pumps has no reservoir entry. History uses ?metric=&range= (sensor+reading ->
env_*/soil_* metric). OTA button posts /ota and shows the 501 stub cleanly
(PR-13). Config interval field relabeled to seconds.

data/ + the frozen contract untouched. node --check clean; rev1 + rev2 build
green; the littlefs image packs the updated assets. host unchanged (311/0).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- firmware/CLAUDE.md: new "Frontend from littlefs (feature 010)" section
  (firmware/web/ source tree, vendored-offline decision, gzip/littlefs
  pipeline, ApiStatic + the GET /* static handler, JS-to-v1 adaptation, the
  reservoir-UI reduction + contract-gap handling).
- specs/010/checklists/hil.md: rev1 rig smoke — assets load gzipped, live
  dashboard, pump/mode control, config persists across reboot, history, phone,
  and the offline-render check (the whole point of vendoring), plus a rev2 note.

T017: frozen data/ + docs/api/openapi.yaml unchanged; host 311/0; rev1 + rev2
build green; littlefs image packs the assets (~106 KB gz, well within 960 KiB).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Addresses the Checkpoint-3 review findings (all clear should-fixes).

Build robustness:
- gzip_assets.py now exits non-zero if it compresses ZERO files, so a
  misconfigured/empty web/ fails the build instead of silently shipping an
  asset-less littlefs image.
- main/CMakeLists.txt: file(GLOB_RECURSE ... CONFIGURE_DEPENDS) so an ADDED or
  removed web asset re-globs on build (not just edits to existing files).

Static handler (ApiServer.cpp):
- staticFileHandler checks std::ferror after the read loop: a mid-file littlefs
  read error now returns ESP_FAIL (broken connection) instead of sending the
  terminating chunk and signalling a complete-but-truncated gzip body.
- Cache-Control: the HTML shell gets no-cache (always revalidate, so a
  frontend-changing OTA is picked up), static libs keep max-age=3600.

Frontend (script.js):
- History chart: timestamps are epoch SECONDS per the frozen contract; construct
  Date(t * 1000) so points no longer land near 1970 (real chart bug; the line
  was inherited verbatim from the legacy frontend).
- updateSensorDisplay: NPK is blanked + re-hidden when soil reads valid:false or
  omits NPK, so a previously shown reading can't linger stale-as-live (matters
  once PR-11 wires the soil reader).

Tests: +5 ApiStatic host cases (// absolute-escape, trailing .. segment,
case-insensitive + htm-alias content types, empty-string -> index) — 311 -> 316.
ApiStatic.cpp: a comment marking the unreachable defensive empty-path guard.

host 316/0; rev1 + rev2 build green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@cryptotomte cryptotomte merged commit 947f2f8 into main Jul 6, 2026
3 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant