feat: implement the open fallback access point and captive portal
This commit is contained in:
@@ -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
|
||||
|
||||
**Status:** `BLOCKED`
|
||||
**Status:** `DONE`
|
||||
**Depends on:** Milestone 027
|
||||
|
||||
### 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`.
|
||||
|
||||
### 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
|
||||
|
||||
+1
-1
@@ -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
@@ -23,9 +23,11 @@
|
||||
<input id="ssid" name="ssid" maxlength="32" autocomplete="off" required>
|
||||
<label for="password">Пароль</label>
|
||||
<input id="password" name="password" type="password" maxlength="63" autocomplete="new-password">
|
||||
<p class="hint">Пароль не отображается на этой странице. Проверка и сохранение сети будут доступны на следующем шаге настройки.</p>
|
||||
<button type="submit">Продолжить</button>
|
||||
<p class="hint">Пароль не отображается после отправки. Пока идёт проверка, оставайтесь в Battleship-open.</p>
|
||||
<button type="submit">Проверить и сохранить</button>
|
||||
</form>
|
||||
<button id="delete-network" type="button" class="secondary">Удалить сохранённую сеть</button>
|
||||
<p id="network-status" role="status"></p>
|
||||
<p><a href="/">Открыть игру</a></p>
|
||||
</main>
|
||||
<script src="/setup.js" defer></script>
|
||||
|
||||
+27
-5
@@ -4,20 +4,42 @@
|
||||
const ssid = document.querySelector('#ssid');
|
||||
const refresh = document.querySelector('#refresh');
|
||||
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;
|
||||
const scan = async () => {
|
||||
clearTimeout(timer); refresh.disabled = true; status.textContent = 'Ищем сети…';
|
||||
try {
|
||||
const response = await fetch('/setup/scan', { cache: 'no-store' });
|
||||
const text = await response.text();
|
||||
const response = await fetch('/api/network/scan', { cache: 'no-store' });
|
||||
const result = await response.json();
|
||||
if (!response.ok) throw new Error('scan unavailable');
|
||||
if (text.trim() === 'scanning') { timer = setTimeout(scan, 700); return; }
|
||||
const names = [...new Set(text.split('\n').map(value => value.trim()).filter(Boolean))];
|
||||
if (result.state === 'scanning') { timer = setTimeout(scan, 700); return; }
|
||||
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; }));
|
||||
status.textContent = names.length ? 'Выберите сеть или введите её вручную.' : 'Сети не найдены. Введите скрытую сеть вручную.';
|
||||
} catch (_) { status.textContent = 'Не удалось выполнить поиск. Введите сеть вручную.'; }
|
||||
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);
|
||||
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();
|
||||
})();
|
||||
|
||||
@@ -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/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/resume` | 96 B body | `{token}`; role and state metadata, 192 B |
|
||||
| `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
|
||||
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
|
||||
|
||||
The HTTP state response and WebSocket `state` event use this single 768-byte
|
||||
|
||||
+13
-2
@@ -13,8 +13,9 @@ does not replace the locked rules in [MVP.md](MVP.md), the HTTP contract in
|
||||
`esp_littlefs` 1.20.4.
|
||||
- USB serial device: normally `/dev/ttyACM0`; confirm with `pio device list`
|
||||
before an upload.
|
||||
- A local `include/wifi_config.h`, copied from `include/wifi_config.h.example`.
|
||||
It is ignored by Git. Never commit the SSID or password.
|
||||
- No firmware-embedded Wi-Fi credentials are required. On first boot, join the
|
||||
open `Battleship-open` network and open `http://192.168.4.1/setup` if the
|
||||
captive portal does not appear.
|
||||
|
||||
## 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
|
||||
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
|
||||
|
||||
Record the observed result, device address, browser/device model, and any
|
||||
|
||||
@@ -35,6 +35,40 @@ paths:
|
||||
application/json:
|
||||
schema:
|
||||
$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:
|
||||
post:
|
||||
summary: Create a session and request a role
|
||||
|
||||
@@ -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
@@ -2,7 +2,7 @@
|
||||
# without default 'CMakeLists.txt' file.
|
||||
|
||||
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"
|
||||
REQUIRES esp_event esp_http_server esp_netif esp_wifi esp_littlefs nvs_flash
|
||||
)
|
||||
|
||||
+178
-15
@@ -20,6 +20,7 @@
|
||||
#include "freertos/portmacro.h"
|
||||
#include "http_api.h"
|
||||
#include "network_credential_store.h"
|
||||
#include "network_configuration.h"
|
||||
#include "network_state.h"
|
||||
#include "nvs_flash.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;
|
||||
static portMUX_TYPE s_wifi_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 wifi_state_t s_wifi_state = {0};
|
||||
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_network_timer;
|
||||
static network_manager_t s_network_manager;
|
||||
static network_configuration_t s_configuration;
|
||||
static bool s_scan_pending;
|
||||
static bool s_scan_ready;
|
||||
static bool s_dns_running;
|
||||
|
||||
enum { kNetworkRequestBytes = 160U, kNetworkResponseBytes = 768U };
|
||||
|
||||
static uint32_t platform_random(void *unused) { (void)unused; return esp_random(); }
|
||||
|
||||
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");
|
||||
}
|
||||
|
||||
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) {
|
||||
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");
|
||||
@@ -315,9 +354,45 @@ static network_action_t network_event_action(bool connected) {
|
||||
|
||||
static void network_timer_callback(void *unused) {
|
||||
(void)unused;
|
||||
const uint32_t now_ms = (uint32_t)(esp_timer_get_time() / 1000U);
|
||||
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);
|
||||
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) {
|
||||
@@ -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));
|
||||
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);
|
||||
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 == 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);
|
||||
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) {
|
||||
if (length >= capacity) return length;
|
||||
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;
|
||||
}
|
||||
|
||||
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) {
|
||||
const esp_err_t result = esp_wifi_scan_start(NULL, false);
|
||||
if (result != 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_start(NULL, false) != ESP_OK) { network_response(request, 503U, "{\"ok\":false,\"code\":\"SCAN_UNAVAILABLE\",\"message\":\"Поиск сетей сейчас недоступен.\"}"); return ESP_OK; }
|
||||
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) return httpd_resp_send(request, "scanning\n", HTTPD_RESP_USE_STRLEN);
|
||||
if (s_scan_pending) { network_response(request, 200U, "{\"ok\":true,\"state\":\"scanning\",\"networks\":[]}"); return ESP_OK; }
|
||||
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;
|
||||
char names[(kNetworkSsidBytes + 1U) * kScanResultLimit + 1U] = {0}; size_t length = 0U;
|
||||
for (uint16_t index = 0U; index < count; ++index) length = append_scan_name(names, sizeof(names), length, records[index].ssid);
|
||||
return httpd_resp_send(request, names, length);
|
||||
char body[kNetworkResponseBytes] = "{\"ok\":true,\"state\":\"ready\",\"networks\":["; size_t length = strlen(body);
|
||||
for (uint16_t index = 0U; index < count && length + kNetworkSsidBytes + 4U < sizeof(body); ++index) {
|
||||
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) {
|
||||
@@ -458,12 +616,16 @@ static esp_err_t static_file_handler(httpd_req_t *request) {
|
||||
|
||||
static esp_err_t start_http_server(void) {
|
||||
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");
|
||||
const httpd_uri_t routes[] = {
|
||||
{.uri = "/", .method = HTTP_GET, .handler = root_handler},
|
||||
{.uri = "/setup", .method = HTTP_GET, .handler = setup_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/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},
|
||||
@@ -499,6 +661,7 @@ void app_main(void) {
|
||||
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));
|
||||
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);
|
||||
sync_service_init(&s_sync, &s_application);
|
||||
mount_littlefs();
|
||||
|
||||
@@ -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
@@ -1,7 +1,7 @@
|
||||
CC ?= cc
|
||||
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
|
||||
$(CC) $(CFLAGS) $^ -o $@
|
||||
@@ -19,6 +19,7 @@ run: all
|
||||
./test_robustness
|
||||
./test_network_foundation
|
||||
./test_captive_portal
|
||||
./test_network_configuration
|
||||
|
||||
test_game_domain: test_game_domain.c ../../src/fleet_generator.c ../../src/game_engine.c
|
||||
$(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
|
||||
$(CC) $(CFLAGS) $^ -o $@
|
||||
|
||||
test_network_configuration: test_network_configuration.c ../../src/network_configuration.c ../../src/network_state.c
|
||||
$(CC) $(CFLAGS) $^ -o $@
|
||||
|
||||
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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user