feat: implement the production HTTP API
This commit is contained in:
@@ -607,7 +607,7 @@ If all criteria pass, set this milestone to `DONE`, append its execution record,
|
|||||||
|
|
||||||
## Milestone 011 — Implement the production HTTP API
|
## Milestone 011 — Implement the production HTTP API
|
||||||
|
|
||||||
**Status:** `READY`
|
**Status:** `DONE`
|
||||||
**Depends on:** Milestone 010
|
**Depends on:** Milestone 010
|
||||||
|
|
||||||
### Objective
|
### Objective
|
||||||
@@ -635,11 +635,22 @@ Expose the contracted session and game commands through bounded, authenticated H
|
|||||||
|
|
||||||
If all criteria pass, set this milestone to `DONE`, append its execution record, and change Milestone 012 from `BLOCKED` to `READY`.
|
If all criteria pass, set this milestone to `DONE`, append its execution record, and change Milestone 012 from `BLOCKED` to `READY`.
|
||||||
|
|
||||||
|
### Execution record
|
||||||
|
|
||||||
|
- Date: 2026-08-28
|
||||||
|
- Board model and revision: ESP32-C6FH4 QFN32, revision v0.2.
|
||||||
|
- Toolchain and library versions: PlatformIO Core 6.1.19; `espressif32` 7.0.1; ESP-IDF 6.0.1; `esp_littlefs` 1.20.4.
|
||||||
|
- Result: PASS.
|
||||||
|
- Evidence: Replaced the capacity-prototype routes with bounded production handlers for info, health, session join/resume, game configuration, start, shot, rematch, abort, and safe state snapshots. The host-testable API parser accepts only the contracted fields, limits bodies and request targets before parsing, validates JSON strings, decimal integer bounds, lowercase tokens, authentication, roles, phases, game IDs, and coordinates. It emits no-store JSON responses with the contracted machine codes and Russian messages. Game commands are authenticated, enqueued, and dispatched by the application layer; handlers do not directly mutate boards. State responses use the Milestone 010 role-safe presenter.
|
||||||
|
- Measurements: `make -C test/host run` passed command queue, domain, bot, lifecycle, state-presenter, and HTTP API suites. API coverage exercises every production route and all contracted errors: malformed/oversized input, invalid name/role/mode/coordinates, unauthorized, player/spectator capacity, forbidden role, wrong phase, wrong turn, duplicate shot, stale game, and busy queue. `pio run -e esp32-c6-devkitm-1` passed with 39,180 / 327,680 B RAM (12.0%) and 1,006,156 / 2,097,152 B flash (48.0%), within the Milestone 005 limits.
|
||||||
|
- Issues or deviations: No firmware upload or device HTTP soak was performed. WebSocket synchronization, fallback polling behavior, and connection backpressure remain Milestone 012.
|
||||||
|
- Next action: Milestone 012 is ready. Do not start it unless explicitly requested.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Milestone 012 — Implement WebSocket synchronization and HTTP recovery
|
## Milestone 012 — Implement WebSocket synchronization and HTTP recovery
|
||||||
|
|
||||||
**Status:** `BLOCKED`
|
**Status:** `READY`
|
||||||
**Depends on:** Milestone 011
|
**Depends on:** Milestone 011
|
||||||
|
|
||||||
### Objective
|
### Objective
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
#include <stddef.h>
|
#include <stddef.h>
|
||||||
#include <stdint.h>
|
#include <stdint.h>
|
||||||
|
|
||||||
typedef struct { uint64_t (*now_ms)(void *context); void *context; } clock_t;
|
typedef struct { uint64_t (*now_ms)(void *context); void *context; } app_clock_t;
|
||||||
typedef struct { uint32_t (*next_u32)(void *context); void *context; } random_source_t;
|
typedef struct { uint32_t (*next_u32)(void *context); void *context; } random_source_t;
|
||||||
typedef struct { bool (*schedule_after_ms)(void *context, uint32_t delay_ms); void *context; } scheduler_t;
|
typedef struct { bool (*schedule_after_ms)(void *context, uint32_t delay_ms); void *context; } scheduler_t;
|
||||||
typedef struct { bool (*send)(void *context, int client_id, const char *data, size_t length); void *context; } transport_t;
|
typedef struct { bool (*send)(void *context, int client_id, const char *data, size_t length); void *context; } transport_t;
|
||||||
|
|||||||
@@ -8,5 +8,13 @@
|
|||||||
/* The application task is the sole owner of lifecycle and game mutations. */
|
/* The application task is the sole owner of lifecycle and game mutations. */
|
||||||
typedef struct { command_queue_t command_queue; game_lifecycle_t lifecycle; } application_t;
|
typedef struct { command_queue_t command_queue; game_lifecycle_t lifecycle; } application_t;
|
||||||
|
|
||||||
|
void application_init(application_t *application, random_source_t random);
|
||||||
bool application_enqueue(application_t *application, const app_command_t *command);
|
bool application_enqueue(application_t *application, const app_command_t *command);
|
||||||
bool application_take_next_command(application_t *application, app_command_t *command);
|
bool application_take_next_command(application_t *application, app_command_t *command);
|
||||||
|
lifecycle_result_t application_join(application_t *application, role_t requested_role, const char *name,
|
||||||
|
uint8_t *session_index);
|
||||||
|
lifecycle_result_t application_resume(application_t *application,
|
||||||
|
const uint8_t token[kSessionTokenBytes], uint8_t *session_index);
|
||||||
|
bool application_session_for_token(const application_t *application,
|
||||||
|
const uint8_t token[kSessionTokenBytes], uint8_t *session_index);
|
||||||
|
lifecycle_result_t application_submit(application_t *application, const app_command_t *command);
|
||||||
|
|||||||
@@ -0,0 +1,54 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <stdbool.h>
|
||||||
|
#include <stddef.h>
|
||||||
|
#include <stdint.h>
|
||||||
|
|
||||||
|
#include "application.h"
|
||||||
|
#include "app_config.h"
|
||||||
|
|
||||||
|
typedef enum { HTTP_API_GET, HTTP_API_POST } http_api_method_t;
|
||||||
|
typedef enum {
|
||||||
|
HTTP_API_ROUTE_INFO,
|
||||||
|
HTTP_API_ROUTE_HEALTH,
|
||||||
|
HTTP_API_ROUTE_JOIN,
|
||||||
|
HTTP_API_ROUTE_RESUME,
|
||||||
|
HTTP_API_ROUTE_CONFIG,
|
||||||
|
HTTP_API_ROUTE_START,
|
||||||
|
HTTP_API_ROUTE_SHOT,
|
||||||
|
HTTP_API_ROUTE_REMATCH,
|
||||||
|
HTTP_API_ROUTE_ABORT,
|
||||||
|
HTTP_API_ROUTE_STATE,
|
||||||
|
} http_api_route_t;
|
||||||
|
|
||||||
|
typedef struct {
|
||||||
|
uint32_t uptime_ms;
|
||||||
|
uint32_t free_heap_bytes;
|
||||||
|
uint32_t minimum_free_heap_bytes;
|
||||||
|
uint32_t largest_free_block_bytes;
|
||||||
|
uint8_t connected_clients;
|
||||||
|
uint16_t rejected_input;
|
||||||
|
const char *wifi_state;
|
||||||
|
} http_api_health_t;
|
||||||
|
|
||||||
|
typedef struct {
|
||||||
|
http_api_method_t method;
|
||||||
|
http_api_route_t route;
|
||||||
|
bool content_type_json;
|
||||||
|
bool target_too_large;
|
||||||
|
const char *body;
|
||||||
|
size_t body_length;
|
||||||
|
const char *session_token;
|
||||||
|
} http_api_request_t;
|
||||||
|
|
||||||
|
typedef struct {
|
||||||
|
uint16_t status;
|
||||||
|
char body[kStateMessageCapacity];
|
||||||
|
size_t body_length;
|
||||||
|
} http_api_response_t;
|
||||||
|
|
||||||
|
typedef struct { application_t *application; http_api_health_t health; } http_api_t;
|
||||||
|
|
||||||
|
void http_api_init(http_api_t *api, application_t *application);
|
||||||
|
void http_api_set_health(http_api_t *api, const http_api_health_t *health);
|
||||||
|
bool http_api_handle(http_api_t *api, const http_api_request_t *request, http_api_response_t *response);
|
||||||
@@ -32,6 +32,8 @@ session_result_t session_manager_join(session_manager_t *manager, role_t request
|
|||||||
random_source_t random, uint8_t *session_index);
|
random_source_t random, uint8_t *session_index);
|
||||||
session_result_t session_manager_resume(session_manager_t *manager, const uint8_t token[kSessionTokenBytes],
|
session_result_t session_manager_resume(session_manager_t *manager, const uint8_t token[kSessionTokenBytes],
|
||||||
uint8_t *session_index);
|
uint8_t *session_index);
|
||||||
|
bool session_manager_find(const session_manager_t *manager, const uint8_t token[kSessionTokenBytes],
|
||||||
|
uint8_t *session_index);
|
||||||
bool session_manager_disconnect(session_manager_t *manager, uint8_t session_index);
|
bool session_manager_disconnect(session_manager_t *manager, uint8_t session_index);
|
||||||
session_result_t session_manager_leave(session_manager_t *manager, uint8_t session_index, phase_t phase);
|
session_result_t session_manager_leave(session_manager_t *manager, uint8_t session_index, phase_t phase);
|
||||||
bool session_manager_player_present(const session_manager_t *manager, uint8_t player_index);
|
bool session_manager_player_present(const session_manager_t *manager, uint8_t player_index);
|
||||||
|
|||||||
+1
-1
@@ -2,7 +2,7 @@
|
|||||||
# without default 'CMakeLists.txt' file.
|
# without default 'CMakeLists.txt' file.
|
||||||
|
|
||||||
idf_component_register(
|
idf_component_register(
|
||||||
SRCS "main.c" "application.c" "command_queue.c" "fleet_generator.c" "game_engine.c" "bot_player.c" "session_manager.c" "game_lifecycle.c" "state_presenter.c"
|
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"
|
INCLUDE_DIRS "../include"
|
||||||
REQUIRES esp_event esp_http_server esp_netif esp_wifi esp_littlefs nvs_flash
|
REQUIRES esp_event esp_http_server esp_netif esp_wifi esp_littlefs nvs_flash
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -2,6 +2,12 @@
|
|||||||
|
|
||||||
#include <stddef.h>
|
#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) {
|
bool application_enqueue(application_t *application, const app_command_t *command) {
|
||||||
return application != NULL && command_queue_push(&application->command_queue, 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) {
|
bool application_take_next_command(application_t *application, app_command_t *command) {
|
||||||
return application != NULL && command_queue_pop(&application->command_queue, 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
@@ -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;
|
||||||
|
}
|
||||||
+172
-537
@@ -1,9 +1,10 @@
|
|||||||
#include <inttypes.h>
|
|
||||||
#include <stdbool.h>
|
#include <stdbool.h>
|
||||||
#include <stdint.h>
|
#include <stdint.h>
|
||||||
#include <stdio.h>
|
#include <stdio.h>
|
||||||
#include <string.h>
|
#include <string.h>
|
||||||
|
|
||||||
|
#include "app_config.h"
|
||||||
|
#include "application.h"
|
||||||
#include "esp_check.h"
|
#include "esp_check.h"
|
||||||
#include "esp_event.h"
|
#include "esp_event.h"
|
||||||
#include "esp_heap_caps.h"
|
#include "esp_heap_caps.h"
|
||||||
@@ -11,10 +12,12 @@
|
|||||||
#include "esp_littlefs.h"
|
#include "esp_littlefs.h"
|
||||||
#include "esp_log.h"
|
#include "esp_log.h"
|
||||||
#include "esp_netif.h"
|
#include "esp_netif.h"
|
||||||
|
#include "esp_random.h"
|
||||||
#include "esp_timer.h"
|
#include "esp_timer.h"
|
||||||
#include "esp_wifi.h"
|
#include "esp_wifi.h"
|
||||||
#include "freertos/FreeRTOS.h"
|
#include "freertos/FreeRTOS.h"
|
||||||
#include "freertos/portmacro.h"
|
#include "freertos/portmacro.h"
|
||||||
|
#include "http_api.h"
|
||||||
#include "nvs_flash.h"
|
#include "nvs_flash.h"
|
||||||
|
|
||||||
#if __has_include("wifi_config.h")
|
#if __has_include("wifi_config.h")
|
||||||
@@ -24,306 +27,25 @@
|
|||||||
#define WIFI_CONFIG_AVAILABLE 0
|
#define WIFI_CONFIG_AVAILABLE 0
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
static const char *const kLogTag = "vertical_slice";
|
static const char *const kLogTag = "battleship";
|
||||||
static const char *const kBuildVersion = "m004";
|
|
||||||
static const char *const kLittlefsBasePath = "/littlefs";
|
static const char *const kLittlefsBasePath = "/littlefs";
|
||||||
static const char *const kLittlefsPartitionLabel = "littlefs";
|
static const char *const kLittlefsPartitionLabel = "littlefs";
|
||||||
static const size_t kFileChunkBytes = 1024;
|
static const size_t kFileChunkBytes = 1024U;
|
||||||
enum {
|
static const uint8_t kHttpMaxOpenSockets = 12U;
|
||||||
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;
|
|
||||||
|
|
||||||
|
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 portMUX_TYPE s_wifi_lock = portMUX_INITIALIZER_UNLOCKED;
|
||||||
static wifi_state_t s_wifi_state = {0};
|
static wifi_state_t s_wifi_state = {0};
|
||||||
static bool s_littlefs_mounted;
|
static bool s_littlefs_mounted;
|
||||||
static httpd_handle_t s_server;
|
static httpd_handle_t s_server;
|
||||||
static esp_timer_handle_t s_state_timer;
|
static application_t s_application;
|
||||||
static portMUX_TYPE s_capacity_lock = portMUX_INITIALIZER_UNLOCKED;
|
static http_api_t s_api;
|
||||||
static uint16_t s_rejected_oversized_input;
|
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;
|
static uint32_t platform_random(void *unused) { (void)unused; return esp_random(); }
|
||||||
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 wifi_state_t wifi_state_snapshot(void) {
|
static wifi_state_t wifi_state_snapshot(void) {
|
||||||
wifi_state_t snapshot;
|
wifi_state_t snapshot;
|
||||||
@@ -333,243 +55,190 @@ static wifi_state_t wifi_state_snapshot(void) {
|
|||||||
return snapshot;
|
return snapshot;
|
||||||
}
|
}
|
||||||
|
|
||||||
#if WIFI_CONFIG_AVAILABLE
|
static const char *wifi_state_name(const wifi_state_t *state) {
|
||||||
static const uint8_t kMaxReconnectExponent = 5;
|
if (!state->configured) return "not_configured";
|
||||||
static esp_timer_handle_t s_reconnect_timer;
|
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) {
|
static void reconnect_timer_callback(void *unused) {
|
||||||
(void)unused;
|
(void)unused;
|
||||||
portENTER_CRITICAL(&s_wifi_lock);
|
portENTER_CRITICAL(&s_wifi_lock); s_wifi_state.retry_scheduled = false; portEXIT_CRITICAL(&s_wifi_lock);
|
||||||
s_wifi_state.retry_scheduled = false;
|
if (esp_wifi_connect() != ESP_OK) ESP_LOGW(kLogTag, "wifi connect request failed");
|
||||||
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));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
static void schedule_reconnect(uint32_t delay_ms) {
|
static void schedule_reconnect(uint32_t delay_ms) {
|
||||||
bool should_start_timer = false;
|
bool schedule = false;
|
||||||
portENTER_CRITICAL(&s_wifi_lock);
|
portENTER_CRITICAL(&s_wifi_lock);
|
||||||
if (s_wifi_state.configured && !s_wifi_state.connected && !s_wifi_state.retry_scheduled) {
|
if (s_wifi_state.configured && !s_wifi_state.connected && !s_wifi_state.retry_scheduled) { s_wifi_state.retry_scheduled = true; schedule = true; }
|
||||||
s_wifi_state.retry_scheduled = true;
|
|
||||||
should_start_timer = true;
|
|
||||||
}
|
|
||||||
portEXIT_CRITICAL(&s_wifi_lock);
|
portEXIT_CRITICAL(&s_wifi_lock);
|
||||||
|
if (schedule && esp_timer_start_once(s_reconnect_timer, (uint64_t)delay_ms * 1000U) != ESP_OK) {
|
||||||
if (!should_start_timer) {
|
portENTER_CRITICAL(&s_wifi_lock); s_wifi_state.retry_scheduled = false; portEXIT_CRITICAL(&s_wifi_lock);
|
||||||
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));
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
static uint32_t next_reconnect_delay_ms(void) {
|
static void wifi_event_handler(void *argument, esp_event_base_t event_base, int32_t event_id, void *event_data) {
|
||||||
uint8_t exponent;
|
(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);
|
portENTER_CRITICAL(&s_wifi_lock);
|
||||||
if (s_wifi_state.reconnect_attempt < kMaxReconnectExponent) {
|
if (s_wifi_state.reconnect_attempt < 5U) ++s_wifi_state.reconnect_attempt;
|
||||||
++s_wifi_state.reconnect_attempt;
|
attempt = s_wifi_state.reconnect_attempt; s_wifi_state.connected = false;
|
||||||
}
|
|
||||||
exponent = s_wifi_state.reconnect_attempt;
|
|
||||||
portEXIT_CRITICAL(&s_wifi_lock);
|
portEXIT_CRITICAL(&s_wifi_lock);
|
||||||
return 1000U << exponent;
|
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);
|
||||||
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();
|
|
||||||
portENTER_CRITICAL(&s_wifi_lock);
|
|
||||||
s_wifi_state.connected = false;
|
|
||||||
s_wifi_state.ip_address[0] = '\0';
|
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
static esp_err_t start_wifi(void) {
|
static esp_err_t start_wifi(void) {
|
||||||
#if !WIFI_CONFIG_AVAILABLE
|
#if !WIFI_CONFIG_AVAILABLE
|
||||||
ESP_LOGE(kLogTag,
|
ESP_LOGE(kLogTag, "wifi is not configured; copy include/wifi_config.h.example to include/wifi_config.h");
|
||||||
"wifi is not configured; copy include/wifi_config.h.example to include/wifi_config.h");
|
|
||||||
return ESP_ERR_INVALID_STATE;
|
return ESP_ERR_INVALID_STATE;
|
||||||
#else
|
#else
|
||||||
wifi_init_config_t init_config = WIFI_INIT_CONFIG_DEFAULT();
|
wifi_init_config_t init_config = WIFI_INIT_CONFIG_DEFAULT();
|
||||||
wifi_config_t station_config = {0};
|
wifi_config_t station_config = {0};
|
||||||
esp_timer_create_args_t reconnect_timer_args = {
|
const esp_timer_create_args_t timer_args = {.callback = reconnect_timer_callback, .name = "wifi_reconnect"};
|
||||||
.callback = reconnect_timer_callback,
|
portENTER_CRITICAL(&s_wifi_lock); s_wifi_state.configured = true; portEXIT_CRITICAL(&s_wifi_lock);
|
||||||
.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_netif_create_default_wifi_sta();
|
||||||
ESP_RETURN_ON_ERROR(esp_wifi_init(&init_config), kLogTag, "wifi initialization failed");
|
ESP_RETURN_ON_ERROR(esp_wifi_init(&init_config), kLogTag, "wifi initialization failed");
|
||||||
ESP_RETURN_ON_ERROR(esp_event_handler_instance_register(
|
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");
|
||||||
WIFI_EVENT, ESP_EVENT_ANY_ID, wifi_event_handler, NULL, NULL),
|
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");
|
||||||
kLogTag,
|
ESP_RETURN_ON_ERROR(esp_timer_create(&timer_args, &s_reconnect_timer), kLogTag, "wifi timer creation failed");
|
||||||
"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");
|
|
||||||
|
|
||||||
snprintf((char *)station_config.sta.ssid, sizeof(station_config.sta.ssid), "%s", WIFI_CONFIG_SSID);
|
snprintf((char *)station_config.sta.ssid, sizeof(station_config.sta.ssid), "%s", WIFI_CONFIG_SSID);
|
||||||
snprintf((char *)station_config.sta.password,
|
snprintf((char *)station_config.sta.password, sizeof(station_config.sta.password), "%s", WIFI_CONFIG_PASSWORD);
|
||||||
sizeof(station_config.sta.password),
|
|
||||||
"%s",
|
|
||||||
WIFI_CONFIG_PASSWORD);
|
|
||||||
station_config.sta.threshold.authmode = WIFI_AUTH_WPA2_PSK;
|
station_config.sta.threshold.authmode = WIFI_AUTH_WPA2_PSK;
|
||||||
station_config.sta.pmf_cfg.capable = true;
|
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_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");
|
ESP_RETURN_ON_ERROR(esp_wifi_set_config(WIFI_IF_STA, &station_config), kLogTag, "wifi configuration failed");
|
||||||
return esp_wifi_start();
|
return esp_wifi_start();
|
||||||
#endif
|
#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) {
|
static const char *mime_type_for_path(const char *path) {
|
||||||
if (strcmp(path, "/index.html") == 0) {
|
if (strcmp(path, "/index.html") == 0) return "text/html; charset=utf-8";
|
||||||
return "text/html; charset=utf-8";
|
return strcmp(path, "/styles.css") == 0 ? "text/css; charset=utf-8" : "application/javascript; charset=utf-8";
|
||||||
}
|
|
||||||
if (strcmp(path, "/styles.css") == 0) {
|
|
||||||
return "text/css; charset=utf-8";
|
|
||||||
}
|
|
||||||
return "application/javascript; charset=utf-8";
|
|
||||||
}
|
}
|
||||||
|
|
||||||
static bool request_accepts_gzip(httpd_req_t *request) {
|
static bool request_accepts_gzip(httpd_req_t *request) {
|
||||||
const size_t header_length = httpd_req_get_hdr_value_len(request, "Accept-Encoding");
|
const size_t length = httpd_req_get_hdr_value_len(request, "Accept-Encoding");
|
||||||
if (header_length == 0 || header_length >= 96) {
|
if (length == 0U || length >= 96U) return false;
|
||||||
return false;
|
char value[96];
|
||||||
}
|
return httpd_req_get_hdr_value_str(request, "Accept-Encoding", value, sizeof(value)) == ESP_OK &&
|
||||||
char header[96];
|
strstr(value, "gzip") != NULL;
|
||||||
return httpd_req_get_hdr_value_str(request, "Accept-Encoding", header, sizeof(header)) == ESP_OK &&
|
|
||||||
strstr(header, "gzip") != NULL;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
static esp_err_t send_static_file(httpd_req_t *request, const char *asset_path) {
|
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];
|
char gzip_path[84];
|
||||||
|
snprintf(path, sizeof(path), "%s%s", kLittlefsBasePath, asset_path);
|
||||||
|
const char *path_to_open = path;
|
||||||
bool gzip = false;
|
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)) {
|
if (request_accepts_gzip(request)) {
|
||||||
snprintf(gzip_path, sizeof(gzip_path), "%s.gz", filesystem_path);
|
snprintf(gzip_path, sizeof(gzip_path), "%s.gz", path);
|
||||||
FILE *const compressed = fopen(gzip_path, "rb");
|
FILE *compressed = fopen(gzip_path, "rb");
|
||||||
if (compressed != NULL) {
|
if (compressed != NULL) { fclose(compressed); path_to_open = gzip_path; gzip = true; }
|
||||||
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");
|
|
||||||
}
|
}
|
||||||
|
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_type(request, mime_type_for_path(asset_path));
|
||||||
httpd_resp_set_hdr(request,
|
httpd_resp_set_hdr(request, "Cache-Control", strcmp(asset_path, "/index.html") == 0 ? "no-cache" : "public, max-age=86400");
|
||||||
"Cache-Control",
|
if (gzip) { httpd_resp_set_hdr(request, "Content-Encoding", "gzip"); httpd_resp_set_hdr(request, "Vary", "Accept-Encoding"); }
|
||||||
strcmp(asset_path, "/index.html") == 0 ? "no-cache" : "public, max-age=86400");
|
char chunk[kFileChunkBytes]; size_t read = 0U; esp_err_t result = ESP_OK;
|
||||||
if (gzip) {
|
while ((read = fread(chunk, 1U, sizeof(chunk), file)) > 0U && result == ESP_OK) result = httpd_resp_send_chunk(request, chunk, read);
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
fclose(file);
|
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) {
|
static esp_err_t root_handler(httpd_req_t *request) {
|
||||||
if (!s_littlefs_mounted) {
|
if (s_littlefs_mounted) return send_static_file(request, "/index.html");
|
||||||
httpd_resp_set_status(request, "503 Service Unavailable");
|
httpd_resp_set_status(request, "503 Service Unavailable");
|
||||||
return httpd_resp_send(request, "LittleFS is unavailable", HTTPD_RESP_USE_STRLEN);
|
return httpd_resp_send(request, "LittleFS is unavailable", HTTPD_RESP_USE_STRLEN);
|
||||||
}
|
|
||||||
return send_static_file(request, "/index.html");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
static esp_err_t static_file_handler(httpd_req_t *request) {
|
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");
|
httpd_resp_set_status(request, "503 Service Unavailable");
|
||||||
return httpd_resp_send(request, "LittleFS is unavailable", HTTPD_RESP_USE_STRLEN);
|
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) {
|
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 httpd_resp_send_err(request, HTTPD_404_NOT_FOUND, "asset not found");
|
|
||||||
}
|
|
||||||
return send_static_file(request, request->uri);
|
return send_static_file(request, request->uri);
|
||||||
}
|
}
|
||||||
|
|
||||||
static esp_err_t start_http_server(void) {
|
static esp_err_t start_http_server(void) {
|
||||||
httpd_handle_t server = NULL;
|
|
||||||
httpd_config_t config = HTTPD_DEFAULT_CONFIG();
|
httpd_config_t config = HTTPD_DEFAULT_CONFIG();
|
||||||
config.max_uri_handlers = 7;
|
config.max_uri_handlers = 14U; config.max_open_sockets = kHttpMaxOpenSockets; config.uri_match_fn = httpd_uri_match_wildcard; config.lru_purge_enable = true;
|
||||||
config.max_open_sockets = kHttpMaxOpenSockets;
|
ESP_RETURN_ON_ERROR(httpd_start(&s_server, &config), kLogTag, "http server start failed");
|
||||||
config.uri_match_fn = httpd_uri_match_wildcard;
|
const httpd_uri_t routes[] = {
|
||||||
config.lru_purge_enable = true;
|
{.uri = "/", .method = HTTP_GET, .handler = root_handler},
|
||||||
ESP_RETURN_ON_ERROR(httpd_start(&server, &config), kLogTag, "http server start failed");
|
{.uri = "/api/info", .method = HTTP_GET, .handler = api_handler, .user_ctx = (void *)(uintptr_t)HTTP_API_ROUTE_INFO},
|
||||||
s_server = server;
|
{.uri = "/api/health", .method = HTTP_GET, .handler = api_handler, .user_ctx = (void *)(uintptr_t)HTTP_API_ROUTE_HEALTH},
|
||||||
const httpd_uri_t root = {.uri = "/", .method = HTTP_GET, .handler = root_handler};
|
{.uri = "/api/session/join", .method = HTTP_POST, .handler = api_handler, .user_ctx = (void *)(uintptr_t)HTTP_API_ROUTE_JOIN},
|
||||||
const httpd_uri_t health = {.uri = "/api/health", .method = HTTP_GET, .handler = health_handler};
|
{.uri = "/api/session/resume", .method = HTTP_POST, .handler = api_handler, .user_ctx = (void *)(uintptr_t)HTTP_API_ROUTE_RESUME},
|
||||||
const httpd_uri_t state = {.uri = "/api/state", .method = HTTP_GET, .handler = state_handler};
|
{.uri = "/api/game/config", .method = HTTP_POST, .handler = api_handler, .user_ctx = (void *)(uintptr_t)HTTP_API_ROUTE_CONFIG},
|
||||||
const httpd_uri_t capacity_run = {.uri = "/api/capacity/run", .method = HTTP_POST,
|
{.uri = "/api/game/start", .method = HTTP_POST, .handler = api_handler, .user_ctx = (void *)(uintptr_t)HTTP_API_ROUTE_START},
|
||||||
.handler = capacity_run_handler};
|
{.uri = "/api/game/shot", .method = HTTP_POST, .handler = api_handler, .user_ctx = (void *)(uintptr_t)HTTP_API_ROUTE_SHOT},
|
||||||
const httpd_uri_t capacity_metrics = {.uri = "/api/capacity/metrics", .method = HTTP_GET,
|
{.uri = "/api/game/rematch", .method = HTTP_POST, .handler = api_handler, .user_ctx = (void *)(uintptr_t)HTTP_API_ROUTE_REMATCH},
|
||||||
.handler = capacity_metrics_handler};
|
{.uri = "/api/game/abort", .method = HTTP_POST, .handler = api_handler, .user_ctx = (void *)(uintptr_t)HTTP_API_ROUTE_ABORT},
|
||||||
const httpd_uri_t websocket = {.uri = "/ws", .method = HTTP_GET, .handler = websocket_handler,
|
{.uri = "/api/state", .method = HTTP_GET, .handler = api_handler, .user_ctx = (void *)(uintptr_t)HTTP_API_ROUTE_STATE},
|
||||||
.is_websocket = true};
|
{.uri = "/*", .method = HTTP_GET, .handler = static_file_handler},
|
||||||
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");
|
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");
|
||||||
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);
|
|
||||||
return ESP_OK;
|
return ESP_OK;
|
||||||
}
|
}
|
||||||
|
|
||||||
static void mount_littlefs(void) {
|
static void mount_littlefs(void) {
|
||||||
const esp_vfs_littlefs_conf_t config = {
|
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};
|
||||||
.base_path = kLittlefsBasePath,
|
if (esp_vfs_littlefs_register(&config) != ESP_OK) { ESP_LOGE(kLogTag, "littlefs mount failed"); return; }
|
||||||
.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);
|
|
||||||
}
|
|
||||||
s_littlefs_mounted = true;
|
s_littlefs_mounted = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
void app_main(void) {
|
void app_main(void) {
|
||||||
esp_err_t result = nvs_flash_init();
|
esp_err_t result = nvs_flash_init();
|
||||||
if (result == ESP_ERR_NVS_NO_FREE_PAGES || result == ESP_ERR_NVS_NEW_VERSION_FOUND) {
|
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(nvs_flash_erase());
|
ESP_ERROR_CHECK(result); ESP_ERROR_CHECK(esp_netif_init()); ESP_ERROR_CHECK(esp_event_loop_create_default());
|
||||||
result = nvs_flash_init();
|
application_init(&s_application, (random_source_t){.next_u32 = platform_random, .context = NULL});
|
||||||
}
|
http_api_init(&s_api, &s_application);
|
||||||
ESP_ERROR_CHECK(result);
|
|
||||||
ESP_ERROR_CHECK(esp_netif_init());
|
|
||||||
ESP_ERROR_CHECK(esp_event_loop_create_default());
|
|
||||||
mount_littlefs();
|
mount_littlefs();
|
||||||
make_capacity_state(&s_capacity_state);
|
|
||||||
ESP_ERROR_CHECK(start_http_server());
|
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();
|
result = start_wifi();
|
||||||
if (result != ESP_OK) {
|
if (result != ESP_OK) ESP_LOGW(kLogTag, "wifi unavailable: %s", esp_err_to_name(result));
|
||||||
ESP_LOGW(kLogTag, "wifi unavailable: %s", esp_err_to_name(result));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -72,6 +72,19 @@ session_result_t session_manager_resume(session_manager_t *manager, const uint8_
|
|||||||
return SESSION_RESULT_UNAUTHORIZED;
|
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) {
|
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 == 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));
|
if (manager->entries[session_index].role == ROLE_SPECTATOR) memset(&manager->entries[session_index], 0, sizeof(session_t));
|
||||||
|
|||||||
+7
-3
@@ -1,9 +1,9 @@
|
|||||||
CC ?= cc
|
CC ?= cc
|
||||||
CFLAGS ?= -std=c11 -Wall -Wextra -Werror -I../../include
|
CFLAGS ?= -std=c11 -Wall -Wextra -Werror -I../../include
|
||||||
|
|
||||||
all: test_command_queue test_game_domain test_bot_player test_game_lifecycle test_state_presenter
|
all: test_command_queue test_game_domain test_bot_player test_game_lifecycle test_state_presenter test_http_api
|
||||||
|
|
||||||
test_command_queue: test_command_queue.c ../../src/command_queue.c ../../src/application.c
|
test_command_queue: test_command_queue.c ../../src/command_queue.c
|
||||||
$(CC) $(CFLAGS) $^ -o $@
|
$(CC) $(CFLAGS) $^ -o $@
|
||||||
|
|
||||||
run: all
|
run: all
|
||||||
@@ -12,6 +12,7 @@ run: all
|
|||||||
./test_bot_player
|
./test_bot_player
|
||||||
./test_game_lifecycle
|
./test_game_lifecycle
|
||||||
./test_state_presenter
|
./test_state_presenter
|
||||||
|
./test_http_api
|
||||||
|
|
||||||
test_game_domain: test_game_domain.c ../../src/fleet_generator.c ../../src/game_engine.c
|
test_game_domain: test_game_domain.c ../../src/fleet_generator.c ../../src/game_engine.c
|
||||||
$(CC) $(CFLAGS) $^ -o $@
|
$(CC) $(CFLAGS) $^ -o $@
|
||||||
@@ -25,5 +26,8 @@ test_game_lifecycle: test_game_lifecycle.c ../../src/session_manager.c ../../src
|
|||||||
test_state_presenter: test_state_presenter.c ../../src/state_presenter.c
|
test_state_presenter: test_state_presenter.c ../../src/state_presenter.c
|
||||||
$(CC) $(CFLAGS) $^ -o $@
|
$(CC) $(CFLAGS) $^ -o $@
|
||||||
|
|
||||||
|
test_http_api: test_http_api.c ../../src/http_api.c ../../src/application.c ../../src/command_queue.c ../../src/session_manager.c ../../src/game_lifecycle.c ../../src/fleet_generator.c ../../src/game_engine.c ../../src/state_presenter.c
|
||||||
|
$(CC) $(CFLAGS) $^ -o $@
|
||||||
|
|
||||||
clean:
|
clean:
|
||||||
rm -f test_command_queue test_game_domain test_bot_player test_game_lifecycle test_state_presenter
|
rm -f test_command_queue test_game_domain test_bot_player test_game_lifecycle test_state_presenter test_http_api
|
||||||
|
|||||||
@@ -0,0 +1,185 @@
|
|||||||
|
#include <assert.h>
|
||||||
|
#include <stdio.h>
|
||||||
|
#include <string.h>
|
||||||
|
|
||||||
|
#include "http_api.h"
|
||||||
|
|
||||||
|
typedef struct { uint32_t value; } test_random_t;
|
||||||
|
|
||||||
|
static uint32_t next_random(void *context) {
|
||||||
|
test_random_t *random = context;
|
||||||
|
random->value = random->value * 1664525U + 1013904223U;
|
||||||
|
return random->value;
|
||||||
|
}
|
||||||
|
|
||||||
|
static http_api_t new_api(application_t *application, test_random_t *random) {
|
||||||
|
application_init(application, (random_source_t){.next_u32 = next_random, .context = random});
|
||||||
|
http_api_t api;
|
||||||
|
http_api_init(&api, application);
|
||||||
|
http_api_set_health(&api, &(http_api_health_t){.uptime_ms = 12U, .free_heap_bytes = 200000U,
|
||||||
|
.minimum_free_heap_bytes = 190000U, .largest_free_block_bytes = 180000U,
|
||||||
|
.connected_clients = 2U, .rejected_input = 3U, .wifi_state = "connected"});
|
||||||
|
return api;
|
||||||
|
}
|
||||||
|
|
||||||
|
static http_api_response_t call(http_api_t *api, http_api_route_t route, http_api_method_t method,
|
||||||
|
const char *body, const char *token) {
|
||||||
|
http_api_response_t response;
|
||||||
|
const http_api_request_t request = {.method = method, .route = route, .content_type_json = true,
|
||||||
|
.body = body, .body_length = body == NULL ? 0U : strlen(body), .session_token = token};
|
||||||
|
assert(http_api_handle(api, &request, &response));
|
||||||
|
assert(response.body_length == strlen(response.body));
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
static void expect_code(const http_api_response_t *response, uint16_t status, const char *code) {
|
||||||
|
char expected[64];
|
||||||
|
snprintf(expected, sizeof(expected), "\"code\":\"%s\"", code);
|
||||||
|
assert(response->status == status);
|
||||||
|
assert(strstr(response->body, expected) != NULL);
|
||||||
|
assert(response->body_length < 160U);
|
||||||
|
}
|
||||||
|
|
||||||
|
static void token_from_response(const http_api_response_t *response, char token[33]) {
|
||||||
|
const char *start = strstr(response->body, "\"token\":\"");
|
||||||
|
assert(start != NULL);
|
||||||
|
start += strlen("\"token\":\"");
|
||||||
|
memcpy(token, start, 32U);
|
||||||
|
token[32] = '\0';
|
||||||
|
}
|
||||||
|
|
||||||
|
static void test_public_routes_and_parse_limits(void) {
|
||||||
|
application_t application;
|
||||||
|
test_random_t random = {.value = 1U};
|
||||||
|
http_api_t api = new_api(&application, &random);
|
||||||
|
http_api_response_t response = call(&api, HTTP_API_ROUTE_INFO, HTTP_API_GET, NULL, NULL);
|
||||||
|
assert(response.status == 200U && response.body_length <= 192U && strstr(response.body, "player1Available") != NULL);
|
||||||
|
response = call(&api, HTTP_API_ROUTE_HEALTH, HTTP_API_GET, NULL, NULL);
|
||||||
|
assert(response.status == 200U && response.body_length <= 320U && strstr(response.body, "connected") != NULL);
|
||||||
|
const http_api_request_t long_target = {.method = HTTP_API_GET, .route = HTTP_API_ROUTE_INFO, .target_too_large = true};
|
||||||
|
assert(http_api_handle(&api, &long_target, &response));
|
||||||
|
expect_code(&response, 413U, "PAYLOAD_TOO_LARGE");
|
||||||
|
|
||||||
|
const char oversized[194] = {[0 ... 192] = 'a', [193] = '\0'};
|
||||||
|
response = call(&api, HTTP_API_ROUTE_JOIN, HTTP_API_POST, oversized, NULL);
|
||||||
|
expect_code(&response, 413U, "PAYLOAD_TOO_LARGE");
|
||||||
|
const http_api_request_t non_json = {.method = HTTP_API_POST, .route = HTTP_API_ROUTE_JOIN,
|
||||||
|
.content_type_json = false, .body = "{}", .body_length = 2U};
|
||||||
|
assert(http_api_handle(&api, &non_json, &response));
|
||||||
|
expect_code(&response, 400U, "MALFORMED_JSON");
|
||||||
|
response = call(&api, HTTP_API_ROUTE_JOIN, HTTP_API_POST, "{\"name\":\"A\",\"requestedRole\":\"invalid\"}", NULL);
|
||||||
|
expect_code(&response, 400U, "INVALID_ROLE");
|
||||||
|
response = call(&api, HTTP_API_ROUTE_JOIN, HTTP_API_POST, "{\"name\":\"<bad>\",\"requestedRole\":\"player1\"}", NULL);
|
||||||
|
expect_code(&response, 400U, "INVALID_NAME");
|
||||||
|
}
|
||||||
|
|
||||||
|
static void test_sessions_commands_and_state(void) {
|
||||||
|
application_t application;
|
||||||
|
test_random_t random = {.value = 9U};
|
||||||
|
http_api_t api = new_api(&application, &random);
|
||||||
|
http_api_response_t response = call(&api, HTTP_API_ROUTE_JOIN, HTTP_API_POST,
|
||||||
|
"{\"name\":\"Alice\",\"requestedRole\":\"player1\"}", NULL);
|
||||||
|
assert(response.status == 200U);
|
||||||
|
char player_1_token[33];
|
||||||
|
token_from_response(&response, player_1_token);
|
||||||
|
response = call(&api, HTTP_API_ROUTE_JOIN, HTTP_API_POST,
|
||||||
|
"{\"name\":\"Other\",\"requestedRole\":\"player1\"}", NULL);
|
||||||
|
expect_code(&response, 409U, "NO_PLAYER_SLOT");
|
||||||
|
response = call(&api, HTTP_API_ROUTE_RESUME, HTTP_API_POST, "{\"token\":\"00000000000000000000000000000000\"}", NULL);
|
||||||
|
expect_code(&response, 401U, "UNAUTHORIZED");
|
||||||
|
char resume_body[48];
|
||||||
|
snprintf(resume_body, sizeof(resume_body), "{\"token\":\"%s\"}", player_1_token);
|
||||||
|
response = call(&api, HTTP_API_ROUTE_RESUME, HTTP_API_POST, resume_body, NULL);
|
||||||
|
assert(response.status == 200U && strstr(response.body, "player1") != NULL);
|
||||||
|
|
||||||
|
response = call(&api, HTTP_API_ROUTE_JOIN, HTTP_API_POST,
|
||||||
|
"{\"name\":\"Bob\",\"requestedRole\":\"player2\"}", NULL);
|
||||||
|
assert(response.status == 200U);
|
||||||
|
char player_2_token[33];
|
||||||
|
token_from_response(&response, player_2_token);
|
||||||
|
response = call(&api, HTTP_API_ROUTE_JOIN, HTTP_API_POST,
|
||||||
|
"{\"name\":\"Watch\",\"requestedRole\":\"spectator\"}", NULL);
|
||||||
|
assert(response.status == 200U);
|
||||||
|
char spectator_token[33];
|
||||||
|
token_from_response(&response, spectator_token);
|
||||||
|
for (uint8_t index = 1U; index < kSpectatorCapacity; ++index) {
|
||||||
|
response = call(&api, HTTP_API_ROUTE_JOIN, HTTP_API_POST,
|
||||||
|
"{\"name\":\"Watch\",\"requestedRole\":\"spectator\"}", NULL);
|
||||||
|
assert(response.status == 200U);
|
||||||
|
}
|
||||||
|
response = call(&api, HTTP_API_ROUTE_JOIN, HTTP_API_POST,
|
||||||
|
"{\"name\":\"Extra\",\"requestedRole\":\"spectator\"}", NULL);
|
||||||
|
expect_code(&response, 409U, "NO_SPECTATOR_SLOT");
|
||||||
|
|
||||||
|
response = call(&api, HTTP_API_ROUTE_CONFIG, HTTP_API_POST,
|
||||||
|
"{\"token\":\"00000000000000000000000000000000\",\"gameId\":1,\"mode\":\"human\"}", NULL);
|
||||||
|
expect_code(&response, 401U, "UNAUTHORIZED");
|
||||||
|
char command[128];
|
||||||
|
snprintf(command, sizeof(command), "{\"token\":\"%s\",\"gameId\":2,\"mode\":\"human\"}", player_1_token);
|
||||||
|
response = call(&api, HTTP_API_ROUTE_CONFIG, HTTP_API_POST, command, NULL);
|
||||||
|
expect_code(&response, 409U, "STALE_GAME");
|
||||||
|
snprintf(command, sizeof(command), "{\"token\":\"%s\",\"gameId\":1,\"mode\":\"invalid\"}", player_1_token);
|
||||||
|
response = call(&api, HTTP_API_ROUTE_CONFIG, HTTP_API_POST, command, NULL);
|
||||||
|
expect_code(&response, 400U, "INVALID_MODE");
|
||||||
|
snprintf(command, sizeof(command), "{\"token\":\"%s\",\"gameId\":1,\"mode\":\"human\"}", player_2_token);
|
||||||
|
response = call(&api, HTTP_API_ROUTE_CONFIG, HTTP_API_POST, command, NULL);
|
||||||
|
expect_code(&response, 403U, "FORBIDDEN_ROLE");
|
||||||
|
snprintf(command, sizeof(command), "{\"token\":\"%s\",\"gameId\":1,\"mode\":\"human\"}", player_1_token);
|
||||||
|
response = call(&api, HTTP_API_ROUTE_CONFIG, HTTP_API_POST, command, NULL);
|
||||||
|
assert(response.status == 200U);
|
||||||
|
snprintf(command, sizeof(command), "{\"token\":\"%s\",\"gameId\":1}", player_1_token);
|
||||||
|
response = call(&api, HTTP_API_ROUTE_START, HTTP_API_POST, command, NULL);
|
||||||
|
assert(response.status == 200U);
|
||||||
|
snprintf(command, sizeof(command), "{\"token\":\"%s\",\"gameId\":1,\"mode\":\"human\"}", player_1_token);
|
||||||
|
response = call(&api, HTTP_API_ROUTE_CONFIG, HTTP_API_POST, command, NULL);
|
||||||
|
expect_code(&response, 409U, "WRONG_PHASE");
|
||||||
|
|
||||||
|
response = call(&api, HTTP_API_ROUTE_STATE, HTTP_API_GET, NULL, NULL);
|
||||||
|
assert(response.status == 200U && strstr(response.body, "\"viewer\":\"spectator\"") != NULL);
|
||||||
|
response = call(&api, HTTP_API_ROUTE_STATE, HTTP_API_GET, NULL, player_1_token);
|
||||||
|
assert(response.status == 200U && strstr(response.body, "\"viewer\":\"player1\"") != NULL);
|
||||||
|
response = call(&api, HTTP_API_ROUTE_STATE, HTTP_API_GET, NULL, "bad");
|
||||||
|
expect_code(&response, 401U, "UNAUTHORIZED");
|
||||||
|
|
||||||
|
snprintf(command, sizeof(command), "{\"token\":\"%s\",\"gameId\":1,\"x\":10,\"y\":0}", player_1_token);
|
||||||
|
response = call(&api, HTTP_API_ROUTE_SHOT, HTTP_API_POST, command, NULL);
|
||||||
|
expect_code(&response, 400U, "INVALID_COORDINATE");
|
||||||
|
snprintf(command, sizeof(command), "{\"token\":\"%s\",\"gameId\":1,\"x\":0,\"y\":0}", spectator_token);
|
||||||
|
response = call(&api, HTTP_API_ROUTE_SHOT, HTTP_API_POST, command, NULL);
|
||||||
|
expect_code(&response, 403U, "FORBIDDEN_ROLE");
|
||||||
|
|
||||||
|
application.lifecycle.game.state.current_player = 0U;
|
||||||
|
application.lifecycle.game.state.boards[1].cells[0] = CELL_SHIP;
|
||||||
|
snprintf(command, sizeof(command), "{\"token\":\"%s\",\"gameId\":1,\"x\":0,\"y\":0}", player_2_token);
|
||||||
|
response = call(&api, HTTP_API_ROUTE_SHOT, HTTP_API_POST, command, NULL);
|
||||||
|
expect_code(&response, 409U, "NOT_YOUR_TURN");
|
||||||
|
snprintf(command, sizeof(command), "{\"token\":\"%s\",\"gameId\":1,\"x\":0,\"y\":0}", player_1_token);
|
||||||
|
response = call(&api, HTTP_API_ROUTE_SHOT, HTTP_API_POST, command, NULL);
|
||||||
|
assert(response.status == 200U);
|
||||||
|
response = call(&api, HTTP_API_ROUTE_SHOT, HTTP_API_POST, command, NULL);
|
||||||
|
expect_code(&response, 409U, "CELL_ALREADY_SHOT");
|
||||||
|
|
||||||
|
application.lifecycle.game.state.phase = PHASE_FINISHED;
|
||||||
|
snprintf(command, sizeof(command), "{\"token\":\"%s\",\"gameId\":1}", player_1_token);
|
||||||
|
response = call(&api, HTTP_API_ROUTE_REMATCH, HTTP_API_POST, command, NULL);
|
||||||
|
assert(response.status == 200U && application.lifecycle.game.state.phase == PHASE_REMATCH_WAIT);
|
||||||
|
application.lifecycle.game.state.phase = PHASE_IN_PROGRESS;
|
||||||
|
application.lifecycle.game.state.mode = MODE_HUMAN;
|
||||||
|
application.lifecycle.sessions.entries[1].connected = false;
|
||||||
|
response = call(&api, HTTP_API_ROUTE_ABORT, HTTP_API_POST, command, NULL);
|
||||||
|
assert(response.status == 200U && application.lifecycle.game.state.phase == PHASE_LOBBY);
|
||||||
|
|
||||||
|
for (uint8_t index = 0; index < kCommandQueueCapacity; ++index) {
|
||||||
|
assert(application_enqueue(&application, &(app_command_t){.type = COMMAND_START}));
|
||||||
|
}
|
||||||
|
snprintf(command, sizeof(command), "{\"token\":\"%s\",\"gameId\":1}", player_1_token);
|
||||||
|
response = call(&api, HTTP_API_ROUTE_REMATCH, HTTP_API_POST, command, NULL);
|
||||||
|
expect_code(&response, 503U, "SERVER_BUSY");
|
||||||
|
}
|
||||||
|
|
||||||
|
int main(void) {
|
||||||
|
test_public_routes_and_parse_limits();
|
||||||
|
test_sessions_commands_and_state();
|
||||||
|
puts("http api tests passed");
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user