Files
battleship/src/main.c
T

675 lines
39 KiB
C

#include <stdbool.h>
#include <stdint.h>
#include <stdio.h>
#include <string.h>
#include "app_config.h"
#include "application.h"
#include "captive_portal.h"
#include "esp_check.h"
#include "esp_event.h"
#include "esp_heap_caps.h"
#include "esp_http_server.h"
#include "esp_littlefs.h"
#include "esp_log.h"
#include "esp_netif.h"
#include "esp_random.h"
#include "esp_timer.h"
#include "esp_wifi.h"
#include "freertos/FreeRTOS.h"
#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"
#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;
enum { kHttpMaxOpenSockets = 12U, kFallbackMaxConnections = 10U, kDnsPort = 53U, kScanResultLimit = 12U };
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;
static httpd_handle_t s_server;
static application_t s_application;
static http_api_t s_api;
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;
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) {
portENTER_CRITICAL(&s_diagnostics_lock);
if (s_rejected_input < UINT16_MAX) ++s_rejected_input;
portEXIT_CRITICAL(&s_diagnostics_lock);
}
static uint16_t rejected_input_snapshot(void) {
uint16_t rejected = 0;
portENTER_CRITICAL(&s_diagnostics_lock);
rejected = s_rejected_input;
portEXIT_CRITICAL(&s_diagnostics_lock);
return rejected;
}
static wifi_state_t wifi_state_snapshot(void) {
wifi_state_t snapshot;
portENTER_CRITICAL(&s_wifi_lock);
snapshot = s_wifi_state;
portEXIT_CRITICAL(&s_wifi_lock);
return snapshot;
}
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";
}
static void refresh_health(void) {
const wifi_state_t wifi_state = wifi_state_snapshot();
size_t clients = kHttpMaxOpenSockets;
int client_fds[kHttpMaxOpenSockets];
if (s_server == NULL || httpd_get_client_list(s_server, &clients, client_fds) != ESP_OK) clients = 0U;
http_api_set_health(&s_api, &(http_api_health_t){
.uptime_ms = (uint32_t)(esp_timer_get_time() / 1000U),
.free_heap_bytes = esp_get_free_heap_size(),
.minimum_free_heap_bytes = esp_get_minimum_free_heap_size(),
.largest_free_block_bytes = heap_caps_get_largest_free_block(MALLOC_CAP_8BIT),
.connected_clients = (uint8_t)clients,
.rejected_input = rejected_input_snapshot(),
.reset_reason = (int8_t)esp_reset_reason(),
.wifi_state = wifi_state_name(&wifi_state),
});
}
static void set_status(httpd_req_t *request, uint16_t status) {
const char *const values[] = {"200 OK", "400 Bad Request", "401 Unauthorized", "403 Forbidden", "404 Not Found", "409 Conflict", "413 Payload Too Large", "503 Service Unavailable"};
const uint16_t codes[] = {200U, 400U, 401U, 403U, 404U, 409U, 413U, 503U};
for (size_t index = 0; index < sizeof(codes) / sizeof(codes[0]); ++index) if (status == codes[index]) { httpd_resp_set_status(request, values[index]); return; }
httpd_resp_set_status(request, "503 Service Unavailable");
}
static esp_err_t send_api_response(httpd_req_t *request, const http_api_response_t *response) {
set_status(request, response->status);
httpd_resp_set_type(request, "application/json");
httpd_resp_set_hdr(request, "Cache-Control", "no-store");
return httpd_resp_send(request, response->body, response->body_length);
}
static bool websocket_send(void *unused, int client_id, const char *payload, size_t length) {
(void)unused;
httpd_ws_frame_t frame = {.final = true, .type = HTTPD_WS_TYPE_TEXT, .payload = (uint8_t *)payload, .len = length};
const esp_err_t result = httpd_ws_send_frame_async(s_server, client_id, &frame);
if (result != ESP_OK) httpd_sess_trigger_close(s_server, client_id);
return result == ESP_OK;
}
static void sync_broadcast_work(void *unused) {
(void)unused;
sync_service_broadcast(&s_sync, websocket_send, NULL);
}
static void queue_state_broadcast(void) {
if (s_server != NULL && httpd_queue_work(s_server, sync_broadcast_work, NULL) != ESP_OK) record_rejected_input();
}
static bool schedule_bot_turn(void *unused, uint32_t delay_ms) {
(void)unused;
return s_bot_timer != NULL && esp_timer_start_once(s_bot_timer, (uint64_t)delay_ms * 1000U) == ESP_OK;
}
static void bot_turn_work(void *unused) {
(void)unused;
const uint32_t version_before = s_application.lifecycle.game.state.version;
if (application_bot_take_turn(&s_application) && s_application.lifecycle.game.state.version != version_before) queue_state_broadcast();
}
static void bot_timer_callback(void *unused) {
(void)unused;
if (s_server != NULL && httpd_queue_work(s_server, bot_turn_work, NULL) != ESP_OK) record_rejected_input();
}
static void sync_expire_work(void *unused) {
(void)unused;
int closed[kSessionCapacity] = {0};
size_t closed_count = 0U;
sync_service_expire(&s_sync, (uint64_t)(esp_timer_get_time() / 1000U), closed, &closed_count);
for (size_t index = 0; index < closed_count; ++index) httpd_sess_trigger_close(s_server, closed[index]);
}
static void sync_timer_callback(void *unused) {
(void)unused;
if (s_server != NULL) httpd_queue_work(s_server, sync_expire_work, NULL);
}
static bool request_has_json_content_type(httpd_req_t *request) {
const size_t length = httpd_req_get_hdr_value_len(request, "Content-Type");
if (length == 0U || length >= 64U) return false;
char value[64];
return httpd_req_get_hdr_value_str(request, "Content-Type", value, sizeof(value)) == ESP_OK && strncmp(value, "application/json", 16U) == 0;
}
static size_t route_body_limit(http_api_route_t route) {
switch (route) {
case HTTP_API_ROUTE_JOIN: return 192U;
case HTTP_API_ROUTE_RESUME:
case HTTP_API_ROUTE_LEAVE:
case HTTP_API_ROUTE_PROFILE_RESET:
case HTTP_API_ROUTE_CONFIG:
case HTTP_API_ROUTE_SHOT: return 96U;
case HTTP_API_ROUTE_START:
case HTTP_API_ROUTE_REMATCH:
case HTTP_API_ROUTE_ABORT: return 80U;
case HTTP_API_ROUTE_RESET: return 80U;
default: return 0U;
}
}
static esp_err_t api_handler(httpd_req_t *request) {
const http_api_route_t route = (http_api_route_t)(uintptr_t)request->user_ctx;
char body[kRequestBodyCapacity + 1U] = {0};
char token[kSessionTokenBytes * 2U + 1U] = {0};
const size_t maximum = route_body_limit(route);
size_t body_length = 0U;
if (request->method == HTTP_POST) {
if ((size_t)request->content_len > maximum) { body_length = maximum + 1U; record_rejected_input(); }
else {
while (body_length < (size_t)request->content_len) {
const int received = httpd_req_recv(request, body + body_length, request->content_len - body_length);
if (received <= 0) { record_rejected_input(); body_length = maximum + 1U; break; }
body_length += (size_t)received;
}
body[body_length <= kRequestBodyCapacity ? body_length : 0U] = '\0';
}
}
const bool target_too_large = request->method == HTTP_GET && httpd_req_get_url_query_len(request) > 128U;
if (target_too_large) record_rejected_input();
if (route == HTTP_API_ROUTE_STATE || route == HTTP_API_ROUTE_STATISTICS) {
const size_t token_length = httpd_req_get_hdr_value_len(request, "X-Session-Token");
if (token_length > 0U && token_length < sizeof(token) && httpd_req_get_hdr_value_str(request, "X-Session-Token", token, sizeof(token)) != ESP_OK) token[0] = '\0';
else if (token_length >= sizeof(token)) snprintf(token, sizeof(token), "%s", "invalid");
}
refresh_health();
http_api_response_t response;
const http_api_request_t api_request = {.method = request->method == HTTP_GET ? HTTP_API_GET : HTTP_API_POST, .route = route,
.content_type_json = request_has_json_content_type(request), .body = body, .body_length = body_length,
.session_token = token[0] == '\0' ? NULL : token, .target_too_large = target_too_large};
const uint32_t version_before = s_application.lifecycle.game.state.version;
if (!http_api_handle(&s_api, &api_request, &response)) return ESP_FAIL;
if (s_application.lifecycle.game.state.version != version_before) queue_state_broadcast();
return send_api_response(request, &response);
}
static esp_err_t websocket_handler(httpd_req_t *request) {
const int client_id = httpd_req_to_sockfd(request);
if (request->method == HTTP_GET) {
if (sync_service_open(&s_sync, client_id, (uint64_t)(esp_timer_get_time() / 1000U))) return ESP_OK;
httpd_sess_trigger_close(s_server, client_id);
return ESP_FAIL;
}
httpd_ws_frame_t frame = {0};
if (httpd_ws_recv_frame(request, &frame, 0U) != ESP_OK || frame.type != HTTPD_WS_TYPE_TEXT ||
frame.len > kWebSocketFrameCapacity) {
record_rejected_input();
sync_service_close(&s_sync, client_id);
httpd_sess_trigger_close(s_server, client_id);
return ESP_OK;
}
char input[kWebSocketFrameCapacity + 1U] = {0};
frame.payload = (uint8_t *)input;
if (httpd_ws_recv_frame(request, &frame, sizeof(input) - 1U) != ESP_OK) {
sync_service_close(&s_sync, client_id);
httpd_sess_trigger_close(s_server, client_id);
return ESP_OK;
}
char output[kStateMessageCapacity] = {0};
size_t output_length = 0U;
bool state_changed = false;
bool close_client = false;
if (!sync_service_receive(&s_sync, client_id, input, frame.len, (uint64_t)(esp_timer_get_time() / 1000U),
output, &output_length, &state_changed, &close_client)) return ESP_FAIL;
if (output_length > 0U) {
httpd_ws_frame_t response = {.final = true, .type = HTTPD_WS_TYPE_TEXT,
.payload = (uint8_t *)output, .len = output_length};
if (httpd_ws_send_frame(request, &response) != ESP_OK) close_client = true;
}
if (state_changed) queue_state_broadcast();
if (close_client) {
sync_service_close(&s_sync, client_id);
httpd_sess_trigger_close(s_server, client_id);
}
return ESP_OK;
}
static bool fallback_active(void) {
const wifi_state_t state = wifi_state_snapshot();
return state.fallback_active;
}
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 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");
}
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;
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, 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) {
(void)argument; (void)event_data;
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);
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) {
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 (!validating && fallback_active()) disable_fallback_ap();
}
}
static esp_err_t start_wifi(void) {
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 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");
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 || 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 || strcmp(path, "/setup.css") == 0 ? "text/css; charset=utf-8" : "application/javascript; charset=utf-8";
}
static bool request_accepts_gzip(httpd_req_t *request) {
const size_t length = httpd_req_get_hdr_value_len(request, "Accept-Encoding");
if (length == 0U || length >= 96U) return false;
char value[96];
return httpd_req_get_hdr_value_str(request, "Accept-Encoding", value, sizeof(value)) == ESP_OK &&
strstr(value, "gzip") != NULL;
}
static esp_err_t send_static_file(httpd_req_t *request, const char *asset_path) {
char path[80];
char gzip_path[84];
snprintf(path, sizeof(path), "%s%s", kLittlefsBasePath, asset_path);
const char *path_to_open = path;
bool gzip = false;
if (request_accepts_gzip(request)) {
snprintf(gzip_path, sizeof(gzip_path), "%s.gz", path);
FILE *compressed = fopen(gzip_path, "rb");
if (compressed != NULL) { fclose(compressed); path_to_open = gzip_path; gzip = true; }
}
FILE *file = fopen(path_to_open, "rb");
if (file == NULL) return httpd_resp_send_err(request, HTTPD_404_NOT_FOUND, "static asset not found");
httpd_resp_set_type(request, mime_type_for_path(asset_path));
httpd_resp_set_hdr(request, "Cache-Control", "no-cache");
if (gzip) { httpd_resp_set_hdr(request, "Content-Encoding", "gzip"); httpd_resp_set_hdr(request, "Vary", "Accept-Encoding"); }
char chunk[kFileChunkBytes]; size_t read = 0U; esp_err_t result = ESP_OK;
while ((read = fread(chunk, 1U, sizeof(chunk), file)) > 0U && result == ESP_OK) result = httpd_resp_send_chunk(request, chunk, read);
fclose(file);
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 && value != '"' && value != '\\' ? (char)value : '?';
}
output[length++] = '\n'; output[length] = '\0'; return length;
}
static esp_err_t setup_scan_handler(httpd_req_t *request) {
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 (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;
}
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) { network_response(request, 503U, "{\"ok\":false,\"code\":\"SCAN_UNAVAILABLE\",\"message\":\"Поиск сетей сейчас недоступен.\"}"); return ESP_OK; }
s_scan_ready = false;
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) {
if (s_littlefs_mounted) return send_static_file(request, "/index.html");
httpd_resp_set_status(request, "503 Service Unavailable");
return httpd_resp_send(request, "LittleFS is unavailable", HTTPD_RESP_USE_STRLEN);
}
static esp_err_t static_file_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);
}
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 && 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 = 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},
{.uri = "/api/session/resume", .method = HTTP_POST, .handler = api_handler, .user_ctx = (void *)(uintptr_t)HTTP_API_ROUTE_RESUME},
{.uri = "/api/session/leave", .method = HTTP_POST, .handler = api_handler, .user_ctx = (void *)(uintptr_t)HTTP_API_ROUTE_LEAVE},
{.uri = "/api/session/profile-reset", .method = HTTP_POST, .handler = api_handler, .user_ctx = (void *)(uintptr_t)HTTP_API_ROUTE_PROFILE_RESET},
{.uri = "/api/game/config", .method = HTTP_POST, .handler = api_handler, .user_ctx = (void *)(uintptr_t)HTTP_API_ROUTE_CONFIG},
{.uri = "/api/game/start", .method = HTTP_POST, .handler = api_handler, .user_ctx = (void *)(uintptr_t)HTTP_API_ROUTE_START},
{.uri = "/api/game/shot", .method = HTTP_POST, .handler = api_handler, .user_ctx = (void *)(uintptr_t)HTTP_API_ROUTE_SHOT},
{.uri = "/api/game/rematch", .method = HTTP_POST, .handler = api_handler, .user_ctx = (void *)(uintptr_t)HTTP_API_ROUTE_REMATCH},
{.uri = "/api/game/abort", .method = HTTP_POST, .handler = api_handler, .user_ctx = (void *)(uintptr_t)HTTP_API_ROUTE_ABORT},
{.uri = "/api/game/reset", .method = HTTP_POST, .handler = api_handler, .user_ctx = (void *)(uintptr_t)HTTP_API_ROUTE_RESET},
{.uri = "/api/state", .method = HTTP_GET, .handler = api_handler, .user_ctx = (void *)(uintptr_t)HTTP_API_ROUTE_STATE},
{.uri = "/api/statistics", .method = HTTP_GET, .handler = api_handler, .user_ctx = (void *)(uintptr_t)HTTP_API_ROUTE_STATISTICS},
{.uri = "/ws", .method = HTTP_GET, .handler = websocket_handler, .is_websocket = true},
{.uri = "/*", .method = HTTP_GET, .handler = static_file_handler},
};
for (size_t index = 0; index < sizeof(routes) / sizeof(routes[0]); ++index) ESP_RETURN_ON_ERROR(httpd_register_uri_handler(s_server, &routes[index]), kLogTag, "route registration failed");
return ESP_OK;
}
static void mount_littlefs(void) {
const esp_vfs_littlefs_conf_t config = {.base_path = kLittlefsBasePath, .partition_label = kLittlefsPartitionLabel, .format_if_mount_failed = false, .dont_mount = false, .grow_on_mount = false};
if (esp_vfs_littlefs_register(&config) != ESP_OK) { ESP_LOGE(kLogTag, "littlefs mount failed"); return; }
s_littlefs_mounted = true;
}
void app_main(void) {
esp_err_t result = nvs_flash_init();
if (result == ESP_ERR_NVS_NO_FREE_PAGES || result == ESP_ERR_NVS_NEW_VERSION_FOUND) { ESP_ERROR_CHECK(nvs_flash_erase()); result = nvs_flash_init(); }
ESP_ERROR_CHECK(result); ESP_ERROR_CHECK(esp_netif_init()); ESP_ERROR_CHECK(esp_event_loop_create_default());
application_init(&s_application, (random_source_t){.next_u32 = platform_random, .context = NULL});
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();
ESP_ERROR_CHECK(start_http_server());
const esp_timer_create_args_t sync_timer_args = {.callback = sync_timer_callback, .name = "sync_expire"};
ESP_ERROR_CHECK(esp_timer_create(&sync_timer_args, &s_sync_timer));
ESP_ERROR_CHECK(esp_timer_start_periodic(s_sync_timer, 1000000U));
result = start_wifi();
if (result != ESP_OK) ESP_LOGW(kLogTag, "wifi unavailable: %s", esp_err_to_name(result));
}