diff --git a/data/setup.css b/data/setup.css
new file mode 100644
index 0000000..89793ec
--- /dev/null
+++ b/data/setup.css
@@ -0,0 +1 @@
+:root { color-scheme: dark; font-family: system-ui, sans-serif; background: #041a2b; color: #e8f7ff; } body { margin: 0; padding: max(1.25rem, env(safe-area-inset-top)) max(1.25rem, env(safe-area-inset-right)) max(1.25rem, env(safe-area-inset-bottom)) max(1.25rem, env(safe-area-inset-left)); background: radial-gradient(circle at top, #0e4563, #041a2b 58%); } main { width: min(100%, 38rem); margin: 0 auto; } .eyebrow { color: #8fdbf3; font-size: .78rem; font-weight: 800; letter-spacing: .08em; } h1 { margin: .25rem 0 .5rem; } section, form { margin-top: 1.1rem; padding: 1rem; border: 1px solid #3a7593; border-radius: .8rem; background: rgb(5 31 50 / 88%); } .section-heading { display: flex; align-items: center; justify-content: space-between; gap: 1rem; } h2 { margin: 0; font-size: 1.1rem; } .networks { display: grid; gap: .5rem; } .network { width: 100%; min-height: 2.75rem; border: 1px solid #548eaa; border-radius: .5rem; background: #123d58; color: #effaff; text-align: left; } label, input, button { display: block; width: 100%; box-sizing: border-box; } label { margin-top: .85rem; font-weight: 700; } input, button { min-height: 2.75rem; margin-top: .35rem; border: 1px solid #609bbb; border-radius: .5rem; padding: .55rem .7rem; font: inherit; } input { background: #061f33; color: #effaff; } button { background: #54c2e5; color: #062033; font-weight: 800; cursor: pointer; } button:focus-visible, input:focus-visible, a:focus-visible { outline: 3px solid #ffe56a; outline-offset: 3px; } .hint, #scan-status { color: #c1ddea; font-size: .9rem; } a { color: #9ce8ff; }
diff --git a/data/setup.html b/data/setup.html
new file mode 100644
index 0000000..1be267c
--- /dev/null
+++ b/data/setup.html
@@ -0,0 +1,33 @@
+
+
+
+
+
+
+ Настройка сети — Морской бой
+
+
+
+
+ BATTLESHIP-OPEN · ЛОКАЛЬНАЯ СЕТЬ
+ Настройка Wi‑Fi
+ Игра доступна в этой открытой сети. Выберите домашнюю сеть или укажите скрытую вручную.
+
+ Доступные сети
+ Нажмите «Обновить», чтобы найти сети.
+
+
+
+ Открыть игру
+
+
+
+
diff --git a/data/setup.js b/data/setup.js
new file mode 100644
index 0000000..019da0a
--- /dev/null
+++ b/data/setup.js
@@ -0,0 +1,23 @@
+(() => {
+ const networks = document.querySelector('#networks');
+ const status = document.querySelector('#scan-status');
+ const ssid = document.querySelector('#ssid');
+ const refresh = document.querySelector('#refresh');
+ const form = document.querySelector('#network-form');
+ let timer = 0;
+ const scan = async () => {
+ clearTimeout(timer); refresh.disabled = true; status.textContent = 'Ищем сети…';
+ try {
+ const response = await fetch('/setup/scan', { cache: 'no-store' });
+ const text = await response.text();
+ if (!response.ok) throw new Error('scan unavailable');
+ if (text.trim() === 'scanning') { timer = setTimeout(scan, 700); return; }
+ const names = [...new Set(text.split('\n').map(value => value.trim()).filter(Boolean))];
+ networks.replaceChildren(...names.map(name => { const button = document.createElement('button'); button.className = 'network'; button.type = 'button'; button.textContent = name; button.addEventListener('click', () => { ssid.value = name; ssid.focus(); }); return button; }));
+ status.textContent = names.length ? 'Выберите сеть или введите её вручную.' : 'Сети не найдены. Введите скрытую сеть вручную.';
+ } catch (_) { status.textContent = 'Не удалось выполнить поиск. Введите сеть вручную.'; }
+ finally { if (!timer) refresh.disabled = false; }
+ };
+ refresh.addEventListener('click', scan);
+ form.addEventListener('submit', event => { event.preventDefault(); status.textContent = 'Сеть выбрана. Проверка и сохранение будут доступны на следующем шаге настройки.'; document.querySelector('#password').value = ''; });
+})();
diff --git a/include/captive_portal.h b/include/captive_portal.h
new file mode 100644
index 0000000..153ea92
--- /dev/null
+++ b/include/captive_portal.h
@@ -0,0 +1,11 @@
+#ifndef CAPTIVE_PORTAL_H
+#define CAPTIVE_PORTAL_H
+
+#include
+#include
+#include
+
+bool captive_portal_is_probe_path(const char *path);
+bool captive_portal_dns_response(const uint8_t *query, size_t query_length, const uint8_t address[4], uint8_t *response, size_t response_capacity, size_t *response_length);
+
+#endif
diff --git a/scripts/compress_web_assets.py b/scripts/compress_web_assets.py
index e136531..91014d6 100644
--- a/scripts/compress_web_assets.py
+++ b/scripts/compress_web_assets.py
@@ -7,7 +7,7 @@ from pathlib import Path
ROOT = Path(env.subst("$PROJECT_DIR"))
DATA = ROOT / "data"
-ASSETS = ("index.html", "styles.css", "app.js", "target_interaction.js", "web_audio.js", "game_sounds.js", "ship-sprite.svg")
+ASSETS = ("index.html", "styles.css", "app.js", "target_interaction.js", "web_audio.js", "game_sounds.js", "ship-sprite.svg", "setup.html", "setup.css", "setup.js")
def minify_html(source):
diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt
index 94d0187..d711c92 100644
--- a/src/CMakeLists.txt
+++ b/src/CMakeLists.txt
@@ -2,7 +2,7 @@
# without default 'CMakeLists.txt' file.
idf_component_register(
- SRCS "main.c" "application.c" "command_queue.c" "fleet_generator.c" "game_engine.c" "bot_player.c" "session_manager.c" "game_lifecycle.c" "state_presenter.c" "http_api.c" "sync_service.c" "network_state.c" "network_credentials.c" "network_credential_store.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" "sync_service.c" "network_state.c" "network_credentials.c" "network_credential_store.c" "captive_portal.c"
INCLUDE_DIRS "../include"
REQUIRES esp_event esp_http_server esp_netif esp_wifi esp_littlefs nvs_flash
)
diff --git a/src/captive_portal.c b/src/captive_portal.c
new file mode 100644
index 0000000..6dc0c9b
--- /dev/null
+++ b/src/captive_portal.c
@@ -0,0 +1,33 @@
+#include "captive_portal.h"
+
+#include
+
+bool captive_portal_is_probe_path(const char *path) {
+ static const char *const paths[] = {"/generate_204", "/gen_204", "/hotspot-detect.html", "/library/test/success.html", "/connecttest.txt", "/ncsi.txt", "/fwlink"};
+ if (path == NULL) return false;
+ for (size_t index = 0U; index < sizeof(paths) / sizeof(paths[0]); ++index) if (strcmp(path, paths[index]) == 0) return true;
+ return false;
+}
+
+bool captive_portal_dns_response(const uint8_t *query, size_t query_length, const uint8_t address[4], uint8_t *response, size_t response_capacity, size_t *response_length) {
+ if (query == NULL || address == NULL || response == NULL || response_length == NULL || query_length < 17U || response_capacity < query_length + 16U) return false;
+ if (query[2] & 0x80U || query[4] != 0U || query[5] != 1U) return false;
+ size_t question_end = 12U;
+ while (question_end < query_length && query[question_end] != 0U) {
+ const uint8_t label_length = query[question_end++];
+ if (label_length == 0U || label_length > 63U || question_end + label_length > query_length) return false;
+ question_end += label_length;
+ }
+ if (question_end + 5U > query_length) return false;
+ question_end += 5U;
+ memcpy(response, query, question_end);
+ response[2] = 0x81U; response[3] = 0x80U;
+ response[6] = 0U; response[7] = 1U;
+ response[8] = 0U; response[9] = 0U; response[10] = 0U; response[11] = 0U;
+ uint8_t *answer = response + question_end;
+ const uint8_t suffix[] = {0xc0U, 0x0cU, 0x00U, 0x01U, 0x00U, 0x01U, 0x00U, 0x00U, 0x00U, 0x3cU, 0x00U, 0x04U};
+ memcpy(answer, suffix, sizeof(suffix));
+ memcpy(answer + sizeof(suffix), address, 4U);
+ *response_length = question_end + sizeof(suffix) + 4U;
+ return true;
+}
diff --git a/src/main.c b/src/main.c
index ed0898b..b11a735 100644
--- a/src/main.c
+++ b/src/main.c
@@ -5,6 +5,7 @@
#include "app_config.h"
#include "application.h"
+#include "captive_portal.h"
#include "esp_check.h"
#include "esp_event.h"
#include "esp_heap_caps.h"
@@ -18,24 +19,21 @@
#include "freertos/FreeRTOS.h"
#include "freertos/portmacro.h"
#include "http_api.h"
+#include "network_credential_store.h"
+#include "network_state.h"
#include "nvs_flash.h"
#include "sync_service.h"
-
-#if __has_include("wifi_config.h")
-#include "wifi_config.h"
-#define WIFI_CONFIG_AVAILABLE 1
-#else
-#define WIFI_CONFIG_AVAILABLE 0
-#endif
+#include "lwip/sockets.h"
static const char *const kLogTag = "battleship";
static const char *const kLittlefsBasePath = "/littlefs";
static const char *const kLittlefsPartitionLabel = "littlefs";
static const size_t kFileChunkBytes = 1024U;
-static const uint8_t kHttpMaxOpenSockets = 12U;
+enum { kHttpMaxOpenSockets = 12U, kFallbackMaxConnections = 10U, kDnsPort = 53U, kScanResultLimit = 12U };
-typedef struct { bool configured; bool connected; bool retry_scheduled; uint8_t reconnect_attempt; } wifi_state_t;
+typedef struct { bool configured; bool connected; bool fallback_active; } wifi_state_t;
static portMUX_TYPE s_wifi_lock = portMUX_INITIALIZER_UNLOCKED;
+static portMUX_TYPE s_network_lock = portMUX_INITIALIZER_UNLOCKED;
static portMUX_TYPE s_diagnostics_lock = portMUX_INITIALIZER_UNLOCKED;
static wifi_state_t s_wifi_state = {0};
static bool s_littlefs_mounted;
@@ -46,9 +44,11 @@ static sync_service_t s_sync;
static uint16_t s_rejected_input;
static esp_timer_handle_t s_sync_timer;
static esp_timer_handle_t s_bot_timer;
-#if WIFI_CONFIG_AVAILABLE
-static esp_timer_handle_t s_reconnect_timer;
-#endif
+static esp_timer_handle_t s_network_timer;
+static network_manager_t s_network_manager;
+static bool s_scan_pending;
+static bool s_scan_ready;
+static bool s_dns_running;
static uint32_t platform_random(void *unused) { (void)unused; return esp_random(); }
@@ -75,6 +75,7 @@ static wifi_state_t wifi_state_snapshot(void) {
}
static const char *wifi_state_name(const wifi_state_t *state) {
+ if (state->fallback_active) return "fallback";
if (!state->configured) return "not_configured";
return state->connected ? "connected" : "connecting";
}
@@ -255,67 +256,114 @@ static esp_err_t websocket_handler(httpd_req_t *request) {
return ESP_OK;
}
-#if WIFI_CONFIG_AVAILABLE
-static void reconnect_timer_callback(void *unused) {
- (void)unused;
- portENTER_CRITICAL(&s_wifi_lock); s_wifi_state.retry_scheduled = false; portEXIT_CRITICAL(&s_wifi_lock);
- if (esp_wifi_connect() != ESP_OK) ESP_LOGW(kLogTag, "wifi connect request failed");
+static bool fallback_active(void) {
+ const wifi_state_t state = wifi_state_snapshot();
+ return state.fallback_active;
}
-static void schedule_reconnect(uint32_t delay_ms) {
- bool schedule = false;
- portENTER_CRITICAL(&s_wifi_lock);
- if (s_wifi_state.configured && !s_wifi_state.connected && !s_wifi_state.retry_scheduled) { s_wifi_state.retry_scheduled = true; schedule = true; }
- portEXIT_CRITICAL(&s_wifi_lock);
- if (schedule && esp_timer_start_once(s_reconnect_timer, (uint64_t)delay_ms * 1000U) != ESP_OK) {
- portENTER_CRITICAL(&s_wifi_lock); s_wifi_state.retry_scheduled = false; portEXIT_CRITICAL(&s_wifi_lock);
+static void captive_dns_task(void *unused) {
+ (void)unused;
+ const int socket_fd = socket(AF_INET, SOCK_DGRAM, IPPROTO_UDP);
+ if (socket_fd < 0) { ESP_LOGW(kLogTag, "captive DNS unavailable"); s_dns_running = false; vTaskDelete(NULL); return; }
+ struct sockaddr_in local = {.sin_family = AF_INET, .sin_port = htons(kDnsPort), .sin_addr.s_addr = htonl(INADDR_ANY)};
+ if (bind(socket_fd, (struct sockaddr *)&local, sizeof(local)) != 0) { close(socket_fd); s_dns_running = false; vTaskDelete(NULL); return; }
+ const struct timeval timeout = {.tv_sec = 1, .tv_usec = 0};
+ setsockopt(socket_fd, SOL_SOCKET, SO_RCVTIMEO, &timeout, sizeof(timeout));
+ const uint8_t address[] = {192U, 168U, 4U, 1U};
+ while (fallback_active()) {
+ uint8_t query[256]; uint8_t response[272]; struct sockaddr_in client = {0}; socklen_t client_length = sizeof(client);
+ const int length = recvfrom(socket_fd, query, sizeof(query), 0, (struct sockaddr *)&client, &client_length);
+ size_t response_length = 0U;
+ if (length > 0 && captive_portal_dns_response(query, (size_t)length, address, response, sizeof(response), &response_length)) sendto(socket_fd, response, response_length, 0, (struct sockaddr *)&client, client_length);
}
+ close(socket_fd); s_dns_running = false; vTaskDelete(NULL);
+}
+
+static void start_captive_dns(void) {
+ if (s_dns_running) return;
+ s_dns_running = true;
+ if (xTaskCreate(captive_dns_task, "captive_dns", 3072U, NULL, 3U, NULL) != pdPASS) { s_dns_running = false; ESP_LOGW(kLogTag, "captive DNS task unavailable"); }
+}
+
+static void enable_fallback_ap(void) {
+ wifi_config_t access_point = {0};
+ snprintf((char *)access_point.ap.ssid, sizeof(access_point.ap.ssid), "%s", "Battleship-open");
+ access_point.ap.ssid_len = strlen((const char *)access_point.ap.ssid);
+ access_point.ap.channel = 1U; access_point.ap.max_connection = kFallbackMaxConnections; access_point.ap.authmode = WIFI_AUTH_OPEN;
+ if (esp_wifi_set_config(WIFI_IF_AP, &access_point) != ESP_OK || esp_wifi_set_mode(WIFI_MODE_APSTA) != ESP_OK) { ESP_LOGW(kLogTag, "fallback access point unavailable"); return; }
+ portENTER_CRITICAL(&s_wifi_lock); s_wifi_state.fallback_active = true; portEXIT_CRITICAL(&s_wifi_lock);
+ start_captive_dns();
+}
+
+static void disable_fallback_ap(void) {
+ portENTER_CRITICAL(&s_wifi_lock); s_wifi_state.fallback_active = false; portEXIT_CRITICAL(&s_wifi_lock);
+ if (esp_wifi_set_mode(WIFI_MODE_STA) != ESP_OK) ESP_LOGW(kLogTag, "fallback access point stop failed");
+}
+
+static void apply_network_action(network_action_t action) {
+ if (action == NETWORK_ACTION_FALLBACK) enable_fallback_ap();
+ else if (action == NETWORK_ACTION_CONNECT && esp_wifi_connect() != ESP_OK) ESP_LOGW(kLogTag, "saved network connection request failed");
+}
+
+static network_action_t network_event_action(bool connected) {
+ network_action_t action;
+ portENTER_CRITICAL(&s_network_lock);
+ action = connected ? network_manager_connected(&s_network_manager) : network_manager_disconnected(&s_network_manager, (uint32_t)(esp_timer_get_time() / 1000U));
+ portEXIT_CRITICAL(&s_network_lock);
+ return action;
+}
+
+static void network_timer_callback(void *unused) {
+ (void)unused;
+ network_action_t action;
+ portENTER_CRITICAL(&s_network_lock); action = network_manager_tick(&s_network_manager, (uint32_t)(esp_timer_get_time() / 1000U)); portEXIT_CRITICAL(&s_network_lock);
+ apply_network_action(action);
}
static void wifi_event_handler(void *argument, esp_event_base_t event_base, int32_t event_id, void *event_data) {
(void)argument; (void)event_data;
- if (event_base == WIFI_EVENT && event_id == WIFI_EVENT_STA_START) schedule_reconnect(0U);
+ if (event_base == WIFI_EVENT && event_id == WIFI_EVENT_STA_START) apply_network_action(network_event_action(false));
else if (event_base == WIFI_EVENT && event_id == WIFI_EVENT_STA_DISCONNECTED) {
- uint8_t attempt;
- portENTER_CRITICAL(&s_wifi_lock);
- if (s_wifi_state.reconnect_attempt < 5U) ++s_wifi_state.reconnect_attempt;
- attempt = s_wifi_state.reconnect_attempt; s_wifi_state.connected = false;
- portEXIT_CRITICAL(&s_wifi_lock);
- schedule_reconnect(1000U << attempt);
- } else if (event_base == IP_EVENT && event_id == IP_EVENT_STA_GOT_IP) {
- portENTER_CRITICAL(&s_wifi_lock); s_wifi_state.connected = true; s_wifi_state.reconnect_attempt = 0U; portEXIT_CRITICAL(&s_wifi_lock);
+ portENTER_CRITICAL(&s_wifi_lock); s_wifi_state.connected = false; portEXIT_CRITICAL(&s_wifi_lock);
+ apply_network_action(network_event_action(false));
+ } else if (event_base == WIFI_EVENT && event_id == WIFI_EVENT_SCAN_DONE) { s_scan_pending = false; s_scan_ready = true; }
+ else if (event_base == IP_EVENT && event_id == IP_EVENT_STA_GOT_IP) {
+ network_event_action(true);
+ portENTER_CRITICAL(&s_wifi_lock); s_wifi_state.connected = true; portEXIT_CRITICAL(&s_wifi_lock);
+ if (fallback_active()) disable_fallback_ap();
}
}
-#endif
static esp_err_t start_wifi(void) {
-#if !WIFI_CONFIG_AVAILABLE
- ESP_LOGE(kLogTag, "wifi is not configured; copy include/wifi_config.h.example to include/wifi_config.h");
- return ESP_ERR_INVALID_STATE;
-#else
+ network_profile_t profile = {0};
+ const bool has_profile = network_credential_store_load(&profile);
wifi_init_config_t init_config = WIFI_INIT_CONFIG_DEFAULT();
wifi_config_t station_config = {0};
- const esp_timer_create_args_t timer_args = {.callback = reconnect_timer_callback, .name = "wifi_reconnect"};
- portENTER_CRITICAL(&s_wifi_lock); s_wifi_state.configured = true; portEXIT_CRITICAL(&s_wifi_lock);
- esp_netif_create_default_wifi_sta();
+ const uint32_t now_ms = (uint32_t)(esp_timer_get_time() / 1000U);
+ portENTER_CRITICAL(&s_network_lock); const network_action_t initial_action = network_manager_init(&s_network_manager, has_profile ? &profile : NULL, now_ms); portEXIT_CRITICAL(&s_network_lock);
+ portENTER_CRITICAL(&s_wifi_lock); s_wifi_state.configured = has_profile; portEXIT_CRITICAL(&s_wifi_lock);
+ esp_netif_create_default_wifi_sta(); esp_netif_create_default_wifi_ap();
ESP_RETURN_ON_ERROR(esp_wifi_init(&init_config), kLogTag, "wifi initialization failed");
ESP_RETURN_ON_ERROR(esp_event_handler_instance_register(WIFI_EVENT, ESP_EVENT_ANY_ID, wifi_event_handler, NULL, NULL), kLogTag, "wifi event registration failed");
ESP_RETURN_ON_ERROR(esp_event_handler_instance_register(IP_EVENT, IP_EVENT_STA_GOT_IP, wifi_event_handler, NULL, NULL), kLogTag, "ip event registration failed");
- ESP_RETURN_ON_ERROR(esp_timer_create(&timer_args, &s_reconnect_timer), kLogTag, "wifi timer creation failed");
- snprintf((char *)station_config.sta.ssid, sizeof(station_config.sta.ssid), "%s", WIFI_CONFIG_SSID);
- snprintf((char *)station_config.sta.password, sizeof(station_config.sta.password), "%s", WIFI_CONFIG_PASSWORD);
- station_config.sta.threshold.authmode = WIFI_AUTH_WPA2_PSK;
- station_config.sta.pmf_cfg.capable = true;
- ESP_RETURN_ON_ERROR(esp_wifi_set_mode(WIFI_MODE_STA), kLogTag, "wifi mode setup failed");
- ESP_RETURN_ON_ERROR(esp_wifi_set_config(WIFI_IF_STA, &station_config), kLogTag, "wifi configuration failed");
- return esp_wifi_start();
-#endif
+ const esp_timer_create_args_t timer_args = {.callback = network_timer_callback, .name = "network_state"};
+ ESP_RETURN_ON_ERROR(esp_timer_create(&timer_args, &s_network_timer), kLogTag, "network timer creation failed");
+ if (has_profile) {
+ memcpy(station_config.sta.ssid, profile.ssid, kNetworkSsidBytes);
+ memcpy(station_config.sta.password, profile.password, kNetworkPasswordBytes);
+ ESP_RETURN_ON_ERROR(esp_wifi_set_config(WIFI_IF_STA, &station_config), kLogTag, "saved network configuration failed");
+ }
+ ESP_RETURN_ON_ERROR(esp_wifi_set_mode(initial_action == NETWORK_ACTION_FALLBACK ? WIFI_MODE_AP : WIFI_MODE_STA), kLogTag, "wifi mode setup failed");
+ ESP_RETURN_ON_ERROR(esp_wifi_start(), kLogTag, "wifi start failed");
+ ESP_RETURN_ON_ERROR(esp_timer_start_periodic(s_network_timer, 1000000U), kLogTag, "network timer start failed");
+ if (initial_action == NETWORK_ACTION_FALLBACK) enable_fallback_ap();
+ return ESP_OK;
}
static const char *mime_type_for_path(const char *path) {
- if (strcmp(path, "/index.html") == 0) return "text/html; charset=utf-8";
+ if (strcmp(path, "/index.html") == 0 || strcmp(path, "/setup.html") == 0) return "text/html; charset=utf-8";
if (strcmp(path, "/ship-sprite.svg") == 0) return "image/svg+xml";
- return strcmp(path, "/styles.css") == 0 ? "text/css; charset=utf-8" : "application/javascript; charset=utf-8";
+ return strcmp(path, "/styles.css") == 0 || strcmp(path, "/setup.css") == 0 ? "text/css; charset=utf-8" : "application/javascript; charset=utf-8";
}
static bool request_accepts_gzip(httpd_req_t *request) {
@@ -348,6 +396,46 @@ static esp_err_t send_static_file(httpd_req_t *request, const char *asset_path)
return result == ESP_OK ? httpd_resp_send_chunk(request, NULL, 0U) : result;
}
+static esp_err_t redirect_to_setup(httpd_req_t *request) {
+ httpd_resp_set_status(request, "302 Found");
+ httpd_resp_set_hdr(request, "Location", "/setup");
+ httpd_resp_set_hdr(request, "Cache-Control", "no-store");
+ return httpd_resp_send(request, NULL, 0U);
+}
+
+static esp_err_t setup_handler(httpd_req_t *request) {
+ if (!s_littlefs_mounted) {
+ httpd_resp_set_status(request, "503 Service Unavailable");
+ return httpd_resp_send(request, "LittleFS is unavailable", HTTPD_RESP_USE_STRLEN);
+ }
+ return send_static_file(request, "/setup.html");
+}
+
+static size_t append_scan_name(char *output, size_t capacity, size_t length, const uint8_t *ssid) {
+ if (length >= capacity) return length;
+ for (size_t index = 0U; index < 32U && ssid[index] != '\0' && length + 1U < capacity; ++index) {
+ const uint8_t value = ssid[index]; output[length++] = value >= 32U && value <= 126U ? (char)value : '?';
+ }
+ output[length++] = '\n'; output[length] = '\0'; return length;
+}
+
+static esp_err_t setup_scan_handler(httpd_req_t *request) {
+ if (!fallback_active()) { httpd_resp_set_status(request, "409 Conflict"); return httpd_resp_send(request, "fallback access point is inactive", HTTPD_RESP_USE_STRLEN); }
+ if (!s_scan_pending && !s_scan_ready) {
+ const esp_err_t result = esp_wifi_scan_start(NULL, false);
+ if (result != ESP_OK) { httpd_resp_set_status(request, "503 Service Unavailable"); return httpd_resp_send(request, "scan unavailable", HTTPD_RESP_USE_STRLEN); }
+ s_scan_pending = true;
+ }
+ httpd_resp_set_type(request, "text/plain; charset=utf-8"); httpd_resp_set_hdr(request, "Cache-Control", "no-store");
+ if (s_scan_pending) return httpd_resp_send(request, "scanning\n", HTTPD_RESP_USE_STRLEN);
+ wifi_ap_record_t records[kScanResultLimit] = {0}; uint16_t count = kScanResultLimit;
+ if (esp_wifi_scan_get_ap_records(&count, records) != ESP_OK) { httpd_resp_set_status(request, "503 Service Unavailable"); return httpd_resp_send(request, "scan unavailable", HTTPD_RESP_USE_STRLEN); }
+ s_scan_ready = false;
+ char names[(kNetworkSsidBytes + 1U) * kScanResultLimit + 1U] = {0}; size_t length = 0U;
+ for (uint16_t index = 0U; index < count; ++index) length = append_scan_name(names, sizeof(names), length, records[index].ssid);
+ return httpd_resp_send(request, names, length);
+}
+
static esp_err_t root_handler(httpd_req_t *request) {
if (s_littlefs_mounted) return send_static_file(request, "/index.html");
httpd_resp_set_status(request, "503 Service Unavailable");
@@ -359,19 +447,23 @@ static esp_err_t static_file_handler(httpd_req_t *request) {
httpd_resp_set_status(request, "503 Service Unavailable");
return httpd_resp_send(request, "LittleFS is unavailable", HTTPD_RESP_USE_STRLEN);
}
+ if (captive_portal_is_probe_path(request->uri)) return redirect_to_setup(request);
if (strcmp(request->uri, "/styles.css") != 0 && strcmp(request->uri, "/app.js") != 0 &&
strcmp(request->uri, "/target_interaction.js") != 0 && strcmp(request->uri, "/web_audio.js") != 0 &&
strcmp(request->uri, "/game_sounds.js") != 0 &&
- strcmp(request->uri, "/ship-sprite.svg") != 0) return httpd_resp_send_err(request, HTTPD_404_NOT_FOUND, "asset not found");
+ strcmp(request->uri, "/ship-sprite.svg") != 0 && strcmp(request->uri, "/setup.css") != 0 &&
+ strcmp(request->uri, "/setup.js") != 0) return fallback_active() ? redirect_to_setup(request) : httpd_resp_send_err(request, HTTPD_404_NOT_FOUND, "asset not found");
return send_static_file(request, request->uri);
}
static esp_err_t start_http_server(void) {
httpd_config_t config = HTTPD_DEFAULT_CONFIG();
- config.max_uri_handlers = 18U; config.max_open_sockets = kHttpMaxOpenSockets; config.uri_match_fn = httpd_uri_match_wildcard; config.lru_purge_enable = true;
+ config.max_uri_handlers = 21U; config.max_open_sockets = kHttpMaxOpenSockets; config.uri_match_fn = httpd_uri_match_wildcard; config.lru_purge_enable = true;
ESP_RETURN_ON_ERROR(httpd_start(&s_server, &config), kLogTag, "http server start failed");
const httpd_uri_t routes[] = {
{.uri = "/", .method = HTTP_GET, .handler = root_handler},
+ {.uri = "/setup", .method = HTTP_GET, .handler = setup_handler},
+ {.uri = "/setup/scan", .method = HTTP_GET, .handler = setup_scan_handler},
{.uri = "/api/info", .method = HTTP_GET, .handler = api_handler, .user_ctx = (void *)(uintptr_t)HTTP_API_ROUTE_INFO},
{.uri = "/api/health", .method = HTTP_GET, .handler = api_handler, .user_ctx = (void *)(uintptr_t)HTTP_API_ROUTE_HEALTH},
{.uri = "/api/session/join", .method = HTTP_POST, .handler = api_handler, .user_ctx = (void *)(uintptr_t)HTTP_API_ROUTE_JOIN},
diff --git a/test/host/Makefile b/test/host/Makefile
index 0bc2d32..6a63252 100644
--- a/test/host/Makefile
+++ b/test/host/Makefile
@@ -1,7 +1,7 @@
CC ?= cc
CFLAGS ?= -std=c11 -Wall -Wextra -Werror -I../../include
-all: test_command_queue test_game_domain test_bot_player test_game_lifecycle test_state_presenter test_http_api test_sync_service test_human_game_integration test_bot_game_integration test_robustness test_network_foundation
+all: test_command_queue test_game_domain test_bot_player test_game_lifecycle test_state_presenter test_http_api test_sync_service test_human_game_integration test_bot_game_integration test_robustness test_network_foundation test_captive_portal
test_command_queue: test_command_queue.c ../../src/command_queue.c
$(CC) $(CFLAGS) $^ -o $@
@@ -18,6 +18,7 @@ run: all
./test_bot_game_integration
./test_robustness
./test_network_foundation
+ ./test_captive_portal
test_game_domain: test_game_domain.c ../../src/fleet_generator.c ../../src/game_engine.c
$(CC) $(CFLAGS) $^ -o $@
@@ -50,5 +51,8 @@ test_robustness: test_robustness.c ../../src/http_api.c ../../src/sync_service.c
test_network_foundation: test_network_foundation.c ../../src/network_state.c ../../src/network_credentials.c
$(CC) $(CFLAGS) $^ -o $@
+test_captive_portal: test_captive_portal.c ../../src/captive_portal.c
+ $(CC) $(CFLAGS) $^ -o $@
+
clean:
- rm -f test_command_queue test_game_domain test_bot_player test_game_lifecycle test_state_presenter test_http_api test_sync_service test_human_game_integration test_bot_game_integration test_robustness test_network_foundation
+ rm -f test_command_queue test_game_domain test_bot_player test_game_lifecycle test_state_presenter test_http_api test_sync_service test_human_game_integration test_bot_game_integration test_robustness test_network_foundation test_captive_portal
diff --git a/test/host/test_captive_portal.c b/test/host/test_captive_portal.c
new file mode 100644
index 0000000..842e0ff
--- /dev/null
+++ b/test/host/test_captive_portal.c
@@ -0,0 +1,15 @@
+#include
+#include
+#include
+#include "captive_portal.h"
+
+int main(void) {
+ const uint8_t query[] = {0x12U, 0x34U, 0x01U, 0x00U, 0x00U, 0x01U, 0x00U, 0x00U, 0x00U, 0x00U, 0x00U, 0x00U, 3U, 'w', 'w', 'w', 7U, 'e', 'x', 'a', 'm', 'p', 'l', 'e', 3U, 'c', 'o', 'm', 0U, 0U, 1U, 0U, 1U};
+ uint8_t response[80] = {0}; size_t response_length = 0U; const uint8_t address[] = {192U, 168U, 4U, 1U};
+ assert(captive_portal_is_probe_path("/generate_204")); assert(captive_portal_is_probe_path("/hotspot-detect.html")); assert(!captive_portal_is_probe_path("/app.js"));
+ assert(captive_portal_dns_response(query, sizeof(query), address, response, sizeof(response), &response_length));
+ assert(response_length == sizeof(query) + 16U && response[0] == 0x12U && response[1] == 0x34U && response[2] == 0x81U && response[7] == 1U);
+ assert(memcmp(response + response_length - 4U, address, sizeof(address)) == 0);
+ assert(!captive_portal_dns_response(query, 12U, address, response, sizeof(response), &response_length));
+ puts("captive portal tests passed"); return 0;
+}