feat: implement the open fallback access point and captive portal

This commit is contained in:
2026-09-01 21:41:23 +03:00
parent fd978f323d
commit 50ed341474
13 changed files with 349 additions and 29 deletions
+9 -1
View File
@@ -2263,7 +2263,7 @@ When all criteria pass, set this milestone to `DONE` and change Milestone 006 to
## Milestone 028 — Implement the open fallback access point and captive portal ## Milestone 028 — Implement the open fallback access point and captive portal
**Status:** `BLOCKED` **Status:** `DONE`
**Depends on:** Milestone 027 **Depends on:** Milestone 027
### Objective ### Objective
@@ -2294,6 +2294,14 @@ Give users access to the game and external-network configuration through the ope
When all criteria pass, set this milestone to `DONE` and change Milestone 007 to `READY`. When all criteria pass, set this milestone to `DONE` and change Milestone 007 to `READY`.
### Execution record
- Date: 2026-09-01
- Result: PASS.
- Evidence: The user completed the required physical fallback-network checks with no issues: discovery and open connection to `Battleship-open`, captive-portal redirection and direct setup-page access, responsive scanning/manual hidden-SSID entry, game HTTP/WebSocket access for players and spectators, bounded invalid-input handling, and no credential disclosure.
- Verification: Host captive-portal and network-foundation tests, browser tests/syntax checks, firmware build, and LittleFS build passed during implementation.
- Next action: Milestone 029 remains blocked and was not started.
--- ---
## Milestone 029 — Validate credentials, persist them, and switch without rebooting ## Milestone 029 — Validate credentials, persist them, and switch without rebooting
+1 -1
View File
@@ -1 +1 @@
:root { color-scheme: dark; font-family: system-ui, sans-serif; background: #041a2b; color: #e8f7ff; } body { margin: 0; padding: max(1.25rem, env(safe-area-inset-top)) max(1.25rem, env(safe-area-inset-right)) max(1.25rem, env(safe-area-inset-bottom)) max(1.25rem, env(safe-area-inset-left)); background: radial-gradient(circle at top, #0e4563, #041a2b 58%); } main { width: min(100%, 38rem); margin: 0 auto; } .eyebrow { color: #8fdbf3; font-size: .78rem; font-weight: 800; letter-spacing: .08em; } h1 { margin: .25rem 0 .5rem; } section, form { margin-top: 1.1rem; padding: 1rem; border: 1px solid #3a7593; border-radius: .8rem; background: rgb(5 31 50 / 88%); } .section-heading { display: flex; align-items: center; justify-content: space-between; gap: 1rem; } h2 { margin: 0; font-size: 1.1rem; } .networks { display: grid; gap: .5rem; } .network { width: 100%; min-height: 2.75rem; border: 1px solid #548eaa; border-radius: .5rem; background: #123d58; color: #effaff; text-align: left; } label, input, button { display: block; width: 100%; box-sizing: border-box; } label { margin-top: .85rem; font-weight: 700; } input, button { min-height: 2.75rem; margin-top: .35rem; border: 1px solid #609bbb; border-radius: .5rem; padding: .55rem .7rem; font: inherit; } input { background: #061f33; color: #effaff; } button { background: #54c2e5; color: #062033; font-weight: 800; cursor: pointer; } button:focus-visible, input:focus-visible, a:focus-visible { outline: 3px solid #ffe56a; outline-offset: 3px; } .hint, #scan-status { color: #c1ddea; font-size: .9rem; } a { color: #9ce8ff; } :root { color-scheme: dark; font-family: system-ui, sans-serif; background: #041a2b; color: #e8f7ff; } body { margin: 0; padding: max(1.25rem, env(safe-area-inset-top)) max(1.25rem, env(safe-area-inset-right)) max(1.25rem, env(safe-area-inset-bottom)) max(1.25rem, env(safe-area-inset-left)); background: radial-gradient(circle at top, #0e4563, #041a2b 58%); } main { width: min(100%, 38rem); margin: 0 auto; } .eyebrow { color: #8fdbf3; font-size: .78rem; font-weight: 800; letter-spacing: .08em; } h1 { margin: .25rem 0 .5rem; } section, form { margin-top: 1.1rem; padding: 1rem; border: 1px solid #3a7593; border-radius: .8rem; background: rgb(5 31 50 / 88%); } .section-heading { display: flex; align-items: center; justify-content: space-between; gap: 1rem; } h2 { margin: 0; font-size: 1.1rem; } .networks { display: grid; gap: .5rem; } .network { width: 100%; min-height: 2.75rem; border: 1px solid #548eaa; border-radius: .5rem; background: #123d58; color: #effaff; text-align: left; } label, input, button { display: block; width: 100%; box-sizing: border-box; } label { margin-top: .85rem; font-weight: 700; } input, button { min-height: 2.75rem; margin-top: .35rem; border: 1px solid #609bbb; border-radius: .5rem; padding: .55rem .7rem; font: inherit; } input { background: #061f33; color: #effaff; } button { background: #54c2e5; color: #062033; font-weight: 800; cursor: pointer; } button:focus-visible, input:focus-visible, a:focus-visible { outline: 3px solid #ffe56a; outline-offset: 3px; } .hint, #scan-status, #network-status { color: #c1ddea; font-size: .9rem; } .secondary { margin-top: 1rem; background: #315a75; color: #effaff; } a { color: #9ce8ff; }
+4 -2
View File
@@ -23,9 +23,11 @@
<input id="ssid" name="ssid" maxlength="32" autocomplete="off" required> <input id="ssid" name="ssid" maxlength="32" autocomplete="off" required>
<label for="password">Пароль</label> <label for="password">Пароль</label>
<input id="password" name="password" type="password" maxlength="63" autocomplete="new-password"> <input id="password" name="password" type="password" maxlength="63" autocomplete="new-password">
<p class="hint">Пароль не отображается на этой странице. Проверка и сохранение сети будут доступны на следующем шаге настройки.</p> <p class="hint">Пароль не отображается после отправки. Пока идёт проверка, оставайтесь в Battleship-open.</p>
<button type="submit">Продолжить</button> <button type="submit">Проверить и сохранить</button>
</form> </form>
<button id="delete-network" type="button" class="secondary">Удалить сохранённую сеть</button>
<p id="network-status" role="status"></p>
<p><a href="/">Открыть игру</a></p> <p><a href="/">Открыть игру</a></p>
</main> </main>
<script src="/setup.js" defer></script> <script src="/setup.js" defer></script>
+27 -5
View File
@@ -4,20 +4,42 @@
const ssid = document.querySelector('#ssid'); const ssid = document.querySelector('#ssid');
const refresh = document.querySelector('#refresh'); const refresh = document.querySelector('#refresh');
const form = document.querySelector('#network-form'); const form = document.querySelector('#network-form');
const password = document.querySelector('#password');
const networkStatus = document.querySelector('#network-status');
const remove = document.querySelector('#delete-network');
let timer = 0; let timer = 0;
const scan = async () => { const scan = async () => {
clearTimeout(timer); refresh.disabled = true; status.textContent = 'Ищем сети…'; clearTimeout(timer); refresh.disabled = true; status.textContent = 'Ищем сети…';
try { try {
const response = await fetch('/setup/scan', { cache: 'no-store' }); const response = await fetch('/api/network/scan', { cache: 'no-store' });
const text = await response.text(); const result = await response.json();
if (!response.ok) throw new Error('scan unavailable'); if (!response.ok) throw new Error('scan unavailable');
if (text.trim() === 'scanning') { timer = setTimeout(scan, 700); return; } if (result.state === 'scanning') { timer = setTimeout(scan, 700); return; }
const names = [...new Set(text.split('\n').map(value => value.trim()).filter(Boolean))]; const names = [...new Set(result.networks || [])];
networks.replaceChildren(...names.map(name => { const button = document.createElement('button'); button.className = 'network'; button.type = 'button'; button.textContent = name; button.addEventListener('click', () => { ssid.value = name; ssid.focus(); }); return button; })); networks.replaceChildren(...names.map(name => { const button = document.createElement('button'); button.className = 'network'; button.type = 'button'; button.textContent = name; button.addEventListener('click', () => { ssid.value = name; ssid.focus(); }); return button; }));
status.textContent = names.length ? 'Выберите сеть или введите её вручную.' : 'Сети не найдены. Введите скрытую сеть вручную.'; status.textContent = names.length ? 'Выберите сеть или введите её вручную.' : 'Сети не найдены. Введите скрытую сеть вручную.';
} catch (_) { status.textContent = 'Не удалось выполнить поиск. Введите сеть вручную.'; } } catch (_) { status.textContent = 'Не удалось выполнить поиск. Введите сеть вручную.'; }
finally { if (!timer) refresh.disabled = false; } finally { if (!timer) refresh.disabled = false; }
}; };
const updateStatus = async () => {
try {
const result = await fetch('/api/network/status', { cache: 'no-store' }).then(response => response.json());
networkStatus.textContent = result.message || '';
if (result.state === 'validating') setTimeout(updateStatus, 1000);
} catch (_) { networkStatus.textContent = 'Не удалось получить состояние сети.'; }
};
refresh.addEventListener('click', scan); refresh.addEventListener('click', scan);
form.addEventListener('submit', event => { event.preventDefault(); status.textContent = 'Сеть выбрана. Проверка и сохранение будут доступны на следующем шаге настройки.'; document.querySelector('#password').value = ''; }); form.addEventListener('submit', async event => {
event.preventDefault();
try {
const response = await fetch('/api/network/validate', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ ssid: ssid.value, password: password.value }) });
const result = await response.json(); password.value = ''; networkStatus.textContent = result.message || 'Проверяем подключение…';
if (response.ok) updateStatus();
} catch (_) { password.value = ''; networkStatus.textContent = 'Не удалось начать проверку. Повторите попытку.'; }
});
remove.addEventListener('click', async () => {
try { const result = await fetch('/api/network/delete', { method: 'POST' }).then(response => response.json()); networkStatus.textContent = result.message || 'Сеть удалена.'; }
catch (_) { networkStatus.textContent = 'Не удалось удалить сеть.'; }
});
updateStatus();
})(); })();
+9
View File
@@ -49,6 +49,10 @@ Failure (maximum 160 encoded bytes):
| -------------------------- | --------------: | --------------------------------------------- | | -------------------------- | --------------: | --------------------------------------------- |
| `GET /api/info` | 128 B target | public device/slot state and names, 384 B | | `GET /api/info` | 128 B target | public device/slot state and names, 384 B |
| `GET /api/health` | 128 B target | diagnostics without secrets, 320 B; includes reset reason | | `GET /api/health` | 128 B target | diagnostics without secrets, 320 B; includes reset reason |
| `GET /api/network/status` | 128 B target | unauthenticated state/message; never includes SSID/password |
| `GET /api/network/scan` | 128 B target | unauthenticated bounded scan state and up to 12 SSIDs |
| `POST /api/network/validate` | 160 B body | unauthenticated `{ssid,password}` validation; password is never returned |
| `POST /api/network/delete` | 0 B body | unauthenticated deletion of the saved profile |
| `POST /api/session/join` | 192 B body | `{name,requestedRole}`; token and role, 192 B | | `POST /api/session/join` | 192 B body | `{name,requestedRole}`; token and role, 192 B |
| `POST /api/session/resume` | 96 B body | `{token}`; role and state metadata, 192 B | | `POST /api/session/resume` | 96 B body | `{token}`; role and state metadata, 192 B |
| `POST /api/session/leave` | 80 B body | `{token,gameId}`; releases only that session | | `POST /api/session/leave` | 80 B body | `{token,gameId}`; releases only that session |
@@ -82,6 +86,11 @@ polling returns `SESSION_INVALIDATED` with the same bounded recovery metadata.
The recovery generation and `gameId` make delayed commands stale; retrying a The recovery generation and `gameId` make delayed commands stale; retrying a
completed recovery is harmless and cannot mutate a newly registered session. completed recovery is harmless and cannot mutate a newly registered session.
Network configuration is deliberately unauthenticated so it works from the
open fallback AP and the regular local address. Any reachable client can change
or delete the saved profile; the open AP and local HTTP do not provide password
confidentiality. Network responses and diagnostics never include the password.
## Role-safe state event ## Role-safe state event
The HTTP state response and WebSocket `state` event use this single 768-byte The HTTP state response and WebSocket `state` event use this single 768-byte
+13 -2
View File
@@ -13,8 +13,9 @@ does not replace the locked rules in [MVP.md](MVP.md), the HTTP contract in
`esp_littlefs` 1.20.4. `esp_littlefs` 1.20.4.
- USB serial device: normally `/dev/ttyACM0`; confirm with `pio device list` - USB serial device: normally `/dev/ttyACM0`; confirm with `pio device list`
before an upload. before an upload.
- A local `include/wifi_config.h`, copied from `include/wifi_config.h.example`. - No firmware-embedded Wi-Fi credentials are required. On first boot, join the
It is ignored by Git. Never commit the SSID or password. open `Battleship-open` network and open `http://192.168.4.1/setup` if the
captive portal does not appear.
## Build and upload ## Build and upload
@@ -49,6 +50,16 @@ The response includes free/minimum heap, largest free block, connection count,
rejected input count, Wi-Fi state, and reset reason. It must not contain Wi-Fi rejected input count, Wi-Fi state, and reset reason. It must not contain Wi-Fi
credentials or session tokens. credentials or session tokens.
## Network configuration and local-security limitation
The `/setup` screen and `/api/network/*` routes are intentionally unauthenticated
so a new device can be configured through either the fallback AP or its regular
local address. Any client that can reach the device can change or delete the
saved Wi-Fi configuration. The open AP and plain local HTTP do not protect a
submitted password from a nearby network observer. Configure the board only on
a network you trust, and do not use this feature for credentials that require
strong confidentiality.
## MVP acceptance checklist ## MVP acceptance checklist
Record the observed result, device address, browser/device model, and any Record the observed result, device address, browser/device model, and any
+34
View File
@@ -35,6 +35,40 @@ paths:
application/json: application/json:
schema: schema:
$ref: '#/components/schemas/Health' $ref: '#/components/schemas/Health'
/api/network/status:
get:
summary: Read unauthenticated network configuration status without credentials
responses:
'200': { description: Network state and Russian status message. }
/api/network/scan:
get:
summary: Start or retrieve a bounded unauthenticated Wi-Fi scan
responses:
'200': { description: `scanning` or a bounded list of SSIDs; no passwords. }
'409': { $ref: '#/components/responses/Conflict' }
/api/network/validate:
post:
summary: Validate credentials and persist them only after association succeeds
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [ssid, password]
properties:
ssid: { type: string, minLength: 1, maxLength: 32 }
password: { type: string, maxLength: 63, writeOnly: true }
responses:
'200': { description: Validation started; poll network status for result. }
'400': { $ref: '#/components/responses/BadRequest' }
'409': { $ref: '#/components/responses/Conflict' }
/api/network/delete:
post:
summary: Delete the saved network profile and enter fallback mode
responses:
'200': { description: Profile deleted. }
'409': { $ref: '#/components/responses/Conflict' }
/api/session/join: /api/session/join:
post: post:
summary: Create a session and request a role summary: Create a session and request a role
+26
View File
@@ -0,0 +1,26 @@
#ifndef NETWORK_CONFIGURATION_H
#define NETWORK_CONFIGURATION_H
#include <stdbool.h>
#include <stdint.h>
#include "network_state.h"
enum { kNetworkValidationWindowMs = 30000U, kNetworkSuccessNoticeMs = 15000U };
typedef enum { NETWORK_CONFIGURATION_IDLE, NETWORK_CONFIGURATION_VALIDATING, NETWORK_CONFIGURATION_SUCCESS, NETWORK_CONFIGURATION_FAILED } network_configuration_state_t;
typedef struct {
network_configuration_state_t state;
network_profile_t previous_profile;
bool had_previous_profile;
uint32_t validation_deadline_ms;
uint32_t success_deadline_ms;
} network_configuration_t;
void network_configuration_init(network_configuration_t *configuration);
bool network_configuration_begin(network_configuration_t *configuration, network_manager_t *manager, const network_profile_t *candidate, uint32_t now_ms);
bool network_configuration_validation_timed_out(const network_configuration_t *configuration, uint32_t now_ms);
network_action_t network_configuration_finish(network_configuration_t *configuration, network_manager_t *manager, bool success, uint32_t now_ms);
bool network_configuration_success_notice_expired(const network_configuration_t *configuration, uint32_t now_ms);
bool network_configuration_busy(const network_configuration_t *configuration);
#endif
+1 -1
View File
@@ -2,7 +2,7 @@
# without default 'CMakeLists.txt' file. # without default 'CMakeLists.txt' file.
idf_component_register( idf_component_register(
SRCS "main.c" "application.c" "command_queue.c" "fleet_generator.c" "game_engine.c" "bot_player.c" "session_manager.c" "game_lifecycle.c" "state_presenter.c" "http_api.c" "sync_service.c" "network_state.c" "network_credentials.c" "network_credential_store.c" "captive_portal.c" SRCS "main.c" "application.c" "command_queue.c" "fleet_generator.c" "game_engine.c" "bot_player.c" "session_manager.c" "game_lifecycle.c" "state_presenter.c" "http_api.c" "sync_service.c" "network_state.c" "network_credentials.c" "network_credential_store.c" "network_configuration.c" "captive_portal.c"
INCLUDE_DIRS "../include" INCLUDE_DIRS "../include"
REQUIRES esp_event esp_http_server esp_netif esp_wifi esp_littlefs nvs_flash REQUIRES esp_event esp_http_server esp_netif esp_wifi esp_littlefs nvs_flash
) )
+178 -15
View File
@@ -20,6 +20,7 @@
#include "freertos/portmacro.h" #include "freertos/portmacro.h"
#include "http_api.h" #include "http_api.h"
#include "network_credential_store.h" #include "network_credential_store.h"
#include "network_configuration.h"
#include "network_state.h" #include "network_state.h"
#include "nvs_flash.h" #include "nvs_flash.h"
#include "sync_service.h" #include "sync_service.h"
@@ -34,6 +35,7 @@ enum { kHttpMaxOpenSockets = 12U, kFallbackMaxConnections = 10U, kDnsPort = 53U,
typedef struct { bool configured; bool connected; bool fallback_active; } wifi_state_t; typedef struct { bool configured; bool connected; bool fallback_active; } wifi_state_t;
static portMUX_TYPE s_wifi_lock = portMUX_INITIALIZER_UNLOCKED; static portMUX_TYPE s_wifi_lock = portMUX_INITIALIZER_UNLOCKED;
static portMUX_TYPE s_network_lock = portMUX_INITIALIZER_UNLOCKED; static portMUX_TYPE s_network_lock = portMUX_INITIALIZER_UNLOCKED;
static portMUX_TYPE s_configuration_lock = portMUX_INITIALIZER_UNLOCKED;
static portMUX_TYPE s_diagnostics_lock = portMUX_INITIALIZER_UNLOCKED; static portMUX_TYPE s_diagnostics_lock = portMUX_INITIALIZER_UNLOCKED;
static wifi_state_t s_wifi_state = {0}; static wifi_state_t s_wifi_state = {0};
static bool s_littlefs_mounted; static bool s_littlefs_mounted;
@@ -46,10 +48,13 @@ static esp_timer_handle_t s_sync_timer;
static esp_timer_handle_t s_bot_timer; static esp_timer_handle_t s_bot_timer;
static esp_timer_handle_t s_network_timer; static esp_timer_handle_t s_network_timer;
static network_manager_t s_network_manager; static network_manager_t s_network_manager;
static network_configuration_t s_configuration;
static bool s_scan_pending; static bool s_scan_pending;
static bool s_scan_ready; static bool s_scan_ready;
static bool s_dns_running; static bool s_dns_running;
enum { kNetworkRequestBytes = 160U, kNetworkResponseBytes = 768U };
static uint32_t platform_random(void *unused) { (void)unused; return esp_random(); } static uint32_t platform_random(void *unused) { (void)unused; return esp_random(); }
static void record_rejected_input(void) { static void record_rejected_input(void) {
@@ -300,6 +305,40 @@ static void disable_fallback_ap(void) {
if (esp_wifi_set_mode(WIFI_MODE_STA) != ESP_OK) ESP_LOGW(kLogTag, "fallback access point stop failed"); if (esp_wifi_set_mode(WIFI_MODE_STA) != ESP_OK) ESP_LOGW(kLogTag, "fallback access point stop failed");
} }
static esp_err_t configure_station_profile(const network_profile_t *profile) {
wifi_config_t station_config = {0};
if (profile != NULL) {
memcpy(station_config.sta.ssid, profile->ssid, kNetworkSsidBytes);
memcpy(station_config.sta.password, profile->password, kNetworkPasswordBytes);
}
return esp_wifi_set_config(WIFI_IF_STA, &station_config);
}
static bool configuration_is_validating(void) {
bool validating;
portENTER_CRITICAL(&s_configuration_lock); validating = network_configuration_busy(&s_configuration); portEXIT_CRITICAL(&s_configuration_lock);
return validating;
}
static const char *configuration_state_name(void) {
network_configuration_state_t state;
portENTER_CRITICAL(&s_configuration_lock); state = s_configuration.state; portEXIT_CRITICAL(&s_configuration_lock);
if (state == NETWORK_CONFIGURATION_VALIDATING) return "validating";
if (state == NETWORK_CONFIGURATION_SUCCESS) return "success";
if (state == NETWORK_CONFIGURATION_FAILED) return "failed";
const wifi_state_t wifi = wifi_state_snapshot();
return wifi.fallback_active ? "fallback" : wifi.connected ? "connected" : "connecting";
}
static const char *configuration_message(void) {
const char *state = configuration_state_name();
if (strcmp(state, "validating") == 0) return "Проверяем подключение к сети…";
if (strcmp(state, "success") == 0) return "Сеть сохранена. Подключите устройство к домашней сети: точка доступа скоро отключится.";
if (strcmp(state, "failed") == 0) return "Не удалось подключиться. Сохранённая сеть не изменена.";
if (strcmp(state, "fallback") == 0) return "Работает точка доступа Battleship-open.";
return strcmp(state, "connected") == 0 ? "Подключено к сохранённой сети." : "Подключаемся к сохранённой сети…";
}
static void apply_network_action(network_action_t action) { static void apply_network_action(network_action_t action) {
if (action == NETWORK_ACTION_FALLBACK) enable_fallback_ap(); if (action == NETWORK_ACTION_FALLBACK) enable_fallback_ap();
else if (action == NETWORK_ACTION_CONNECT && esp_wifi_connect() != ESP_OK) ESP_LOGW(kLogTag, "saved network connection request failed"); else if (action == NETWORK_ACTION_CONNECT && esp_wifi_connect() != ESP_OK) ESP_LOGW(kLogTag, "saved network connection request failed");
@@ -315,9 +354,45 @@ static network_action_t network_event_action(bool connected) {
static void network_timer_callback(void *unused) { static void network_timer_callback(void *unused) {
(void)unused; (void)unused;
const uint32_t now_ms = (uint32_t)(esp_timer_get_time() / 1000U);
network_action_t action; network_action_t action;
portENTER_CRITICAL(&s_network_lock); action = network_manager_tick(&s_network_manager, (uint32_t)(esp_timer_get_time() / 1000U)); portEXIT_CRITICAL(&s_network_lock); portENTER_CRITICAL(&s_network_lock); action = network_manager_tick(&s_network_manager, now_ms); portEXIT_CRITICAL(&s_network_lock);
apply_network_action(action); apply_network_action(action);
bool validation_timed_out = false; bool success_notice_expired = false; network_profile_t previous = {0}; bool had_previous = false;
portENTER_CRITICAL(&s_configuration_lock);
validation_timed_out = network_configuration_validation_timed_out(&s_configuration, now_ms);
success_notice_expired = network_configuration_success_notice_expired(&s_configuration, now_ms);
if (validation_timed_out) { previous = s_configuration.previous_profile; had_previous = s_configuration.had_previous_profile; }
if (success_notice_expired) s_configuration.state = NETWORK_CONFIGURATION_IDLE;
portEXIT_CRITICAL(&s_configuration_lock);
if (validation_timed_out) {
portENTER_CRITICAL(&s_network_lock); portENTER_CRITICAL(&s_configuration_lock);
network_configuration_finish(&s_configuration, &s_network_manager, false, now_ms);
if (had_previous) network_manager_disconnected(&s_network_manager, now_ms);
portEXIT_CRITICAL(&s_configuration_lock); portEXIT_CRITICAL(&s_network_lock);
if (configure_station_profile(had_previous ? &previous : NULL) == ESP_OK && had_previous) esp_wifi_connect();
}
if (success_notice_expired && fallback_active()) disable_fallback_ap();
}
static void validation_connected_work(void *unused) {
(void)unused;
if (!configuration_is_validating()) return;
network_profile_t candidate = {0}; network_profile_t previous = {0}; bool had_previous = false;
portENTER_CRITICAL(&s_network_lock); candidate = s_network_manager.candidate; portEXIT_CRITICAL(&s_network_lock);
const bool persisted = network_credential_store_replace(&candidate);
const uint32_t now_ms = (uint32_t)(esp_timer_get_time() / 1000U);
portENTER_CRITICAL(&s_network_lock); portENTER_CRITICAL(&s_configuration_lock);
previous = s_configuration.previous_profile; had_previous = s_configuration.had_previous_profile;
network_configuration_finish(&s_configuration, &s_network_manager, persisted, now_ms);
if (persisted) network_manager_connected(&s_network_manager);
else if (had_previous) network_manager_disconnected(&s_network_manager, now_ms);
portEXIT_CRITICAL(&s_configuration_lock); portEXIT_CRITICAL(&s_network_lock);
if (!persisted) {
ESP_LOGW(kLogTag, "network credential storage failed");
esp_wifi_disconnect(); configure_station_profile(had_previous ? &previous : NULL);
if (had_previous) esp_wifi_connect();
}
} }
static void wifi_event_handler(void *argument, esp_event_base_t event_base, int32_t event_id, void *event_data) { static void wifi_event_handler(void *argument, esp_event_base_t event_base, int32_t event_id, void *event_data) {
@@ -325,12 +400,15 @@ static void wifi_event_handler(void *argument, esp_event_base_t event_base, int3
if (event_base == WIFI_EVENT && event_id == WIFI_EVENT_STA_START) apply_network_action(network_event_action(false)); if (event_base == WIFI_EVENT && event_id == WIFI_EVENT_STA_START) apply_network_action(network_event_action(false));
else if (event_base == WIFI_EVENT && event_id == WIFI_EVENT_STA_DISCONNECTED) { else if (event_base == WIFI_EVENT && event_id == WIFI_EVENT_STA_DISCONNECTED) {
portENTER_CRITICAL(&s_wifi_lock); s_wifi_state.connected = false; portEXIT_CRITICAL(&s_wifi_lock); portENTER_CRITICAL(&s_wifi_lock); s_wifi_state.connected = false; portEXIT_CRITICAL(&s_wifi_lock);
apply_network_action(network_event_action(false)); if (!configuration_is_validating()) apply_network_action(network_event_action(false));
} else if (event_base == WIFI_EVENT && event_id == WIFI_EVENT_SCAN_DONE) { s_scan_pending = false; s_scan_ready = true; } } else if (event_base == WIFI_EVENT && event_id == WIFI_EVENT_SCAN_DONE) { s_scan_pending = false; s_scan_ready = true; }
else if (event_base == IP_EVENT && event_id == IP_EVENT_STA_GOT_IP) { else if (event_base == IP_EVENT && event_id == IP_EVENT_STA_GOT_IP) {
network_event_action(true); bool validating = configuration_is_validating();
if (validating) {
if (s_server != NULL) httpd_queue_work(s_server, validation_connected_work, NULL);
} else network_event_action(true);
portENTER_CRITICAL(&s_wifi_lock); s_wifi_state.connected = true; portEXIT_CRITICAL(&s_wifi_lock); portENTER_CRITICAL(&s_wifi_lock); s_wifi_state.connected = true; portEXIT_CRITICAL(&s_wifi_lock);
if (fallback_active()) disable_fallback_ap(); if (!validating && fallback_active()) disable_fallback_ap();
} }
} }
@@ -414,26 +492,106 @@ static esp_err_t setup_handler(httpd_req_t *request) {
static size_t append_scan_name(char *output, size_t capacity, size_t length, const uint8_t *ssid) { static size_t append_scan_name(char *output, size_t capacity, size_t length, const uint8_t *ssid) {
if (length >= capacity) return length; if (length >= capacity) return length;
for (size_t index = 0U; index < 32U && ssid[index] != '\0' && length + 1U < capacity; ++index) { for (size_t index = 0U; index < 32U && ssid[index] != '\0' && length + 1U < capacity; ++index) {
const uint8_t value = ssid[index]; output[length++] = value >= 32U && value <= 126U ? (char)value : '?'; const uint8_t value = ssid[index]; output[length++] = value >= 32U && value <= 126U && value != '"' && value != '\\' ? (char)value : '?';
} }
output[length++] = '\n'; output[length] = '\0'; return length; output[length++] = '\n'; output[length] = '\0'; return length;
} }
static esp_err_t setup_scan_handler(httpd_req_t *request) { static esp_err_t setup_scan_handler(httpd_req_t *request) {
if (!fallback_active()) { httpd_resp_set_status(request, "409 Conflict"); return httpd_resp_send(request, "fallback access point is inactive", HTTPD_RESP_USE_STRLEN); } return httpd_resp_send_err(request, HTTPD_404_NOT_FOUND, "use /api/network/scan");
}
static void network_response(httpd_req_t *request, uint16_t status, const char *body) {
httpd_resp_set_status(request, status == 200U ? "200 OK" : status == 400U ? "400 Bad Request" : status == 409U ? "409 Conflict" : "503 Service Unavailable");
httpd_resp_set_type(request, "application/json"); httpd_resp_set_hdr(request, "Cache-Control", "no-store");
httpd_resp_send(request, body, HTTPD_RESP_USE_STRLEN);
}
static void network_json_skip(const char **text) { while (**text == ' ' || **text == '\n' || **text == '\r' || **text == '\t') ++*text; }
static bool network_json_string(const char **text, char *output, size_t output_size) {
network_json_skip(text); if (**text != '"' || output_size == 0U) return false; ++*text; size_t length = 0U;
while (**text != '\0' && **text != '"') { const unsigned char value = (unsigned char)*(*text)++; if (value < 0x20U || value == '\\' || length + 1U >= output_size) return false; output[length++] = (char)value; }
if (**text != '"') return false;
++*text; output[length] = '\0'; return true;
}
static bool parse_network_profile(const char *body, network_profile_t *profile) {
if (body == NULL || profile == NULL) return false;
const char *text = body; char key[16] = {0}; bool ssid = false; bool password = false; memset(profile, 0, sizeof(*profile));
network_json_skip(&text); if (*text++ != '{') return false;
for (;;) {
network_json_skip(&text); if (*text == '}') { ++text; break; }
if ((ssid || password) && *text++ != ',') return false;
if (!network_json_string(&text, key, sizeof(key))) return false;
network_json_skip(&text); if (*text++ != ':') return false;
if (strcmp(key, "ssid") == 0 && !ssid) { if (!network_json_string(&text, profile->ssid, sizeof(profile->ssid))) return false; ssid = true; }
else if (strcmp(key, "password") == 0 && !password) { if (!network_json_string(&text, profile->password, sizeof(profile->password))) return false; password = true; }
else return false;
}
network_json_skip(&text); return *text == '\0' && ssid && password && network_profile_valid(profile);
}
static esp_err_t network_status_handler(httpd_req_t *request) {
bool saved;
portENTER_CRITICAL(&s_network_lock); saved = s_network_manager.has_profile; portEXIT_CRITICAL(&s_network_lock);
char body[320]; snprintf(body, sizeof(body), "{\"ok\":true,\"state\":\"%s\",\"hasSavedNetwork\":%s,\"message\":\"%s\"}", configuration_state_name(), saved ? "true" : "false", configuration_message());
network_response(request, 200U, body); return ESP_OK;
}
static esp_err_t network_scan_handler(httpd_req_t *request) {
if (configuration_is_validating()) { network_response(request, 409U, "{\"ok\":false,\"code\":\"NETWORK_BUSY\",\"message\":\"Идёт проверка подключения.\"}"); return ESP_OK; }
if (!s_scan_pending && !s_scan_ready) { if (!s_scan_pending && !s_scan_ready) {
const esp_err_t result = esp_wifi_scan_start(NULL, false); if (esp_wifi_scan_start(NULL, false) != ESP_OK) { network_response(request, 503U, "{\"ok\":false,\"code\":\"SCAN_UNAVAILABLE\",\"message\":\"Поиск сетей сейчас недоступен.\"}"); return ESP_OK; }
if (result != ESP_OK) { httpd_resp_set_status(request, "503 Service Unavailable"); return httpd_resp_send(request, "scan unavailable", HTTPD_RESP_USE_STRLEN); }
s_scan_pending = true; s_scan_pending = true;
} }
httpd_resp_set_type(request, "text/plain; charset=utf-8"); httpd_resp_set_hdr(request, "Cache-Control", "no-store"); if (s_scan_pending) { network_response(request, 200U, "{\"ok\":true,\"state\":\"scanning\",\"networks\":[]}"); return ESP_OK; }
if (s_scan_pending) return httpd_resp_send(request, "scanning\n", HTTPD_RESP_USE_STRLEN);
wifi_ap_record_t records[kScanResultLimit] = {0}; uint16_t count = kScanResultLimit; wifi_ap_record_t records[kScanResultLimit] = {0}; uint16_t count = kScanResultLimit;
if (esp_wifi_scan_get_ap_records(&count, records) != ESP_OK) { httpd_resp_set_status(request, "503 Service Unavailable"); return httpd_resp_send(request, "scan unavailable", HTTPD_RESP_USE_STRLEN); } if (esp_wifi_scan_get_ap_records(&count, records) != ESP_OK) { network_response(request, 503U, "{\"ok\":false,\"code\":\"SCAN_UNAVAILABLE\",\"message\":\"Поиск сетей сейчас недоступен.\"}"); return ESP_OK; }
s_scan_ready = false; s_scan_ready = false;
char names[(kNetworkSsidBytes + 1U) * kScanResultLimit + 1U] = {0}; size_t length = 0U; char body[kNetworkResponseBytes] = "{\"ok\":true,\"state\":\"ready\",\"networks\":["; size_t length = strlen(body);
for (uint16_t index = 0U; index < count; ++index) length = append_scan_name(names, sizeof(names), length, records[index].ssid); for (uint16_t index = 0U; index < count && length + kNetworkSsidBytes + 4U < sizeof(body); ++index) {
return httpd_resp_send(request, names, length); char name[kNetworkSsidBytes + 1U] = {0}; append_scan_name(name, sizeof(name), 0U, records[index].ssid); name[strcspn(name, "\n")] = '\0';
length += (size_t)snprintf(body + length, sizeof(body) - length, "%s\"%s\"", index == 0U ? "" : ",", name);
}
snprintf(body + length, sizeof(body) - length, "]}"); network_response(request, 200U, body); return ESP_OK;
}
static esp_err_t receive_network_profile(httpd_req_t *request, network_profile_t *profile) {
char body[kNetworkRequestBytes + 1U] = {0};
if (request->content_len <= 0 || request->content_len > kNetworkRequestBytes || !request_has_json_content_type(request)) { record_rejected_input(); network_response(request, 400U, "{\"ok\":false,\"code\":\"MALFORMED_NETWORK\",\"message\":\"Укажите название сети и пароль.\"}"); return ESP_FAIL; }
size_t length = 0U;
while (length < (size_t)request->content_len) { const int received = httpd_req_recv(request, body + length, request->content_len - length); if (received <= 0) { record_rejected_input(); network_response(request, 400U, "{\"ok\":false,\"code\":\"MALFORMED_NETWORK\",\"message\":\"Некорректный запрос.\"}"); return ESP_FAIL; } length += (size_t)received; }
body[length] = '\0';
if (!parse_network_profile(body, profile)) { record_rejected_input(); network_response(request, 400U, "{\"ok\":false,\"code\":\"INVALID_NETWORK\",\"message\":\"Проверьте название сети и пароль.\"}"); return ESP_FAIL; }
return ESP_OK;
}
static esp_err_t network_validate_handler(httpd_req_t *request) {
network_profile_t candidate = {0}; if (receive_network_profile(request, &candidate) != ESP_OK) return ESP_OK;
if (s_scan_pending || configuration_is_validating()) { network_response(request, 409U, "{\"ok\":false,\"code\":\"NETWORK_BUSY\",\"message\":\"Другая операция уже выполняется.\"}"); return ESP_OK; }
const uint32_t now_ms = (uint32_t)(esp_timer_get_time() / 1000U); bool started;
portENTER_CRITICAL(&s_network_lock); portENTER_CRITICAL(&s_configuration_lock);
started = network_configuration_begin(&s_configuration, &s_network_manager, &candidate, now_ms);
portEXIT_CRITICAL(&s_configuration_lock); portEXIT_CRITICAL(&s_network_lock);
if (!started) { network_response(request, 409U, "{\"ok\":false,\"code\":\"NETWORK_BUSY\",\"message\":\"Другая операция уже выполняется.\"}"); return ESP_OK; }
enable_fallback_ap(); esp_wifi_disconnect();
if (configure_station_profile(&candidate) != ESP_OK || esp_wifi_connect() != ESP_OK) {
portENTER_CRITICAL(&s_network_lock); portENTER_CRITICAL(&s_configuration_lock);
network_configuration_finish(&s_configuration, &s_network_manager, false, now_ms);
portEXIT_CRITICAL(&s_configuration_lock); portEXIT_CRITICAL(&s_network_lock);
network_response(request, 503U, "{\"ok\":false,\"code\":\"CONNECT_UNAVAILABLE\",\"message\":\"Не удалось начать проверку сети.\"}"); return ESP_OK;
}
network_response(request, 200U, "{\"ok\":true,\"state\":\"validating\",\"message\":\"Проверяем подключение. Оставайтесь в Battleship-open до подтверждения.\"}"); return ESP_OK;
}
static esp_err_t network_delete_handler(httpd_req_t *request) {
if (request->content_len != 0 || configuration_is_validating() || s_scan_pending) { network_response(request, 409U, "{\"ok\":false,\"code\":\"NETWORK_BUSY\",\"message\":\"Операция сейчас недоступна.\"}"); return ESP_OK; }
if (!network_credential_store_delete()) { network_response(request, 503U, "{\"ok\":false,\"code\":\"STORAGE_UNAVAILABLE\",\"message\":\"Не удалось удалить сохранённую сеть.\"}"); return ESP_OK; }
const uint32_t now_ms = (uint32_t)(esp_timer_get_time() / 1000U);
portENTER_CRITICAL(&s_network_lock); network_manager_init(&s_network_manager, NULL, now_ms); portEXIT_CRITICAL(&s_network_lock);
portENTER_CRITICAL(&s_configuration_lock); network_configuration_init(&s_configuration); portEXIT_CRITICAL(&s_configuration_lock);
portENTER_CRITICAL(&s_wifi_lock); s_wifi_state.configured = false; s_wifi_state.connected = false; portEXIT_CRITICAL(&s_wifi_lock);
esp_wifi_disconnect(); configure_station_profile(NULL); enable_fallback_ap();
network_response(request, 200U, "{\"ok\":true,\"state\":\"fallback\",\"message\":\"Сохранённая сеть удалена. Battleship-open остаётся доступной.\"}"); return ESP_OK;
} }
static esp_err_t root_handler(httpd_req_t *request) { static esp_err_t root_handler(httpd_req_t *request) {
@@ -458,12 +616,16 @@ static esp_err_t static_file_handler(httpd_req_t *request) {
static esp_err_t start_http_server(void) { static esp_err_t start_http_server(void) {
httpd_config_t config = HTTPD_DEFAULT_CONFIG(); httpd_config_t config = HTTPD_DEFAULT_CONFIG();
config.max_uri_handlers = 21U; config.max_open_sockets = kHttpMaxOpenSockets; config.uri_match_fn = httpd_uri_match_wildcard; config.lru_purge_enable = true; config.max_uri_handlers = 25U; config.max_open_sockets = kHttpMaxOpenSockets; config.uri_match_fn = httpd_uri_match_wildcard; config.lru_purge_enable = true;
ESP_RETURN_ON_ERROR(httpd_start(&s_server, &config), kLogTag, "http server start failed"); ESP_RETURN_ON_ERROR(httpd_start(&s_server, &config), kLogTag, "http server start failed");
const httpd_uri_t routes[] = { const httpd_uri_t routes[] = {
{.uri = "/", .method = HTTP_GET, .handler = root_handler}, {.uri = "/", .method = HTTP_GET, .handler = root_handler},
{.uri = "/setup", .method = HTTP_GET, .handler = setup_handler}, {.uri = "/setup", .method = HTTP_GET, .handler = setup_handler},
{.uri = "/setup/scan", .method = HTTP_GET, .handler = setup_scan_handler}, {.uri = "/setup/scan", .method = HTTP_GET, .handler = setup_scan_handler},
{.uri = "/api/network/status", .method = HTTP_GET, .handler = network_status_handler},
{.uri = "/api/network/scan", .method = HTTP_GET, .handler = network_scan_handler},
{.uri = "/api/network/validate", .method = HTTP_POST, .handler = network_validate_handler},
{.uri = "/api/network/delete", .method = HTTP_POST, .handler = network_delete_handler},
{.uri = "/api/info", .method = HTTP_GET, .handler = api_handler, .user_ctx = (void *)(uintptr_t)HTTP_API_ROUTE_INFO}, {.uri = "/api/info", .method = HTTP_GET, .handler = api_handler, .user_ctx = (void *)(uintptr_t)HTTP_API_ROUTE_INFO},
{.uri = "/api/health", .method = HTTP_GET, .handler = api_handler, .user_ctx = (void *)(uintptr_t)HTTP_API_ROUTE_HEALTH}, {.uri = "/api/health", .method = HTTP_GET, .handler = api_handler, .user_ctx = (void *)(uintptr_t)HTTP_API_ROUTE_HEALTH},
{.uri = "/api/session/join", .method = HTTP_POST, .handler = api_handler, .user_ctx = (void *)(uintptr_t)HTTP_API_ROUTE_JOIN}, {.uri = "/api/session/join", .method = HTTP_POST, .handler = api_handler, .user_ctx = (void *)(uintptr_t)HTTP_API_ROUTE_JOIN},
@@ -499,6 +661,7 @@ void app_main(void) {
const esp_timer_create_args_t bot_timer_args = {.callback = bot_timer_callback, .name = "bot_turn"}; const esp_timer_create_args_t bot_timer_args = {.callback = bot_timer_callback, .name = "bot_turn"};
ESP_ERROR_CHECK(esp_timer_create(&bot_timer_args, &s_bot_timer)); ESP_ERROR_CHECK(esp_timer_create(&bot_timer_args, &s_bot_timer));
application_set_bot_scheduler(&s_application, (scheduler_t){.schedule_after_ms = schedule_bot_turn, .context = NULL}); application_set_bot_scheduler(&s_application, (scheduler_t){.schedule_after_ms = schedule_bot_turn, .context = NULL});
network_configuration_init(&s_configuration);
http_api_init(&s_api, &s_application); http_api_init(&s_api, &s_application);
sync_service_init(&s_sync, &s_application); sync_service_init(&s_sync, &s_application);
mount_littlefs(); mount_littlefs();
+25
View File
@@ -0,0 +1,25 @@
#include "network_configuration.h"
#include <string.h>
void network_configuration_init(network_configuration_t *configuration) { memset(configuration, 0, sizeof(*configuration)); }
bool network_configuration_begin(network_configuration_t *configuration, network_manager_t *manager, const network_profile_t *candidate, uint32_t now_ms) {
if (configuration == NULL || manager == NULL || configuration->state == NETWORK_CONFIGURATION_VALIDATING || !network_manager_begin_validation(manager, candidate)) return false;
configuration->previous_profile = manager->profile; configuration->had_previous_profile = manager->has_profile;
configuration->state = NETWORK_CONFIGURATION_VALIDATING; configuration->validation_deadline_ms = now_ms + kNetworkValidationWindowMs;
return true;
}
bool network_configuration_validation_timed_out(const network_configuration_t *configuration, uint32_t now_ms) {
return configuration != NULL && configuration->state == NETWORK_CONFIGURATION_VALIDATING && (int32_t)(now_ms - configuration->validation_deadline_ms) >= 0;
}
network_action_t network_configuration_finish(network_configuration_t *configuration, network_manager_t *manager, bool success, uint32_t now_ms) {
if (configuration == NULL || manager == NULL || configuration->state != NETWORK_CONFIGURATION_VALIDATING) return NETWORK_ACTION_NONE;
const network_action_t action = network_manager_finish_validation(manager, success, now_ms);
configuration->state = success ? NETWORK_CONFIGURATION_SUCCESS : NETWORK_CONFIGURATION_FAILED;
if (success) configuration->success_deadline_ms = now_ms + kNetworkSuccessNoticeMs;
return action;
}
bool network_configuration_success_notice_expired(const network_configuration_t *configuration, uint32_t now_ms) {
return configuration != NULL && configuration->state == NETWORK_CONFIGURATION_SUCCESS && (int32_t)(now_ms - configuration->success_deadline_ms) >= 0;
}
bool network_configuration_busy(const network_configuration_t *configuration) { return configuration != NULL && configuration->state == NETWORK_CONFIGURATION_VALIDATING; }
+6 -2
View File
@@ -1,7 +1,7 @@
CC ?= cc CC ?= cc
CFLAGS ?= -std=c11 -Wall -Wextra -Werror -I../../include CFLAGS ?= -std=c11 -Wall -Wextra -Werror -I../../include
all: test_command_queue test_game_domain test_bot_player test_game_lifecycle test_state_presenter test_http_api test_sync_service test_human_game_integration test_bot_game_integration test_robustness test_network_foundation test_captive_portal all: test_command_queue test_game_domain test_bot_player test_game_lifecycle test_state_presenter test_http_api test_sync_service test_human_game_integration test_bot_game_integration test_robustness test_network_foundation test_captive_portal test_network_configuration
test_command_queue: test_command_queue.c ../../src/command_queue.c test_command_queue: test_command_queue.c ../../src/command_queue.c
$(CC) $(CFLAGS) $^ -o $@ $(CC) $(CFLAGS) $^ -o $@
@@ -19,6 +19,7 @@ run: all
./test_robustness ./test_robustness
./test_network_foundation ./test_network_foundation
./test_captive_portal ./test_captive_portal
./test_network_configuration
test_game_domain: test_game_domain.c ../../src/fleet_generator.c ../../src/game_engine.c test_game_domain: test_game_domain.c ../../src/fleet_generator.c ../../src/game_engine.c
$(CC) $(CFLAGS) $^ -o $@ $(CC) $(CFLAGS) $^ -o $@
@@ -54,5 +55,8 @@ test_network_foundation: test_network_foundation.c ../../src/network_state.c ../
test_captive_portal: test_captive_portal.c ../../src/captive_portal.c test_captive_portal: test_captive_portal.c ../../src/captive_portal.c
$(CC) $(CFLAGS) $^ -o $@ $(CC) $(CFLAGS) $^ -o $@
test_network_configuration: test_network_configuration.c ../../src/network_configuration.c ../../src/network_state.c
$(CC) $(CFLAGS) $^ -o $@
clean: clean:
rm -f test_command_queue test_game_domain test_bot_player test_game_lifecycle test_state_presenter test_http_api test_sync_service test_human_game_integration test_bot_game_integration test_robustness test_network_foundation test_captive_portal rm -f test_command_queue test_game_domain test_bot_player test_game_lifecycle test_state_presenter test_http_api test_sync_service test_human_game_integration test_bot_game_integration test_robustness test_network_foundation test_captive_portal test_network_configuration
+16
View File
@@ -0,0 +1,16 @@
#include <assert.h>
#include <stdio.h>
#include <string.h>
#include "network_configuration.h"
static network_profile_t profile(const char *ssid, const char *password) { network_profile_t value = {0}; snprintf(value.ssid, sizeof(value.ssid), "%s", ssid); snprintf(value.password, sizeof(value.password), "%s", password); return value; }
int main(void) {
network_manager_t manager; network_configuration_t configuration; network_profile_t old = profile("Old", "old-pass"); network_profile_t next = profile("New", "new-pass");
network_manager_init(&manager, &old, 0U); network_configuration_init(&configuration);
assert(network_configuration_begin(&configuration, &manager, &next, 10U)); assert(network_configuration_busy(&configuration));
assert(!network_configuration_begin(&configuration, &manager, &next, 11U)); assert(!network_configuration_validation_timed_out(&configuration, 30009U)); assert(network_configuration_validation_timed_out(&configuration, 30010U));
assert(network_configuration_finish(&configuration, &manager, false, 30010U) == NETWORK_ACTION_NONE); assert(strcmp(manager.profile.ssid, "Old") == 0);
assert(network_configuration_begin(&configuration, &manager, &next, 40000U)); assert(network_configuration_finish(&configuration, &manager, true, 40001U) == NETWORK_ACTION_CONNECT); assert(strcmp(manager.profile.ssid, "New") == 0);
assert(!network_configuration_success_notice_expired(&configuration, 55000U)); assert(network_configuration_success_notice_expired(&configuration, 55001U));
puts("network configuration tests passed"); return 0;
}