feat: validate credentials, persist them, and switch without rebooting

This commit is contained in:
2026-09-01 21:24:55 +03:00
parent bad0803a6a
commit fd978f323d
10 changed files with 270 additions and 58 deletions
+146 -54
View File
@@ -5,6 +5,7 @@
#include "app_config.h"
#include "application.h"
#include "captive_portal.h"
#include "esp_check.h"
#include "esp_event.h"
#include "esp_heap_caps.h"
@@ -18,24 +19,21 @@
#include "freertos/FreeRTOS.h"
#include "freertos/portmacro.h"
#include "http_api.h"
#include "network_credential_store.h"
#include "network_state.h"
#include "nvs_flash.h"
#include "sync_service.h"
#if __has_include("wifi_config.h")
#include "wifi_config.h"
#define WIFI_CONFIG_AVAILABLE 1
#else
#define WIFI_CONFIG_AVAILABLE 0
#endif
#include "lwip/sockets.h"
static const char *const kLogTag = "battleship";
static const char *const kLittlefsBasePath = "/littlefs";
static const char *const kLittlefsPartitionLabel = "littlefs";
static const size_t kFileChunkBytes = 1024U;
static const uint8_t kHttpMaxOpenSockets = 12U;
enum { kHttpMaxOpenSockets = 12U, kFallbackMaxConnections = 10U, kDnsPort = 53U, kScanResultLimit = 12U };
typedef struct { bool configured; bool connected; bool retry_scheduled; uint8_t reconnect_attempt; } 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_network_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,9 +44,11 @@ static sync_service_t s_sync;
static uint16_t s_rejected_input;
static esp_timer_handle_t s_sync_timer;
static esp_timer_handle_t s_bot_timer;
#if WIFI_CONFIG_AVAILABLE
static esp_timer_handle_t s_reconnect_timer;
#endif
static esp_timer_handle_t s_network_timer;
static network_manager_t s_network_manager;
static bool s_scan_pending;
static bool s_scan_ready;
static bool s_dns_running;
static uint32_t platform_random(void *unused) { (void)unused; return esp_random(); }
@@ -75,6 +75,7 @@ static wifi_state_t wifi_state_snapshot(void) {
}
static const char *wifi_state_name(const wifi_state_t *state) {
if (state->fallback_active) return "fallback";
if (!state->configured) return "not_configured";
return state->connected ? "connected" : "connecting";
}
@@ -255,67 +256,114 @@ static esp_err_t websocket_handler(httpd_req_t *request) {
return ESP_OK;
}
#if WIFI_CONFIG_AVAILABLE
static void reconnect_timer_callback(void *unused) {
(void)unused;
portENTER_CRITICAL(&s_wifi_lock); s_wifi_state.retry_scheduled = false; portEXIT_CRITICAL(&s_wifi_lock);
if (esp_wifi_connect() != ESP_OK) ESP_LOGW(kLogTag, "wifi connect request failed");
static bool fallback_active(void) {
const wifi_state_t state = wifi_state_snapshot();
return state.fallback_active;
}
static void schedule_reconnect(uint32_t delay_ms) {
bool schedule = false;
portENTER_CRITICAL(&s_wifi_lock);
if (s_wifi_state.configured && !s_wifi_state.connected && !s_wifi_state.retry_scheduled) { s_wifi_state.retry_scheduled = true; schedule = true; }
portEXIT_CRITICAL(&s_wifi_lock);
if (schedule && esp_timer_start_once(s_reconnect_timer, (uint64_t)delay_ms * 1000U) != ESP_OK) {
portENTER_CRITICAL(&s_wifi_lock); s_wifi_state.retry_scheduled = false; portEXIT_CRITICAL(&s_wifi_lock);
static void captive_dns_task(void *unused) {
(void)unused;
const int socket_fd = socket(AF_INET, SOCK_DGRAM, IPPROTO_UDP);
if (socket_fd < 0) { ESP_LOGW(kLogTag, "captive DNS unavailable"); s_dns_running = false; vTaskDelete(NULL); return; }
struct sockaddr_in local = {.sin_family = AF_INET, .sin_port = htons(kDnsPort), .sin_addr.s_addr = htonl(INADDR_ANY)};
if (bind(socket_fd, (struct sockaddr *)&local, sizeof(local)) != 0) { close(socket_fd); s_dns_running = false; vTaskDelete(NULL); return; }
const struct timeval timeout = {.tv_sec = 1, .tv_usec = 0};
setsockopt(socket_fd, SOL_SOCKET, SO_RCVTIMEO, &timeout, sizeof(timeout));
const uint8_t address[] = {192U, 168U, 4U, 1U};
while (fallback_active()) {
uint8_t query[256]; uint8_t response[272]; struct sockaddr_in client = {0}; socklen_t client_length = sizeof(client);
const int length = recvfrom(socket_fd, query, sizeof(query), 0, (struct sockaddr *)&client, &client_length);
size_t response_length = 0U;
if (length > 0 && captive_portal_dns_response(query, (size_t)length, address, response, sizeof(response), &response_length)) sendto(socket_fd, response, response_length, 0, (struct sockaddr *)&client, client_length);
}
close(socket_fd); s_dns_running = false; vTaskDelete(NULL);
}
static void start_captive_dns(void) {
if (s_dns_running) return;
s_dns_running = true;
if (xTaskCreate(captive_dns_task, "captive_dns", 3072U, NULL, 3U, NULL) != pdPASS) { s_dns_running = false; ESP_LOGW(kLogTag, "captive DNS task unavailable"); }
}
static void enable_fallback_ap(void) {
wifi_config_t access_point = {0};
snprintf((char *)access_point.ap.ssid, sizeof(access_point.ap.ssid), "%s", "Battleship-open");
access_point.ap.ssid_len = strlen((const char *)access_point.ap.ssid);
access_point.ap.channel = 1U; access_point.ap.max_connection = kFallbackMaxConnections; access_point.ap.authmode = WIFI_AUTH_OPEN;
if (esp_wifi_set_config(WIFI_IF_AP, &access_point) != ESP_OK || esp_wifi_set_mode(WIFI_MODE_APSTA) != ESP_OK) { ESP_LOGW(kLogTag, "fallback access point unavailable"); return; }
portENTER_CRITICAL(&s_wifi_lock); s_wifi_state.fallback_active = true; portEXIT_CRITICAL(&s_wifi_lock);
start_captive_dns();
}
static void disable_fallback_ap(void) {
portENTER_CRITICAL(&s_wifi_lock); s_wifi_state.fallback_active = false; portEXIT_CRITICAL(&s_wifi_lock);
if (esp_wifi_set_mode(WIFI_MODE_STA) != ESP_OK) ESP_LOGW(kLogTag, "fallback access point stop failed");
}
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");
}
static network_action_t network_event_action(bool connected) {
network_action_t action;
portENTER_CRITICAL(&s_network_lock);
action = connected ? network_manager_connected(&s_network_manager) : network_manager_disconnected(&s_network_manager, (uint32_t)(esp_timer_get_time() / 1000U));
portEXIT_CRITICAL(&s_network_lock);
return action;
}
static void network_timer_callback(void *unused) {
(void)unused;
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);
apply_network_action(action);
}
static void wifi_event_handler(void *argument, esp_event_base_t event_base, int32_t event_id, void *event_data) {
(void)argument; (void)event_data;
if (event_base == WIFI_EVENT && event_id == WIFI_EVENT_STA_START) schedule_reconnect(0U);
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) {
uint8_t attempt;
portENTER_CRITICAL(&s_wifi_lock);
if (s_wifi_state.reconnect_attempt < 5U) ++s_wifi_state.reconnect_attempt;
attempt = s_wifi_state.reconnect_attempt; s_wifi_state.connected = false;
portEXIT_CRITICAL(&s_wifi_lock);
schedule_reconnect(1000U << attempt);
} else if (event_base == IP_EVENT && event_id == IP_EVENT_STA_GOT_IP) {
portENTER_CRITICAL(&s_wifi_lock); s_wifi_state.connected = true; s_wifi_state.reconnect_attempt = 0U; 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));
} 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);
portENTER_CRITICAL(&s_wifi_lock); s_wifi_state.connected = true; portEXIT_CRITICAL(&s_wifi_lock);
if (fallback_active()) disable_fallback_ap();
}
}
#endif
static esp_err_t start_wifi(void) {
#if !WIFI_CONFIG_AVAILABLE
ESP_LOGE(kLogTag, "wifi is not configured; copy include/wifi_config.h.example to include/wifi_config.h");
return ESP_ERR_INVALID_STATE;
#else
network_profile_t profile = {0};
const bool has_profile = network_credential_store_load(&profile);
wifi_init_config_t init_config = WIFI_INIT_CONFIG_DEFAULT();
wifi_config_t station_config = {0};
const esp_timer_create_args_t timer_args = {.callback = reconnect_timer_callback, .name = "wifi_reconnect"};
portENTER_CRITICAL(&s_wifi_lock); s_wifi_state.configured = true; portEXIT_CRITICAL(&s_wifi_lock);
esp_netif_create_default_wifi_sta();
const uint32_t now_ms = (uint32_t)(esp_timer_get_time() / 1000U);
portENTER_CRITICAL(&s_network_lock); const network_action_t initial_action = network_manager_init(&s_network_manager, has_profile ? &profile : NULL, now_ms); portEXIT_CRITICAL(&s_network_lock);
portENTER_CRITICAL(&s_wifi_lock); s_wifi_state.configured = has_profile; portEXIT_CRITICAL(&s_wifi_lock);
esp_netif_create_default_wifi_sta(); esp_netif_create_default_wifi_ap();
ESP_RETURN_ON_ERROR(esp_wifi_init(&init_config), kLogTag, "wifi initialization failed");
ESP_RETURN_ON_ERROR(esp_event_handler_instance_register(WIFI_EVENT, ESP_EVENT_ANY_ID, wifi_event_handler, NULL, NULL), kLogTag, "wifi event registration failed");
ESP_RETURN_ON_ERROR(esp_event_handler_instance_register(IP_EVENT, IP_EVENT_STA_GOT_IP, wifi_event_handler, NULL, NULL), kLogTag, "ip event registration failed");
ESP_RETURN_ON_ERROR(esp_timer_create(&timer_args, &s_reconnect_timer), kLogTag, "wifi timer creation failed");
snprintf((char *)station_config.sta.ssid, sizeof(station_config.sta.ssid), "%s", WIFI_CONFIG_SSID);
snprintf((char *)station_config.sta.password, sizeof(station_config.sta.password), "%s", WIFI_CONFIG_PASSWORD);
station_config.sta.threshold.authmode = WIFI_AUTH_WPA2_PSK;
station_config.sta.pmf_cfg.capable = true;
ESP_RETURN_ON_ERROR(esp_wifi_set_mode(WIFI_MODE_STA), kLogTag, "wifi mode setup failed");
ESP_RETURN_ON_ERROR(esp_wifi_set_config(WIFI_IF_STA, &station_config), kLogTag, "wifi configuration failed");
return esp_wifi_start();
#endif
const esp_timer_create_args_t timer_args = {.callback = network_timer_callback, .name = "network_state"};
ESP_RETURN_ON_ERROR(esp_timer_create(&timer_args, &s_network_timer), kLogTag, "network timer creation failed");
if (has_profile) {
memcpy(station_config.sta.ssid, profile.ssid, kNetworkSsidBytes);
memcpy(station_config.sta.password, profile.password, kNetworkPasswordBytes);
ESP_RETURN_ON_ERROR(esp_wifi_set_config(WIFI_IF_STA, &station_config), kLogTag, "saved network configuration failed");
}
ESP_RETURN_ON_ERROR(esp_wifi_set_mode(initial_action == NETWORK_ACTION_FALLBACK ? WIFI_MODE_AP : WIFI_MODE_STA), kLogTag, "wifi mode setup failed");
ESP_RETURN_ON_ERROR(esp_wifi_start(), kLogTag, "wifi start failed");
ESP_RETURN_ON_ERROR(esp_timer_start_periodic(s_network_timer, 1000000U), kLogTag, "network timer start failed");
if (initial_action == NETWORK_ACTION_FALLBACK) enable_fallback_ap();
return ESP_OK;
}
static const char *mime_type_for_path(const char *path) {
if (strcmp(path, "/index.html") == 0) return "text/html; charset=utf-8";
if (strcmp(path, "/index.html") == 0 || strcmp(path, "/setup.html") == 0) return "text/html; charset=utf-8";
if (strcmp(path, "/ship-sprite.svg") == 0) return "image/svg+xml";
return strcmp(path, "/styles.css") == 0 ? "text/css; charset=utf-8" : "application/javascript; charset=utf-8";
return strcmp(path, "/styles.css") == 0 || strcmp(path, "/setup.css") == 0 ? "text/css; charset=utf-8" : "application/javascript; charset=utf-8";
}
static bool request_accepts_gzip(httpd_req_t *request) {
@@ -348,6 +396,46 @@ static esp_err_t send_static_file(httpd_req_t *request, const char *asset_path)
return result == ESP_OK ? httpd_resp_send_chunk(request, NULL, 0U) : result;
}
static esp_err_t redirect_to_setup(httpd_req_t *request) {
httpd_resp_set_status(request, "302 Found");
httpd_resp_set_hdr(request, "Location", "/setup");
httpd_resp_set_hdr(request, "Cache-Control", "no-store");
return httpd_resp_send(request, NULL, 0U);
}
static esp_err_t setup_handler(httpd_req_t *request) {
if (!s_littlefs_mounted) {
httpd_resp_set_status(request, "503 Service Unavailable");
return httpd_resp_send(request, "LittleFS is unavailable", HTTPD_RESP_USE_STRLEN);
}
return send_static_file(request, "/setup.html");
}
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 : '?';
}
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); }
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); }
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);
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); }
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);
}
static esp_err_t root_handler(httpd_req_t *request) {
if (s_littlefs_mounted) return send_static_file(request, "/index.html");
httpd_resp_set_status(request, "503 Service Unavailable");
@@ -359,19 +447,23 @@ static esp_err_t static_file_handler(httpd_req_t *request) {
httpd_resp_set_status(request, "503 Service Unavailable");
return httpd_resp_send(request, "LittleFS is unavailable", HTTPD_RESP_USE_STRLEN);
}
if (captive_portal_is_probe_path(request->uri)) return redirect_to_setup(request);
if (strcmp(request->uri, "/styles.css") != 0 && strcmp(request->uri, "/app.js") != 0 &&
strcmp(request->uri, "/target_interaction.js") != 0 && strcmp(request->uri, "/web_audio.js") != 0 &&
strcmp(request->uri, "/game_sounds.js") != 0 &&
strcmp(request->uri, "/ship-sprite.svg") != 0) return httpd_resp_send_err(request, HTTPD_404_NOT_FOUND, "asset not found");
strcmp(request->uri, "/ship-sprite.svg") != 0 && strcmp(request->uri, "/setup.css") != 0 &&
strcmp(request->uri, "/setup.js") != 0) return fallback_active() ? redirect_to_setup(request) : httpd_resp_send_err(request, HTTPD_404_NOT_FOUND, "asset not found");
return send_static_file(request, request->uri);
}
static esp_err_t start_http_server(void) {
httpd_config_t config = HTTPD_DEFAULT_CONFIG();
config.max_uri_handlers = 18U; config.max_open_sockets = kHttpMaxOpenSockets; config.uri_match_fn = httpd_uri_match_wildcard; config.lru_purge_enable = true;
config.max_uri_handlers = 21U; 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/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},