Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 41 additions & 0 deletions firmware/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -568,6 +568,47 @@ the diagnostic probe can no longer overlap bus transactions. Lock order is alway
(soil mutex → modbus mutex) or (modbus mutex alone) — no deadlock. HIL checklist:
`specs/011-watering-controller-host-tests/checklists/hil.md`.

## Frontend from littlefs (feature 010)

Feature 010 (PR-10) serves the web dashboard from the littlefs `storage`
partition over the SAME `esp_http_server` that exposes `/api/v1/`, minimally
adapted to the frozen contract — it is the end-to-end test client. No redesign,
no framework (that is a separate PRD).

- **Source of truth: `firmware/web/`** — adapted COPIES of the frozen `data/`
frontend (`index.html`/`script.js`/`styles.css`/`favicon.ico`); `data/` stays
read-only, `wifi_setup.html` is not carried over (its `/api/wifi/*` endpoints
do not exist in v1). `firmware/web/vendor/` holds the third-party libs vendored
locally so the greenhouse client needs NO internet: Chart.js 4.4.3,
chartjs-adapter-date-fns 3.0.0, and a PURGED Tailwind 3.4.17 CSS. See
`firmware/web/README.md` for pinned versions + the manual Tailwind regen step.
- **Build pipeline:** `firmware/tools/gzip_assets.py` deterministically
(`mtime=0`, level 9) gzips `firmware/web/` into a build staging dir merged with
the `storage_image/` seed; `firmware/main/CMakeLists.txt` stages it and points
`littlefs_create_partition_image` at it (assets land at `/storage/*.gz`,
coexisting with runtime history/event files). NO committed `.gz`, NO
node/Tailwind toolchain in CI — only Python gzip (Constitution III). `.md`
files are excluded from the image.
- **Static serving:** a `GET /*` catch-all in `ApiServer` (target-only),
registered LAST so the exact `/api/v1/` routes match first; it sanitizes the
path (`ApiStatic` — pure, host-tested: `sanitizeAssetPath` rejects `..`/NUL/
backslash/absolute-escape, maps `/`→`index.html`; `contentTypeForPath`), opens
`<StorageMount base>/<path>.gz`, and streams it with `Content-Encoding: gzip`
+ content-type + `Cache-Control`. Missing/rejected → the JSON 404 envelope.
GET-only (POST API routes unaffected); file I/O only, off the watering buses
(isolation class of `/history`); no second server/port/task.
- **JS adaptation (`firmware/web/script.js`):** `ENDPOINT=/api/v1`; reads
`environmental/soil .valid` (null-safe — soil `valid:false` until PR-11);
status remapped (wifi not network, storage in bytes, `mode` string) with pump
state from `GET /pumps` and config from `GET /config`; JSON POST bodies for
mode/config/pump-run-stop with 409/4xx handling. Reservoir UI reduced to manual
start/stop + level display (v1 has no enable/auto-level endpoint) and hidden on
single-pump rev2. History by `?metric=&range=`. OTA button hits the `/ota` 501
stub (real OTA is PR-13). The frozen `/api/v1/` contract is unchanged — any
genuine contract gap is escalated as a PR-09 amendment, never patched here.

HIL checklist: `specs/010-frontend-littlefs-assets/checklists/hil.md`.

## Partition layout (4MB flash)

