feat: create firmware skeleton and reproducible build
This commit is contained in:
@@ -111,7 +111,7 @@ passport were repaired and reverified before returning the milestone to
|
|||||||
|
|
||||||
## Milestone 002 — Firmware skeleton and reproducible build
|
## Milestone 002 — Firmware skeleton and reproducible build
|
||||||
|
|
||||||
**Status:** `NOT_STARTED`
|
**Status:** `COMPLETE`
|
||||||
|
|
||||||
### Objective
|
### Objective
|
||||||
|
|
||||||
@@ -146,9 +146,48 @@ builds reproducibly with the locked platform and partition configuration.
|
|||||||
|
|
||||||
### Evidence
|
### Evidence
|
||||||
|
|
||||||
Completion evidence is not yet available because this milestone has not
|
- On 2026-09-03, `/home/sasa/.platformio/penv/bin/pio project config` parsed
|
||||||
started. Required evidence includes clean build output, image sizes, asset
|
the updated configuration successfully with the locked platform, ESP-IDF,
|
||||||
reproducibility checks, and a target-board boot log.
|
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
|
## Milestone 003 — Wi-Fi provisioning and persistent configuration
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -0,0 +1,4 @@
|
|||||||
|
idf_component_register(
|
||||||
|
SRCS "maze32_health.c"
|
||||||
|
INCLUDE_DIRS "include"
|
||||||
|
)
|
||||||
@@ -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
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
#include "maze32_health.h"
|
||||||
|
|
||||||
|
#include <stddef.h>
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
idf_component_register(
|
||||||
|
SRCS "maze32_web.c"
|
||||||
|
INCLUDE_DIRS "include"
|
||||||
|
REQUIRES esp_http_server maze32_platform
|
||||||
|
)
|
||||||
@@ -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
|
||||||
@@ -0,0 +1,118 @@
|
|||||||
|
#include "maze32_web.h"
|
||||||
|
|
||||||
|
#include <stdio.h>
|
||||||
|
|
||||||
|
#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;
|
||||||
|
}
|
||||||
Binary file not shown.
@@ -18,5 +18,18 @@ board_build.filesystem = littlefs
|
|||||||
board_build.sdkconfig_defaults = sdkconfig.defaults
|
board_build.sdkconfig_defaults = sdkconfig.defaults
|
||||||
board_upload.flash_size = 4MB
|
board_upload.flash_size = 4MB
|
||||||
lib_deps = https://github.com/joltwallet/esp_littlefs.git#v1.20.4
|
lib_deps = https://github.com/joltwallet/esp_littlefs.git#v1.20.4
|
||||||
|
build_flags =
|
||||||
|
-DCONFIG_LITTLEFS_MAX_PARTITIONS=1
|
||||||
|
-DCONFIG_LITTLEFS_PAGE_SIZE=256
|
||||||
|
-DCONFIG_LITTLEFS_OBJ_NAME_LEN=64
|
||||||
|
-DCONFIG_LITTLEFS_READ_SIZE=128
|
||||||
|
-DCONFIG_LITTLEFS_WRITE_SIZE=128
|
||||||
|
-DCONFIG_LITTLEFS_LOOKAHEAD_SIZE=128
|
||||||
|
-DCONFIG_LITTLEFS_CACHE_SIZE=512
|
||||||
|
-DCONFIG_LITTLEFS_BLOCK_CYCLES=512
|
||||||
|
-DCONFIG_LITTLEFS_MALLOC_STRATEGY_DEFAULT=1
|
||||||
|
extra_scripts = post:scripts/platformio_build.py
|
||||||
monitor_port = /dev/ttyACM0
|
monitor_port = /dev/ttyACM0
|
||||||
monitor_speed = 115200
|
monitor_speed = 115200
|
||||||
|
monitor_dtr = 0
|
||||||
|
monitor_rts = 0
|
||||||
|
|||||||
Binary file not shown.
Executable
+75
@@ -0,0 +1,75 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Generate deterministic gzip-compressed LittleFS web assets."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import gzip
|
||||||
|
import os
|
||||||
|
import shutil
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
SUPPORTED_SUFFIXES = {".css", ".html", ".ico", ".js", ".json", ".svg"}
|
||||||
|
|
||||||
|
|
||||||
|
def generate_assets(source_directory: Path, output_directory: Path) -> 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())
|
||||||
@@ -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",
|
||||||
|
)
|
||||||
Executable
+52
@@ -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())
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
#define _GNU_SOURCE
|
||||||
|
|
||||||
|
#include <stddef.h>
|
||||||
|
#include <sys/syscall.h>
|
||||||
|
#include <sys/time.h>
|
||||||
|
#include <time.h>
|
||||||
|
#include <unistd.h>
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
Executable
+15
@@ -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"
|
||||||
Executable
+54
@@ -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())
|
||||||
@@ -1,2 +1,4 @@
|
|||||||
CONFIG_ESPTOOLPY_FLASHSIZE_4MB=y
|
CONFIG_ESPTOOLPY_FLASHSIZE_4MB=y
|
||||||
CONFIG_LWIP_MAX_SOCKETS=16
|
CONFIG_LWIP_MAX_SOCKETS=16
|
||||||
|
CONFIG_ESP_CONSOLE_USB_SERIAL_JTAG=y
|
||||||
|
CONFIG_ESP_CONSOLE_SECONDARY_NONE=y
|
||||||
|
|||||||
@@ -1942,28 +1942,17 @@ CONFIG_ESP_CRYPTO_DPA_PROTECTION_LEVEL=1
|
|||||||
#
|
#
|
||||||
# ESP-STDIO
|
# ESP-STDIO
|
||||||
#
|
#
|
||||||
# default:
|
# CONFIG_ESP_CONSOLE_UART_DEFAULT is not set
|
||||||
CONFIG_ESP_CONSOLE_UART_DEFAULT=y
|
CONFIG_ESP_CONSOLE_USB_SERIAL_JTAG=y
|
||||||
# default:
|
|
||||||
# CONFIG_ESP_CONSOLE_USB_SERIAL_JTAG is not set
|
|
||||||
# default:
|
|
||||||
# CONFIG_ESP_CONSOLE_UART_CUSTOM is not set
|
# CONFIG_ESP_CONSOLE_UART_CUSTOM is not set
|
||||||
# default:
|
|
||||||
# CONFIG_ESP_CONSOLE_NONE is not set
|
# CONFIG_ESP_CONSOLE_NONE is not set
|
||||||
# default:
|
CONFIG_ESP_CONSOLE_SECONDARY_NONE=y
|
||||||
# CONFIG_ESP_CONSOLE_SECONDARY_NONE is not set
|
|
||||||
# default:
|
|
||||||
CONFIG_ESP_CONSOLE_SECONDARY_USB_SERIAL_JTAG=y
|
|
||||||
# default:
|
# default:
|
||||||
CONFIG_ESP_CONSOLE_USB_SERIAL_JTAG_ENABLED=y
|
CONFIG_ESP_CONSOLE_USB_SERIAL_JTAG_ENABLED=y
|
||||||
# default:
|
# default:
|
||||||
CONFIG_ESP_CONSOLE_UART=y
|
CONFIG_ESP_CONSOLE_UART_NUM=-1
|
||||||
# default:
|
# default:
|
||||||
CONFIG_ESP_CONSOLE_UART_NUM=0
|
CONFIG_ESP_CONSOLE_ROM_SERIAL_PORT_NUM=3
|
||||||
# default:
|
|
||||||
CONFIG_ESP_CONSOLE_ROM_SERIAL_PORT_NUM=0
|
|
||||||
# default:
|
|
||||||
CONFIG_ESP_CONSOLE_UART_BAUDRATE=115200
|
|
||||||
# end of ESP-STDIO
|
# end of ESP-STDIO
|
||||||
|
|
||||||
#
|
#
|
||||||
@@ -3709,13 +3698,11 @@ CONFIG_ESP32_PHY_MAX_TX_POWER=20
|
|||||||
# CONFIG_REDUCE_PHY_TX_POWER is not set
|
# CONFIG_REDUCE_PHY_TX_POWER is not set
|
||||||
# CONFIG_ESP32_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_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_CUSTOM is not set
|
||||||
# CONFIG_CONSOLE_UART_NONE is not set
|
# CONFIG_CONSOLE_UART_NONE is not set
|
||||||
# CONFIG_ESP_CONSOLE_UART_NONE is not set
|
# CONFIG_ESP_CONSOLE_UART_NONE is not set
|
||||||
CONFIG_CONSOLE_UART=y
|
CONFIG_CONSOLE_UART_NUM=-1
|
||||||
CONFIG_CONSOLE_UART_NUM=0
|
|
||||||
CONFIG_CONSOLE_UART_BAUDRATE=115200
|
|
||||||
CONFIG_ESP_SYSTEM_PMP_IDRAM_SPLIT=y
|
CONFIG_ESP_SYSTEM_PMP_IDRAM_SPLIT=y
|
||||||
CONFIG_ESP_SYSTEM_MEMPROT_FEATURE_VIA_TEE=y
|
CONFIG_ESP_SYSTEM_MEMPROT_FEATURE_VIA_TEE=y
|
||||||
CONFIG_ESP_SYSTEM_MEMPROT_FEATURE=y
|
CONFIG_ESP_SYSTEM_MEMPROT_FEATURE=y
|
||||||
|
|||||||
+4
-6
@@ -1,6 +1,4 @@
|
|||||||
# This file was automatically generated for projects
|
idf_component_register(
|
||||||
# without default 'CMakeLists.txt' file.
|
SRCS "main.c"
|
||||||
|
REQUIRES esp_event esp_netif maze32_platform maze32_web
|
||||||
FILE(GLOB_RECURSE app_sources ${CMAKE_SOURCE_DIR}/src/*.*)
|
)
|
||||||
|
|
||||||
idf_component_register(SRCS ${app_sources})
|
|
||||||
|
|||||||
+83
-1
@@ -1 +1,83 @@
|
|||||||
void app_main() {}
|
#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));
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,33 @@
|
|||||||
|
#include "maze32_health.h"
|
||||||
|
|
||||||
|
#include <assert.h>
|
||||||
|
#include <stdio.h>
|
||||||
|
#include <string.h>
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
0.2.0
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||||
|
<title>maze32</title>
|
||||||
|
<style>
|
||||||
|
:root { color-scheme: dark; font-family: system-ui, sans-serif; }
|
||||||
|
body { display: grid; min-height: 100vh; margin: 0; place-items: center; background: #10151d; color: #e7edf5; }
|
||||||
|
main { width: min(32rem, calc(100% - 3rem)); padding: 2rem; border: 1px solid #344255; border-radius: 1rem; background: #18212d; }
|
||||||
|
h1 { margin-top: 0; letter-spacing: .08em; }
|
||||||
|
output { color: #79dfa3; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<main>
|
||||||
|
<h1>maze32</h1>
|
||||||
|
<p>The firmware and static application are running.</p>
|
||||||
|
<p>Device health: <output id="health">checking</output></p>
|
||||||
|
</main>
|
||||||
|
<script>
|
||||||
|
const output = document.querySelector('#health');
|
||||||
|
fetch('/health', { cache: 'no-store' })
|
||||||
|
.then(response => response.ok ? response.json() : Promise.reject(new Error('Health request failed')))
|
||||||
|
.then(health => { output.textContent = `${health.status} · version ${health.version}`; })
|
||||||
|
.catch(() => { output.textContent = 'unavailable'; });
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
Reference in New Issue
Block a user