feat: implement WebSocket synchronization and HTTP recovery

This commit is contained in:
2026-08-28 23:33:14 +03:00
parent 95b3450317
commit 03b283f892
8 changed files with 524 additions and 13 deletions
+82 -1
View File
@@ -19,6 +19,7 @@
#include "freertos/portmacro.h"
#include "http_api.h"
#include "nvs_flash.h"
#include "sync_service.h"
#if __has_include("wifi_config.h")
#include "wifi_config.h"
@@ -40,7 +41,9 @@ static bool s_littlefs_mounted;
static httpd_handle_t s_server;
static application_t s_application;
static http_api_t s_api;
static sync_service_t s_sync;
static uint16_t s_rejected_input;
static esp_timer_handle_t s_sync_timer;
#if WIFI_CONFIG_AVAILABLE
static esp_timer_handle_t s_reconnect_timer;
#endif
@@ -90,6 +93,36 @@ static esp_err_t send_api_response(httpd_req_t *request, const http_api_response
return httpd_resp_send(request, response->body, response->body_length);
}
static bool websocket_send(void *unused, int client_id, const char *payload, size_t length) {
(void)unused;
httpd_ws_frame_t frame = {.final = true, .type = HTTPD_WS_TYPE_TEXT, .payload = (uint8_t *)payload, .len = length};
const esp_err_t result = httpd_ws_send_frame_async(s_server, client_id, &frame);
if (result != ESP_OK) httpd_sess_trigger_close(s_server, client_id);
return result == ESP_OK;
}
static void sync_broadcast_work(void *unused) {
(void)unused;
sync_service_broadcast(&s_sync, websocket_send, NULL);
}
static void queue_state_broadcast(void) {
if (s_server != NULL && httpd_queue_work(s_server, sync_broadcast_work, NULL) != ESP_OK) ++s_rejected_input;
}
static void sync_expire_work(void *unused) {
(void)unused;
int closed[kSessionCapacity] = {0};
size_t closed_count = 0U;
sync_service_expire(&s_sync, (uint64_t)(esp_timer_get_time() / 1000U), closed, &closed_count);
for (size_t index = 0; index < closed_count; ++index) httpd_sess_trigger_close(s_server, closed[index]);
}
static void sync_timer_callback(void *unused) {
(void)unused;
if (s_server != NULL) httpd_queue_work(s_server, sync_expire_work, NULL);
}
static bool request_has_json_content_type(httpd_req_t *request) {
const size_t length = httpd_req_get_hdr_value_len(request, "Content-Type");
if (length == 0U || length >= 64U) return false;
@@ -139,10 +172,53 @@ static esp_err_t api_handler(httpd_req_t *request) {
const http_api_request_t api_request = {.method = request->method == HTTP_GET ? HTTP_API_GET : HTTP_API_POST, .route = route,
.content_type_json = request_has_json_content_type(request), .body = body, .body_length = body_length,
.session_token = token[0] == '\0' ? NULL : token, .target_too_large = target_too_large};
const uint32_t version_before = s_application.lifecycle.game.state.version;
if (!http_api_handle(&s_api, &api_request, &response)) return ESP_FAIL;
if (s_application.lifecycle.game.state.version != version_before) queue_state_broadcast();
return send_api_response(request, &response);
}
static esp_err_t websocket_handler(httpd_req_t *request) {
const int client_id = httpd_req_to_sockfd(request);
if (request->method == HTTP_GET) {
if (sync_service_open(&s_sync, client_id, (uint64_t)(esp_timer_get_time() / 1000U))) return ESP_OK;
httpd_sess_trigger_close(s_server, client_id);
return ESP_FAIL;
}
httpd_ws_frame_t frame = {0};
if (httpd_ws_recv_frame(request, &frame, 0U) != ESP_OK || frame.type != HTTPD_WS_TYPE_TEXT ||
frame.len > kWebSocketFrameCapacity) {
++s_rejected_input;
sync_service_close(&s_sync, client_id);
httpd_sess_trigger_close(s_server, client_id);
return ESP_OK;
}
char input[kWebSocketFrameCapacity + 1U] = {0};
frame.payload = (uint8_t *)input;
if (httpd_ws_recv_frame(request, &frame, sizeof(input) - 1U) != ESP_OK) {
sync_service_close(&s_sync, client_id);
httpd_sess_trigger_close(s_server, client_id);
return ESP_OK;
}
char output[kStateMessageCapacity] = {0};
size_t output_length = 0U;
bool state_changed = false;
bool close_client = false;
if (!sync_service_receive(&s_sync, client_id, input, frame.len, (uint64_t)(esp_timer_get_time() / 1000U),
output, &output_length, &state_changed, &close_client)) return ESP_FAIL;
if (output_length > 0U) {
httpd_ws_frame_t response = {.final = true, .type = HTTPD_WS_TYPE_TEXT,
.payload = (uint8_t *)output, .len = output_length};
if (httpd_ws_send_frame(request, &response) != ESP_OK) close_client = true;
}
if (state_changed) queue_state_broadcast();
if (close_client) {
sync_service_close(&s_sync, client_id);
httpd_sess_trigger_close(s_server, client_id);
}
return ESP_OK;
}
#if WIFI_CONFIG_AVAILABLE
static void reconnect_timer_callback(void *unused) {
(void)unused;
@@ -252,7 +328,7 @@ static esp_err_t static_file_handler(httpd_req_t *request) {
static esp_err_t start_http_server(void) {
httpd_config_t config = HTTPD_DEFAULT_CONFIG();
config.max_uri_handlers = 14U; config.max_open_sockets = kHttpMaxOpenSockets; config.uri_match_fn = httpd_uri_match_wildcard; config.lru_purge_enable = true;
config.max_uri_handlers = 15U; config.max_open_sockets = kHttpMaxOpenSockets; config.uri_match_fn = httpd_uri_match_wildcard; config.lru_purge_enable = true;
ESP_RETURN_ON_ERROR(httpd_start(&s_server, &config), kLogTag, "http server start failed");
const httpd_uri_t routes[] = {
{.uri = "/", .method = HTTP_GET, .handler = root_handler},
@@ -266,6 +342,7 @@ static esp_err_t start_http_server(void) {
{.uri = "/api/game/rematch", .method = HTTP_POST, .handler = api_handler, .user_ctx = (void *)(uintptr_t)HTTP_API_ROUTE_REMATCH},
{.uri = "/api/game/abort", .method = HTTP_POST, .handler = api_handler, .user_ctx = (void *)(uintptr_t)HTTP_API_ROUTE_ABORT},
{.uri = "/api/state", .method = HTTP_GET, .handler = api_handler, .user_ctx = (void *)(uintptr_t)HTTP_API_ROUTE_STATE},
{.uri = "/ws", .method = HTTP_GET, .handler = websocket_handler, .is_websocket = true},
{.uri = "/*", .method = HTTP_GET, .handler = static_file_handler},
};
for (size_t index = 0; index < sizeof(routes) / sizeof(routes[0]); ++index) ESP_RETURN_ON_ERROR(httpd_register_uri_handler(s_server, &routes[index]), kLogTag, "route registration failed");
@@ -284,8 +361,12 @@ void app_main(void) {
ESP_ERROR_CHECK(result); ESP_ERROR_CHECK(esp_netif_init()); ESP_ERROR_CHECK(esp_event_loop_create_default());
application_init(&s_application, (random_source_t){.next_u32 = platform_random, .context = NULL});
http_api_init(&s_api, &s_application);
sync_service_init(&s_sync, &s_application);
mount_littlefs();
ESP_ERROR_CHECK(start_http_server());
const esp_timer_create_args_t sync_timer_args = {.callback = sync_timer_callback, .name = "sync_expire"};
ESP_ERROR_CHECK(esp_timer_create(&sync_timer_args, &s_sync_timer));
ESP_ERROR_CHECK(esp_timer_start_periodic(s_sync_timer, 1000000U));
result = start_wifi();
if (result != ESP_OK) ESP_LOGW(kLogTag, "wifi unavailable: %s", esp_err_to_name(result));
}