nvs (0x9000, 16K) | otadata (0xd000, 8K) | phy_init (0xf000, 4K) |
Expand Down
4 changes: 3 additions & 1 deletion firmware/components/api/CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ if(${IDF_TARGET} STREQUAL "linux")
"src/ApiRoutes.cpp"
"src/ApiSerialize.cpp"
"src/ApiRequests.cpp"
"src/ApiStatic.cpp"
INCLUDE_DIRS "include"
REQUIRES interfaces cjson network time events
)
Expand All @@ -35,8 +36,9 @@ else()
"src/ApiSerialize.cpp"
"src/ApiServer.cpp"
"src/ApiRequests.cpp"
"src/ApiStatic.cpp"
INCLUDE_DIRS "include"
REQUIRES interfaces cjson network time events
PRIV_REQUIRES esp_http_server esp_netif esp_app_format
PRIV_REQUIRES esp_http_server esp_netif esp_app_format storage
)
endif()
29 changes: 29 additions & 0 deletions firmware/components/api/include/api/ApiStatic.h
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
// SPDX-FileCopyrightText: 2026 Cryptotomte
// SPDX-License-Identifier: AGPL-3.0-or-later
//
// ApiStatic.h
// Pure helpers for serving the static frontend assets from littlefs
// (feature 010 / PR-10). Both functions are PURE C++ with no
// esp_http_server dependency, so they build on the linux preview target
// and are host-tested. The target-only ApiServer uses them to map a
// request URI onto a littlefs asset path and its Content-Type header.

#ifndef WATERINGSYSTEM_API_APISTATIC_H
#define WATERINGSYSTEM_API_APISTATIC_H

#include <optional>
#include <string>

namespace api {

// Map a request URI to a safe relative asset path, or nullopt to reject (404).
// Strips query, maps "/" -> "index.html", strips one leading '/', rejects ".."
// segments, NUL, backslash, and absolute-escape. Does NOT append ".gz".
std::optional<std::string> sanitizeAssetPath(const std::string& uri);

// Content-Type for a path by extension; "application/octet-stream" default.
const char* contentTypeForPath(const std::string& path);

} // namespace api

#endif // WATERINGSYSTEM_API_APISTATIC_H
59 changes: 59 additions & 0 deletions firmware/components/api/src/ApiServer.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@
#include <cmath>
#include <cstddef>
#include <cstdint>
#include <cstdio>
#include <cstdlib>
#include <cstring>
#include <optional>
Expand All @@ -41,8 +42,10 @@
#include "api/ApiRequests.h"
#include "api/ApiRoutes.h"
#include "api/ApiSerialize.h"
#include "api/ApiStatic.h"
#include "events/EventLogger.h"
#include "network/WifiState.h"
#include "storage/StorageMount.h"
#include "time/TimeService.h"

namespace api {
Expand Down Expand Up @@ -128,6 +131,56 @@ esp_err_t powerHandler(httpd_req_t* req)
return sendJson(req, ApiStatus::Ok, server->buildPowerBody());
}

// Serve a gzipped static asset from littlefs. Registered as the GET /* catch-all
// AFTER the exact /api/v1/ routes, so those match first; any other GET falls
// here. Assets are stored pre-gzipped at <base>/<path>.gz (feature 010). File
// I/O only, off the watering buses (same isolation class as history/events).
esp_err_t staticFileHandler(httpd_req_t* req)
{
const std::optional<std::string> rel = sanitizeAssetPath(req->uri);
if (!rel.has_value()) {
return sendJson(req, ApiStatus::NotFound, errorBody("not found"));
}
const std::string full =
std::string(StorageMount::kBasePath) + "/" + *rel + ".gz";
FILE* f = std::fopen(full.c_str(), "rb");
if (f == nullptr) {
return sendJson(req, ApiStatus::NotFound, errorBody("not found"));
}
httpd_resp_set_type(req, contentTypeForPath(*rel));
httpd_resp_set_hdr(req, "Content-Encoding", "gzip");
// The HTML shell must always revalidate so a frontend-changing OTA (PR-13)
// is picked up immediately; static libs stay cached for an hour.
const bool isHtml =
rel->size() >= 5 && rel->compare(rel->size() - 5, 5, ".html") == 0;
httpd_resp_set_hdr(req, "Cache-Control",
isHtml ? "no-cache" : "max-age=3600");
char buf[1024];
size_t n;
esp_err_t sendErr = ESP_OK;
while ((n = std::fread(buf, 1, sizeof(buf), f)) > 0) {
sendErr = httpd_resp_send_chunk(req, buf, n);
if (sendErr != ESP_OK) {
break;
}
}
// std::fread returns 0 on BOTH EOF and a read error, so a mid-file
// littlefs failure would otherwise exit the loop with sendErr == ESP_OK.
// Capture ferror before fclose; on a read error return ESP_FAIL WITHOUT
// the terminating empty chunk, so the client sees a broken connection
// rather than a bogus "complete" (truncated) gzip body.
const bool readError = (std::ferror(f) != 0);
std::fclose(f);
if (sendErr != ESP_OK) {
return sendErr;
}
if (readError) {
ESP_LOGE(TAG, "read error streaming %s (truncated)", full.c_str());
return ESP_FAIL;
}
return httpd_resp_send_chunk(req, nullptr, 0);
}

