diff --git a/PLANS.md b/PLANS.md index bba8535..bf31c56 100644 --- a/PLANS.md +++ b/PLANS.md @@ -193,7 +193,7 @@ builds reproducibly with the locked platform and partition configuration. ## Milestone 003 — Wi-Fi provisioning and persistent configuration -**Status:** `NOT_STARTED` +**Status:** `COMPLETE` ### Objective @@ -225,9 +225,57 @@ access-point provisioning flow. ### Evidence -Completion evidence is not yet available because this milestone has not -started. Required evidence includes NVS tests and target-board logs for first -boot, successful station connection, reboot persistence, and fallback recovery. +- On 2026-09-03, `./scripts/run_host_tests.sh` reported + `health state tests: PASS`, `wifi configuration tests: PASS`, + `configuration NVS tests: PASS`, `provisioning protocol tests: PASS`, and + `web asset reproducibility: PASS + (b36a6203a78964a324e3d56986a99abb7e36a48a84688cb4ecfe4ac1934b9f92)`. + The tests exercise credential length and control-byte bounds, invalid-input + non-mutation, mandatory reconfiguration guards, real store code across an + NVS reinitialization, schema mismatch, reset, strict two-field JSON parsing, + and malformed or duplicated request fields. +- After a clean on 2026-09-03, + `/home/sasa/.platformio/penv/bin/pio run -e esp32-c6-devkitm-1` completed + with `[SUCCESS]` in 46.25 seconds. PlatformIO reported 37,452 bytes of RAM + and 985,280 of 2,097,152 application bytes in use; the independent size hook + reported `firmware.bin uses 985920/2097152 bytes in partition factory`. +- `/home/sasa/.platformio/penv/bin/pio run -e esp32-c6-devkitm-1 --target + buildfs` completed with `[SUCCESS]` and reported + `littlefs.bin uses 2031616/2031616 bytes in partition littlefs`. The image + contains deterministic compressed application and provisioning pages; its + SHA-256 was + `f3e529542737221d46146f46ff57eb3a88a1b3c5b25c9f4298b9d3e3e22a7d3a`. +- The application and filesystem images were written to the physical + ESP32-C6FH4 and hash-verified. A production first boot with empty + configuration reported `App version: 0.3.0`, + `No usable saved station configuration; enabling fallback`, + `Fallback access point ready: SSID=maze32-F7B9, + URL=http://192.168.4.1/provision`, DHCP at `192.168.4.1`, + `Provisioning endpoints ready`, and `Health state: serving`. +- The isolated `milestone-003-device-test` environment exercised the real NVS + and network manager without accessing local Wi-Fi secrets. One continuous + target log reported `credentials saved; restarting`, reset reason `SW_CPU`, + `credentials survived restart`, station connection to the persisted test + SSID with three bounded retries, `Station connection timed out or exhausted + retries; enabling fallback`, and `failed station returned to fallback; + credentials cleared`. Normal production firmware was restored afterward and + its boot log confirmed the test credentials were absent. +- On 2026-09-03, the project owner used the device-hosted provisioning page + from a separate client to save credentials for `Mazda7`; no credential was + exposed in the test record. A subsequent physical-board reboot loaded that + persisted SSID, associated using WPA2, received `192.168.1.50`, reported + `Network state: station_connected` and `Station connected: + IP=192.168.1.50`, registered the HTTP and provisioning routes, and reached + `Health state: serving`. +- Live requests over `Mazda7` returned + `{"state":"station_connected",...,"ip":"192.168.1.50"}` from + `/api/wifi/status`, a healthy version `0.3.0` response from `/health`, and + the decompressed Russian provisioning page from `/provision`. A deliberately + invalid two-field configuration request returned HTTP 400. A final board + reboot again loaded and connected to `Mazda7`, proving the rejected request + did not alter the persisted valid configuration. +- All five acceptance criteria therefore have recorded evidence. Milestone 004 + remains `NOT_STARTED`. ## Milestone 004 — HTTP, WebSocket, sessions, and lobby diff --git a/README.md b/README.md index ff892de..3d97a15 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,8 @@ # maze32 -maze32 is an ESP32-C6-hosted browser maze game. The current firmware skeleton -mounts a LittleFS image, serves a compressed static page, and exposes a bounded -`/health` response containing the firmware version and startup state. +maze32 is an ESP32-C6-hosted browser maze game. The firmware mounts LittleFS, +serves compressed local pages, exposes a bounded health response, and connects +to saved Wi-Fi credentials or starts a fallback provisioning access point. ## Prerequisites @@ -60,7 +60,38 @@ Monitor startup at 115200 baud: /home/sasa/.platformio/penv/bin/pio device monitor ``` -A successful boot reports the firmware version, LittleFS capacity and use, the -HTTP service startup, and health state `serving`. Network provisioning is part -of a later milestone; this milestone only establishes the HTTP/static service -that will be attached to that network lifecycle. +A successful boot reports the firmware version, LittleFS capacity and use, +network state, HTTP service startup, and health state `serving`. + +## Wi-Fi provisioning and recovery + +With no saved credentials, or after a failed station connection, the device +starts an open access point named `maze32-XXXX`, where `XXXX` comes from the +device MAC address. Connect to it and open: + +```text +http://192.168.4.1/provision +``` + +Scan for nearby networks, select or enter an SSID, and save the password. The +device stores a schema-versioned configuration in NVS and retries in station +mode. If the attempt fails, the fallback access point returns automatically. +After a successful connection, `/api/wifi/status` reports the station address. + +To recover from obsolete or incorrect credentials, reconnect to the fallback +access point after the failed attempt and use **Удалить настройки сети**. The +same reset operation is available as `POST /api/wifi/reset`. Saving or clearing +credentials always invokes the runtime reconfiguration guard first; a future +active match service can reject the operation until it has explicitly stopped +the match. + +The isolated on-device NVS/reboot diagnostic uses a deliberately unavailable +SSID, refuses to overwrite unexpected saved credentials, and clears its test +record after verifying fallback recovery: + +```sh +/home/sasa/.platformio/penv/bin/pio run -e milestone-003-device-test +/home/sasa/.platformio/penv/bin/pio run -e milestone-003-device-test --target upload +``` + +Restore the normal firmware afterward with the standard upload command. diff --git a/components/maze32_config/CMakeLists.txt b/components/maze32_config/CMakeLists.txt new file mode 100644 index 0000000..d94cb94 --- /dev/null +++ b/components/maze32_config/CMakeLists.txt @@ -0,0 +1,5 @@ +idf_component_register( + SRCS "maze32_config_store.c" "maze32_wifi_config.c" + INCLUDE_DIRS "include" + REQUIRES nvs_flash +) diff --git a/components/maze32_config/include/maze32_config_store.h b/components/maze32_config/include/maze32_config_store.h new file mode 100644 index 0000000..c178627 --- /dev/null +++ b/components/maze32_config/include/maze32_config_store.h @@ -0,0 +1,20 @@ +#ifndef MAZE32_CONFIG_STORE_H +#define MAZE32_CONFIG_STORE_H + +#include "esp_err.h" +#include "maze32_wifi_config.h" + +#ifdef __cplusplus +extern "C" { +#endif + +esp_err_t maze32_config_store_init(void); +esp_err_t maze32_config_store_load(maze32_wifi_credentials_t *credentials); +esp_err_t maze32_config_store_save(const maze32_wifi_credentials_t *credentials); +esp_err_t maze32_config_store_clear(void); + +#ifdef __cplusplus +} +#endif + +#endif diff --git a/components/maze32_config/include/maze32_wifi_config.h b/components/maze32_config/include/maze32_wifi_config.h new file mode 100644 index 0000000..a6214d9 --- /dev/null +++ b/components/maze32_config/include/maze32_wifi_config.h @@ -0,0 +1,45 @@ +#ifndef MAZE32_WIFI_CONFIG_H +#define MAZE32_WIFI_CONFIG_H + +#include +#include + +#ifdef __cplusplus +extern "C" { +#endif + +#define MAZE32_WIFI_CONFIG_SCHEMA_VERSION 1U +#define MAZE32_WIFI_SSID_MAX_BYTES 32U +#define MAZE32_WIFI_PASSWORD_MAX_BYTES 63U + +typedef struct { + char ssid[MAZE32_WIFI_SSID_MAX_BYTES + 1U]; + char password[MAZE32_WIFI_PASSWORD_MAX_BYTES + 1U]; +} maze32_wifi_credentials_t; + +typedef enum { + MAZE32_WIFI_CONFIG_VALID = 0, + MAZE32_WIFI_CONFIG_INVALID_SSID, + MAZE32_WIFI_CONFIG_INVALID_PASSWORD, +} maze32_wifi_config_validation_t; + +typedef int (*maze32_reconfiguration_guard_fn)(void *context); +typedef int (*maze32_credentials_save_fn)(const maze32_wifi_credentials_t *credentials, + void *context); +typedef int (*maze32_credentials_clear_fn)(void *context); + +maze32_wifi_config_validation_t maze32_wifi_credentials_validate( + const maze32_wifi_credentials_t *credentials); +int maze32_wifi_config_apply(const maze32_wifi_credentials_t *credentials, + maze32_reconfiguration_guard_fn guard, + maze32_credentials_save_fn save, + void *context); +int maze32_wifi_config_reset(maze32_reconfiguration_guard_fn guard, + maze32_credentials_clear_fn clear, + void *context); + +#ifdef __cplusplus +} +#endif + +#endif diff --git a/components/maze32_config/maze32_config_store.c b/components/maze32_config/maze32_config_store.c new file mode 100644 index 0000000..0aac10e --- /dev/null +++ b/components/maze32_config/maze32_config_store.c @@ -0,0 +1,110 @@ +#include "maze32_config_store.h" + +#include + +#include "nvs.h" +#include "nvs_flash.h" + +static const char *NAMESPACE = "maze32"; +static const char *SCHEMA_KEY = "cfg_ver"; +static const char *SSID_KEY = "wifi_ssid"; +static const char *PASSWORD_KEY = "wifi_pass"; + +esp_err_t maze32_config_store_init(void) +{ + esp_err_t result = nvs_flash_init(); + if (result == ESP_ERR_NVS_NO_FREE_PAGES || result == ESP_ERR_NVS_NEW_VERSION_FOUND) { + result = nvs_flash_erase(); + if (result == ESP_OK) { + result = nvs_flash_init(); + } + } + return result; +} + +esp_err_t maze32_config_store_load(maze32_wifi_credentials_t *credentials) +{ + if (credentials == NULL) { + return ESP_ERR_INVALID_ARG; + } + + nvs_handle_t handle; + esp_err_t result = nvs_open(NAMESPACE, NVS_READONLY, &handle); + if (result == ESP_ERR_NVS_NOT_FOUND) { + return ESP_ERR_NOT_FOUND; + } + if (result != ESP_OK) { + return result; + } + + uint32_t schema_version = 0U; + result = nvs_get_u32(handle, SCHEMA_KEY, &schema_version); + if (result == ESP_OK && schema_version != MAZE32_WIFI_CONFIG_SCHEMA_VERSION) { + result = ESP_ERR_INVALID_VERSION; + } + + maze32_wifi_credentials_t loaded = {0}; + size_t ssid_size = sizeof(loaded.ssid); + size_t password_size = sizeof(loaded.password); + if (result == ESP_OK) { + result = nvs_get_str(handle, SSID_KEY, loaded.ssid, &ssid_size); + } + if (result == ESP_OK) { + result = nvs_get_str(handle, PASSWORD_KEY, loaded.password, &password_size); + } + nvs_close(handle); + + if (result == ESP_ERR_NVS_NOT_FOUND) { + return ESP_ERR_NOT_FOUND; + } + + if (result == ESP_OK && + maze32_wifi_credentials_validate(&loaded) != MAZE32_WIFI_CONFIG_VALID) { + result = ESP_ERR_INVALID_STATE; + } + if (result == ESP_OK) { + memcpy(credentials, &loaded, sizeof(*credentials)); + } + return result; +} + +esp_err_t maze32_config_store_save(const maze32_wifi_credentials_t *credentials) +{ + if (maze32_wifi_credentials_validate(credentials) != MAZE32_WIFI_CONFIG_VALID) { + return ESP_ERR_INVALID_ARG; + } + + nvs_handle_t handle; + esp_err_t result = nvs_open(NAMESPACE, NVS_READWRITE, &handle); + if (result != ESP_OK) { + return result; + } + + result = nvs_set_str(handle, SSID_KEY, credentials->ssid); + if (result == ESP_OK) { + result = nvs_set_str(handle, PASSWORD_KEY, credentials->password); + } + if (result == ESP_OK) { + result = nvs_set_u32(handle, SCHEMA_KEY, MAZE32_WIFI_CONFIG_SCHEMA_VERSION); + } + if (result == ESP_OK) { + result = nvs_commit(handle); + } + nvs_close(handle); + return result; +} + +esp_err_t maze32_config_store_clear(void) +{ + nvs_handle_t handle; + esp_err_t result = nvs_open(NAMESPACE, NVS_READWRITE, &handle); + if (result != ESP_OK) { + return result; + } + result = nvs_erase_all(handle); + if (result == ESP_OK) { + result = nvs_commit(handle); + } + nvs_close(handle); + return result; +} diff --git a/components/maze32_config/maze32_wifi_config.c b/components/maze32_config/maze32_wifi_config.c new file mode 100644 index 0000000..ec694e6 --- /dev/null +++ b/components/maze32_config/maze32_wifi_config.c @@ -0,0 +1,75 @@ +#include "maze32_wifi_config.h" + +#include + +static size_t bounded_length(const char *value, size_t capacity) +{ + size_t length = 0U; + while (length < capacity && value[length] != '\0') { + ++length; + } + return length; +} + +static int has_control_bytes(const char *value) +{ + const unsigned char *cursor = (const unsigned char *)value; + while (*cursor != '\0') { + if (*cursor < 0x20U || *cursor == 0x7fU) { + return 1; + } + ++cursor; + } + return 0; +} + +maze32_wifi_config_validation_t maze32_wifi_credentials_validate( + const maze32_wifi_credentials_t *credentials) +{ + if (credentials == NULL) { + return MAZE32_WIFI_CONFIG_INVALID_SSID; + } + + size_t ssid_length = bounded_length(credentials->ssid, sizeof(credentials->ssid)); + if (ssid_length == 0U || ssid_length > MAZE32_WIFI_SSID_MAX_BYTES || + has_control_bytes(credentials->ssid)) { + return MAZE32_WIFI_CONFIG_INVALID_SSID; + } + + size_t password_length = bounded_length(credentials->password, sizeof(credentials->password)); + if (password_length > MAZE32_WIFI_PASSWORD_MAX_BYTES || + (password_length > 0U && password_length < 8U) || + has_control_bytes(credentials->password)) { + return MAZE32_WIFI_CONFIG_INVALID_PASSWORD; + } + + return MAZE32_WIFI_CONFIG_VALID; +} + +int maze32_wifi_config_apply(const maze32_wifi_credentials_t *credentials, + maze32_reconfiguration_guard_fn guard, + maze32_credentials_save_fn save, + void *context) +{ + if (maze32_wifi_credentials_validate(credentials) != MAZE32_WIFI_CONFIG_VALID || + guard == NULL || save == NULL) { + return -1; + } + if (guard(context) != 0) { + return -2; + } + return save(credentials, context) == 0 ? 0 : -3; +} + +int maze32_wifi_config_reset(maze32_reconfiguration_guard_fn guard, + maze32_credentials_clear_fn clear, + void *context) +{ + if (guard == NULL || clear == NULL) { + return -1; + } + if (guard(context) != 0) { + return -2; + } + return clear(context) == 0 ? 0 : -3; +} diff --git a/components/maze32_network/CMakeLists.txt b/components/maze32_network/CMakeLists.txt new file mode 100644 index 0000000..11fef57 --- /dev/null +++ b/components/maze32_network/CMakeLists.txt @@ -0,0 +1,5 @@ +idf_component_register( + SRCS "maze32_network.c" "maze32_provisioning.c" "maze32_provisioning_protocol.c" + INCLUDE_DIRS "include" + REQUIRES esp_event esp_http_server esp_netif esp_wifi maze32_config +) diff --git a/components/maze32_network/include/maze32_network.h b/components/maze32_network/include/maze32_network.h new file mode 100644 index 0000000..5d26bb7 --- /dev/null +++ b/components/maze32_network/include/maze32_network.h @@ -0,0 +1,73 @@ +#ifndef MAZE32_NETWORK_H +#define MAZE32_NETWORK_H + +#include +#include + +#include "esp_err.h" +#include "esp_http_server.h" +#include "esp_netif.h" +#include "freertos/FreeRTOS.h" +#include "freertos/event_groups.h" +#include "freertos/semphr.h" +#include "maze32_wifi_config.h" + +#ifdef __cplusplus +extern "C" { +#endif + +#define MAZE32_WIFI_SCAN_LIMIT 8U + +typedef enum { + MAZE32_NETWORK_BOOTING = 0, + MAZE32_NETWORK_STATION_CONNECTING, + MAZE32_NETWORK_STATION_CONNECTED, + MAZE32_NETWORK_PROVISIONING, + MAZE32_NETWORK_ERROR, +} maze32_network_state_t; + +typedef struct { + char ssid[MAZE32_WIFI_SSID_MAX_BYTES + 1U]; + int8_t rssi; + uint8_t auth_mode; +} maze32_wifi_scan_result_t; + +typedef struct { + EventGroupHandle_t events; + SemaphoreHandle_t operation_mutex; + esp_netif_t *station_netif; + esp_netif_t *access_point_netif; + volatile maze32_network_state_t state; + unsigned int retry_count; + char access_point_ssid[24]; + maze32_reconfiguration_guard_fn reconfiguration_guard; + void *guard_context; + httpd_handle_t server; +} maze32_network_t; + +esp_err_t maze32_network_init(maze32_network_t *network, + maze32_reconfiguration_guard_fn guard, + void *guard_context); +esp_err_t maze32_network_start(maze32_network_t *network); +esp_err_t maze32_network_apply_credentials(maze32_network_t *network, + const maze32_wifi_credentials_t *credentials); +esp_err_t maze32_network_reset_credentials(maze32_network_t *network); +void maze32_network_retry_saved_credentials(void *context); +void maze32_network_return_to_provisioning(void *context); +esp_err_t maze32_network_scan(maze32_network_t *network, + maze32_wifi_scan_result_t *results, + size_t capacity, + size_t *count); +maze32_network_state_t maze32_network_state(const maze32_network_t *network); +const char *maze32_network_state_name(maze32_network_state_t state); +const char *maze32_network_access_point_ssid(const maze32_network_t *network); +esp_err_t maze32_network_ip_address(const maze32_network_t *network, + char *output, + size_t output_size); +esp_err_t maze32_provisioning_register(httpd_handle_t server, maze32_network_t *network); + +#ifdef __cplusplus +} +#endif + +#endif diff --git a/components/maze32_network/include/maze32_provisioning_protocol.h b/components/maze32_network/include/maze32_provisioning_protocol.h new file mode 100644 index 0000000..03a8b75 --- /dev/null +++ b/components/maze32_network/include/maze32_provisioning_protocol.h @@ -0,0 +1,20 @@ +#ifndef MAZE32_PROVISIONING_PROTOCOL_H +#define MAZE32_PROVISIONING_PROTOCOL_H + +#include + +#include "maze32_wifi_config.h" + +#ifdef __cplusplus +extern "C" { +#endif + +int maze32_provisioning_parse_credentials(const char *body, + size_t length, + maze32_wifi_credentials_t *credentials); + +#ifdef __cplusplus +} +#endif + +#endif diff --git a/components/maze32_network/maze32_network.c b/components/maze32_network/maze32_network.c new file mode 100644 index 0000000..7bdc52f --- /dev/null +++ b/components/maze32_network/maze32_network.c @@ -0,0 +1,352 @@ +#include "maze32_network.h" + +#include +#include + +#include "esp_log.h" +#include "esp_mac.h" +#include "esp_wifi.h" +#include "maze32_config_store.h" + +#define MAZE32_CONNECTED_BIT BIT0 +#define MAZE32_FAILED_BIT BIT1 +#define MAZE32_STATION_RETRY_LIMIT 3U +#define MAZE32_STATION_TIMEOUT_MS 12000U + +static const char *TAG = "maze32_network"; +static maze32_network_t *active_network; + +static void set_state(maze32_network_t *network, maze32_network_state_t state) +{ + network->state = state; + ESP_LOGI(TAG, "Network state: %s", maze32_network_state_name(state)); +} + +static void network_event(void *argument, + esp_event_base_t event_base, + int32_t event_id, + void *event_data) +{ + (void)event_data; + maze32_network_t *network = argument; + if (event_base == WIFI_EVENT && event_id == WIFI_EVENT_STA_START && + network->state == MAZE32_NETWORK_STATION_CONNECTING) { + esp_wifi_connect(); + } else if (event_base == WIFI_EVENT && event_id == WIFI_EVENT_STA_DISCONNECTED && + network->state == MAZE32_NETWORK_STATION_CONNECTING) { + if (network->retry_count < MAZE32_STATION_RETRY_LIMIT) { + ++network->retry_count; + ESP_LOGW(TAG, "Station connection failed; retry %u/%u", + network->retry_count, MAZE32_STATION_RETRY_LIMIT); + esp_wifi_connect(); + } else { + xEventGroupSetBits(network->events, MAZE32_FAILED_BIT); + } + } else if (event_base == IP_EVENT && event_id == IP_EVENT_STA_GOT_IP) { + xEventGroupSetBits(network->events, MAZE32_CONNECTED_BIT); + } +} + +static esp_err_t stop_wifi(void) +{ + esp_err_t result = esp_wifi_stop(); + if (result == ESP_ERR_WIFI_NOT_STARTED || result == ESP_ERR_WIFI_NOT_INIT) { + return ESP_OK; + } + return result; +} + +static esp_err_t start_provisioning(maze32_network_t *network) +{ + set_state(network, MAZE32_NETWORK_PROVISIONING); + esp_err_t result = stop_wifi(); + if (result != ESP_OK) { + return result; + } + + wifi_config_t access_point = {0}; + size_t ssid_length = strlen(network->access_point_ssid); + memcpy(access_point.ap.ssid, network->access_point_ssid, ssid_length); + access_point.ap.ssid_len = ssid_length; + access_point.ap.channel = 1U; + access_point.ap.max_connection = 4U; + access_point.ap.authmode = WIFI_AUTH_OPEN; + access_point.ap.pmf_cfg.required = false; + + result = esp_wifi_set_mode(WIFI_MODE_APSTA); + if (result == ESP_OK) { + result = esp_wifi_set_config(WIFI_IF_AP, &access_point); + } + if (result == ESP_OK) { + result = esp_wifi_start(); + } + if (result == ESP_OK) { + ESP_LOGI(TAG, "Fallback access point ready: SSID=%s, URL=http://192.168.4.1/provision", + network->access_point_ssid); + } else { + set_state(network, MAZE32_NETWORK_ERROR); + } + return result; +} + +static esp_err_t connect_station(maze32_network_t *network, + const maze32_wifi_credentials_t *credentials) +{ + esp_err_t result = stop_wifi(); + if (result != ESP_OK) { + return result; + } + + wifi_config_t station = {0}; + memcpy(station.sta.ssid, credentials->ssid, strlen(credentials->ssid)); + memcpy(station.sta.password, credentials->password, strlen(credentials->password)); + station.sta.threshold.authmode = WIFI_AUTH_OPEN; + station.sta.sae_pwe_h2e = WPA3_SAE_PWE_BOTH; + + network->retry_count = 0U; + xEventGroupClearBits(network->events, MAZE32_CONNECTED_BIT | MAZE32_FAILED_BIT); + set_state(network, MAZE32_NETWORK_STATION_CONNECTING); + ESP_LOGI(TAG, "Connecting to configured station SSID: %s", credentials->ssid); + + result = esp_wifi_set_mode(WIFI_MODE_STA); + if (result == ESP_OK) { + result = esp_wifi_set_config(WIFI_IF_STA, &station); + } + if (result == ESP_OK) { + result = esp_wifi_start(); + } + if (result != ESP_OK) { + ESP_LOGE(TAG, "Station startup failed: %s", esp_err_to_name(result)); + return start_provisioning(network); + } + + EventBits_t bits = xEventGroupWaitBits( + network->events, + MAZE32_CONNECTED_BIT | MAZE32_FAILED_BIT, + pdTRUE, + pdFALSE, + pdMS_TO_TICKS(MAZE32_STATION_TIMEOUT_MS)); + if ((bits & MAZE32_CONNECTED_BIT) != 0U) { + set_state(network, MAZE32_NETWORK_STATION_CONNECTED); + char address[16]; + if (maze32_network_ip_address(network, address, sizeof(address)) == ESP_OK) { + ESP_LOGI(TAG, "Station connected: IP=%s", address); + } + return ESP_OK; + } + + ESP_LOGW(TAG, "Station connection timed out or exhausted retries; enabling fallback"); + return start_provisioning(network); +} + +static int save_credentials(const maze32_wifi_credentials_t *credentials, void *context) +{ + (void)context; + return maze32_config_store_save(credentials) == ESP_OK ? 0 : -1; +} + +static int clear_credentials(void *context) +{ + (void)context; + return maze32_config_store_clear() == ESP_OK ? 0 : -1; +} + +esp_err_t maze32_network_init(maze32_network_t *network, + maze32_reconfiguration_guard_fn guard, + void *guard_context) +{ + if (network == NULL || guard == NULL || active_network != NULL) { + return ESP_ERR_INVALID_ARG; + } + memset(network, 0, sizeof(*network)); + network->state = MAZE32_NETWORK_BOOTING; + network->reconfiguration_guard = guard; + network->guard_context = guard_context; + network->events = xEventGroupCreate(); + network->operation_mutex = xSemaphoreCreateMutex(); + if (network->events == NULL || network->operation_mutex == NULL) { + return ESP_ERR_NO_MEM; + } + + uint8_t mac[6]; + esp_err_t result = esp_read_mac(mac, ESP_MAC_WIFI_SOFTAP); + if (result != ESP_OK) { + return result; + } + snprintf(network->access_point_ssid, sizeof(network->access_point_ssid), + "maze32-%02X%02X", mac[4], mac[5]); + + wifi_init_config_t wifi_configuration = WIFI_INIT_CONFIG_DEFAULT(); + result = esp_wifi_init(&wifi_configuration); + if (result != ESP_OK) { + return result; + } + network->station_netif = esp_netif_create_default_wifi_sta(); + network->access_point_netif = esp_netif_create_default_wifi_ap(); + if (network->station_netif == NULL || network->access_point_netif == NULL) { + return ESP_ERR_NO_MEM; + } + result = esp_event_handler_register(WIFI_EVENT, ESP_EVENT_ANY_ID, network_event, network); + if (result == ESP_OK) { + result = esp_event_handler_register(IP_EVENT, IP_EVENT_STA_GOT_IP, network_event, network); + } + if (result == ESP_OK) { + active_network = network; + } + return result; +} + +esp_err_t maze32_network_start(maze32_network_t *network) +{ + if (network == NULL || xSemaphoreTake(network->operation_mutex, portMAX_DELAY) != pdTRUE) { + return ESP_ERR_INVALID_ARG; + } + maze32_wifi_credentials_t credentials = {0}; + esp_err_t result = maze32_config_store_load(&credentials); + if (result == ESP_OK) { + result = connect_station(network, &credentials); + } else if (result == ESP_ERR_NOT_FOUND || result == ESP_ERR_INVALID_VERSION || + result == ESP_ERR_INVALID_STATE) { + ESP_LOGI(TAG, "No usable saved station configuration; enabling fallback"); + result = start_provisioning(network); + } + xSemaphoreGive(network->operation_mutex); + return result; +} + +esp_err_t maze32_network_apply_credentials(maze32_network_t *network, + const maze32_wifi_credentials_t *credentials) +{ + if (network == NULL) { + return ESP_ERR_INVALID_ARG; + } + int result = maze32_wifi_config_apply(credentials, + network->reconfiguration_guard, + save_credentials, + network->guard_context); + if (result == -1) { + return ESP_ERR_INVALID_ARG; + } + if (result == -2) { + return ESP_ERR_INVALID_STATE; + } + return result == 0 ? ESP_OK : ESP_FAIL; +} + +esp_err_t maze32_network_reset_credentials(maze32_network_t *network) +{ + if (network == NULL) { + return ESP_ERR_INVALID_ARG; + } + int result = maze32_wifi_config_reset(network->reconfiguration_guard, + clear_credentials, + network->guard_context); + if (result == -2) { + return ESP_ERR_INVALID_STATE; + } + return result == 0 ? ESP_OK : ESP_FAIL; +} + +void maze32_network_retry_saved_credentials(void *context) +{ + maze32_network_t *network = context; + if (network == NULL || xSemaphoreTake(network->operation_mutex, portMAX_DELAY) != pdTRUE) { + return; + } + maze32_wifi_credentials_t credentials = {0}; + esp_err_t result = maze32_config_store_load(&credentials); + if (result == ESP_OK) { + result = connect_station(network, &credentials); + } + if (result != ESP_OK) { + ESP_LOGE(TAG, "Saved station retry failed: %s", esp_err_to_name(result)); + start_provisioning(network); + } + xSemaphoreGive(network->operation_mutex); +} + +void maze32_network_return_to_provisioning(void *context) +{ + maze32_network_t *network = context; + if (network == NULL || xSemaphoreTake(network->operation_mutex, portMAX_DELAY) != pdTRUE) { + return; + } + esp_err_t result = start_provisioning(network); + if (result != ESP_OK) { + ESP_LOGE(TAG, "Failed to restore provisioning access point: %s", esp_err_to_name(result)); + } + xSemaphoreGive(network->operation_mutex); +} + +esp_err_t maze32_network_scan(maze32_network_t *network, + maze32_wifi_scan_result_t *results, + size_t capacity, + size_t *count) +{ + if (network == NULL || results == NULL || count == NULL || capacity == 0U || + capacity > MAZE32_WIFI_SCAN_LIMIT || + xSemaphoreTake(network->operation_mutex, pdMS_TO_TICKS(1000U)) != pdTRUE) { + return ESP_ERR_INVALID_ARG; + } + if (network->state != MAZE32_NETWORK_PROVISIONING) { + xSemaphoreGive(network->operation_mutex); + return ESP_ERR_INVALID_STATE; + } + + wifi_scan_config_t scan_configuration = {0}; + esp_err_t result = esp_wifi_scan_start(&scan_configuration, true); + uint16_t found = (uint16_t)capacity; + wifi_ap_record_t records[MAZE32_WIFI_SCAN_LIMIT]; + if (result == ESP_OK) { + result = esp_wifi_scan_get_ap_records(&found, records); + } + if (result == ESP_OK) { + *count = found; + for (size_t index = 0U; index < found; ++index) { + memcpy(results[index].ssid, records[index].ssid, MAZE32_WIFI_SSID_MAX_BYTES); + results[index].ssid[MAZE32_WIFI_SSID_MAX_BYTES] = '\0'; + results[index].rssi = records[index].rssi; + results[index].auth_mode = (uint8_t)records[index].authmode; + } + } + xSemaphoreGive(network->operation_mutex); + return result; +} + +maze32_network_state_t maze32_network_state(const maze32_network_t *network) +{ + return network == NULL ? MAZE32_NETWORK_ERROR : network->state; +} + +const char *maze32_network_state_name(maze32_network_state_t state) +{ + switch (state) { + case MAZE32_NETWORK_BOOTING: return "booting"; + case MAZE32_NETWORK_STATION_CONNECTING: return "station_connecting"; + case MAZE32_NETWORK_STATION_CONNECTED: return "station_connected"; + case MAZE32_NETWORK_PROVISIONING: return "provisioning"; + case MAZE32_NETWORK_ERROR: return "error"; + default: return "unknown"; + } +} + +const char *maze32_network_access_point_ssid(const maze32_network_t *network) +{ + return network == NULL ? "" : network->access_point_ssid; +} + +esp_err_t maze32_network_ip_address(const maze32_network_t *network, + char *output, + size_t output_size) +{ + if (network == NULL || output == NULL || output_size < 16U || + network->station_netif == NULL) { + return ESP_ERR_INVALID_ARG; + } + esp_netif_ip_info_t information; + esp_err_t result = esp_netif_get_ip_info(network->station_netif, &information); + if (result == ESP_OK) { + snprintf(output, output_size, IPSTR, IP2STR(&information.ip)); + } + return result; +} diff --git a/components/maze32_network/maze32_provisioning.c b/components/maze32_network/maze32_provisioning.c new file mode 100644 index 0000000..f770208 --- /dev/null +++ b/components/maze32_network/maze32_provisioning.c @@ -0,0 +1,261 @@ +#include "maze32_network.h" + +#include +#include + +#include "esp_log.h" +#include "maze32_provisioning_protocol.h" + +#define MAZE32_PROVISION_BODY_LIMIT 256U +#define MAZE32_PROVISION_RESPONSE_LIMIT 2048U + +static const char *TAG = "maze32_provision"; +static const char *PROVISION_PATH = "/littlefs/provision.html.gz"; + +static int append_json_string(char *output, + size_t capacity, + size_t *length, + const char *value) +{ + if (*length + 1U >= capacity) { + return 0; + } + output[(*length)++] = '"'; + for (const unsigned char *cursor = (const unsigned char *)value; + *cursor != '\0'; ++cursor) { + const char *escape = NULL; + if (*cursor == '"') { + escape = "\\\""; + } else if (*cursor == '\\') { + escape = "\\\\"; + } else if (*cursor == '\b') { + escape = "\\b"; + } else if (*cursor == '\f') { + escape = "\\f"; + } else if (*cursor == '\n') { + escape = "\\n"; + } else if (*cursor == '\r') { + escape = "\\r"; + } else if (*cursor == '\t') { + escape = "\\t"; + } + if (escape != NULL) { + if (*length + 2U >= capacity) { + return 0; + } + output[(*length)++] = escape[0]; + output[(*length)++] = escape[1]; + } else { + if (*cursor < 0x20U) { + if (*length + 6U >= capacity) { + return 0; + } + int written = snprintf(output + *length, capacity - *length, + "\\u%04x", *cursor); + if (written != 6) { + return 0; + } + *length += 6U; + continue; + } + if (*length + 1U >= capacity) { + return 0; + } + output[(*length)++] = (char)*cursor; + } + } + if (*length + 1U >= capacity) { + return 0; + } + output[(*length)++] = '"'; + output[*length] = '\0'; + return 1; +} + +static esp_err_t send_json(httpd_req_t *request, const char *status, const char *body) +{ + httpd_resp_set_status(request, status); + httpd_resp_set_type(request, "application/json"); + httpd_resp_set_hdr(request, "Cache-Control", "no-store"); + return httpd_resp_sendstr(request, body); +} + +static esp_err_t send_provision_page(httpd_req_t *request) +{ + FILE *file = fopen(PROVISION_PATH, "rb"); + if (file == NULL) { + ESP_LOGE(TAG, "Provisioning asset is unavailable: %s", PROVISION_PATH); + return httpd_resp_send_err(request, HTTPD_500_INTERNAL_SERVER_ERROR, + "Provisioning application is unavailable"); + } + httpd_resp_set_type(request, "text/html; charset=utf-8"); + httpd_resp_set_hdr(request, "Content-Encoding", "gzip"); + httpd_resp_set_hdr(request, "Cache-Control", "no-cache"); + + char buffer[1024]; + esp_err_t result = ESP_OK; + size_t bytes_read; + while ((bytes_read = fread(buffer, 1, sizeof(buffer), file)) > 0U) { + result = httpd_resp_send_chunk(request, buffer, bytes_read); + if (result != ESP_OK) { + break; + } + } + if (ferror(file) != 0) { + result = ESP_FAIL; + } + fclose(file); + return result == ESP_OK ? httpd_resp_send_chunk(request, NULL, 0) : result; +} + +static esp_err_t send_status(httpd_req_t *request) +{ + maze32_network_t *network = request->user_ctx; + char address[16] = ""; + if (maze32_network_state(network) == MAZE32_NETWORK_STATION_CONNECTED) { + maze32_network_ip_address(network, address, sizeof(address)); + } + char response[256]; + int length = snprintf(response, sizeof(response), + "{\"state\":\"%s\",\"accessPointSsid\":\"%s\",\"ip\":\"%s\"}", + maze32_network_state_name(maze32_network_state(network)), + maze32_network_access_point_ssid(network), address); + if (length < 0 || (size_t)length >= sizeof(response)) { + return ESP_FAIL; + } + return send_json(request, "200 OK", response); +} + +static esp_err_t send_scan(httpd_req_t *request) +{ + maze32_network_t *network = request->user_ctx; + maze32_wifi_scan_result_t results[MAZE32_WIFI_SCAN_LIMIT]; + size_t count = 0U; + esp_err_t result = maze32_network_scan(network, results, MAZE32_WIFI_SCAN_LIMIT, &count); + if (result != ESP_OK) { + return send_json(request, "503 Service Unavailable", + "{\"error\":\"Сканирование сейчас недоступно\"}"); + } + + char response[MAZE32_PROVISION_RESPONSE_LIMIT] = "{\"networks\":["; + size_t length = strlen(response); + for (size_t index = 0U; index < count; ++index) { + const char *prefix = index == 0U ? "{" : ",{"; + int written = snprintf(response + length, sizeof(response) - length, + "%s\"ssid\":", prefix); + if (written < 0 || (size_t)written >= sizeof(response) - length) { + return ESP_ERR_NO_MEM; + } + length += (size_t)written; + if (!append_json_string(response, sizeof(response), &length, results[index].ssid)) { + return ESP_ERR_NO_MEM; + } + written = snprintf(response + length, sizeof(response) - length, + ",\"rssi\":%d,\"secure\":%s}", + results[index].rssi, + results[index].auth_mode == 0U ? "false" : "true"); + if (written < 0 || (size_t)written >= sizeof(response) - length) { + return ESP_ERR_NO_MEM; + } + length += (size_t)written; + } + int written = snprintf(response + length, sizeof(response) - length, "]}"); + if (written < 0 || (size_t)written >= sizeof(response) - length) { + return ESP_ERR_NO_MEM; + } + return send_json(request, "200 OK", response); +} + +static esp_err_t read_credentials(httpd_req_t *request, + maze32_wifi_credentials_t *credentials) +{ + if (request->content_len == 0U || request->content_len > MAZE32_PROVISION_BODY_LIMIT) { + return ESP_ERR_INVALID_SIZE; + } + char body[MAZE32_PROVISION_BODY_LIMIT + 1U]; + size_t received = 0U; + while (received < request->content_len) { + int current = httpd_req_recv(request, body + received, request->content_len - received); + if (current <= 0) { + return ESP_FAIL; + } + received += (size_t)current; + } + body[received] = '\0'; + + if (!maze32_provisioning_parse_credentials(body, received, credentials)) { + return ESP_ERR_INVALID_ARG; + } + return ESP_OK; +} + +static esp_err_t apply_credentials(httpd_req_t *request) +{ + maze32_network_t *network = request->user_ctx; + maze32_wifi_credentials_t credentials; + esp_err_t result = read_credentials(request, &credentials); + if (result != ESP_OK) { + return send_json(request, "400 Bad Request", + "{\"error\":\"Проверьте имя сети и пароль\"}"); + } + result = maze32_network_apply_credentials(network, &credentials); + memset(&credentials, 0, sizeof(credentials)); + if (result == ESP_ERR_INVALID_STATE) { + return send_json(request, "409 Conflict", + "{\"error\":\"Сначала завершите текущую игру\"}"); + } + if (result != ESP_OK) { + return send_json(request, "500 Internal Server Error", + "{\"error\":\"Не удалось сохранить настройки\"}"); + } + result = httpd_queue_work(network->server, maze32_network_retry_saved_credentials, network); + if (result != ESP_OK) { + return send_json(request, "500 Internal Server Error", + "{\"error\":\"Настройки сохранены; перезагрузите устройство\"}"); + } + return send_json(request, "202 Accepted", + "{\"status\":\"saved\",\"message\":\"Подключаемся к сети…\"}"); +} + +static esp_err_t reset_credentials(httpd_req_t *request) +{ + maze32_network_t *network = request->user_ctx; + esp_err_t result = maze32_network_reset_credentials(network); + if (result == ESP_ERR_INVALID_STATE) { + return send_json(request, "409 Conflict", + "{\"error\":\"Сначала завершите текущую игру\"}"); + } + if (result != ESP_OK) { + return send_json(request, "500 Internal Server Error", + "{\"error\":\"Не удалось сбросить настройки\"}"); + } + result = httpd_queue_work(network->server, maze32_network_return_to_provisioning, network); + if (result != ESP_OK) { + return ESP_FAIL; + } + return send_json(request, "202 Accepted", + "{\"status\":\"reset\",\"message\":\"Настройки сети удалены\"}"); +} + +esp_err_t maze32_provisioning_register(httpd_handle_t server, maze32_network_t *network) +{ + if (server == NULL || network == NULL) { + return ESP_ERR_INVALID_ARG; + } + network->server = server; + const httpd_uri_t routes[] = { + {.uri = "/provision", .method = HTTP_GET, .handler = send_provision_page, .user_ctx = network}, + {.uri = "/api/wifi/status", .method = HTTP_GET, .handler = send_status, .user_ctx = network}, + {.uri = "/api/wifi/scan", .method = HTTP_GET, .handler = send_scan, .user_ctx = network}, + {.uri = "/api/wifi/config", .method = HTTP_POST, .handler = apply_credentials, .user_ctx = network}, + {.uri = "/api/wifi/reset", .method = HTTP_POST, .handler = reset_credentials, .user_ctx = network}, + }; + for (size_t index = 0U; index < sizeof(routes) / sizeof(routes[0]); ++index) { + esp_err_t result = httpd_register_uri_handler(server, &routes[index]); + if (result != ESP_OK) { + return result; + } + } + ESP_LOGI(TAG, "Provisioning endpoints ready"); + return ESP_OK; +} diff --git a/components/maze32_network/maze32_provisioning_protocol.c b/components/maze32_network/maze32_provisioning_protocol.c new file mode 100644 index 0000000..51de5fd --- /dev/null +++ b/components/maze32_network/maze32_provisioning_protocol.c @@ -0,0 +1,113 @@ +#include "maze32_provisioning_protocol.h" + +#include +#include + +static const char *skip_space(const char *cursor, const char *end) +{ + while (cursor < end && isspace((unsigned char)*cursor)) { + ++cursor; + } + return cursor; +} + +static int parse_json_string(const char **cursor_pointer, + const char *end, + char *output, + size_t capacity) +{ + const char *cursor = *cursor_pointer; + size_t length = 0U; + if (cursor >= end || *cursor++ != '"') { + return 0; + } + while (cursor < end && *cursor != '"') { + unsigned char value = (unsigned char)*cursor++; + if (value == '\\') { + if (cursor >= end) { + return 0; + } + char escaped = *cursor++; + if (escaped == '"' || escaped == '\\' || escaped == '/') { + value = (unsigned char)escaped; + } else if (escaped == 'b') { + value = '\b'; + } else if (escaped == 'f') { + value = '\f'; + } else if (escaped == 'n') { + value = '\n'; + } else if (escaped == 'r') { + value = '\r'; + } else if (escaped == 't') { + value = '\t'; + } else { + return 0; + } + } + if (value < 0x20U || length + 1U >= capacity) { + return 0; + } + output[length++] = (char)value; + } + if (cursor >= end || *cursor++ != '"') { + return 0; + } + output[length] = '\0'; + *cursor_pointer = cursor; + return 1; +} + +int maze32_provisioning_parse_credentials(const char *body, + size_t length, + maze32_wifi_credentials_t *credentials) +{ + if (body == NULL || credentials == NULL) { + return 0; + } + const char *cursor = body; + const char *end = body + length; + int have_ssid = 0; + int have_password = 0; + memset(credentials, 0, sizeof(*credentials)); + cursor = skip_space(cursor, end); + if (cursor >= end || *cursor++ != '{') { + return 0; + } + for (int field_index = 0; field_index < 2; ++field_index) { + char key[12]; + char value[MAZE32_WIFI_PASSWORD_MAX_BYTES + 1U]; + cursor = skip_space(cursor, end); + if (!parse_json_string(&cursor, end, key, sizeof(key))) { + return 0; + } + cursor = skip_space(cursor, end); + if (cursor >= end || *cursor++ != ':') { + return 0; + } + cursor = skip_space(cursor, end); + if (!parse_json_string(&cursor, end, value, sizeof(value))) { + return 0; + } + if (strcmp(key, "ssid") == 0 && !have_ssid && + strlen(value) <= MAZE32_WIFI_SSID_MAX_BYTES) { + memcpy(credentials->ssid, value, strlen(value) + 1U); + have_ssid = 1; + } else if (strcmp(key, "password") == 0 && !have_password) { + memcpy(credentials->password, value, strlen(value) + 1U); + have_password = 1; + } else { + return 0; + } + cursor = skip_space(cursor, end); + if (field_index == 0 && (cursor >= end || *cursor++ != ',')) { + return 0; + } + } + cursor = skip_space(cursor, end); + if (cursor >= end || *cursor++ != '}') { + return 0; + } + cursor = skip_space(cursor, end); + return cursor == end && have_ssid && have_password && + maze32_wifi_credentials_validate(credentials) == MAZE32_WIFI_CONFIG_VALID; +} diff --git a/components/maze32_web/maze32_web.c b/components/maze32_web/maze32_web.c index 570d315..f84ea1d 100644 --- a/components/maze32_web/maze32_web.c +++ b/components/maze32_web/maze32_web.c @@ -72,7 +72,7 @@ esp_err_t maze32_web_start(httpd_handle_t *server, maze32_health_t *health) } httpd_config_t configuration = HTTPD_DEFAULT_CONFIG(); - configuration.max_uri_handlers = 3; + configuration.max_uri_handlers = 8; configuration.max_open_sockets = 7; configuration.lru_purge_enable = true; diff --git a/data/provision.html.gz b/data/provision.html.gz new file mode 100644 index 0000000..d5c7b90 Binary files /dev/null and b/data/provision.html.gz differ diff --git a/platformio.ini b/platformio.ini index 607a36f..c74469a 100644 --- a/platformio.ini +++ b/platformio.ini @@ -8,6 +8,9 @@ ; Please visit documentation for the other options and examples ; https://docs.platformio.org/page/projectconf.html +[platformio] +default_envs = esp32-c6-devkitm-1 + [env:esp32-c6-devkitm-1] platform = espressif32 @ 7.0.1 board = esp32-c6-devkitm-1 @@ -33,3 +36,9 @@ monitor_port = /dev/ttyACM0 monitor_speed = 115200 monitor_dtr = 0 monitor_rts = 0 + +[env:milestone-003-device-test] +extends = env:esp32-c6-devkitm-1 +build_flags = + ${env:esp32-c6-devkitm-1.build_flags} + -DMAZE32_MILESTONE_003_DEVICE_TEST=1 diff --git a/scripts/run_host_tests.sh b/scripts/run_host_tests.sh index cec5196..d98d4f7 100755 --- a/scripts/run_host_tests.sh +++ b/scripts/run_host_tests.sh @@ -12,4 +12,33 @@ cc -std=c11 -Wall -Wextra -Werror -pedantic \ -o "$test_directory/test_health" "$test_directory/test_health" + +cc -std=c11 -Wall -Wextra -Werror -pedantic \ + -I"$project_directory/components/maze32_config/include" \ + "$project_directory/components/maze32_config/maze32_wifi_config.c" \ + "$project_directory/test/host/test_wifi_config.c" \ + -o "$test_directory/test_wifi_config" + +"$test_directory/test_wifi_config" + +cc -std=c11 -Wall -Wextra -Werror -pedantic \ + -I"$project_directory/test/host/fakes" \ + -I"$project_directory/components/maze32_config/include" \ + "$project_directory/components/maze32_config/maze32_wifi_config.c" \ + "$project_directory/components/maze32_config/maze32_config_store.c" \ + "$project_directory/test/host/fake_nvs.c" \ + "$project_directory/test/host/test_config_store.c" \ + -o "$test_directory/test_config_store" + +"$test_directory/test_config_store" + +cc -std=c11 -Wall -Wextra -Werror -pedantic \ + -I"$project_directory/components/maze32_config/include" \ + -I"$project_directory/components/maze32_network/include" \ + "$project_directory/components/maze32_config/maze32_wifi_config.c" \ + "$project_directory/components/maze32_network/maze32_provisioning_protocol.c" \ + "$project_directory/test/host/test_provisioning_protocol.c" \ + -o "$test_directory/test_provisioning_protocol" + +"$test_directory/test_provisioning_protocol" python3 "$project_directory/scripts/verify_web_assets.py" diff --git a/scripts/verify_web_assets.py b/scripts/verify_web_assets.py index 5349525..1c658d6 100755 --- a/scripts/verify_web_assets.py +++ b/scripts/verify_web_assets.py @@ -46,6 +46,11 @@ def main() -> int: if gzip.decompress(index_compressed) != index_source: raise AssertionError("Compressed index does not reproduce its source") + provision_source = (source_directory / "provision.html").read_bytes() + provision_compressed = (first / "provision.html.gz").read_bytes() + if gzip.decompress(provision_compressed) != provision_source: + raise AssertionError("Compressed provisioning page does not reproduce its source") + print(f"web asset reproducibility: PASS ({first_digest})") return 0 diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index 87971cb..5b548ee 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -1,4 +1,4 @@ idf_component_register( SRCS "main.c" - REQUIRES esp_event esp_netif maze32_platform maze32_web + REQUIRES esp_event esp_netif maze32_config maze32_network maze32_platform maze32_web ) diff --git a/src/main.c b/src/main.c index d2e4367..a6e6d6d 100644 --- a/src/main.c +++ b/src/main.c @@ -4,14 +4,70 @@ #include "esp_littlefs.h" #include "esp_log.h" #include "esp_netif.h" +#ifdef MAZE32_MILESTONE_003_DEVICE_TEST +#include "esp_system.h" +#include +#endif #include "freertos/FreeRTOS.h" #include "freertos/task.h" #include "maze32_health.h" +#include "maze32_config_store.h" +#include "maze32_network.h" #include "maze32_web.h" static const char *TAG = "maze32"; static maze32_health_t health; static httpd_handle_t web_server; +static maze32_network_t network; + +#ifdef MAZE32_MILESTONE_003_DEVICE_TEST +static int device_test_has_persisted_credentials; + +static esp_err_t run_device_test_before_network(void) +{ + const maze32_wifi_credentials_t expected = { + .ssid = "maze32-unavailable-test", + .password = "test-only-password", + }; + maze32_wifi_credentials_t loaded = {0}; + esp_err_t result = maze32_config_store_load(&loaded); + if (result == ESP_ERR_NOT_FOUND) { + result = maze32_config_store_save(&expected); + if (result == ESP_OK) { + ESP_LOGI(TAG, "M003 device test: credentials saved; restarting"); + esp_restart(); + } + return result; + } + if (result != ESP_OK || strcmp(loaded.ssid, expected.ssid) != 0 || + strcmp(loaded.password, expected.password) != 0) { + ESP_LOGE(TAG, "M003 device test refused to overwrite unexpected NVS credentials"); + return ESP_ERR_INVALID_STATE; + } + device_test_has_persisted_credentials = 1; + ESP_LOGI(TAG, "M003 device test: credentials survived restart"); + return ESP_OK; +} + +static esp_err_t finish_device_test(void) +{ + if (!device_test_has_persisted_credentials) { + return ESP_ERR_INVALID_STATE; + } + esp_err_t result = maze32_config_store_clear(); + if (result == ESP_OK) { + ESP_LOGI(TAG, "M003 device test: failed station returned to fallback; credentials cleared"); + } + return result; +} +#endif + +static int prepare_for_network_reconfiguration(void *context) +{ + (void)context; + ESP_LOGW(TAG, "Runtime accepted network reconfiguration; no active match exists"); + return 0; +} static esp_err_t mount_littlefs(void) { @@ -57,6 +113,22 @@ void app_main(void) maze32_health_set(&health, MAZE32_HEALTH_FILESYSTEM_READY, 0); + result = maze32_config_store_init(); + if (result != ESP_OK) { + maze32_health_set(&health, MAZE32_HEALTH_ERROR, (int)result); + ESP_LOGE(TAG, "Persistent configuration initialization failed: %s", + esp_err_to_name(result)); + return; + } + +#ifdef MAZE32_MILESTONE_003_DEVICE_TEST + result = run_device_test_before_network(); + if (result != ESP_OK) { + maze32_health_set(&health, MAZE32_HEALTH_ERROR, (int)result); + return; + } +#endif + result = esp_netif_init(); if (result != ESP_OK) { maze32_health_set(&health, MAZE32_HEALTH_ERROR, (int)result); @@ -71,13 +143,38 @@ void app_main(void) return; } - maze32_health_set(&health, MAZE32_HEALTH_SERVING, 0); - result = maze32_web_start(&web_server, &health); + result = maze32_network_init(&network, prepare_for_network_reconfiguration, NULL); + if (result == ESP_OK) { + result = maze32_network_start(&network); + } if (result != ESP_OK) { + maze32_health_set(&health, MAZE32_HEALTH_ERROR, (int)result); + ESP_LOGE(TAG, "Network initialization failed: %s", esp_err_to_name(result)); + return; + } + +#ifdef MAZE32_MILESTONE_003_DEVICE_TEST + result = finish_device_test(); + if (result != ESP_OK) { + maze32_health_set(&health, MAZE32_HEALTH_ERROR, (int)result); + return; + } +#endif + + result = maze32_web_start(&web_server, &health); + if (result == ESP_OK) { + result = maze32_provisioning_register(web_server, &network); + } + if (result != ESP_OK) { + if (web_server != NULL) { + httpd_stop(web_server); + web_server = NULL; + } maze32_health_set(&health, MAZE32_HEALTH_ERROR, (int)result); ESP_LOGE(TAG, "HTTP server startup failed: %s", esp_err_to_name(result)); return; } + maze32_health_set(&health, MAZE32_HEALTH_SERVING, 0); ESP_LOGI(TAG, "Health state: %s", maze32_health_state_name(health.state)); } diff --git a/test/host/fake_nvs.c b/test/host/fake_nvs.c new file mode 100644 index 0000000..7e824e2 --- /dev/null +++ b/test/host/fake_nvs.c @@ -0,0 +1,100 @@ +#include "nvs.h" +#include "nvs_flash.h" + +#include + +static int initialized; +static int present; +static uint32_t schema; +static char ssid[33]; +static char password[64]; + +esp_err_t nvs_flash_init(void) +{ + initialized = 1; + return ESP_OK; +} + +esp_err_t nvs_flash_deinit(void) +{ + initialized = 0; + return ESP_OK; +} + +esp_err_t nvs_flash_erase(void) +{ + present = 0; + return ESP_OK; +} + +esp_err_t nvs_open(const char *namespace_name, int mode, nvs_handle_t *handle) +{ + if (!initialized || strcmp(namespace_name, "maze32") != 0) return ESP_FAIL; + if (mode == NVS_READONLY && !present) return ESP_ERR_NVS_NOT_FOUND; + *handle = 1U; + return ESP_OK; +} + +void nvs_close(nvs_handle_t handle) +{ + (void)handle; +} + +esp_err_t nvs_get_u32(nvs_handle_t handle, const char *key, uint32_t *value) +{ + (void)handle; + if (!present || strcmp(key, "cfg_ver") != 0) return ESP_ERR_NVS_NOT_FOUND; + *value = schema; + return ESP_OK; +} + +esp_err_t nvs_set_u32(nvs_handle_t handle, const char *key, uint32_t value) +{ + (void)handle; + if (strcmp(key, "cfg_ver") != 0) return ESP_FAIL; + schema = value; + present = 1; + return ESP_OK; +} + +esp_err_t nvs_get_str(nvs_handle_t handle, const char *key, char *output, size_t *length) +{ + (void)handle; + const char *source = strcmp(key, "wifi_ssid") == 0 ? ssid : + strcmp(key, "wifi_pass") == 0 ? password : NULL; + if (!present || source == NULL) return ESP_ERR_NVS_NOT_FOUND; + size_t required = strlen(source) + 1U; + if (*length < required) return ESP_FAIL; + memcpy(output, source, required); + *length = required; + return ESP_OK; +} + +esp_err_t nvs_set_str(nvs_handle_t handle, const char *key, const char *value) +{ + (void)handle; + char *destination = strcmp(key, "wifi_ssid") == 0 ? ssid : + strcmp(key, "wifi_pass") == 0 ? password : NULL; + size_t capacity = strcmp(key, "wifi_ssid") == 0 ? sizeof(ssid) : sizeof(password); + if (destination == NULL || strlen(value) >= capacity) return ESP_FAIL; + memcpy(destination, value, strlen(value) + 1U); + return ESP_OK; +} + +esp_err_t nvs_erase_all(nvs_handle_t handle) +{ + (void)handle; + present = 0; + return ESP_OK; +} + +esp_err_t nvs_commit(nvs_handle_t handle) +{ + (void)handle; + return ESP_OK; +} + +void fake_nvs_set_schema(uint32_t version) +{ + schema = version; +} diff --git a/test/host/fakes/esp_err.h b/test/host/fakes/esp_err.h new file mode 100644 index 0000000..0a1bed9 --- /dev/null +++ b/test/host/fakes/esp_err.h @@ -0,0 +1,17 @@ +#ifndef TEST_ESP_ERR_H +#define TEST_ESP_ERR_H + +typedef int esp_err_t; + +#define ESP_OK 0 +#define ESP_FAIL 0x101 +#define ESP_ERR_NO_MEM 0x102 +#define ESP_ERR_INVALID_ARG 0x103 +#define ESP_ERR_INVALID_STATE 0x104 +#define ESP_ERR_INVALID_VERSION 0x105 +#define ESP_ERR_NOT_FOUND 0x106 +#define ESP_ERR_NVS_NOT_FOUND 0x1102 +#define ESP_ERR_NVS_NO_FREE_PAGES 0x110d +#define ESP_ERR_NVS_NEW_VERSION_FOUND 0x1110 + +#endif diff --git a/test/host/fakes/nvs.h b/test/host/fakes/nvs.h new file mode 100644 index 0000000..d0b44ee --- /dev/null +++ b/test/host/fakes/nvs.h @@ -0,0 +1,25 @@ +#ifndef TEST_NVS_H +#define TEST_NVS_H + +#include +#include + +#include "esp_err.h" + +typedef unsigned int nvs_handle_t; + +#define NVS_READONLY 0 +#define NVS_READWRITE 1 + +esp_err_t nvs_open(const char *namespace_name, int mode, nvs_handle_t *handle); +void nvs_close(nvs_handle_t handle); +esp_err_t nvs_get_u32(nvs_handle_t handle, const char *key, uint32_t *value); +esp_err_t nvs_set_u32(nvs_handle_t handle, const char *key, uint32_t value); +esp_err_t nvs_get_str(nvs_handle_t handle, const char *key, char *output, size_t *length); +esp_err_t nvs_set_str(nvs_handle_t handle, const char *key, const char *value); +esp_err_t nvs_erase_all(nvs_handle_t handle); +esp_err_t nvs_commit(nvs_handle_t handle); + +void fake_nvs_set_schema(uint32_t version); + +#endif diff --git a/test/host/fakes/nvs_flash.h b/test/host/fakes/nvs_flash.h new file mode 100644 index 0000000..54e3dfb --- /dev/null +++ b/test/host/fakes/nvs_flash.h @@ -0,0 +1,10 @@ +#ifndef TEST_NVS_FLASH_H +#define TEST_NVS_FLASH_H + +#include "esp_err.h" + +esp_err_t nvs_flash_init(void); +esp_err_t nvs_flash_erase(void); +esp_err_t nvs_flash_deinit(void); + +#endif diff --git a/test/host/test_config_store.c b/test/host/test_config_store.c new file mode 100644 index 0000000..c831ece --- /dev/null +++ b/test/host/test_config_store.c @@ -0,0 +1,45 @@ +#include +#include +#include + +#include "maze32_config_store.h" +#include "nvs.h" +#include "nvs_flash.h" + +static maze32_wifi_credentials_t credentials(const char *ssid, const char *password) +{ + maze32_wifi_credentials_t value = {0}; + memcpy(value.ssid, ssid, strlen(ssid) + 1U); + memcpy(value.password, password, strlen(password) + 1U); + return value; +} + +int main(void) +{ + assert(maze32_config_store_init() == ESP_OK); + maze32_wifi_credentials_t loaded = {0}; + assert(maze32_config_store_load(&loaded) == ESP_ERR_NOT_FOUND); + + maze32_wifi_credentials_t saved = credentials("PersistentNetwork", "safe-passphrase"); + assert(maze32_config_store_save(&saved) == ESP_OK); + assert(nvs_flash_deinit() == ESP_OK); + assert(maze32_config_store_init() == ESP_OK); + assert(maze32_config_store_load(&loaded) == ESP_OK); + assert(strcmp(loaded.ssid, saved.ssid) == 0); + assert(strcmp(loaded.password, saved.password) == 0); + + maze32_wifi_credentials_t invalid = credentials("PersistentNetwork", "short"); + assert(maze32_config_store_save(&invalid) == ESP_ERR_INVALID_ARG); + memset(&loaded, 0, sizeof(loaded)); + assert(maze32_config_store_load(&loaded) == ESP_OK); + assert(strcmp(loaded.password, saved.password) == 0); + + fake_nvs_set_schema(MAZE32_WIFI_CONFIG_SCHEMA_VERSION + 1U); + assert(maze32_config_store_load(&loaded) == ESP_ERR_INVALID_VERSION); + fake_nvs_set_schema(MAZE32_WIFI_CONFIG_SCHEMA_VERSION); + + assert(maze32_config_store_clear() == ESP_OK); + assert(maze32_config_store_load(&loaded) == ESP_ERR_NOT_FOUND); + puts("configuration NVS tests: PASS"); + return 0; +} diff --git a/test/host/test_provisioning_protocol.c b/test/host/test_provisioning_protocol.c new file mode 100644 index 0000000..2e73222 --- /dev/null +++ b/test/host/test_provisioning_protocol.c @@ -0,0 +1,40 @@ +#include +#include +#include + +#include "maze32_provisioning_protocol.h" + +static void accept(const char *json, const char *ssid, const char *password) +{ + maze32_wifi_credentials_t credentials; + assert(maze32_provisioning_parse_credentials(json, strlen(json), &credentials)); + assert(strcmp(credentials.ssid, ssid) == 0); + assert(strcmp(credentials.password, password) == 0); +} + +static void reject(const char *json) +{ + maze32_wifi_credentials_t credentials; + assert(!maze32_provisioning_parse_credentials(json, strlen(json), &credentials)); +} + +int main(void) +{ + accept("{\"ssid\":\"Maze WiFi\",\"password\":\"password8\"}", + "Maze WiFi", "password8"); + accept(" { \"password\" : \"quote\\\"pass\" , \"ssid\" : \"Maze\" } ", + "Maze", "quote\"pass"); + accept("{\"ssid\":\"Open\",\"password\":\"\"}", "Open", ""); + + reject("{}"); + reject("{\"ssid\":\"Maze\"}"); + reject("{\"ssid\":\"Maze\",\"password\":\"short\"}"); + reject("{\"ssid\":\"Maze\",\"password\":\"password8\",\"extra\":1}"); + reject("{\"ssid\":12,\"password\":\"password8\"}"); + reject("{\"ssid\":\"Maze\\nBad\",\"password\":\"password8\"}"); + reject("{\"ssid\":\"Maze\",\"ssid\":\"Again\"}"); + reject("not-json"); + + puts("provisioning protocol tests: PASS"); + return 0; +} diff --git a/test/host/test_wifi_config.c b/test/host/test_wifi_config.c new file mode 100644 index 0000000..75369e6 --- /dev/null +++ b/test/host/test_wifi_config.c @@ -0,0 +1,115 @@ +#include +#include +#include + +#include "maze32_wifi_config.h" + +typedef struct { + int guard_result; + int guard_calls; + int save_calls; + int clear_calls; + maze32_wifi_credentials_t saved; +} fixture_t; + +static int guard(void *context) +{ + fixture_t *fixture = context; + ++fixture->guard_calls; + return fixture->guard_result; +} + +static int save(const maze32_wifi_credentials_t *credentials, void *context) +{ + fixture_t *fixture = context; + ++fixture->save_calls; + fixture->saved = *credentials; + return 0; +} + +static int clear(void *context) +{ + fixture_t *fixture = context; + ++fixture->clear_calls; + return 0; +} + +static maze32_wifi_credentials_t valid_credentials(void) +{ + maze32_wifi_credentials_t credentials = {0}; + memcpy(credentials.ssid, "Labyrinth", sizeof("Labyrinth")); + memcpy(credentials.password, "eight888", sizeof("eight888")); + return credentials; +} + +static void test_validation_boundaries(void) +{ + maze32_wifi_credentials_t credentials = valid_credentials(); + assert(maze32_wifi_credentials_validate(&credentials) == MAZE32_WIFI_CONFIG_VALID); + + credentials.password[0] = '\0'; + assert(maze32_wifi_credentials_validate(&credentials) == MAZE32_WIFI_CONFIG_VALID); + + memcpy(credentials.password, "short", sizeof("short")); + assert(maze32_wifi_credentials_validate(&credentials) == + MAZE32_WIFI_CONFIG_INVALID_PASSWORD); + + credentials = valid_credentials(); + credentials.ssid[0] = '\0'; + assert(maze32_wifi_credentials_validate(&credentials) == MAZE32_WIFI_CONFIG_INVALID_SSID); + + memset(credentials.ssid, 's', sizeof(credentials.ssid)); + assert(maze32_wifi_credentials_validate(&credentials) == MAZE32_WIFI_CONFIG_INVALID_SSID); + + credentials = valid_credentials(); + credentials.ssid[2] = '\n'; + assert(maze32_wifi_credentials_validate(&credentials) == MAZE32_WIFI_CONFIG_INVALID_SSID); +} + +static void test_invalid_input_never_mutates_storage(void) +{ + fixture_t fixture = {0}; + maze32_wifi_credentials_t credentials = valid_credentials(); + credentials.password[0] = 'x'; + credentials.password[1] = '\0'; + assert(maze32_wifi_config_apply(&credentials, guard, save, &fixture) == -1); + assert(fixture.guard_calls == 0); + assert(fixture.save_calls == 0); +} + +static void test_guard_precedes_every_mutation(void) +{ + fixture_t fixture = {.guard_result = -1}; + maze32_wifi_credentials_t credentials = valid_credentials(); + assert(maze32_wifi_config_apply(&credentials, guard, save, &fixture) == -2); + assert(fixture.guard_calls == 1); + assert(fixture.save_calls == 0); + assert(maze32_wifi_config_reset(guard, clear, &fixture) == -2); + assert(fixture.guard_calls == 2); + assert(fixture.clear_calls == 0); +} + +static void test_valid_apply_and_reset(void) +{ + fixture_t fixture = {0}; + maze32_wifi_credentials_t credentials = valid_credentials(); + assert(maze32_wifi_config_apply(&credentials, guard, save, &fixture) == 0); + assert(fixture.guard_calls == 1); + assert(fixture.save_calls == 1); + assert(strcmp(fixture.saved.ssid, credentials.ssid) == 0); + assert(strcmp(fixture.saved.password, credentials.password) == 0); + + assert(maze32_wifi_config_reset(guard, clear, &fixture) == 0); + assert(fixture.guard_calls == 2); + assert(fixture.clear_calls == 1); +} + +int main(void) +{ + test_validation_boundaries(); + test_invalid_input_never_mutates_storage(); + test_guard_precedes_every_mutation(); + test_valid_apply_and_reset(); + puts("wifi configuration tests: PASS"); + return 0; +} diff --git a/version.txt b/version.txt index 0ea3a94..0d91a54 100644 --- a/version.txt +++ b/version.txt @@ -1 +1 @@ -0.2.0 +0.3.0 diff --git a/web/provision.html b/web/provision.html new file mode 100644 index 0000000..6920938 --- /dev/null +++ b/web/provision.html @@ -0,0 +1,47 @@ + + + + + + maze32 — настройка сети + + + +
+

MAZE32 // WI-FI

+

Поможем коробочке найти интернет. Сам лабиринт в интернет не убежит.

+
Проверяем сетевой режим…
+ +
+ + + + + + + Оставьте пустым для открытой сети; иначе нужно 8–63 символа. + +
+ +

+
+ + +