PR-10: Frontend served from littlefs#17
Merged
Merged
Conversation
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Feature 010 — serve the web dashboard from the littlefs
storagepartition (gzipped) over the sameesp_http_serverthat 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 frozendata/frontend (index.html/script.js/styles.css/favicon);data/untouched,wifi_setup.htmldropped (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.tools/gzip_assets.py(deterministic,mtime=0) + a CMake staging step gzip the web tree into the littlefs image (merged with thestorage_imageseed). 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 inApiServer— registered LAST so exact/api/v1/routes win; serves<base>/<path>.gzwithContent-Encoding: gzip+ content-type + cache; JSON 404 on miss; detects a truncated read; GET-only; file I/O only, off the watering buses.script.jsadapted to/api/v1/— sensors read.valid(null-safe; soilvalid:falseuntil 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/ota501 stub (PR-13).Verification
ApiStaticcases incl. traversal/security).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