/// Global 404: any unregistered route answers the JSON error envelope (parity
/// §4 — the API never returns HTML/plain text). Registered via
/// httpd_register_err_handler so it does not interfere with exact route
Expand Down Expand Up @@ -909,6 +962,12 @@ bool ApiServer::start()
.handler = &otaHandler,
.user_ctx = this,
},
{
.uri = "/*",
.method = HTTP_GET,
.handler = &staticFileHandler,
.user_ctx = this,
},
};
for (const httpd_uri_t& route : routes) {
err = httpd_register_uri_handler(server, &route);
Expand Down
107 changes: 107 additions & 0 deletions firmware/components/api/src/ApiStatic.cpp
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
// SPDX-FileCopyrightText: 2026 Cryptotomte
// SPDX-License-Identifier: AGPL-3.0-or-later
//
// ApiStatic.cpp
// Implementation of the pure static-asset helpers (feature 010 / PR-10).

#include "api/ApiStatic.h"

#include <cstddef>

namespace api {

std::optional<std::string> sanitizeAssetPath(const std::string& uri)
{
std::string path = uri;

// Reject an embedded NUL outright (path-truncation defence).
if (path.find('\0') != std::string::npos) {
return std::nullopt;
}

// Strip the query string, if any.
std::size_t query = path.find('?');
if (query != std::string::npos) {
path.erase(query);
}

// Root (or empty) maps to the SPA entry point.
if (path.empty() || path == "/") {
return std::string("index.html");
}

// Backslash is never a valid separator in a littlefs asset path.
if (path.find('\\') != std::string::npos) {
return std::nullopt;
}

// Strip exactly one leading '/'. An empty remainder, or a second
// leading '/' (a "//..." absolute-escape attempt), is rejected.
if (path.front() == '/') {
path.erase(0, 1);
// Defensive guard: unreachable in practice ("/" already returned
// index.html above), kept as a belt-and-braces check.
if (path.empty()) {
return std::nullopt;
}
if (path.front() == '/') {
return std::nullopt;
}
}

// Reject any whole-segment "..". A segment that merely contains ".."
// among other characters (e.g. "a..b") is allowed.
std::size_t start = 0;
while (start <= path.size()) {
std::size_t slash = path.find('/', start);
std::size_t end = (slash == std::string::npos) ? path.size() : slash;
if (path.compare(start, end - start, "..") == 0) {
return std::nullopt;
}
if (slash == std::string::npos) {
break;
}
start = slash + 1;
}

return path;
}

const char* contentTypeForPath(const std::string& path)
{
std::size_t dot = path.rfind('.');
if (dot == std::string::npos) {
return "application/octet-stream";
}

// Lowercase the extension for a case-insensitive compare.
std::string ext = path.substr(dot + 1);
for (char& c : ext) {
if (c >= 'A' && c <= 'Z') {
c = static_cast<char>(c - 'A' + 'a');
}
}

if (ext == "html" || ext == "htm") {
return "text/html";
}
if (ext == "js") {
return "application/javascript";
}
if (ext == "css") {
return "text/css";
}
if (ext == "ico") {
return "image/x-icon";
}
if (ext == "json") {
return "application/json";
}
if (ext == "svg") {
return "image/svg+xml";
}

return "application/octet-stream";
}

} // namespace api
48 changes: 38 additions & 10 deletions firmware/main/CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -9,13 +9,41 @@ idf_component_register(
network time events esp_system api control
)

# Build-time littlefs image of the committed seed directory
# (firmware/storage_image), flashed to the `storage` partition — research.md
# D1. The function comes from the managed joltwallet/littlefs component's
# project_include.cmake and registers an ALL target, so build/storage.bin is
# produced on every `idf.py build` (CI verifies the file exists);
# FLASH_IN_PROJECT attaches the image to `idf.py flash` for a deterministic
# fresh-flash filesystem. Canonical placement per the component's example:
# a component CMakeLists, after idf_component_register (the relative path
# resolves against this directory).
littlefs_create_partition_image(storage ../storage_image FLASH_IN_PROJECT)
# Build-time littlefs image of the `storage` partition (research.md D1),
# flashed via FLASH_IN_PROJECT. Feature 010 (PR-10) adds the frontend: the
# image is now a build-time STAGING dir = the committed seed (firmware/
# storage_image) PLUS the gzipped web assets (firmware/web -> *.gz), produced
# by tools/gzip_assets.py. This keeps `data/` frozen (the adapted copies live
# in firmware/web) and generates the .gz at build time (no committed gzip),
# with no node/Tailwind toolchain in CI (only Python gzip). The staged dir is
# regenerated on every build so it always reflects the current web sources.
set(WEB_IMAGE_DIR "${CMAKE_CURRENT_BINARY_DIR}/web_image")
set(WEB_SRC_DIR "${CMAKE_CURRENT_SOURCE_DIR}/../web")
set(SEED_DIR "${CMAKE_CURRENT_SOURCE_DIR}/../storage_image")
set(GZIP_TOOL "${CMAKE_CURRENT_SOURCE_DIR}/../tools/gzip_assets.py")

# The Python interpreter IDF resolved for this build (same one littlefs uses).
idf_build_get_property(python PYTHON)

# Collect the web sources so the stage target re-runs when any of them change.
# CONFIGURE_DEPENDS re-globs on every build so an ADDED/REMOVED asset is picked
# up (not just edits to existing files).
file(GLOB_RECURSE WEB_SRC_FILES CONFIGURE_DEPENDS "${WEB_SRC_DIR}/*")

add_custom_command(
OUTPUT "${WEB_IMAGE_DIR}/.stamp"
COMMENT "Staging littlefs image (seed + gzipped web assets)"
COMMAND ${CMAKE_COMMAND} -E rm -rf "${WEB_IMAGE_DIR}"
COMMAND ${CMAKE_COMMAND} -E make_directory "${WEB_IMAGE_DIR}"
COMMAND ${CMAKE_COMMAND} -E copy_directory "${SEED_DIR}" "${WEB_IMAGE_DIR}"
COMMAND ${python} "${GZIP_TOOL}" --src "${WEB_SRC_DIR}" --dst "${WEB_IMAGE_DIR}"
COMMAND ${CMAKE_COMMAND} -E touch "${WEB_IMAGE_DIR}/.stamp"
DEPENDS ${WEB_SRC_FILES} "${GZIP_TOOL}"
VERBATIM
)
add_custom_target(web_image_stage ALL DEPENDS "${WEB_IMAGE_DIR}/.stamp")

# Pack the staged dir. littlefs_create_partition_image registers its own ALL
# target; make it wait for the staging to finish.
littlefs_create_partition_image(storage "${WEB_IMAGE_DIR}" FLASH_IN_PROJECT
DEPENDS web_image_stage)
1 change: 1 addition & 0 deletions firmware/test_apps/host/main/CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ idf_component_register(
"test_api_serialize.cpp"
"test_api_requests.cpp"
"test_api_routes.cpp"
"test_api_static.cpp"
"test_watering_controller.cpp"
"test_reservoir.cpp"
# Compile-time board-contract TUs (T016): each defines one board
Expand Down
Loading
Loading