From 9c274800b525fcd999e9ca4603c87464ce88b4c8 Mon Sep 17 00:00:00 2001 From: sasa Date: Thu, 3 Sep 2026 01:02:40 +0300 Subject: [PATCH] feat: create firmware skeleton and reproducible build --- PLANS.md | 47 ++++++- README.md | 66 ++++++++++ components/maze32_platform/CMakeLists.txt | 4 + .../maze32_platform/include/maze32_health.h | 30 +++++ components/maze32_platform/maze32_health.c | 45 +++++++ components/maze32_web/CMakeLists.txt | 5 + components/maze32_web/include/maze32_web.h | 18 +++ components/maze32_web/maze32_web.c | 118 ++++++++++++++++++ data/index.html.gz | Bin 0 -> 683 bytes platformio.ini | 13 ++ .../generate_web_assets.cpython-313.pyc | Bin 0 -> 4176 bytes scripts/generate_web_assets.py | 75 +++++++++++ scripts/platformio_build.py | 93 ++++++++++++++ scripts/reproducible_mklittlefs.py | 52 ++++++++ scripts/reproducible_time.c | 37 ++++++ scripts/run_host_tests.sh | 15 +++ scripts/verify_web_assets.py | 54 ++++++++ sdkconfig.defaults | 2 + sdkconfig.esp32-c6-devkitm-1 | 27 ++-- src/CMakeLists.txt | 10 +- src/main.c | 84 ++++++++++++- test/host/test_health.c | 33 +++++ version.txt | 1 + web/index.html | 29 +++++ 24 files changed, 827 insertions(+), 31 deletions(-) create mode 100644 components/maze32_platform/CMakeLists.txt create mode 100644 components/maze32_platform/include/maze32_health.h create mode 100644 components/maze32_platform/maze32_health.c create mode 100644 components/maze32_web/CMakeLists.txt create mode 100644 components/maze32_web/include/maze32_web.h create mode 100644 components/maze32_web/maze32_web.c create mode 100644 data/index.html.gz create mode 100644 scripts/__pycache__/generate_web_assets.cpython-313.pyc create mode 100755 scripts/generate_web_assets.py create mode 100644 scripts/platformio_build.py create mode 100755 scripts/reproducible_mklittlefs.py create mode 100644 scripts/reproducible_time.c create mode 100755 scripts/run_host_tests.sh create mode 100755 scripts/verify_web_assets.py create mode 100644 test/host/test_health.c create mode 100644 version.txt create mode 100644 web/index.html diff --git a/PLANS.md b/PLANS.md index 15cea81..a4af137 100644 --- a/PLANS.md +++ b/PLANS.md @@ -111,7 +111,7 @@ passport were repaired and reverified before returning the milestone to ## Milestone 002 — Firmware skeleton and reproducible build -**Status:** `NOT_STARTED` +**Status:** `COMPLETE` ### Objective @@ -146,9 +146,48 @@ builds reproducibly with the locked platform and partition configuration. ### Evidence -Completion evidence is not yet available because this milestone has not -started. Required evidence includes clean build output, image sizes, asset -reproducibility checks, and a target-board boot log. +- On 2026-09-03, `/home/sasa/.platformio/penv/bin/pio project config` parsed + the updated configuration successfully with the locked platform, ESP-IDF, + 4 MB Flash, root partition table, LittleFS, pinned `esp_littlefs`, bounded + LittleFS settings, and the build hook. +- On 2026-09-03, `./scripts/run_host_tests.sh` reported + `health state tests: PASS` and + `web asset reproducibility: PASS + (611eacdb06b81bdb064a9074352ff4c6f82d3af3ad3d01e65705387744b86b21)`. +- After `/home/sasa/.platformio/penv/bin/pio run --target clean`, running + `/home/sasa/.platformio/penv/bin/pio run` on 2026-09-03 completed with + `[SUCCESS]` in 43.10 seconds. PlatformIO reported 14,084 bytes of RAM and + 350,232 of 2,097,152 application bytes in use. The independent build hook + reported `firmware.bin uses 350864/2097152 bytes in partition factory`. +- Running `/home/sasa/.platformio/penv/bin/pio run --target buildfs` on + 2026-09-03 regenerated `data/index.html.gz`, built the LittleFS image, and + reported `[SUCCESS]`. The independent build hook reported + `littlefs.bin uses 2031616/2031616 bytes in partition littlefs`; the image is + intentionally emitted at full partition size and contains the 683-byte + compressed application asset. +- Two LittleFS builds separated by a clean produced the identical SHA-256 + `ad203373be69f4541106b2d0dfd7096adb8132967ab49e2d23c391ede8f4d4f5`. + The generated gzip asset remained + `950c60077c381d4484c11b43762bc5c8378587b6cd91924c344cfbc406771bb9`. + Listing the image showed both file and image creation times normalized to + 1970-01-01, demonstrating deterministic compression and packaging. +- `strings .pio/build/esp32-c6-devkitm-1/firmware.bin` found firmware version + `0.2.0` and the startup log formats for version, LittleFS capacity, and health + state. This verifies their presence in the built artifact but is not a + substitute for target-board execution. +- On 2026-09-03, `/home/sasa/.platformio/penv/bin/pio run --target uploadfs + --upload-port /dev/ttyACM0` wrote and hash-verified the 2,031,616-byte + LittleFS image at `0x210000`. A subsequent application upload identified the + target as `ESP32-C6FH4 (QFN32) (revision v0.2)` with embedded 4 MB Flash and + USB Serial/JTAG, wrote the 350,864-byte application image, and verified its + hash. +- A target-board boot capture on 2026-09-03 reported `App version: 0.2.0`, + `SPI Flash Size : 4MB`, + `LittleFS mounted: total=2031616 bytes, used=12288 bytes`, + `HTTP server ready with static and health endpoints`, and + `Health state: serving`. The capture contained no application error after + startup. This supplies the physical-board evidence for acceptance criterion + 5. ## Milestone 003 — Wi-Fi provisioning and persistent configuration diff --git a/README.md b/README.md index e69de29..ff892de 100644 --- a/README.md +++ b/README.md @@ -0,0 +1,66 @@ +# maze32 + +maze32 is an ESP32-C6-hosted browser maze game. The current firmware skeleton +mounts a LittleFS image, serves a compressed static page, and exposes a bounded +`/health` response containing the firmware version and startup state. + +## Prerequisites + +- PlatformIO Core installed at `/home/sasa/.platformio/penv/bin/pio`; +- a C11 host compiler available as `cc`; +- Python 3. + +## Build and verify + +Parse the locked PlatformIO configuration: + +```sh +/home/sasa/.platformio/penv/bin/pio project config +``` + +Run the portable host tests and deterministic asset check: + +```sh +./scripts/run_host_tests.sh +``` + +Build the firmware. The PlatformIO hook checks the application image against +the 2 MiB `factory` partition: + +```sh +/home/sasa/.platformio/penv/bin/pio run +``` + +Generate compressed assets explicitly when needed: + +```sh +/home/sasa/.platformio/penv/bin/python scripts/generate_web_assets.py +``` + +Build the LittleFS image. Asset generation runs automatically before packaging, +and the PlatformIO hook checks the image against the 1,984 KiB `littlefs` +partition: + +```sh +/home/sasa/.platformio/penv/bin/pio run --target buildfs +``` + +## Device commands + +Upload the application and LittleFS image: + +```sh +/home/sasa/.platformio/penv/bin/pio run --target upload +/home/sasa/.platformio/penv/bin/pio run --target uploadfs +``` + +Monitor startup at 115200 baud: + +```sh +/home/sasa/.platformio/penv/bin/pio device monitor +``` + +A successful boot reports the firmware version, LittleFS capacity and use, the +HTTP service startup, and health state `serving`. Network provisioning is part +of a later milestone; this milestone only establishes the HTTP/static service +that will be attached to that network lifecycle. diff --git a/components/maze32_platform/CMakeLists.txt b/components/maze32_platform/CMakeLists.txt new file mode 100644 index 0000000..5438c61 --- /dev/null +++ b/components/maze32_platform/CMakeLists.txt @@ -0,0 +1,4 @@ +idf_component_register( + SRCS "maze32_health.c" + INCLUDE_DIRS "include" +) diff --git a/components/maze32_platform/include/maze32_health.h b/components/maze32_platform/include/maze32_health.h new file mode 100644 index 0000000..ea00373 --- /dev/null +++ b/components/maze32_platform/include/maze32_health.h @@ -0,0 +1,30 @@ +#ifndef MAZE32_HEALTH_H +#define MAZE32_HEALTH_H + +#ifdef __cplusplus +extern "C" { +#endif + +typedef enum { + MAZE32_HEALTH_BOOTING = 0, + MAZE32_HEALTH_FILESYSTEM_READY, + MAZE32_HEALTH_SERVING, + MAZE32_HEALTH_ERROR, +} maze32_health_state_t; + +typedef struct { + const char *version; + maze32_health_state_t state; + int error_code; +} maze32_health_t; + +void maze32_health_init(maze32_health_t *health, const char *version); +void maze32_health_set(maze32_health_t *health, maze32_health_state_t state, int error_code); +const char *maze32_health_state_name(maze32_health_state_t state); +int maze32_health_is_healthy(const maze32_health_t *health); + +#ifdef __cplusplus +} +#endif + +#endif diff --git a/components/maze32_platform/maze32_health.c b/components/maze32_platform/maze32_health.c new file mode 100644 index 0000000..8da43ce --- /dev/null +++ b/components/maze32_platform/maze32_health.c @@ -0,0 +1,45 @@ +#include "maze32_health.h" + +#include + +void maze32_health_init(maze32_health_t *health, const char *version) +{ + if (health == NULL) { + return; + } + + health->version = version; + health->state = MAZE32_HEALTH_BOOTING; + health->error_code = 0; +} + +void maze32_health_set(maze32_health_t *health, maze32_health_state_t state, int error_code) +{ + if (health == NULL) { + return; + } + + health->state = state; + health->error_code = error_code; +} + +const char *maze32_health_state_name(maze32_health_state_t state) +{ + switch (state) { + case MAZE32_HEALTH_BOOTING: + return "booting"; + case MAZE32_HEALTH_FILESYSTEM_READY: + return "filesystem_ready"; + case MAZE32_HEALTH_SERVING: + return "serving"; + case MAZE32_HEALTH_ERROR: + return "error"; + default: + return "unknown"; + } +} + +int maze32_health_is_healthy(const maze32_health_t *health) +{ + return health != NULL && health->state == MAZE32_HEALTH_SERVING; +} diff --git a/components/maze32_web/CMakeLists.txt b/components/maze32_web/CMakeLists.txt new file mode 100644 index 0000000..56d6e35 --- /dev/null +++ b/components/maze32_web/CMakeLists.txt @@ -0,0 +1,5 @@ +idf_component_register( + SRCS "maze32_web.c" + INCLUDE_DIRS "include" + REQUIRES esp_http_server maze32_platform +) diff --git a/components/maze32_web/include/maze32_web.h b/components/maze32_web/include/maze32_web.h new file mode 100644 index 0000000..bbe4fef --- /dev/null +++ b/components/maze32_web/include/maze32_web.h @@ -0,0 +1,18 @@ +#ifndef MAZE32_WEB_H +#define MAZE32_WEB_H + +#include "esp_err.h" +#include "esp_http_server.h" +#include "maze32_health.h" + +#ifdef __cplusplus +extern "C" { +#endif + +esp_err_t maze32_web_start(httpd_handle_t *server, maze32_health_t *health); + +#ifdef __cplusplus +} +#endif + +#endif diff --git a/components/maze32_web/maze32_web.c b/components/maze32_web/maze32_web.c new file mode 100644 index 0000000..570d315 --- /dev/null +++ b/components/maze32_web/maze32_web.c @@ -0,0 +1,118 @@ +#include "maze32_web.h" + +#include + +#include "esp_log.h" + +#define MAZE32_STATIC_CHUNK_SIZE 1024 + +static const char *TAG = "maze32_web"; +static const char *INDEX_PATH = "/littlefs/index.html.gz"; + +static esp_err_t send_index(httpd_req_t *request) +{ + FILE *file = fopen(INDEX_PATH, "rb"); + if (file == NULL) { + ESP_LOGE(TAG, "Static asset is unavailable: %s", INDEX_PATH); + httpd_resp_send_err(request, HTTPD_500_INTERNAL_SERVER_ERROR, + "Static application is unavailable"); + return ESP_FAIL; + } + + httpd_resp_set_type(request, "text/html; charset=utf-8"); + httpd_resp_set_hdr(request, "Content-Encoding", "gzip"); + httpd_resp_set_hdr(request, "Cache-Control", "no-cache"); + + char buffer[MAZE32_STATIC_CHUNK_SIZE]; + size_t bytes_read; + esp_err_t result = ESP_OK; + while ((bytes_read = fread(buffer, 1, sizeof(buffer), file)) > 0) { + result = httpd_resp_send_chunk(request, buffer, bytes_read); + if (result != ESP_OK) { + break; + } + } + + if (ferror(file) != 0) { + ESP_LOGE(TAG, "Failed while reading static asset: %s", INDEX_PATH); + result = ESP_FAIL; + } + fclose(file); + + if (result == ESP_OK) { + result = httpd_resp_send_chunk(request, NULL, 0); + } + return result; +} + +static esp_err_t send_health(httpd_req_t *request) +{ + const maze32_health_t *health = request->user_ctx; + char response[192]; + int length = snprintf(response, sizeof(response), + "{\"status\":\"%s\",\"healthy\":%s,\"version\":\"%s\",\"errorCode\":%d}", + maze32_health_state_name(health->state), + maze32_health_is_healthy(health) ? "true" : "false", + health->version, health->error_code); + if (length < 0 || (size_t)length >= sizeof(response)) { + httpd_resp_send_err(request, HTTPD_500_INTERNAL_SERVER_ERROR, + "Health response exceeded its fixed buffer"); + return ESP_FAIL; + } + + httpd_resp_set_type(request, "application/json"); + httpd_resp_set_hdr(request, "Cache-Control", "no-store"); + return httpd_resp_send(request, response, length); +} + +esp_err_t maze32_web_start(httpd_handle_t *server, maze32_health_t *health) +{ + if (server == NULL || health == NULL) { + return ESP_ERR_INVALID_ARG; + } + + httpd_config_t configuration = HTTPD_DEFAULT_CONFIG(); + configuration.max_uri_handlers = 3; + configuration.max_open_sockets = 7; + configuration.lru_purge_enable = true; + + esp_err_t result = httpd_start(server, &configuration); + if (result != ESP_OK) { + return result; + } + + const httpd_uri_t root = { + .uri = "/", + .method = HTTP_GET, + .handler = send_index, + .user_ctx = NULL, + }; + const httpd_uri_t index = { + .uri = "/index.html", + .method = HTTP_GET, + .handler = send_index, + .user_ctx = NULL, + }; + const httpd_uri_t health_uri = { + .uri = "/health", + .method = HTTP_GET, + .handler = send_health, + .user_ctx = health, + }; + + result = httpd_register_uri_handler(*server, &root); + if (result == ESP_OK) { + result = httpd_register_uri_handler(*server, &index); + } + if (result == ESP_OK) { + result = httpd_register_uri_handler(*server, &health_uri); + } + if (result != ESP_OK) { + httpd_stop(*server); + *server = NULL; + return result; + } + + ESP_LOGI(TAG, "HTTP server ready with static and health endpoints"); + return ESP_OK; +} diff --git a/data/index.html.gz b/data/index.html.gz new file mode 100644 index 0000000000000000000000000000000000000000..3a0e6ddda1b57858deca4535b0b511e59cf0c083 GIT binary patch literal 683 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 ew>ZB<2fuK6e{ud-7nWQ*3(U@EG-5*LS^onWq list[Path]: + source_directory = source_directory.resolve() + output_directory = output_directory.resolve() + if not source_directory.is_dir(): + raise ValueError(f"Web source directory does not exist: {source_directory}") + if source_directory == output_directory: + raise ValueError("Web source and output directories must differ") + + source_files = sorted( + path for path in source_directory.rglob("*") + if path.is_file() and path.suffix.lower() in SUPPORTED_SUFFIXES + ) + if not source_files: + raise ValueError(f"No supported web assets found in {source_directory}") + + if output_directory.exists(): + shutil.rmtree(output_directory) + output_directory.mkdir(parents=True) + + generated_files: list[Path] = [] + for source_path in source_files: + relative_path = source_path.relative_to(source_directory) + output_path = output_directory / f"{relative_path}.gz" + output_path.parent.mkdir(parents=True, exist_ok=True) + with source_path.open("rb") as source, output_path.open("wb") as output: + with gzip.GzipFile( + filename="", + mode="wb", + compresslevel=9, + fileobj=output, + mtime=0, + ) as compressed: + shutil.copyfileobj(source, compressed) + os.utime(output_path, ns=(0, 0)) + generated_files.append(output_path) + + for directory in sorted( + (path for path in output_directory.rglob("*") if path.is_dir()), + reverse=True, + ): + os.utime(directory, ns=(0, 0)) + os.utime(output_directory, ns=(0, 0)) + + return generated_files + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--source", type=Path, default=Path("web")) + parser.add_argument("--output", type=Path, default=Path("data")) + arguments = parser.parse_args() + + generated_files = generate_assets(arguments.source, arguments.output) + for path in generated_files: + print(path) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/platformio_build.py b/scripts/platformio_build.py new file mode 100644 index 0000000..4fea634 --- /dev/null +++ b/scripts/platformio_build.py @@ -0,0 +1,93 @@ +"""PlatformIO hooks for deterministic assets and partition size limits.""" + +from __future__ import annotations + +import csv +import subprocess +from pathlib import Path + +Import("env") + + +PROJECT_DIRECTORY = Path(env.subst("$PROJECT_DIR")) +BUILD_DIRECTORY = Path(env.subst("$BUILD_DIR")) +PARTITION_FILE = PROJECT_DIRECTORY / "partitions.csv" +ASSET_SCRIPT = PROJECT_DIRECTORY / "scripts" / "generate_web_assets.py" +FILESYSTEM_WRAPPER = PROJECT_DIRECTORY / "scripts" / "reproducible_mklittlefs.py" + + +def parse_size(value: str) -> int: + value = value.strip() + suffixes = {"K": 1024, "M": 1024 * 1024} + if value[-1:].upper() in suffixes: + return int(value[:-1], 0) * suffixes[value[-1].upper()] + return int(value, 0) + + +def partition_sizes() -> dict[str, int]: + sizes: dict[str, int] = {} + with PARTITION_FILE.open(newline="", encoding="utf-8") as partition_stream: + for row in csv.reader(partition_stream): + if not row or row[0].lstrip().startswith("#"): + continue + if len(row) < 5: + raise RuntimeError(f"Invalid partition row: {row}") + sizes[row[0].strip()] = parse_size(row[4]) + return sizes + + +def generate_assets(source, target, env) -> None: + command = [ + env.subst("$PYTHONEXE"), + str(ASSET_SCRIPT), + "--source", + str(PROJECT_DIRECTORY / "web"), + "--output", + str(PROJECT_DIRECTORY / "data"), + ] + subprocess.run(command, check=True) + + +def enforce_size(path: Path, partition_name: str) -> None: + limits = partition_sizes() + if partition_name not in limits: + raise RuntimeError(f"Missing required partition: {partition_name}") + actual_size = path.stat().st_size + maximum_size = limits[partition_name] + if actual_size > maximum_size: + raise RuntimeError( + f"{path.name} is {actual_size} bytes and exceeds the " + f"{partition_name} partition limit of {maximum_size} bytes" + ) + print( + f"maze32 size check: {path.name} uses {actual_size}/{maximum_size} bytes " + f"in partition {partition_name}" + ) + + +def check_application(source, target, env) -> None: + enforce_size(application_image, "factory") + + +def check_filesystem(source, target, env) -> None: + enforce_size(filesystem_image, "littlefs") + + +filesystem_image = BUILD_DIRECTORY / "littlefs.bin" +application_image = BUILD_DIRECTORY / f"{env.subst('$PROGNAME')}.bin" +real_filesystem_tool = env.subst("$MKFSTOOL") + +env["ENV"]["MAZE32_REAL_MKFSTOOL"] = real_filesystem_tool +env["ENV"]["MAZE32_BUILD_DIR"] = str(BUILD_DIRECTORY) +env.Replace(MKFSTOOL=str(FILESYSTEM_WRAPPER)) + +env.AddPreAction("$BUILD_DIR/littlefs.bin", generate_assets) +env.AddPostAction("$BUILD_DIR/littlefs.bin", check_filesystem) +env.AddPostAction("$BUILD_DIR/${PROGNAME}.bin", check_application) +env.AddCustomTarget( + "webassets", + None, + generate_assets, + title="Generate deterministic web assets", + description="Compress web sources into the LittleFS data directory", +) diff --git a/scripts/reproducible_mklittlefs.py b/scripts/reproducible_mklittlefs.py new file mode 100755 index 0000000..73c8a1f --- /dev/null +++ b/scripts/reproducible_mklittlefs.py @@ -0,0 +1,52 @@ +#!/usr/bin/env python3 +"""Run mklittlefs with a fixed wall clock for reproducible images.""" + +from __future__ import annotations + +import os +import shutil +import subprocess +import sys +from pathlib import Path + + +def build_time_override(output_path: Path) -> None: + source_path = Path(__file__).with_name("reproducible_time.c") + compiler = shutil.which("cc") + if compiler is None: + raise RuntimeError("A host C compiler named 'cc' is required") + subprocess.run( + [ + compiler, + "-shared", + "-fPIC", + "-O2", + str(source_path), + "-o", + str(output_path), + ], + check=True, + ) + + +def main() -> int: + real_tool = os.environ.get("MAZE32_REAL_MKFSTOOL") + build_directory = os.environ.get("MAZE32_BUILD_DIR") + if not real_tool or not build_directory: + raise RuntimeError("PlatformIO did not provide the filesystem tool environment") + + override_path = Path(build_directory) / "reproducible_time.so" + override_path.parent.mkdir(parents=True, exist_ok=True) + build_time_override(override_path) + + environment = os.environ.copy() + existing_preload = environment.get("LD_PRELOAD") + environment["LD_PRELOAD"] = ( + f"{override_path}:{existing_preload}" if existing_preload else str(override_path) + ) + completed = subprocess.run([real_tool, *sys.argv[1:]], env=environment) + return completed.returncode + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/reproducible_time.c b/scripts/reproducible_time.c new file mode 100644 index 0000000..b44cf76 --- /dev/null +++ b/scripts/reproducible_time.c @@ -0,0 +1,37 @@ +#define _GNU_SOURCE + +#include +#include +#include +#include +#include + +static const time_t FIXED_TIME = 0; + +time_t time(time_t *result) +{ + if (result != NULL) { + *result = FIXED_TIME; + } + return FIXED_TIME; +} + +int gettimeofday(struct timeval *value, void *timezone) +{ + (void)timezone; + if (value != NULL) { + value->tv_sec = FIXED_TIME; + value->tv_usec = 0; + } + return 0; +} + +int clock_gettime(clockid_t clock_id, struct timespec *value) +{ + if (clock_id == CLOCK_REALTIME) { + value->tv_sec = FIXED_TIME; + value->tv_nsec = 0; + return 0; + } + return (int)syscall(SYS_clock_gettime, clock_id, value); +} diff --git a/scripts/run_host_tests.sh b/scripts/run_host_tests.sh new file mode 100755 index 0000000..cec5196 --- /dev/null +++ b/scripts/run_host_tests.sh @@ -0,0 +1,15 @@ +#!/usr/bin/env sh +set -eu + +project_directory=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd) +test_directory=$(mktemp -d "${TMPDIR:-/tmp}/maze32-host-tests.XXXXXX") +trap 'rm -rf "$test_directory"' EXIT HUP INT TERM + +cc -std=c11 -Wall -Wextra -Werror -pedantic \ + -I"$project_directory/components/maze32_platform/include" \ + "$project_directory/components/maze32_platform/maze32_health.c" \ + "$project_directory/test/host/test_health.c" \ + -o "$test_directory/test_health" + +"$test_directory/test_health" +python3 "$project_directory/scripts/verify_web_assets.py" diff --git a/scripts/verify_web_assets.py b/scripts/verify_web_assets.py new file mode 100755 index 0000000..5349525 --- /dev/null +++ b/scripts/verify_web_assets.py @@ -0,0 +1,54 @@ +#!/usr/bin/env python3 +"""Verify deterministic web generation and the checked-in generated assets.""" + +from __future__ import annotations + +import gzip +import hashlib +import tempfile +from pathlib import Path + +from generate_web_assets import generate_assets + + +PROJECT_DIRECTORY = Path(__file__).resolve().parent.parent + + +def tree_digest(directory: Path) -> str: + digest = hashlib.sha256() + for path in sorted(item for item in directory.rglob("*") if item.is_file()): + digest.update(path.relative_to(directory).as_posix().encode("utf-8")) + digest.update(b"\0") + digest.update(path.read_bytes()) + return digest.hexdigest() + + +def main() -> int: + source_directory = PROJECT_DIRECTORY / "web" + checked_in_directory = PROJECT_DIRECTORY / "data" + with tempfile.TemporaryDirectory(prefix="maze32-assets-") as temporary: + temporary_directory = Path(temporary) + first = temporary_directory / "first" + second = temporary_directory / "second" + generate_assets(source_directory, first) + generate_assets(source_directory, second) + + first_digest = tree_digest(first) + second_digest = tree_digest(second) + checked_in_digest = tree_digest(checked_in_directory) + if first_digest != second_digest: + raise AssertionError("Repeated web-asset generation produced different output") + if first_digest != checked_in_digest: + raise AssertionError("Generated web assets are stale; regenerate the data directory") + + index_source = (source_directory / "index.html").read_bytes() + index_compressed = (first / "index.html.gz").read_bytes() + if gzip.decompress(index_compressed) != index_source: + raise AssertionError("Compressed index does not reproduce its source") + + print(f"web asset reproducibility: PASS ({first_digest})") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/sdkconfig.defaults b/sdkconfig.defaults index 7d50b42..f93fe83 100644 --- a/sdkconfig.defaults +++ b/sdkconfig.defaults @@ -1,2 +1,4 @@ CONFIG_ESPTOOLPY_FLASHSIZE_4MB=y CONFIG_LWIP_MAX_SOCKETS=16 +CONFIG_ESP_CONSOLE_USB_SERIAL_JTAG=y +CONFIG_ESP_CONSOLE_SECONDARY_NONE=y diff --git a/sdkconfig.esp32-c6-devkitm-1 b/sdkconfig.esp32-c6-devkitm-1 index e1ea586..174593e 100644 --- a/sdkconfig.esp32-c6-devkitm-1 +++ b/sdkconfig.esp32-c6-devkitm-1 @@ -1942,28 +1942,17 @@ CONFIG_ESP_CRYPTO_DPA_PROTECTION_LEVEL=1 # # ESP-STDIO # -# default: -CONFIG_ESP_CONSOLE_UART_DEFAULT=y -# default: -# CONFIG_ESP_CONSOLE_USB_SERIAL_JTAG is not set -# default: +# CONFIG_ESP_CONSOLE_UART_DEFAULT is not set +CONFIG_ESP_CONSOLE_USB_SERIAL_JTAG=y # CONFIG_ESP_CONSOLE_UART_CUSTOM is not set -# default: # CONFIG_ESP_CONSOLE_NONE is not set -# default: -# CONFIG_ESP_CONSOLE_SECONDARY_NONE is not set -# default: -CONFIG_ESP_CONSOLE_SECONDARY_USB_SERIAL_JTAG=y +CONFIG_ESP_CONSOLE_SECONDARY_NONE=y # default: CONFIG_ESP_CONSOLE_USB_SERIAL_JTAG_ENABLED=y # default: -CONFIG_ESP_CONSOLE_UART=y +CONFIG_ESP_CONSOLE_UART_NUM=-1 # default: -CONFIG_ESP_CONSOLE_UART_NUM=0 -# default: -CONFIG_ESP_CONSOLE_ROM_SERIAL_PORT_NUM=0 -# default: -CONFIG_ESP_CONSOLE_UART_BAUDRATE=115200 +CONFIG_ESP_CONSOLE_ROM_SERIAL_PORT_NUM=3 # end of ESP-STDIO # @@ -3709,13 +3698,11 @@ CONFIG_ESP32_PHY_MAX_TX_POWER=20 # CONFIG_REDUCE_PHY_TX_POWER is not set # CONFIG_ESP32_REDUCE_PHY_TX_POWER is not set CONFIG_ESP_SYSTEM_PM_POWER_DOWN_CPU=y -CONFIG_CONSOLE_UART_DEFAULT=y +# CONFIG_CONSOLE_UART_DEFAULT is not set # CONFIG_CONSOLE_UART_CUSTOM is not set # CONFIG_CONSOLE_UART_NONE is not set # CONFIG_ESP_CONSOLE_UART_NONE is not set -CONFIG_CONSOLE_UART=y -CONFIG_CONSOLE_UART_NUM=0 -CONFIG_CONSOLE_UART_BAUDRATE=115200 +CONFIG_CONSOLE_UART_NUM=-1 CONFIG_ESP_SYSTEM_PMP_IDRAM_SPLIT=y CONFIG_ESP_SYSTEM_MEMPROT_FEATURE_VIA_TEE=y CONFIG_ESP_SYSTEM_MEMPROT_FEATURE=y diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index 483bc0c..87971cb 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -1,6 +1,4 @@ -# This file was automatically generated for projects -# without default 'CMakeLists.txt' file. - -FILE(GLOB_RECURSE app_sources ${CMAKE_SOURCE_DIR}/src/*.*) - -idf_component_register(SRCS ${app_sources}) +idf_component_register( + SRCS "main.c" + REQUIRES esp_event esp_netif maze32_platform maze32_web +) diff --git a/src/main.c b/src/main.c index 3b5b685..d2e4367 100644 --- a/src/main.c +++ b/src/main.c @@ -1 +1,83 @@ -void app_main() {} \ No newline at end of file +#include "esp_app_desc.h" +#include "esp_err.h" +#include "esp_event.h" +#include "esp_littlefs.h" +#include "esp_log.h" +#include "esp_netif.h" +#include "freertos/FreeRTOS.h" +#include "freertos/task.h" +#include "maze32_health.h" +#include "maze32_web.h" + +static const char *TAG = "maze32"; +static maze32_health_t health; +static httpd_handle_t web_server; + +static esp_err_t mount_littlefs(void) +{ + const esp_vfs_littlefs_conf_t configuration = { + .base_path = "/littlefs", + .partition_label = "littlefs", + .format_if_mount_failed = false, + .dont_mount = false, + }; + + esp_err_t result = esp_vfs_littlefs_register(&configuration); + if (result != ESP_OK) { + return result; + } + + size_t total_bytes = 0; + size_t used_bytes = 0; + result = esp_littlefs_info(configuration.partition_label, &total_bytes, &used_bytes); + if (result != ESP_OK) { + esp_vfs_littlefs_unregister(configuration.partition_label); + return result; + } + + ESP_LOGI(TAG, "LittleFS mounted: total=%u bytes, used=%u bytes", + (unsigned int)total_bytes, (unsigned int)used_bytes); + return ESP_OK; +} + +void app_main(void) +{ + const esp_app_desc_t *application = esp_app_get_description(); + maze32_health_init(&health, application->version); + + vTaskDelay(pdMS_TO_TICKS(1500)); + ESP_LOGI(TAG, "maze32 firmware version %s starting", application->version); + + esp_err_t result = mount_littlefs(); + if (result != ESP_OK) { + maze32_health_set(&health, MAZE32_HEALTH_ERROR, (int)result); + ESP_LOGE(TAG, "LittleFS mount failed: %s", esp_err_to_name(result)); + return; + } + + maze32_health_set(&health, MAZE32_HEALTH_FILESYSTEM_READY, 0); + + result = esp_netif_init(); + if (result != ESP_OK) { + maze32_health_set(&health, MAZE32_HEALTH_ERROR, (int)result); + ESP_LOGE(TAG, "TCP/IP stack initialization failed: %s", esp_err_to_name(result)); + return; + } + + result = esp_event_loop_create_default(); + if (result != ESP_OK) { + maze32_health_set(&health, MAZE32_HEALTH_ERROR, (int)result); + ESP_LOGE(TAG, "Default event loop initialization failed: %s", esp_err_to_name(result)); + return; + } + + maze32_health_set(&health, MAZE32_HEALTH_SERVING, 0); + result = maze32_web_start(&web_server, &health); + if (result != ESP_OK) { + maze32_health_set(&health, MAZE32_HEALTH_ERROR, (int)result); + ESP_LOGE(TAG, "HTTP server startup failed: %s", esp_err_to_name(result)); + return; + } + + ESP_LOGI(TAG, "Health state: %s", maze32_health_state_name(health.state)); +} diff --git a/test/host/test_health.c b/test/host/test_health.c new file mode 100644 index 0000000..47aa1ad --- /dev/null +++ b/test/host/test_health.c @@ -0,0 +1,33 @@ +#include "maze32_health.h" + +#include +#include +#include + +int main(void) +{ + maze32_health_t health; + maze32_health_init(&health, "test-version"); + + assert(strcmp(health.version, "test-version") == 0); + assert(health.state == MAZE32_HEALTH_BOOTING); + assert(strcmp(maze32_health_state_name(health.state), "booting") == 0); + assert(maze32_health_is_healthy(&health) == 0); + + maze32_health_set(&health, MAZE32_HEALTH_FILESYSTEM_READY, 0); + assert(strcmp(maze32_health_state_name(health.state), "filesystem_ready") == 0); + assert(maze32_health_is_healthy(&health) == 0); + + maze32_health_set(&health, MAZE32_HEALTH_SERVING, 0); + assert(strcmp(maze32_health_state_name(health.state), "serving") == 0); + assert(maze32_health_is_healthy(&health) == 1); + + maze32_health_set(&health, MAZE32_HEALTH_ERROR, 17); + assert(strcmp(maze32_health_state_name(health.state), "error") == 0); + assert(health.error_code == 17); + assert(maze32_health_is_healthy(&health) == 0); + assert(strcmp(maze32_health_state_name((maze32_health_state_t)99), "unknown") == 0); + + printf("health state tests: PASS\n"); + return 0; +} diff --git a/version.txt b/version.txt new file mode 100644 index 0000000..0ea3a94 --- /dev/null +++ b/version.txt @@ -0,0 +1 @@ +0.2.0 diff --git a/web/index.html b/web/index.html new file mode 100644 index 0000000..7a05ea1 --- /dev/null +++ b/web/index.html @@ -0,0 +1,29 @@ + + + + + + maze32 + + + +
+

maze32

+

The firmware and static application are running.

+

Device health: checking

+
+ + +