From ef141358e704e99b46b3d2493d72ad3154249497 Mon Sep 17 00:00:00 2001 From: sasa Date: Fri, 28 Aug 2026 00:35:27 +0300 Subject: [PATCH] feat: prove real-time transport, fallback, and state isolation --- PLANS.md | 15 ++++++- data/app.js | 41 +++++++++++++++++ dependencies.lock | 10 +++++ src/main.c | 111 +++++++++++++++++++++++++++++++++++++++++++++- 4 files changed, 174 insertions(+), 3 deletions(-) create mode 100644 dependencies.lock diff --git a/PLANS.md b/PLANS.md index 497f0e7..3bda3e1 100644 --- a/PLANS.md +++ b/PLANS.md @@ -218,7 +218,7 @@ If all criteria pass, set this milestone to `DONE`, append its execution record, ## Milestone 003 — Prove real-time transport, fallback, and state isolation -**Status:** `IN PROGRESS` +**Status:** `DONE` **Depends on:** Milestone 002 ### Objective @@ -253,11 +253,22 @@ If the asynchronous server library is unstable with the pinned Arduino Core, rep If all criteria pass, set this milestone to `DONE`, append its execution record, and change Milestone 004 from `BLOCKED` to `READY`. +### Execution record + +- Date: 2026-08-28 +- Board model and revision: ESP32-C6FH4 QFN32, revision v0.2; carrier board remains an unidentified SuperMini-style ESP32-C6 Mini. +- Toolchain and library versions: PlatformIO Core 6.1.19; `espressif32` 7.0.1; ESP-IDF 6.0.1; ESP-IDF built-in `esp_http_server` WebSocket support; pinned `esp_littlefs` 1.20.4. +- Result: PASS +- Evidence: Enabled `CONFIG_HTTPD_WS_SUPPORT` and added `/ws` plus versioned `GET /api/state?role=...`. The server holds only a monotonic public counter and version; role-specific payloads contain only `version`, `public_counter`, and `viewer`, never simulated hidden state. WebSocket text frames are capped at 64 bytes. The browser retries WebSocket after 1, 2, 5, and 10 seconds and polls `/api/state` every 2 seconds while disconnected. Firmware and LittleFS were flashed with hashes verified. A LAN WebSocket client received consecutive spectator frames for versions 3 and 4, and the player HTTP state response contained only public fields. +- Measurements: Build used 37,212 / 327,680 bytes RAM (11.4%) and 997,224 / 2,097,152 bytes flash (47.6%). +- Issues or deviations: `pio test -e esp32-c6-devkitm-1 --without-uploading` errored because `test/` contains no test suite. On 2026-08-28, the user confirmed completion of the four-client delivery, forced WebSocket interruption/fallback/recovery, malformed and oversized frame, role-payload inspection, and 30-minute stability checks; their physical observations are accepted as the required evidence. +- Next action: Milestone 004 is ready but is not started as part of this task. + --- ## Milestone 004 — Prove MVP capacity and make the Go/No-Go decision -**Status:** `BLOCKED` +**Status:** `READY` **Depends on:** Milestone 003 ### Objective diff --git a/data/app.js b/data/app.js index ec1deac..4d7a803 100644 --- a/data/app.js +++ b/data/app.js @@ -1,5 +1,10 @@ (() => { const target = document.querySelector('#health'); + const role = new URLSearchParams(location.search).get('role') || 'spectator'; + let socket; + let retryIndex = 0; + let retryTimer; + let pollTimer; const labels = { uptime_ms: 'Время работы (мс)', wifi_state: 'Wi-Fi', rssi_dbm: 'RSSI (дБм)', free_heap_bytes: 'Свободная память (байт)', min_free_heap_bytes: 'Мин. свободная память (байт)', @@ -27,6 +32,42 @@ } } + async function pollState() { + try { + const response = await fetch(`/api/state?role=${encodeURIComponent(role)}`, { cache: 'no-store' }); + if (!response.ok) throw new Error(`HTTP ${response.status}`); + const state = await response.json(); + document.title = `Морской бой — версия ${state.version}`; + } catch (_) {} + } + + function beginPolling() { + if (!pollTimer) pollTimer = window.setInterval(pollState, 2000); + pollState(); + } + + function stopPolling() { + if (pollTimer) window.clearInterval(pollTimer); + pollTimer = undefined; + } + + function connectSocket() { + socket = new WebSocket(`ws://${location.host}/ws?role=${encodeURIComponent(role)}`); + socket.onopen = () => { retryIndex = 0; stopPolling(); }; + socket.onmessage = event => { + const state = JSON.parse(event.data); + document.title = `Морской бой — версия ${state.version}`; + }; + socket.onclose = () => { + beginPolling(); + const delays = [1000, 2000, 5000, 10000]; + const delay = delays[Math.min(retryIndex++, delays.length - 1)]; + retryTimer = window.setTimeout(connectSocket, delay); + }; + socket.onerror = () => socket.close(); + } + refresh(); window.setInterval(refresh, 5000); + connectSocket(); })(); diff --git a/dependencies.lock b/dependencies.lock new file mode 100644 index 0000000..9fb8507 --- /dev/null +++ b/dependencies.lock @@ -0,0 +1,10 @@ +dependencies: + idf: + source: + type: idf + version: 6.0.1 +direct_dependencies: +- idf +manifest_hash: 38889ab999c3c022cdccd1d77c9d34b52f3ef7d3841674a4298208c1864ba68a +target: esp32c6 +version: 2.0.0 diff --git a/src/main.c b/src/main.c index 0055a4e..d2685b5 100644 --- a/src/main.c +++ b/src/main.c @@ -40,6 +40,106 @@ typedef struct { static portMUX_TYPE s_wifi_lock = portMUX_INITIALIZER_UNLOCKED; static wifi_state_t s_wifi_state = {0}; static bool s_littlefs_mounted; +static httpd_handle_t s_server; +static esp_timer_handle_t s_state_timer; +static uint32_t s_state_version; +static uint32_t s_public_counter; + +typedef enum { VIEW_PLAYER_1, VIEW_PLAYER_2, VIEW_SPECTATOR } view_role_t; +typedef struct { int fd; view_role_t role; } ws_client_t; +static ws_client_t s_ws_clients[4]; + +static view_role_t role_from_query(httpd_req_t *request) { + char query[48] = {0}; + char role[16] = {0}; + if (httpd_req_get_url_query_len(request) < sizeof(query) && + httpd_req_get_url_query_str(request, query, sizeof(query)) == ESP_OK && + httpd_query_key_value(query, "role", role, sizeof(role)) == ESP_OK) { + if (strcmp(role, "player1") == 0) return VIEW_PLAYER_1; + if (strcmp(role, "player2") == 0) return VIEW_PLAYER_2; + } + return VIEW_SPECTATOR; +} + +static const char *role_name(view_role_t role) { + return role == VIEW_PLAYER_1 ? "player1" : role == VIEW_PLAYER_2 ? "player2" : "spectator"; +} + +static void remember_ws_client(int fd, view_role_t role) { + for (size_t index = 0; index < sizeof(s_ws_clients) / sizeof(s_ws_clients[0]); ++index) { + if (s_ws_clients[index].fd == fd || s_ws_clients[index].fd == 0) { + s_ws_clients[index] = (ws_client_t){.fd = fd, .role = role}; + return; + } + } +} + +static view_role_t role_for_fd(int fd) { + for (size_t index = 0; index < sizeof(s_ws_clients) / sizeof(s_ws_clients[0]); ++index) { + if (s_ws_clients[index].fd == fd) return s_ws_clients[index].role; + } + return VIEW_SPECTATOR; +} + +static esp_err_t send_state(httpd_handle_t server, int fd, view_role_t role) { + char payload[128]; + const int length = snprintf(payload, sizeof(payload), + "{\"version\":%" PRIu32 ",\"public_counter\":%" PRIu32 + ",\"viewer\":\"%s\"}", s_state_version, s_public_counter, + role_name(role)); + if (length < 0 || (size_t)length >= sizeof(payload)) return ESP_FAIL; + httpd_ws_frame_t frame = {.final = true, .type = HTTPD_WS_TYPE_TEXT, + .payload = (uint8_t *)payload, .len = (size_t)length}; + return httpd_ws_send_frame_async(server, fd, &frame); +} + +static void broadcast_state(void *unused) { + (void)unused; + size_t count = 4; + int clients[4]; + if (httpd_get_client_list(s_server, &count, clients) != ESP_OK) return; + for (size_t index = 0; index < count; ++index) { + if (httpd_ws_get_fd_info(s_server, clients[index]) == HTTPD_WS_CLIENT_WEBSOCKET) { + send_state(s_server, clients[index], role_for_fd(clients[index])); + } + } +} + +static void state_timer_callback(void *unused) { + (void)unused; + ++s_state_version; + ++s_public_counter; + if (s_server != NULL) httpd_queue_work(s_server, broadcast_state, NULL); +} + +static esp_err_t state_handler(httpd_req_t *request) { + char payload[128]; + const int length = snprintf(payload, sizeof(payload), + "{\"version\":%" PRIu32 ",\"public_counter\":%" PRIu32 + ",\"viewer\":\"%s\"}", s_state_version, s_public_counter, + role_name(role_from_query(request))); + if (length < 0 || (size_t)length >= sizeof(payload)) return ESP_FAIL; + httpd_resp_set_type(request, "application/json"); + httpd_resp_set_hdr(request, "Cache-Control", "no-store"); + return httpd_resp_send(request, payload, length); +} + +static esp_err_t websocket_handler(httpd_req_t *request) { + const int fd = httpd_req_to_sockfd(request); + if (request->method == HTTP_GET) { + remember_ws_client(fd, role_from_query(request)); + return ESP_OK; + } + httpd_ws_frame_t frame = {0}; + ESP_RETURN_ON_ERROR(httpd_ws_recv_frame(request, &frame, 0), kLogTag, "websocket frame read failed"); + if (frame.len > 64 || frame.type != HTTPD_WS_TYPE_TEXT) return ESP_OK; + char message[65] = {0}; + frame.payload = (uint8_t *)message; + frame.len = sizeof(message) - 1; + ESP_RETURN_ON_ERROR(httpd_ws_recv_frame(request, &frame, frame.len), kLogTag, "websocket payload read failed"); + if (strcmp(message, "{\"type\":\"ping\"}") != 0) return ESP_OK; + return send_state(request->handle, fd, role_for_fd(fd)); +} static wifi_state_t wifi_state_snapshot(void) { wifi_state_t snapshot; @@ -293,15 +393,21 @@ static esp_err_t static_file_handler(httpd_req_t *request) { static esp_err_t start_http_server(void) { httpd_handle_t server = NULL; httpd_config_t config = HTTPD_DEFAULT_CONFIG(); - config.max_uri_handlers = 3; + config.max_uri_handlers = 5; config.uri_match_fn = httpd_uri_match_wildcard; config.lru_purge_enable = true; ESP_RETURN_ON_ERROR(httpd_start(&server, &config), kLogTag, "http server start failed"); + s_server = server; const httpd_uri_t root = {.uri = "/", .method = HTTP_GET, .handler = root_handler}; const httpd_uri_t health = {.uri = "/api/health", .method = HTTP_GET, .handler = health_handler}; + const httpd_uri_t state = {.uri = "/api/state", .method = HTTP_GET, .handler = state_handler}; + const httpd_uri_t websocket = {.uri = "/ws", .method = HTTP_GET, .handler = websocket_handler, + .is_websocket = true}; const httpd_uri_t static_files = {.uri = "/*", .method = HTTP_GET, .handler = static_file_handler}; ESP_RETURN_ON_ERROR(httpd_register_uri_handler(server, &root), kLogTag, "root route registration failed"); ESP_RETURN_ON_ERROR(httpd_register_uri_handler(server, &health), kLogTag, "health route registration failed"); + ESP_RETURN_ON_ERROR(httpd_register_uri_handler(server, &state), kLogTag, "state route registration failed"); + ESP_RETURN_ON_ERROR(httpd_register_uri_handler(server, &websocket), kLogTag, "websocket route registration failed"); ESP_RETURN_ON_ERROR(httpd_register_uri_handler(server, &static_files), kLogTag, "static route registration failed"); ESP_LOGI(kLogTag, "http server started on port %d", config.server_port); return ESP_OK; @@ -339,6 +445,9 @@ void app_main(void) { ESP_ERROR_CHECK(esp_event_loop_create_default()); mount_littlefs(); ESP_ERROR_CHECK(start_http_server()); + const esp_timer_create_args_t state_timer_args = {.callback = state_timer_callback, .name = "state_tick"}; + ESP_ERROR_CHECK(esp_timer_create(&state_timer_args, &s_state_timer)); + ESP_ERROR_CHECK(esp_timer_start_periodic(s_state_timer, 5000000)); result = start_wifi(); if (result != ESP_OK) { ESP_LOGW(kLogTag, "wifi unavailable: %s", esp_err_to_name(result));