feat: implement the production HTTP API

This commit is contained in:
2026-08-28 23:20:07 +03:00
parent 97bebfd8fb
commit 95b3450317
12 changed files with 872 additions and 546 deletions
+1 -1
View File
@@ -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"
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"
INCLUDE_DIRS "../include"
REQUIRES esp_event esp_http_server esp_netif esp_wifi esp_littlefs nvs_flash
)
+43
View File
@@ -2,6 +2,12 @@
#include <stddef.h>
void application_init(application_t *application, random_source_t random) {
if (application == NULL) return;
application->command_queue = (command_queue_t){0};
game_lifecycle_init(&application->lifecycle, random);
}
bool application_enqueue(application_t *application, const app_command_t *command) {
return application != NULL && command_queue_push(&application->command_queue, command);
}
@@ -9,3 +15,40 @@ bool application_enqueue(application_t *application, const app_command_t *comman
bool application_take_next_command(application_t *application, app_command_t *command) {
return application != NULL && command_queue_pop(&application->command_queue, command);
}
lifecycle_result_t application_join(application_t *application, role_t requested_role, const char *name,
uint8_t *session_index) {
return application == NULL ? LIFECYCLE_RESULT_UNAUTHORIZED :
game_lifecycle_join(&application->lifecycle, requested_role, name, session_index);
}
lifecycle_result_t application_resume(application_t *application,
const uint8_t token[kSessionTokenBytes], uint8_t *session_index) {
return application == NULL ? LIFECYCLE_RESULT_UNAUTHORIZED :
game_lifecycle_resume(&application->lifecycle, token, session_index);
}
bool application_session_for_token(const application_t *application,
const uint8_t token[kSessionTokenBytes], uint8_t *session_index) {
return application != NULL && session_manager_find(&application->lifecycle.sessions, token, session_index);
}
lifecycle_result_t application_submit(application_t *application, const app_command_t *command) {
if (!application_enqueue(application, command)) return LIFECYCLE_RESULT_GENERATION_FAILED;
app_command_t next = {0};
if (!application_take_next_command(application, &next)) return LIFECYCLE_RESULT_GENERATION_FAILED;
switch (next.type) {
case COMMAND_CONFIG:
return game_lifecycle_configure(&application->lifecycle, next.session_index, next.game_id, next.mode);
case COMMAND_START:
return game_lifecycle_start(&application->lifecycle, next.session_index, next.game_id);
case COMMAND_SHOT:
return game_lifecycle_shot(&application->lifecycle, next.session_index, next.game_id, next.coordinate, NULL);
case COMMAND_REMATCH:
return game_lifecycle_rematch(&application->lifecycle, next.session_index, next.game_id);
case COMMAND_ABORT:
return game_lifecycle_abort(&application->lifecycle, next.session_index, next.game_id);
default:
return LIFECYCLE_RESULT_GENERATION_FAILED;
}
}
+371
View File
@@ -0,0 +1,371 @@
#include "http_api.h"
#include <inttypes.h>
#include <stdarg.h>
#include <stdio.h>
#include <string.h>
#include "state_presenter.h"
enum { kErrorMessageBytes = 80, kErrorResponseBytes = 160 };
typedef struct { const char *text; size_t length; } json_reader_t;
static void skip_space(json_reader_t *reader) {
while (reader->length > 0U && (*reader->text == ' ' || *reader->text == '\n' ||
*reader->text == '\r' || *reader->text == '\t')) {
++reader->text;
--reader->length;
}
}
static bool take_character(json_reader_t *reader, char expected) {
skip_space(reader);
if (reader->length == 0U || *reader->text != expected) return false;
++reader->text;
--reader->length;
return true;
}
static bool parse_string(json_reader_t *reader, char *output, size_t output_size) {
if (!take_character(reader, '"') || output_size == 0U) return false;
size_t written = 0;
while (reader->length > 0U && *reader->text != '"') {
unsigned char character = (unsigned char)*reader->text++;
--reader->length;
if (character < 0x20U) return false;
if (character == '\\') {
if (reader->length == 0U) return false;
const char escaped = *reader->text++;
--reader->length;
if (escaped == '"' || escaped == '\\' || escaped == '/') character = (unsigned char)escaped;
else if (escaped == 'b') character = '\b';
else if (escaped == 'f') character = '\f';
else if (escaped == 'n') character = '\n';
else if (escaped == 'r') character = '\r';
else if (escaped == 't') character = '\t';
else return false;
}
if (written + 1U >= output_size) return false;
output[written++] = (char)character;
}
if (reader->length == 0U) return false;
++reader->text;
--reader->length;
output[written] = '\0';
return true;
}
static bool parse_u32(json_reader_t *reader, uint32_t *value) {
skip_space(reader);
if (reader->length == 0U || *reader->text < '0' || *reader->text > '9') return false;
uint32_t parsed = 0;
do {
const uint8_t digit = (uint8_t)(*reader->text - '0');
if (parsed > (UINT32_MAX - digit) / 10U) return false;
parsed = parsed * 10U + digit;
++reader->text;
--reader->length;
} while (reader->length > 0U && *reader->text >= '0' && *reader->text <= '9');
*value = parsed;
return true;
}
static bool parse_object_start(json_reader_t *reader) { return take_character(reader, '{'); }
static bool parse_next_key(json_reader_t *reader, bool *first, char *key, size_t key_size) {
skip_space(reader);
if (!*first && !take_character(reader, ',')) return false;
*first = false;
if (!parse_string(reader, key, key_size)) return false;
return take_character(reader, ':');
}
static bool parse_object_end(json_reader_t *reader) {
if (!take_character(reader, '}')) return false;
skip_space(reader);
return reader->length == 0U;
}
static bool parse_token(const char *text, uint8_t token[kSessionTokenBytes]) {
if (text == NULL || strlen(text) != kSessionTokenBytes * 2U) return false;
for (uint8_t index = 0; index < kSessionTokenBytes; ++index) {
const char high = text[index * 2U];
const char low = text[index * 2U + 1U];
if (high < '0' || (high > '9' && high < 'a') || high > 'f' ||
low < '0' || (low > '9' && low < 'a') || low > 'f') return false;
const uint8_t high_value = (uint8_t)(high <= '9' ? high - '0' : high - 'a' + 10);
const uint8_t low_value = (uint8_t)(low <= '9' ? low - '0' : low - 'a' + 10);
token[index] = (uint8_t)((high_value << 4U) | low_value);
}
return true;
}
static void token_text(const uint8_t token[kSessionTokenBytes], char output[kSessionTokenBytes * 2U + 1U]) {
static const char hex[] = "0123456789abcdef";
for (uint8_t index = 0; index < kSessionTokenBytes; ++index) {
output[index * 2U] = hex[token[index] >> 4U];
output[index * 2U + 1U] = hex[token[index] & 0x0fU];
}
output[kSessionTokenBytes * 2U] = '\0';
}
static const char *role_text(role_t role) {
return role == ROLE_PLAYER_1 ? "player1" : role == ROLE_PLAYER_2 ? "player2" : "spectator";
}
static const char *phase_text(phase_t phase) {
static const char *const values[] = {"lobby", "preparing", "in_progress", "finished", "rematch_wait"};
return phase <= PHASE_REMATCH_WAIT ? values[phase] : "lobby";
}
static void response_write(http_api_response_t *response, uint16_t status, const char *format, ...) {
va_list arguments;
va_start(arguments, format);
const int length = vsnprintf(response->body, sizeof(response->body), format, arguments);
va_end(arguments);
response->status = length >= 0 && (size_t)length < sizeof(response->body) ? status : 500U;
response->body_length = response->status == status ? (size_t)length : 0U;
if (response->status != status) response->body[0] = '\0';
}
static void response_error(http_api_response_t *response, uint16_t status, const char *code, const char *message,
uint32_t version) {
response_write(response, status, "{\"ok\":false,\"code\":\"%s\",\"message\":\"%s\",\"version\":%" PRIu32 "}",
code, message, version);
if (response->body_length >= kErrorResponseBytes) response_write(response, 500U, "{\"ok\":false,\"code\":\"SERVER_BUSY\",\"message\":\"Ошибка сервера\",\"version\":0}");
}
static void response_lifecycle_error(http_api_response_t *response, lifecycle_result_t result, uint32_t version) {
switch (result) {
case LIFECYCLE_RESULT_INVALID_NAME: response_error(response, 400U, "INVALID_NAME", "Некорректное имя", version); break;
case LIFECYCLE_RESULT_INVALID_ROLE: response_error(response, 400U, "INVALID_ROLE", "Некорректная роль", version); break;
case LIFECYCLE_RESULT_INVALID_MODE: response_error(response, 400U, "INVALID_MODE", "Некорректный режим", version); break;
case LIFECYCLE_RESULT_INVALID_COORDINATE: response_error(response, 400U, "INVALID_COORDINATE", "Некорректные координаты", version); break;
case LIFECYCLE_RESULT_NO_PLAYER_SLOT: response_error(response, 409U, "NO_PLAYER_SLOT", "Нет места игрока", version); break;
case LIFECYCLE_RESULT_NO_SPECTATOR_SLOT: response_error(response, 409U, "NO_SPECTATOR_SLOT", "Нет места зрителя", version); break;
case LIFECYCLE_RESULT_FORBIDDEN_ROLE: response_error(response, 403U, "FORBIDDEN_ROLE", "Роль не может выполнить действие", version); break;
case LIFECYCLE_RESULT_WRONG_PHASE: response_error(response, 409U, "WRONG_PHASE", "Действие недоступно сейчас", version); break;
case LIFECYCLE_RESULT_NOT_YOUR_TURN: response_error(response, 409U, "NOT_YOUR_TURN", "Сейчас ход соперника", version); break;
case LIFECYCLE_RESULT_CELL_ALREADY_SHOT: response_error(response, 409U, "CELL_ALREADY_SHOT", "Клетка уже обстреляна", version); break;
case LIFECYCLE_RESULT_STALE_GAME: response_error(response, 409U, "STALE_GAME", "Партия уже изменилась", version); break;
case LIFECYCLE_RESULT_UNAUTHORIZED: response_error(response, 401U, "UNAUTHORIZED", "Сессия не найдена", version); break;
default: response_error(response, 503U, "SERVER_BUSY", "Сервер занят", version); break;
}
}
static bool expect_post_body(const http_api_request_t *request, size_t maximum, http_api_response_t *response,
uint32_t version) {
if (request->method != HTTP_API_POST || !request->content_type_json) {
response_error(response, 400U, "MALFORMED_JSON", "Ожидается JSON запрос", version);
return false;
}
if (request->body == NULL || request->body_length > maximum) {
response_error(response, 413U, "PAYLOAD_TOO_LARGE", "Слишком большой запрос", version);
return false;
}
return true;
}
static bool parse_join(const http_api_request_t *request, char name[kDisplayNameBytes + 1U], role_t *role) {
json_reader_t reader = {.text = request->body, .length = request->body_length};
char key[16];
char role_value[16];
bool first = true;
uint8_t fields = 0;
if (!parse_object_start(&reader)) return false;
while (reader.length > 0U && *reader.text != '}') {
if (!parse_next_key(&reader, &first, key, sizeof(key))) return false;
if (strcmp(key, "name") == 0 && (fields & 1U) == 0U) {
if (!parse_string(&reader, name, kDisplayNameBytes + 1U)) return false;
fields |= 1U;
} else if (strcmp(key, "requestedRole") == 0 && (fields & 2U) == 0U) {
if (!parse_string(&reader, role_value, sizeof(role_value))) return false;
fields |= 2U;
} else return false;
}
if (!parse_object_end(&reader) || fields != 3U) return false;
if (strcmp(role_value, "player1") == 0) *role = ROLE_PLAYER_1;
else if (strcmp(role_value, "player2") == 0) *role = ROLE_PLAYER_2;
else if (strcmp(role_value, "spectator") == 0) *role = ROLE_SPECTATOR;
else *role = (role_t)UINT8_MAX;
return true;
}
static bool parse_token_only(const http_api_request_t *request, char token[kSessionTokenBytes * 2U + 1U]) {
json_reader_t reader = {.text = request->body, .length = request->body_length};
char key[16];
bool first = true;
if (!parse_object_start(&reader) || !parse_next_key(&reader, &first, key, sizeof(key)) || strcmp(key, "token") != 0 ||
!parse_string(&reader, token, kSessionTokenBytes * 2U + 1U)) return false;
return parse_object_end(&reader);
}
static bool parse_command_with_token(const http_api_request_t *request, app_command_t *command, bool needs_mode,
bool needs_coordinate, uint8_t token[kSessionTokenBytes]) {
char body_token[kSessionTokenBytes * 2U + 1U] = {0};
/* Parse into a temporary command first; token bytes never occupy command storage. */
json_reader_t reader = {.text = request->body, .length = request->body_length};
char key[16];
char mode[8] = {0};
bool first = true;
uint8_t fields = 0;
uint32_t x = 0;
uint32_t y = 0;
if (!parse_object_start(&reader)) return false;
while (reader.length > 0U && *reader.text != '}') {
if (!parse_next_key(&reader, &first, key, sizeof(key))) return false;
if (strcmp(key, "token") == 0 && (fields & 1U) == 0U) {
if (!parse_string(&reader, body_token, sizeof(body_token))) return false;
fields |= 1U;
} else if (strcmp(key, "gameId") == 0 && (fields & 2U) == 0U) {
if (!parse_u32(&reader, &command->game_id)) return false;
fields |= 2U;
} else if (needs_mode && strcmp(key, "mode") == 0 && (fields & 4U) == 0U) {
if (!parse_string(&reader, mode, sizeof(mode))) return false;
fields |= 4U;
} else if (needs_coordinate && strcmp(key, "x") == 0 && (fields & 4U) == 0U) {
if (!parse_u32(&reader, &x)) return false;
fields |= 4U;
} else if (needs_coordinate && strcmp(key, "y") == 0 && (fields & 8U) == 0U) {
if (!parse_u32(&reader, &y)) return false;
fields |= 8U;
} else return false;
}
const uint8_t expected = needs_coordinate ? 15U : needs_mode ? 7U : 3U;
if (!parse_object_end(&reader) || fields != expected || !parse_token(body_token, token)) return false;
if (needs_mode) {
if (strcmp(mode, "human") == 0) command->mode = MODE_HUMAN;
else if (strcmp(mode, "bot") == 0) command->mode = MODE_BOT;
else command->mode = (game_mode_t)UINT8_MAX;
}
if (needs_coordinate) {
command->coordinate = x >= kBoardWidth || y >= kBoardHeight ?
(coordinate_t){.x = kBoardWidth, .y = 0U} : (coordinate_t){.x = (uint8_t)x, .y = (uint8_t)y};
}
return true;
}
void http_api_init(http_api_t *api, application_t *application) {
if (api == NULL) return;
*api = (http_api_t){.application = application, .health = {.wifi_state = "connecting"}};
}
void http_api_set_health(http_api_t *api, const http_api_health_t *health) {
if (api != NULL && health != NULL) api->health = *health;
}
bool http_api_handle(http_api_t *api, const http_api_request_t *request, http_api_response_t *response) {
if (api == NULL || api->application == NULL || request == NULL || response == NULL) return false;
*response = (http_api_response_t){0};
game_lifecycle_t *lifecycle = &api->application->lifecycle;
const uint32_t version = lifecycle->game.state.version;
if (request->target_too_large) {
response_error(response, 413U, "PAYLOAD_TOO_LARGE", "Слишком большой запрос", version);
return true;
}
if (request->route == HTTP_API_ROUTE_INFO && request->method == HTTP_API_GET) {
const session_manager_t *sessions = &lifecycle->sessions;
uint8_t spectators = 0;
for (uint8_t index = kPlayerCapacity; index < kSessionCapacity; ++index) spectators += sessions->entries[index].occupied;
response_write(response, 200U, "{\"ok\":true,\"phase\":\"%s\",\"gameId\":%" PRIu32 ",\"version\":%" PRIu32
",\"player1Available\":%s,\"player2Available\":%s,\"spectatorsAvailable\":%u}",
phase_text(lifecycle->game.state.phase), lifecycle->game.state.game_id, version,
sessions->entries[0].occupied ? "false" : "true", sessions->entries[1].occupied ? "false" : "true",
(unsigned int)(kSpectatorCapacity - spectators));
return response->body_length <= 192U;
}
if (request->route == HTTP_API_ROUTE_HEALTH && request->method == HTTP_API_GET) {
response_write(response, 200U, "{\"ok\":true,\"uptimeMs\":%" PRIu32 ",\"wifiState\":\"%s\",\"freeHeapBytes\":%" PRIu32
",\"minimumFreeHeapBytes\":%" PRIu32 ",\"largestFreeBlockBytes\":%" PRIu32
",\"connectedClients\":%u,\"rejectedInput\":%u}", api->health.uptime_ms,
api->health.wifi_state == NULL ? "unknown" : api->health.wifi_state, api->health.free_heap_bytes,
api->health.minimum_free_heap_bytes, api->health.largest_free_block_bytes,
api->health.connected_clients, api->health.rejected_input);
return response->body_length <= 320U;
}
if (request->route == HTTP_API_ROUTE_STATE && request->method == HTTP_API_GET) {
role_t viewer = ROLE_SPECTATOR;
uint8_t token[kSessionTokenBytes];
uint8_t session_index = 0;
if (request->session_token != NULL && request->session_token[0] != '\0') {
if (!parse_token(request->session_token, token) || !application_session_for_token(api->application, token, &session_index)) {
response_error(response, 401U, "UNAUTHORIZED", "Сессия не найдена", version);
return true;
}
viewer = lifecycle->sessions.entries[session_index].role;
}
if (!state_presenter_write_lifecycle(lifecycle, viewer, response->body, sizeof(response->body), &response->body_length)) {
response_error(response, 503U, "SERVER_BUSY", "Сервер занят", version);
} else response->status = 200U;
return true;
}
if (request->route == HTTP_API_ROUTE_JOIN) {
char name[kDisplayNameBytes + 1U] = {0};
role_t role = ROLE_SPECTATOR;
if (!expect_post_body(request, 192U, response, version)) return true;
if (!parse_join(request, name, &role)) {
response_error(response, 400U, "MALFORMED_JSON", "Некорректный JSON", version);
return true;
}
if (role > ROLE_SPECTATOR) {
response_error(response, 400U, "INVALID_ROLE", "Некорректная роль", version);
return true;
}
uint8_t session_index = 0;
const lifecycle_result_t result = application_join(api->application, role, name, &session_index);
if (result != LIFECYCLE_RESULT_OK) response_lifecycle_error(response, result, lifecycle->game.state.version);
else {
char token[kSessionTokenBytes * 2U + 1U];
token_text(lifecycle->sessions.entries[session_index].token, token);
response_write(response, 200U, "{\"ok\":true,\"token\":\"%s\",\"role\":\"%s\",\"version\":%" PRIu32 ",\"gameId\":%" PRIu32 "}",
token, role_text(lifecycle->sessions.entries[session_index].role), lifecycle->game.state.version,
lifecycle->game.state.game_id);
}
return true;
}
if (request->route == HTTP_API_ROUTE_RESUME) {
char token_text_value[kSessionTokenBytes * 2U + 1U] = {0};
uint8_t token[kSessionTokenBytes];
if (!expect_post_body(request, 96U, response, version)) return true;
if (!parse_token_only(request, token_text_value) || !parse_token(token_text_value, token)) {
response_error(response, 400U, "MALFORMED_JSON", "Некорректный JSON", version);
return true;
}
uint8_t session_index = 0;
const lifecycle_result_t result = application_resume(api->application, token, &session_index);
if (result != LIFECYCLE_RESULT_OK) response_lifecycle_error(response, result, lifecycle->game.state.version);
else response_write(response, 200U, "{\"ok\":true,\"role\":\"%s\",\"version\":%" PRIu32 ",\"gameId\":%" PRIu32 "}",
role_text(lifecycle->sessions.entries[session_index].role), lifecycle->game.state.version,
lifecycle->game.state.game_id);
return true;
}
app_command_t command = {0};
uint8_t token[kSessionTokenBytes];
bool needs_mode = request->route == HTTP_API_ROUTE_CONFIG;
bool needs_coordinate = request->route == HTTP_API_ROUTE_SHOT;
size_t maximum = needs_coordinate || needs_mode ? 96U : 80U;
if (!expect_post_body(request, maximum, response, version)) return true;
if (!parse_command_with_token(request, &command, needs_mode, needs_coordinate, token)) {
response_error(response, 400U, "MALFORMED_JSON", "Некорректный JSON", version);
return true;
}
if (!application_session_for_token(api->application, token, &command.session_index)) {
response_error(response, 401U, "UNAUTHORIZED", "Сессия не найдена", version);
return true;
}
switch (request->route) {
case HTTP_API_ROUTE_CONFIG: command.type = COMMAND_CONFIG; break;
case HTTP_API_ROUTE_START: command.type = COMMAND_START; break;
case HTTP_API_ROUTE_SHOT: command.type = COMMAND_SHOT; break;
case HTTP_API_ROUTE_REMATCH: command.type = COMMAND_REMATCH; break;
case HTTP_API_ROUTE_ABORT: command.type = COMMAND_ABORT; break;
default: response_error(response, 404U, "MALFORMED_JSON", "Маршрут не найден", version); return true;
}
command.version = version;
const lifecycle_result_t result = application_submit(api->application, &command);
if (result != LIFECYCLE_RESULT_OK) response_lifecycle_error(response, result, lifecycle->game.state.version);
else response_write(response, 200U, "{\"ok\":true,\"version\":%" PRIu32 ",\"gameId\":%" PRIu32 "}",
lifecycle->game.state.version, lifecycle->game.state.game_id);
return true;
}
+174 -539
View File
@@ -1,9 +1,10 @@
#include <inttypes.h>
#include <stdbool.h>
#include <stdint.h>
#include <stdio.h>
#include <string.h>
#include "app_config.h"
#include "application.h"
#include "esp_check.h"
#include "esp_event.h"
#include "esp_heap_caps.h"
@@ -11,10 +12,12 @@
#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 "nvs_flash.h"
#if __has_include("wifi_config.h")
@@ -24,306 +27,25 @@
#define WIFI_CONFIG_AVAILABLE 0
#endif
static const char *const kLogTag = "vertical_slice";
static const char *const kBuildVersion = "m004";
static const char *const kLogTag = "battleship";
static const char *const kLittlefsBasePath = "/littlefs";
static const char *const kLittlefsPartitionLabel = "littlefs";
static const size_t kFileChunkBytes = 1024;
enum {
kCapacityPlayerCount = 2,
kCapacitySpectatorCount = 8,
kCapacitySessionCount = kCapacityPlayerCount + kCapacitySpectatorCount,
kCapacityGames = 20,
kCapacityChangesPerGame = 200,
kCapacityBoardCells = 100,
kCapacityShipsPerBoard = 10,
kCapacityTickMs = 25,
kMaxStateMessageBytes = 512,
kHttpMaxOpenSockets = 12,
};
typedef struct {
bool configured;
bool connected;
bool retry_scheduled;
uint8_t reconnect_attempt;
char ip_address[16];
} wifi_state_t;
static const size_t kFileChunkBytes = 1024U;
static const uint8_t kHttpMaxOpenSockets = 12U;
typedef struct { bool configured; bool connected; bool retry_scheduled; uint8_t reconnect_attempt; } wifi_state_t;
static portMUX_TYPE s_wifi_lock = portMUX_INITIALIZER_UNLOCKED;
static wifi_state_t s_wifi_state = {0};
static bool s_littlefs_mounted;
static httpd_handle_t s_server;
static esp_timer_handle_t s_state_timer;
static portMUX_TYPE s_capacity_lock = portMUX_INITIALIZER_UNLOCKED;
static uint16_t s_rejected_oversized_input;
static application_t s_application;
static http_api_t s_api;
static uint16_t s_rejected_input;
#if WIFI_CONFIG_AVAILABLE
static esp_timer_handle_t s_reconnect_timer;
#endif
typedef enum { VIEW_PLAYER_1, VIEW_PLAYER_2, VIEW_SPECTATOR } view_role_t;
typedef struct { int fd; view_role_t role; } ws_client_t;
static ws_client_t s_ws_clients[kCapacitySessionCount];
typedef struct {
uint8_t x;
uint8_t y;
uint8_t length;
bool horizontal;
uint8_t hits;
} mock_ship_t;
typedef struct {
uint8_t cells[2][kCapacityBoardCells];
mock_ship_t ships[2][kCapacityShipsPerBoard];
uint8_t session_ids[kCapacitySessionCount][16];
uint16_t completed_games;
uint16_t player_wins[kCapacityPlayerCount];
uint32_t game_id;
uint32_t version;
uint16_t changes_in_game;
uint8_t current_player;
bool running;
uint32_t initial_free_heap_bytes;
uint32_t minimum_free_heap_bytes;
uint32_t maximum_json_bytes;
uint32_t maximum_generation_us;
uint32_t maximum_delivery_enqueue_us;
uint32_t websocket_reconnections;
} capacity_state_t;
static capacity_state_t s_capacity_state;
static view_role_t role_from_query(httpd_req_t *request) {
char query[48] = {0};
char role[16] = {0};
if (httpd_req_get_url_query_len(request) < sizeof(query) &&
httpd_req_get_url_query_str(request, query, sizeof(query)) == ESP_OK &&
httpd_query_key_value(query, "role", role, sizeof(role)) == ESP_OK) {
if (strcmp(role, "player1") == 0) return VIEW_PLAYER_1;
if (strcmp(role, "player2") == 0) return VIEW_PLAYER_2;
}
return VIEW_SPECTATOR;
}
static const char *role_name(view_role_t role) {
return role == VIEW_PLAYER_1 ? "player1" : role == VIEW_PLAYER_2 ? "player2" : "spectator";
}
static void remember_ws_client(int fd, view_role_t role) {
size_t reusable_index = sizeof(s_ws_clients) / sizeof(s_ws_clients[0]);
for (size_t index = 0; index < sizeof(s_ws_clients) / sizeof(s_ws_clients[0]); ++index) {
if (s_ws_clients[index].fd == fd) {
s_ws_clients[index].role = role;
return;
}
if (reusable_index == sizeof(s_ws_clients) / sizeof(s_ws_clients[0]) &&
(s_ws_clients[index].fd == 0 ||
httpd_ws_get_fd_info(s_server, s_ws_clients[index].fd) != HTTPD_WS_CLIENT_WEBSOCKET)) {
reusable_index = index;
}
}
if (reusable_index != sizeof(s_ws_clients) / sizeof(s_ws_clients[0])) {
const bool reconnect = s_ws_clients[reusable_index].fd != 0;
s_ws_clients[reusable_index] = (ws_client_t){.fd = fd, .role = role};
if (reconnect) {
portENTER_CRITICAL(&s_capacity_lock);
if (s_capacity_state.running) ++s_capacity_state.websocket_reconnections;
portEXIT_CRITICAL(&s_capacity_lock);
}
return;
}
ESP_LOGW(kLogTag, "websocket client table is full");
}
static view_role_t role_for_fd(int fd) {
for (size_t index = 0; index < sizeof(s_ws_clients) / sizeof(s_ws_clients[0]); ++index) {
if (s_ws_clients[index].fd == fd) return s_ws_clients[index].role;
}
return VIEW_SPECTATOR;
}
static char public_cell(uint8_t cell, bool reveal_ships) {
if (cell == 1 && !reveal_ships) return '0';
return (char)('0' + cell);
}
static int build_state_payload(char *payload, size_t payload_size, view_role_t role) {
capacity_state_t snapshot;
portENTER_CRITICAL(&s_capacity_lock);
snapshot = s_capacity_state;
portEXIT_CRITICAL(&s_capacity_lock);
char board_a[kCapacityBoardCells + 1];
char board_b[kCapacityBoardCells + 1];
const bool reveal_player_1 = role == VIEW_PLAYER_1;
const bool reveal_player_2 = role == VIEW_PLAYER_2;
for (size_t index = 0; index < kCapacityBoardCells; ++index) {
board_a[index] = public_cell(snapshot.cells[0][index], reveal_player_1);
board_b[index] = public_cell(snapshot.cells[1][index], reveal_player_2);
}
board_a[kCapacityBoardCells] = '\0';
board_b[kCapacityBoardCells] = '\0';
return snprintf(payload, payload_size,
"{\"version\":%" PRIu32 ",\"game_id\":%" PRIu32
",\"viewer\":\"%s\",\"phase\":\"capacity\",\"current_player\":%u"
",\"boards\":[\"%s\",\"%s\"],\"wins\":[%u,%u]}",
snapshot.version, snapshot.game_id, role_name(role), snapshot.current_player,
board_a, board_b, snapshot.player_wins[0], snapshot.player_wins[1]);
}
static esp_err_t send_state(httpd_handle_t server, int fd, view_role_t role) {
const int64_t started_us = esp_timer_get_time();
char payload[kMaxStateMessageBytes];
const int length = build_state_payload(payload, sizeof(payload), role);
if (length < 0 || (size_t)length >= sizeof(payload)) return ESP_FAIL;
httpd_ws_frame_t frame = {.final = true, .type = HTTPD_WS_TYPE_TEXT,
.payload = (uint8_t *)payload, .len = (size_t)length};
const esp_err_t result = httpd_ws_send_frame_async(server, fd, &frame);
const uint32_t elapsed_us = (uint32_t)(esp_timer_get_time() - started_us);
portENTER_CRITICAL(&s_capacity_lock);
if ((uint32_t)length > s_capacity_state.maximum_json_bytes) s_capacity_state.maximum_json_bytes = length;
if (elapsed_us > s_capacity_state.maximum_delivery_enqueue_us) {
s_capacity_state.maximum_delivery_enqueue_us = elapsed_us;
}
portEXIT_CRITICAL(&s_capacity_lock);
return result;
}
static void broadcast_state(void *unused) {
(void)unused;
size_t count = kHttpMaxOpenSockets;
int clients[kHttpMaxOpenSockets];
if (httpd_get_client_list(s_server, &count, clients) != ESP_OK) return;
for (size_t index = 0; index < count; ++index) {
if (httpd_ws_get_fd_info(s_server, clients[index]) == HTTPD_WS_CLIENT_WEBSOCKET) {
send_state(s_server, clients[index], role_for_fd(clients[index]));
}
}
}
static void state_timer_callback(void *unused) {
(void)unused;
bool changed = false;
const int64_t started_us = esp_timer_get_time();
portENTER_CRITICAL(&s_capacity_lock);
if (s_capacity_state.running) {
const uint16_t shot = s_capacity_state.changes_in_game++ % kCapacityBoardCells;
const uint8_t target = s_capacity_state.current_player == 0 ? 1 : 0;
s_capacity_state.cells[target][shot] = (shot % 5U == 0U) ? 3 : 2;
++s_capacity_state.version;
s_capacity_state.current_player = (shot % 5U == 0U) ? s_capacity_state.current_player
: (uint8_t)(1U - s_capacity_state.current_player);
const uint32_t free_heap = esp_get_free_heap_size();
if (free_heap < s_capacity_state.minimum_free_heap_bytes) {
s_capacity_state.minimum_free_heap_bytes = free_heap;
}
if (s_capacity_state.changes_in_game == kCapacityChangesPerGame) {
++s_capacity_state.completed_games;
++s_capacity_state.player_wins[s_capacity_state.completed_games % kCapacityPlayerCount];
s_capacity_state.changes_in_game = 0;
++s_capacity_state.game_id;
memset(s_capacity_state.cells, 0, sizeof(s_capacity_state.cells));
if (s_capacity_state.completed_games == kCapacityGames) s_capacity_state.running = false;
}
changed = true;
}
const uint32_t elapsed_us = (uint32_t)(esp_timer_get_time() - started_us);
if (elapsed_us > s_capacity_state.maximum_generation_us) s_capacity_state.maximum_generation_us = elapsed_us;
portEXIT_CRITICAL(&s_capacity_lock);
if (changed && s_server != NULL) httpd_queue_work(s_server, broadcast_state, NULL);
}
static esp_err_t state_handler(httpd_req_t *request) {
char payload[kMaxStateMessageBytes];
const int length = build_state_payload(payload, sizeof(payload), role_from_query(request));
if (length < 0 || (size_t)length >= sizeof(payload)) return ESP_FAIL;
httpd_resp_set_type(request, "application/json");
httpd_resp_set_hdr(request, "Cache-Control", "no-store");
return httpd_resp_send(request, payload, length);
}
static void make_capacity_state(capacity_state_t *state) {
memset(state, 0, sizeof(*state));
state->game_id = 1;
state->initial_free_heap_bytes = esp_get_free_heap_size();
state->minimum_free_heap_bytes = state->initial_free_heap_bytes;
for (size_t player = 0; player < 2; ++player) {
for (size_t ship = 0; ship < kCapacityShipsPerBoard; ++ship) {
state->ships[player][ship] = (mock_ship_t){
.x = (uint8_t)((ship * 3U) % 10U), .y = (uint8_t)((ship * 7U) % 10U),
.length = (uint8_t)(1U + (ship % 4U)), .horizontal = (ship % 2U) == 0U};
state->cells[player][ship * 10U] = 1;
}
}
for (size_t session = 0; session < kCapacitySessionCount; ++session) {
for (size_t byte = 0; byte < sizeof(state->session_ids[session]); ++byte) {
state->session_ids[session][byte] = (uint8_t)(session * 17U + byte);
}
}
}
static esp_err_t capacity_run_handler(httpd_req_t *request) {
if (request->content_len != 0) {
++s_rejected_oversized_input;
return httpd_resp_send_err(request, HTTPD_400_BAD_REQUEST, "capacity run accepts no body");
}
capacity_state_t fresh_state;
make_capacity_state(&fresh_state);
portENTER_CRITICAL(&s_capacity_lock);
if (s_capacity_state.running) {
portEXIT_CRITICAL(&s_capacity_lock);
httpd_resp_set_status(request, "409 Conflict");
return httpd_resp_send(request, "capacity run already active", HTTPD_RESP_USE_STRLEN);
}
fresh_state.running = true;
s_capacity_state = fresh_state;
portEXIT_CRITICAL(&s_capacity_lock);
httpd_resp_set_type(request, "application/json");
httpd_resp_set_hdr(request, "Cache-Control", "no-store");
return httpd_resp_sendstr(request,
"{\"ok\":true,\"games\":20,\"changes_per_game\":200,\"sessions\":10}");
}
static esp_err_t capacity_metrics_handler(httpd_req_t *request) {
capacity_state_t snapshot;
portENTER_CRITICAL(&s_capacity_lock);
snapshot = s_capacity_state;
portEXIT_CRITICAL(&s_capacity_lock);
char response[512];
const int length = snprintf(
response, sizeof(response),
"{\"running\":%s,\"completed_games\":%u,\"changes_in_game\":%u,\"sessions\":10"
",\"initial_free_heap_bytes\":%" PRIu32 ",\"current_free_heap_bytes\":%u"
",\"minimum_free_heap_bytes\":%" PRIu32 ",\"maximum_json_bytes\":%" PRIu32
",\"maximum_generation_us\":%" PRIu32 ",\"maximum_delivery_enqueue_us\":%" PRIu32
",\"websocket_reconnections\":%" PRIu32 ",\"reset_reason\":%d}",
snapshot.running ? "true" : "false", snapshot.completed_games, snapshot.changes_in_game,
snapshot.initial_free_heap_bytes, (unsigned int)esp_get_free_heap_size(),
snapshot.minimum_free_heap_bytes, snapshot.maximum_json_bytes, snapshot.maximum_generation_us,
snapshot.maximum_delivery_enqueue_us, snapshot.websocket_reconnections, (int)esp_reset_reason());
if (length < 0 || (size_t)length >= sizeof(response)) return ESP_FAIL;
httpd_resp_set_type(request, "application/json");
httpd_resp_set_hdr(request, "Cache-Control", "no-store");
return httpd_resp_send(request, response, length);
}
static esp_err_t websocket_handler(httpd_req_t *request) {
const int fd = httpd_req_to_sockfd(request);
if (request->method == HTTP_GET) {
remember_ws_client(fd, role_from_query(request));
return ESP_OK;
}
httpd_ws_frame_t frame = {0};
ESP_RETURN_ON_ERROR(httpd_ws_recv_frame(request, &frame, 0), kLogTag, "websocket frame read failed");
if (frame.len > 64 || frame.type != HTTPD_WS_TYPE_TEXT) {
++s_rejected_oversized_input;
return ESP_OK;
}
char message[65] = {0};
frame.payload = (uint8_t *)message;
frame.len = sizeof(message) - 1;
ESP_RETURN_ON_ERROR(httpd_ws_recv_frame(request, &frame, frame.len), kLogTag, "websocket payload read failed");
if (strcmp(message, "{\"type\":\"ping\"}") != 0) return ESP_OK;
return send_state(request->handle, fd, role_for_fd(fd));
}
static uint32_t platform_random(void *unused) { (void)unused; return esp_random(); }
static wifi_state_t wifi_state_snapshot(void) {
wifi_state_t snapshot;
@@ -333,243 +55,190 @@ static wifi_state_t wifi_state_snapshot(void) {
return snapshot;
}
#if WIFI_CONFIG_AVAILABLE
static const uint8_t kMaxReconnectExponent = 5;
static esp_timer_handle_t s_reconnect_timer;
static const char *wifi_state_name(const wifi_state_t *state) {
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 = s_rejected_input,
.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 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_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;
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; ++s_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) { ++s_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) ++s_rejected_input;
if (route == HTTP_API_ROUTE_STATE) {
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};
if (!http_api_handle(&s_api, &api_request, &response)) return ESP_FAIL;
return send_api_response(request, &response);
}
#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);
const esp_err_t result = esp_wifi_connect();
if (result != ESP_OK) {
ESP_LOGW(kLogTag, "wifi connect request failed: %s", esp_err_to_name(result));
}
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 void schedule_reconnect(uint32_t delay_ms) {
bool should_start_timer = false;
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;
should_start_timer = true;
}
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 (!should_start_timer) {
return;
}
const esp_err_t result = esp_timer_start_once(s_reconnect_timer, (uint64_t)delay_ms * 1000U);
if (result != ESP_OK) {
portENTER_CRITICAL(&s_wifi_lock);
s_wifi_state.retry_scheduled = false;
portEXIT_CRITICAL(&s_wifi_lock);
ESP_LOGW(kLogTag, "wifi reconnect timer failed: %s", esp_err_to_name(result));
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 uint32_t next_reconnect_delay_ms(void) {
uint8_t exponent;
portENTER_CRITICAL(&s_wifi_lock);
if (s_wifi_state.reconnect_attempt < kMaxReconnectExponent) {
++s_wifi_state.reconnect_attempt;
}
exponent = s_wifi_state.reconnect_attempt;
portEXIT_CRITICAL(&s_wifi_lock);
return 1000U << exponent;
}
static void wifi_event_handler(void *argument,
esp_event_base_t event_base,
int32_t event_id,
void *event_data) {
(void)argument;
if (event_base == WIFI_EVENT && event_id == WIFI_EVENT_STA_START) {
ESP_LOGI(kLogTag, "wifi station started");
schedule_reconnect(0);
return;
}
if (event_base == WIFI_EVENT && event_id == WIFI_EVENT_STA_DISCONNECTED) {
const uint32_t delay_ms = next_reconnect_delay_ms();
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);
else if (event_base == WIFI_EVENT && event_id == WIFI_EVENT_STA_DISCONNECTED) {
uint8_t attempt;
portENTER_CRITICAL(&s_wifi_lock);
s_wifi_state.connected = false;
s_wifi_state.ip_address[0] = '\0';
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);
ESP_LOGW(kLogTag, "wifi disconnected; reconnecting in %" PRIu32 " ms", delay_ms);
schedule_reconnect(delay_ms);
return;
}
if (event_base == IP_EVENT && event_id == IP_EVENT_STA_GOT_IP) {
const ip_event_got_ip_t *const event = (const ip_event_got_ip_t *)event_data;
char address[16];
esp_ip4addr_ntoa(&event->ip_info.ip, address, sizeof(address));
portENTER_CRITICAL(&s_wifi_lock);
s_wifi_state.connected = true;
s_wifi_state.reconnect_attempt = 0;
snprintf(s_wifi_state.ip_address, sizeof(s_wifi_state.ip_address), "%s", address);
portEXIT_CRITICAL(&s_wifi_lock);
ESP_LOGI(kLogTag, "wifi connected ip=%s", address);
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);
}
}
#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");
ESP_LOGE(kLogTag, "wifi is not configured; copy include/wifi_config.h.example to include/wifi_config.h");
return ESP_ERR_INVALID_STATE;
#else
wifi_init_config_t init_config = WIFI_INIT_CONFIG_DEFAULT();
wifi_config_t station_config = {0};
esp_timer_create_args_t reconnect_timer_args = {
.callback = reconnect_timer_callback,
.name = "wifi_reconnect",
};
portENTER_CRITICAL(&s_wifi_lock);
s_wifi_state.configured = true;
portEXIT_CRITICAL(&s_wifi_lock);
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();
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(&reconnect_timer_args, &s_reconnect_timer),
kLogTag,
"wifi reconnect timer creation 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);
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;
station_config.sta.pmf_cfg.required = false;
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
}
static const char *wifi_state_name(const wifi_state_t *state) {
if (!state->configured) {
return "not_configured";
}
return state->connected ? "connected" : "connecting";
}
static esp_err_t health_handler(httpd_req_t *request) {
const wifi_state_t wifi_state = wifi_state_snapshot();
size_t client_count = kHttpMaxOpenSockets;
int clients[kHttpMaxOpenSockets];
if (s_server == NULL || httpd_get_client_list(s_server, &client_count, clients) != ESP_OK) client_count = 0;
int8_t rssi_dbm = 0;
wifi_ap_record_t access_point = {0};
if (wifi_state.connected && esp_wifi_sta_get_ap_info(&access_point) == ESP_OK) {
rssi_dbm = access_point.rssi;
}
char response[384];
const int written = snprintf(response,
sizeof(response),
"{\"uptime_ms\":%" PRIu64
",\"wifi_state\":\"%s\",\"rssi_dbm\":%d"
",\"free_heap_bytes\":%u,\"min_free_heap_bytes\":%u"
",\"largest_free_block_bytes\":%u,\"connected_clients\":%u"
",\"rejected_oversized_input\":%u,\"reset_reason\":%d"
",\"build_version\":\"%s\"}",
(uint64_t)(esp_timer_get_time() / 1000),
wifi_state_name(&wifi_state),
(int)rssi_dbm,
(unsigned int)esp_get_free_heap_size(),
(unsigned int)esp_get_minimum_free_heap_size(),
(unsigned int)heap_caps_get_largest_free_block(MALLOC_CAP_8BIT),
(unsigned int)client_count,
(unsigned int)s_rejected_oversized_input,
(int)esp_reset_reason(),
kBuildVersion);
if (written < 0 || (size_t)written >= sizeof(response)) {
return httpd_resp_send_err(request, HTTPD_500_INTERNAL_SERVER_ERROR, "health response failed");
}
httpd_resp_set_type(request, "application/json");
httpd_resp_set_hdr(request, "Cache-Control", "no-store");
return httpd_resp_send(request, response, HTTPD_RESP_USE_STRLEN);
}
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, "/styles.css") == 0) {
return "text/css; charset=utf-8";
}
return "application/javascript; charset=utf-8";
if (strcmp(path, "/index.html") == 0) return "text/html; charset=utf-8";
return strcmp(path, "/styles.css") == 0 ? "text/css; charset=utf-8" : "application/javascript; charset=utf-8";
}
static bool request_accepts_gzip(httpd_req_t *request) {
const size_t header_length = httpd_req_get_hdr_value_len(request, "Accept-Encoding");
if (header_length == 0 || header_length >= 96) {
return false;
}
char header[96];
return httpd_req_get_hdr_value_str(request, "Accept-Encoding", header, sizeof(header)) == ESP_OK &&
strstr(header, "gzip") != NULL;
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 filesystem_path[80];
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;
const char *path_to_open = filesystem_path;
snprintf(filesystem_path, sizeof(filesystem_path), "%s%s", kLittlefsBasePath, asset_path);
if (request_accepts_gzip(request)) {
snprintf(gzip_path, sizeof(gzip_path), "%s.gz", filesystem_path);
FILE *const compressed = fopen(gzip_path, "rb");
if (compressed != NULL) {
fclose(compressed);
path_to_open = gzip_path;
gzip = true;
}
}
FILE *const file = fopen(path_to_open, "rb");
if (file == NULL) {
return httpd_resp_send_err(request, HTTPD_404_NOT_FOUND, "static asset not found");
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",
strcmp(asset_path, "/index.html") == 0 ? "no-cache" : "public, max-age=86400");
if (gzip) {
httpd_resp_set_hdr(request, "Content-Encoding", "gzip");
httpd_resp_set_hdr(request, "Vary", "Accept-Encoding");
}
char chunk[kFileChunkBytes];
size_t bytes_read;
esp_err_t result = ESP_OK;
while ((bytes_read = fread(chunk, 1, sizeof(chunk), file)) > 0) {
result = httpd_resp_send_chunk(request, chunk, bytes_read);
if (result != ESP_OK) {
break;
}
}
httpd_resp_set_hdr(request, "Cache-Control", strcmp(asset_path, "/index.html") == 0 ? "no-cache" : "public, max-age=86400");
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, 0) : result;
return result == ESP_OK ? httpd_resp_send_chunk(request, NULL, 0U) : result;
}
static esp_err_t root_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, "/index.html");
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) {
@@ -577,80 +246,46 @@ 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 (strcmp(request->uri, "/styles.css") != 0 && strcmp(request->uri, "/app.js") != 0) {
return httpd_resp_send_err(request, HTTPD_404_NOT_FOUND, "asset not found");
}
if (strcmp(request->uri, "/styles.css") != 0 && strcmp(request->uri, "/app.js") != 0) return 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_handle_t server = NULL;
httpd_config_t config = HTTPD_DEFAULT_CONFIG();
config.max_uri_handlers = 7;
config.max_open_sockets = kHttpMaxOpenSockets;
config.uri_match_fn = httpd_uri_match_wildcard;
config.lru_purge_enable = true;
ESP_RETURN_ON_ERROR(httpd_start(&server, &config), kLogTag, "http server start failed");
s_server = server;
const httpd_uri_t root = {.uri = "/", .method = HTTP_GET, .handler = root_handler};
const httpd_uri_t health = {.uri = "/api/health", .method = HTTP_GET, .handler = health_handler};
const httpd_uri_t state = {.uri = "/api/state", .method = HTTP_GET, .handler = state_handler};
const httpd_uri_t capacity_run = {.uri = "/api/capacity/run", .method = HTTP_POST,
.handler = capacity_run_handler};
const httpd_uri_t capacity_metrics = {.uri = "/api/capacity/metrics", .method = HTTP_GET,
.handler = capacity_metrics_handler};
const httpd_uri_t websocket = {.uri = "/ws", .method = HTTP_GET, .handler = websocket_handler,
.is_websocket = true};
const httpd_uri_t static_files = {.uri = "/*", .method = HTTP_GET, .handler = static_file_handler};
ESP_RETURN_ON_ERROR(httpd_register_uri_handler(server, &root), kLogTag, "root route registration failed");
ESP_RETURN_ON_ERROR(httpd_register_uri_handler(server, &health), kLogTag, "health route registration failed");
ESP_RETURN_ON_ERROR(httpd_register_uri_handler(server, &state), kLogTag, "state route registration failed");
ESP_RETURN_ON_ERROR(httpd_register_uri_handler(server, &capacity_run), kLogTag, "capacity run route registration failed");
ESP_RETURN_ON_ERROR(httpd_register_uri_handler(server, &capacity_metrics), kLogTag, "capacity metrics route registration failed");
ESP_RETURN_ON_ERROR(httpd_register_uri_handler(server, &websocket), kLogTag, "websocket route registration failed");
ESP_RETURN_ON_ERROR(httpd_register_uri_handler(server, &static_files), kLogTag, "static route registration failed");
ESP_LOGI(kLogTag, "http server started on port %d", config.server_port);
config.max_uri_handlers = 14U; 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 = "/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/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/state", .method = HTTP_GET, .handler = api_handler, .user_ctx = (void *)(uintptr_t)HTTP_API_ROUTE_STATE},
{.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,
};
const esp_err_t result = esp_vfs_littlefs_register(&config);
if (result != ESP_OK) {
ESP_LOGE(kLogTag, "littlefs mount failed: %s; HTTP static assets remain unavailable", esp_err_to_name(result));
return;
}
size_t total_bytes = 0;
size_t used_bytes = 0;
if (esp_littlefs_info(kLittlefsPartitionLabel, &total_bytes, &used_bytes) == ESP_OK) {
ESP_LOGI(kLogTag, "littlefs mounted total_bytes=%u used_bytes=%u", (unsigned int)total_bytes, (unsigned int)used_bytes);
}
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());
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});
http_api_init(&s_api, &s_application);
mount_littlefs();
make_capacity_state(&s_capacity_state);
ESP_ERROR_CHECK(start_http_server());
const esp_timer_create_args_t state_timer_args = {.callback = state_timer_callback, .name = "state_tick"};
ESP_ERROR_CHECK(esp_timer_create(&state_timer_args, &s_state_timer));
ESP_ERROR_CHECK(esp_timer_start_periodic(s_state_timer, (uint64_t)kCapacityTickMs * 1000U));
result = start_wifi();
if (result != ESP_OK) {
ESP_LOGW(kLogTag, "wifi unavailable: %s", esp_err_to_name(result));
}
if (result != ESP_OK) ESP_LOGW(kLogTag, "wifi unavailable: %s", esp_err_to_name(result));
}
+13
View File
@@ -72,6 +72,19 @@ session_result_t session_manager_resume(session_manager_t *manager, const uint8_
return SESSION_RESULT_UNAUTHORIZED;
}
bool session_manager_find(const session_manager_t *manager, const uint8_t token[kSessionTokenBytes],
uint8_t *session_index) {
if (manager == NULL || token == NULL || session_index == NULL) return false;
for (uint8_t index = 0; index < kSessionCapacity; ++index) {
const session_t *session = &manager->entries[index];
if (session->occupied && memcmp(session->token, token, kSessionTokenBytes) == 0) {
*session_index = index;
return true;
}
}
return false;
}
bool session_manager_disconnect(session_manager_t *manager, uint8_t session_index) {
if (manager == NULL || session_index >= kSessionCapacity || !manager->entries[session_index].occupied) return false;
if (manager->entries[session_index].role == ROLE_SPECTATOR) memset(&manager->entries[session_index], 0, sizeof(session_t));