feat: implement Wi-Fi provisioning and persistent configuration
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
idf_component_register(
|
||||
SRCS "maze32_config_store.c" "maze32_wifi_config.c"
|
||||
INCLUDE_DIRS "include"
|
||||
REQUIRES nvs_flash
|
||||
)
|
||||
@@ -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
|
||||
@@ -0,0 +1,45 @@
|
||||
#ifndef MAZE32_WIFI_CONFIG_H
|
||||
#define MAZE32_WIFI_CONFIG_H
|
||||
|
||||
#include <stddef.h>
|
||||
#include <stdint.h>
|
||||
|
||||
#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
|
||||
@@ -0,0 +1,110 @@
|
||||
#include "maze32_config_store.h"
|
||||
|
||||
#include <string.h>
|
||||
|
||||
#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;
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
#include "maze32_wifi_config.h"
|
||||
|
||||
#include <string.h>
|
||||
|
||||
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;
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
@@ -0,0 +1,73 @@
|
||||
#ifndef MAZE32_NETWORK_H
|
||||
#define MAZE32_NETWORK_H
|
||||
|
||||
#include <stddef.h>
|
||||
#include <stdint.h>
|
||||
|
||||
#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
|
||||
@@ -0,0 +1,20 @@
|
||||
#ifndef MAZE32_PROVISIONING_PROTOCOL_H
|
||||
#define MAZE32_PROVISIONING_PROTOCOL_H
|
||||
|
||||
#include <stddef.h>
|
||||
|
||||
#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
|
||||
@@ -0,0 +1,352 @@
|
||||
#include "maze32_network.h"
|
||||
|
||||
#include <stdio.h>
|
||||
#include <string.h>
|
||||
|
||||
#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;
|
||||
}
|
||||
@@ -0,0 +1,261 @@
|
||||
#include "maze32_network.h"
|
||||
|
||||
#include <stdio.h>
|
||||
#include <string.h>
|
||||
|
||||
#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;
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
#include "maze32_provisioning_protocol.h"
|
||||
|
||||
#include <ctype.h>
|
||||
#include <string.h>
|
||||
|
||||
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;
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
|
||||
Binary file not shown.
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
+1
-1
@@ -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
|
||||
)
|
||||
|
||||
+99
-2
@@ -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 <string.h>
|
||||
#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));
|
||||
}
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
#include "nvs.h"
|
||||
#include "nvs_flash.h"
|
||||
|
||||
#include <string.h>
|
||||
|
||||
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;
|
||||
}
|
||||
@@ -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
|
||||
@@ -0,0 +1,25 @@
|
||||
#ifndef TEST_NVS_H
|
||||
#define TEST_NVS_H
|
||||
|
||||
#include <stddef.h>
|
||||
#include <stdint.h>
|
||||
|
||||
#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
|
||||
@@ -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
|
||||
@@ -0,0 +1,45 @@
|
||||
#include <assert.h>
|
||||
#include <stdio.h>
|
||||
#include <string.h>
|
||||
|
||||
#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;
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
#include <assert.h>
|
||||
#include <stdio.h>
|
||||
#include <string.h>
|
||||
|
||||
#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;
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
#include <assert.h>
|
||||
#include <stdio.h>
|
||||
#include <string.h>
|
||||
|
||||
#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;
|
||||
}
|
||||
+1
-1
@@ -1 +1 @@
|
||||
0.2.0
|
||||
0.3.0
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
<!doctype html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>maze32 — настройка сети</title>
|
||||
<style>
|
||||
:root{color-scheme:dark;--bg:#09070f;--panel:#281445;--accent:#38e8d1;--ok:#68dc78;--warn:#ffd45c;--bad:#ff647c}
|
||||
*{box-sizing:border-box}body{margin:0;min-height:100vh;background:radial-gradient(circle at top,#211138,var(--bg) 58%);color:#f4efff;font:16px/1.45 ui-monospace,SFMono-Regular,Consolas,monospace;padding:24px}
|
||||
main{width:min(620px,100%);margin:auto;background:var(--panel);border:2px solid #8058b7;box-shadow:8px 8px 0 #10071d;padding:clamp(18px,5vw,36px)}
|
||||
h1{margin:0;color:var(--accent);letter-spacing:.08em}p{margin:.55rem 0 1.3rem}.status{border-left:4px solid var(--warn);padding:.7rem 1rem;background:#160d27}
|
||||
label{display:block;margin:1rem 0 .35rem}input,select,button{width:100%;min-height:48px;border:2px solid #8058b7;background:#10091d;color:#fff;font:inherit;padding:.65rem}
|
||||
input:focus,select:focus,button:focus{outline:3px solid var(--accent);outline-offset:2px}button{margin-top:1rem;border-color:var(--accent);color:var(--accent);font-weight:700;cursor:pointer}button:disabled{opacity:.45;cursor:wait}.danger{border-color:var(--bad);color:var(--bad)}
|
||||
#message[data-kind="ok"]{color:var(--ok)}#message[data-kind="error"]{color:var(--bad)}small{color:#c9badd}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<main>
|
||||
<h1>MAZE32 // WI-FI</h1>
|
||||
<p>Поможем коробочке найти интернет. Сам лабиринт в интернет не убежит.</p>
|
||||
<div class="status" id="status">Проверяем сетевой режим…</div>
|
||||
<button id="scan" type="button">Сканировать сети</button>
|
||||
<form id="form">
|
||||
<label for="networks">Найденные сети</label>
|
||||
<select id="networks"><option value="">Выберите сеть или введите имя ниже</option></select>
|
||||
<label for="ssid">Имя сети (SSID)</label>
|
||||
<input id="ssid" name="ssid" maxlength="32" required autocomplete="off">
|
||||
<label for="password">Пароль</label>
|
||||
<input id="password" name="password" type="password" maxlength="63" autocomplete="new-password" aria-describedby="password-help">
|
||||
<small id="password-help">Оставьте пустым для открытой сети; иначе нужно 8–63 символа.</small>
|
||||
<button id="save" type="submit">Сохранить и подключиться</button>
|
||||
</form>
|
||||
<button id="reset" class="danger" type="button">Удалить настройки сети</button>
|
||||
<p id="message" role="status" aria-live="polite"></p>
|
||||
</main>
|
||||
<script>
|
||||
const $=id=>document.getElementById(id),message=(text,kind='')=>{$('message').textContent=text;$('message').dataset.kind=kind};
|
||||
async function json(url,options){const response=await fetch(url,options),body=await response.json().catch(()=>({}));if(!response.ok)throw new Error(body.error||'Устройство не ответило');return body}
|
||||
async function status(){try{const value=await json('/api/wifi/status');$('status').textContent=value.state==='provisioning'?`Точка настройки: ${value.accessPointSsid}`:value.state==='station_connected'?`Подключено. Адрес: ${value.ip}`:`Состояние: ${value.state}`}catch(error){$('status').textContent=error.message}}
|
||||
$('networks').addEventListener('change',event=>{if(event.target.value)$('ssid').value=event.target.value});
|
||||
$('scan').addEventListener('click',async()=>{const button=$('scan');button.disabled=true;message('Ищем Wi-Fi…');try{const value=await json('/api/wifi/scan'),select=$('networks');select.length=1;for(const network of value.networks){const option=document.createElement('option');option.value=network.ssid;option.textContent=`${network.ssid||'<скрытая>'} (${network.rssi} dBm${network.secure?', 🔒':''})`;select.append(option)}message(`Найдено сетей: ${value.networks.length}`,'ok')}catch(error){message(error.message,'error')}finally{button.disabled=false}});
|
||||
$('form').addEventListener('submit',async event=>{event.preventDefault();const button=$('save');button.disabled=true;message('Сохраняем настройки…');try{const value=await json('/api/wifi/config',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({ssid:$('ssid').value,password:$('password').value})});$('password').value='';message(value.message,'ok');setTimeout(status,14000)}catch(error){message(error.message,'error')}finally{button.disabled=false}});
|
||||
$('reset').addEventListener('click',async()=>{const button=$('reset');button.disabled=true;try{const value=await json('/api/wifi/reset',{method:'POST'});$('password').value='';message(value.message,'ok');setTimeout(status,1000)}catch(error){message(error.message,'error')}finally{button.disabled=false}});
|
||||
status();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user