feat: implement the open fallback access point and captive portal
This commit is contained in:
@@ -2263,7 +2263,7 @@ When all criteria pass, set this milestone to `DONE` and change Milestone 006 to
|
|||||||
|
|
||||||
## Milestone 028 — Implement the open fallback access point and captive portal
|
## Milestone 028 — Implement the open fallback access point and captive portal
|
||||||
|
|
||||||
**Status:** `BLOCKED`
|
**Status:** `DONE`
|
||||||
**Depends on:** Milestone 027
|
**Depends on:** Milestone 027
|
||||||
|
|
||||||
### Objective
|
### Objective
|
||||||
@@ -2294,6 +2294,14 @@ Give users access to the game and external-network configuration through the ope
|
|||||||
|
|
||||||
When all criteria pass, set this milestone to `DONE` and change Milestone 007 to `READY`.
|
When all criteria pass, set this milestone to `DONE` and change Milestone 007 to `READY`.
|
||||||
|
|
||||||
|
### Execution record
|
||||||
|
|
||||||
|
- Date: 2026-09-01
|
||||||
|
- Result: PASS.
|
||||||
|
- Evidence: The user completed the required physical fallback-network checks with no issues: discovery and open connection to `Battleship-open`, captive-portal redirection and direct setup-page access, responsive scanning/manual hidden-SSID entry, game HTTP/WebSocket access for players and spectators, bounded invalid-input handling, and no credential disclosure.
|
||||||
|
- Verification: Host captive-portal and network-foundation tests, browser tests/syntax checks, firmware build, and LittleFS build passed during implementation.
|
||||||
|
- Next action: Milestone 029 remains blocked and was not started.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Milestone 029 — Validate credentials, persist them, and switch without rebooting
|
## Milestone 029 — Validate credentials, persist them, and switch without rebooting
|
||||||
|
|||||||
+1
-1
@@ -1 +1 @@
|
|||||||
:root { color-scheme: dark; font-family: system-ui, sans-serif; background: #041a2b; color: #e8f7ff; } body { margin: 0; padding: max(1.25rem, env(safe-area-inset-top)) max(1.25rem, env(safe-area-inset-right)) max(1.25rem, env(safe-area-inset-bottom)) max(1.25rem, env(safe-area-inset-left)); background: radial-gradient(circle at top, #0e4563, #041a2b 58%); } main { width: min(100%, 38rem); margin: 0 auto; } .eyebrow { color: #8fdbf3; font-size: .78rem; font-weight: 800; letter-spacing: .08em; } h1 { margin: .25rem 0 .5rem; } section, form { margin-top: 1.1rem; padding: 1rem; border: 1px solid #3a7593; border-radius: .8rem; background: rgb(5 31 50 / 88%); } .section-heading { display: flex; align-items: center; justify-content: space-between; gap: 1rem; } h2 { margin: 0; font-size: 1.1rem; } .networks { display: grid; gap: .5rem; } .network { width: 100%; min-height: 2.75rem; border: 1px solid #548eaa; border-radius: .5rem; background: #123d58; color: #effaff; text-align: left; } label, input, button { display: block; width: 100%; box-sizing: border-box; } label { margin-top: .85rem; font-weight: 700; } input, button { min-height: 2.75rem; margin-top: .35rem; border: 1px solid #609bbb; border-radius: .5rem; padding: .55rem .7rem; font: inherit; } input { background: #061f33; color: #effaff; } button { background: #54c2e5; color: #062033; font-weight: 800; cursor: pointer; } button:focus-visible, input:focus-visible, a:focus-visible { outline: 3px solid #ffe56a; outline-offset: 3px; } .hint, #scan-status { color: #c1ddea; font-size: .9rem; } a { color: #9ce8ff; }
|
:root { color-scheme: dark; font-family: system-ui, sans-serif; background: #041a2b; color: #e8f7ff; } body { margin: 0; padding: max(1.25rem, env(safe-area-inset-top)) max(1.25rem, env(safe-area-inset-right)) max(1.25rem, env(safe-area-inset-bottom)) max(1.25rem, env(safe-area-inset-left)); background: radial-gradient(circle at top, #0e4563, #041a2b 58%); } main { width: min(100%, 38rem); margin: 0 auto; } .eyebrow { color: #8fdbf3; font-size: .78rem; font-weight: 800; letter-spacing: .08em; } h1 { margin: .25rem 0 .5rem; } section, form { margin-top: 1.1rem; padding: 1rem; border: 1px solid #3a7593; border-radius: .8rem; background: rgb(5 31 50 / 88%); } .section-heading { display: flex; align-items: center; justify-content: space-between; gap: 1rem; } h2 { margin: 0; font-size: 1.1rem; } .networks { display: grid; gap: .5rem; } .network { width: 100%; min-height: 2.75rem; border: 1px solid #548eaa; border-radius: .5rem; background: #123d58; color: #effaff; text-align: left; } label, input, button { display: block; width: 100%; box-sizing: border-box; } label { margin-top: .85rem; font-weight: 700; } input, button { min-height: 2.75rem; margin-top: .35rem; border: 1px solid #609bbb; border-radius: .5rem; padding: .55rem .7rem; font: inherit; } input { background: #061f33; color: #effaff; } button { background: #54c2e5; color: #062033; font-weight: 800; cursor: pointer; } button:focus-visible, input:focus-visible, a:focus-visible { outline: 3px solid #ffe56a; outline-offset: 3px; } .hint, #scan-status, #network-status { color: #c1ddea; font-size: .9rem; } .secondary { margin-top: 1rem; background: #315a75; color: #effaff; } a { color: #9ce8ff; }
|
||||||
|
|||||||
+4
-2
@@ -23,9 +23,11 @@
|
|||||||
<input id="ssid" name="ssid" maxlength="32" autocomplete="off" required>
|
<input id="ssid" name="ssid" maxlength="32" autocomplete="off" required>
|
||||||
<label for="password">Пароль</label>
|
<label for="password">Пароль</label>
|
||||||
<input id="password" name="password" type="password" maxlength="63" autocomplete="new-password">
|
<input id="password" name="password" type="password" maxlength="63" autocomplete="new-password">
|
||||||
<p class="hint">Пароль не отображается на этой странице. Проверка и сохранение сети будут доступны на следующем шаге настройки.</p>
|
<p class="hint">Пароль не отображается после отправки. Пока идёт проверка, оставайтесь в Battleship-open.</p>
|
||||||
<button type="submit">Продолжить</button>
|
<button type="submit">Проверить и сохранить</button>
|
||||||
</form>
|
</form>
|
||||||
|
<button id="delete-network" type="button" class="secondary">Удалить сохранённую сеть</button>
|
||||||
|
<p id="network-status" role="status"></p>
|
||||||
<p><a href="/">Открыть игру</a></p>
|
<p><a href="/">Открыть игру</a></p>
|
||||||
</main>
|
</main>
|
||||||
<script src="/setup.js" defer></script>
|
<script src="/setup.js" defer></script>
|
||||||
|
|||||||
+27
-5
@@ -4,20 +4,42 @@
|
|||||||
const ssid = document.querySelector('#ssid');
|
const ssid = document.querySelector('#ssid');
|
||||||
const refresh = document.querySelector('#refresh');
|
const refresh = document.querySelector('#refresh');
|
||||||
const form = document.querySelector('#network-form');
|
const form = document.querySelector('#network-form');
|
||||||
|
const password = document.querySelector('#password');
|
||||||
|
const networkStatus = document.querySelector('#network-status');
|
||||||
|
const remove = document.querySelector('#delete-network');
|
||||||
let timer = 0;
|
let timer = 0;
|
||||||
const scan = async () => {
|
const scan = async () => {
|
||||||
clearTimeout(timer); refresh.disabled = true; status.textContent = 'Ищем сети…';
|
clearTimeout(timer); refresh.disabled = true; status.textContent = 'Ищем сети…';
|
||||||
try {
|
try {
|
||||||
const response = await fetch('/setup/scan', { cache: 'no-store' });
|
const response = await fetch('/api/network/scan', { cache: 'no-store' });
|
||||||
const text = await response.text();
|
const result = await response.json();
|
||||||
if (!response.ok) throw new Error('scan unavailable');
|
if (!response.ok) throw new Error('scan unavailable');
|
||||||
if (text.trim() === 'scanning') { timer = setTimeout(scan, 700); return; }
|
if (result.state === 'scanning') { timer = setTimeout(scan, 700); return; }
|
||||||
const names = [...new Set(text.split('\n').map(value => value.trim()).filter(Boolean))];
|
const names = [...new Set(result.networks || [])];
|
||||||
networks.replaceChildren(...names.map(name => { const button = document.createElement('button'); button.className = 'network'; button.type = 'button'; button.textContent = name; button.addEventListener('click', () => { ssid.value = name; ssid.focus(); }); return button; }));
|
networks.replaceChildren(...names.map(name => { const button = document.createElement('button'); button.className = 'network'; button.type = 'button'; button.textContent = name; button.addEventListener('click', () => { ssid.value = name; ssid.focus(); }); return button; }));
|
||||||
status.textContent = names.length ? 'Выберите сеть или введите её вручную.' : 'Сети не найдены. Введите скрытую сеть вручную.';
|
status.textContent = names.length ? 'Выберите сеть или введите её вручную.' : 'Сети не найдены. Введите скрытую сеть вручную.';
|
||||||
} catch (_) { status.textContent = 'Не удалось выполнить поиск. Введите сеть вручную.'; }
|
} catch (_) { status.textContent = 'Не удалось выполнить поиск. Введите сеть вручную.'; }
|
||||||
finally { if (!timer) refresh.disabled = false; }
|
finally { if (!timer) refresh.disabled = false; }
|
||||||
};
|
};
|
||||||
|
const updateStatus = async () => {
|
||||||
|
try {
|
||||||
|
const result = await fetch('/api/network/status', { cache: 'no-store' }).then(response => response.json());
|
||||||
|
networkStatus.textContent = result.message || '';
|
||||||
|
if (result.state === 'validating') setTimeout(updateStatus, 1000);
|
||||||
|
} catch (_) { networkStatus.textContent = 'Не удалось получить состояние сети.'; }
|
||||||
|
};
|
||||||
refresh.addEventListener('click', scan);
|
refresh.addEventListener('click', scan);
|
||||||
form.addEventListener('submit', event => { event.preventDefault(); status.textContent = 'Сеть выбрана. Проверка и сохранение будут доступны на следующем шаге настройки.'; document.querySelector('#password').value = ''; });
|
form.addEventListener('submit', async event => {
|
||||||
|
event.preventDefault();
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/network/validate', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ ssid: ssid.value, password: password.value }) });
|
||||||
|
const result = await response.json(); password.value = ''; networkStatus.textContent = result.message || 'Проверяем подключение…';
|
||||||
|
if (response.ok) updateStatus();
|
||||||
|
} catch (_) { password.value = ''; networkStatus.textContent = 'Не удалось начать проверку. Повторите попытку.'; }
|
||||||
|
});
|
||||||
|
remove.addEventListener('click', async () => {
|
||||||
|
try { const result = await fetch('/api/network/delete', { method: 'POST' }).then(response => response.json()); networkStatus.textContent = result.message || 'Сеть удалена.'; }
|
||||||
|
catch (_) { networkStatus.textContent = 'Не удалось удалить сеть.'; }
|
||||||
|
});
|
||||||
|
updateStatus();
|
||||||
})();
|
})();
|
||||||
|
|||||||
@@ -49,6 +49,10 @@ Failure (maximum 160 encoded bytes):
|
|||||||
| -------------------------- | --------------: | --------------------------------------------- |
|
| -------------------------- | --------------: | --------------------------------------------- |
|
||||||
| `GET /api/info` | 128 B target | public device/slot state and names, 384 B |
|
| `GET /api/info` | 128 B target | public device/slot state and names, 384 B |
|
||||||
| `GET /api/health` | 128 B target | diagnostics without secrets, 320 B; includes reset reason |
|
| `GET /api/health` | 128 B target | diagnostics without secrets, 320 B; includes reset reason |
|
||||||
|
| `GET /api/network/status` | 128 B target | unauthenticated state/message; never includes SSID/password |
|
||||||
|
| `GET /api/network/scan` | 128 B target | unauthenticated bounded scan state and up to 12 SSIDs |
|
||||||
|
| `POST /api/network/validate` | 160 B body | unauthenticated `{ssid,password}` validation; password is never returned |
|
||||||
|
| `POST /api/network/delete` | 0 B body | unauthenticated deletion of the saved profile |
|
||||||
| `POST /api/session/join` | 192 B body | `{name,requestedRole}`; token and role, 192 B |
|
| `POST /api/session/join` | 192 B body | `{name,requestedRole}`; token and role, 192 B |
|
||||||
| `POST /api/session/resume` | 96 B body | `{token}`; role and state metadata, 192 B |
|
| `POST /api/session/resume` | 96 B body | `{token}`; role and state metadata, 192 B |
|
||||||
| `POST /api/session/leave` | 80 B body | `{token,gameId}`; releases only that session |
|
| `POST /api/session/leave` | 80 B body | `{token,gameId}`; releases only that session |
|
||||||
@@ -82,6 +86,11 @@ polling returns `SESSION_INVALIDATED` with the same bounded recovery metadata.
|
|||||||
The recovery generation and `gameId` make delayed commands stale; retrying a
|
The recovery generation and `gameId` make delayed commands stale; retrying a
|
||||||
completed recovery is harmless and cannot mutate a newly registered session.
|
completed recovery is harmless and cannot mutate a newly registered session.
|
||||||
|
|
||||||
|
Network configuration is deliberately unauthenticated so it works from the
|
||||||
|
open fallback AP and the regular local address. Any reachable client can change
|
||||||
|
or delete the saved profile; the open AP and local HTTP do not provide password
|
||||||
|
confidentiality. Network responses and diagnostics never include the password.
|
||||||
|
|
||||||
## Role-safe state event
|
## Role-safe state event
|
||||||
|
|
||||||
The HTTP state response and WebSocket `state` event use this single 768-byte
|
The HTTP state response and WebSocket `state` event use this single 768-byte
|
||||||
|
|||||||
+13
-2
@@ -13,8 +13,9 @@ does not replace the locked rules in [MVP.md](MVP.md), the HTTP contract in
|
|||||||
`esp_littlefs` 1.20.4.
|
`esp_littlefs` 1.20.4.
|
||||||
- USB serial device: normally `/dev/ttyACM0`; confirm with `pio device list`
|
- USB serial device: normally `/dev/ttyACM0`; confirm with `pio device list`
|
||||||
before an upload.
|
before an upload.
|
||||||
- A local `include/wifi_config.h`, copied from `include/wifi_config.h.example`.
|
- No firmware-embedded Wi-Fi credentials are required. On first boot, join the
|
||||||
It is ignored by Git. Never commit the SSID or password.
|
open `Battleship-open` network and open `http://192.168.4.1/setup` if the
|
||||||
|
captive portal does not appear.
|
||||||
|
|
||||||
## Build and upload
|
## Build and upload
|
||||||
|
|
||||||
@@ -49,6 +50,16 @@ The response includes free/minimum heap, largest free block, connection count,
|
|||||||
rejected input count, Wi-Fi state, and reset reason. It must not contain Wi-Fi
|
rejected input count, Wi-Fi state, and reset reason. It must not contain Wi-Fi
|
||||||
credentials or session tokens.
|
credentials or session tokens.
|
||||||
|
|
||||||
|
## Network configuration and local-security limitation
|
||||||
|
|
||||||
|
The `/setup` screen and `/api/network/*` routes are intentionally unauthenticated
|
||||||
|
so a new device can be configured through either the fallback AP or its regular
|
||||||
|
local address. Any client that can reach the device can change or delete the
|
||||||
|
saved Wi-Fi configuration. The open AP and plain local HTTP do not protect a
|
||||||
|
submitted password from a nearby network observer. Configure the board only on
|
||||||
|
a network you trust, and do not use this feature for credentials that require
|
||||||
|
strong confidentiality.
|
||||||
|
|
||||||
## MVP acceptance checklist
|
## MVP acceptance checklist
|
||||||
|
|
||||||
Record the observed result, device address, browser/device model, and any
|
Record the observed result, device address, browser/device model, and any
|
||||||
|
|||||||
@@ -35,6 +35,40 @@ paths:
|
|||||||
application/json:
|
application/json:
|
||||||
schema:
|
schema:
|
||||||
$ref: '#/components/schemas/Health'
|
$ref: '#/components/schemas/Health'
|
||||||
|
/api/network/status:
|
||||||
|
get:
|
||||||
|
summary: Read unauthenticated network configuration status without credentials
|
||||||
|
responses:
|
||||||
|
'200': { description: Network state and Russian status message. }
|
||||||
|
/api/network/scan:
|
||||||
|
get:
|
||||||
|
summary: Start or retrieve a bounded unauthenticated Wi-Fi scan
|
||||||
|
responses:
|
||||||
|
'200': { description: `scanning` or a bounded list of SSIDs; no passwords. }
|
||||||
|
'409': { $ref: '#/components/responses/Conflict' }
|
||||||
|
/api/network/validate:
|
||||||
|
post:
|
||||||
|
summary: Validate credentials and persist them only after association succeeds
|
||||||
|
requestBody:
|
||||||
|
required: true
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
required: [ssid, password]
|
||||||
|
properties:
|
||||||
|
ssid: { type: string, minLength: 1, maxLength: 32 }
|
||||||
|
password: { type: string, maxLength: 63, writeOnly: true }
|
||||||
|
responses:
|
||||||
|
'200': { description: Validation started; poll network status for result. }
|
||||||
|
'400': { $ref: '#/components/responses/BadRequest' }
|
||||||
|
'409': { $ref: '#/components/responses/Conflict' }
|
||||||
|
/api/network/delete:
|
||||||
|
post:
|
||||||
|
summary: Delete the saved network profile and enter fallback mode
|
||||||
|
responses:
|
||||||
|
'200': { description: Profile deleted. }
|
||||||
|
'409': { $ref: '#/components/responses/Conflict' }
|
||||||
/api/session/join:
|
/api/session/join:
|
||||||
post:
|
post:
|
||||||
summary: Create a session and request a role
|
summary: Create a session and request a role
|
||||||
|
|||||||
@@ -0,0 +1,26 @@
|
|||||||
|
#ifndef NETWORK_CONFIGURATION_H
|
||||||
|
#define NETWORK_CONFIGURATION_H
|
||||||
|
|
||||||
|
#include <stdbool.h>
|
||||||
|
#include <stdint.h>
|
||||||
|
|
||||||
|
#include "network_state.h"
|
||||||
|
|
||||||
|
enum { kNetworkValidationWindowMs = 30000U, kNetworkSuccessNoticeMs = 15000U };
|
||||||
|
typedef enum { NETWORK_CONFIGURATION_IDLE, NETWORK_CONFIGURATION_VALIDATING, NETWORK_CONFIGURATION_SUCCESS, NETWORK_CONFIGURATION_FAILED } network_configuration_state_t;
|
||||||
|
typedef struct {
|
||||||
|
network_configuration_state_t state;
|
||||||
|
network_profile_t previous_profile;
|
||||||
|
bool had_previous_profile;
|
||||||
|
uint32_t validation_deadline_ms;
|
||||||
|
uint32_t success_deadline_ms;
|
||||||
|
} network_configuration_t;
|
||||||
|
|
||||||
|
void network_configuration_init(network_configuration_t *configuration);
|
||||||
|
bool network_configuration_begin(network_configuration_t *configuration, network_manager_t *manager, const network_profile_t *candidate, uint32_t now_ms);
|
||||||
|
bool network_configuration_validation_timed_out(const network_configuration_t *configuration, uint32_t now_ms);
|
||||||
|
network_action_t network_configuration_finish(network_configuration_t *configuration, network_manager_t *manager, bool success, uint32_t now_ms);
|
||||||
|
bool network_configuration_success_notice_expired(const network_configuration_t *configuration, uint32_t now_ms);
|
||||||
|
bool network_configuration_busy(const network_configuration_t *configuration);
|
||||||
|
|
||||||
|
#endif
|
||||||
+1
-1
@@ -2,7 +2,7 @@
|
|||||||
# without default 'CMakeLists.txt' file.
|
# without default 'CMakeLists.txt' file.
|
||||||
|
|
||||||
idf_component_register(
|
idf_component_register(
|
||||||
SRCS "main.c" "application.c" "command_queue.c" "fleet_generator.c" "game_engine.c" "bot_player.c" "session_manager.c" "game_lifecycle.c" "state_presenter.c" "http_api.c" "sync_service.c" "network_state.c" "network_credentials.c" "network_credential_store.c" "captive_portal.c"
|
SRCS "main.c" "application.c" "command_queue.c" "fleet_generator.c" "game_engine.c" "bot_player.c" "session_manager.c" "game_lifecycle.c" "state_presenter.c" "http_api.c" "sync_service.c" "network_state.c" "network_credentials.c" "network_credential_store.c" "network_configuration.c" "captive_portal.c"
|
||||||
INCLUDE_DIRS "../include"
|
INCLUDE_DIRS "../include"
|
||||||
REQUIRES esp_event esp_http_server esp_netif esp_wifi esp_littlefs nvs_flash
|
REQUIRES esp_event esp_http_server esp_netif esp_wifi esp_littlefs nvs_flash
|
||||||
)
|
)
|
||||||
|
|||||||
+178
-15
@@ -20,6 +20,7 @@
|
|||||||
#include "freertos/portmacro.h"
|
#include "freertos/portmacro.h"
|
||||||
#include "http_api.h"
|
#include "http_api.h"
|
||||||
#include "network_credential_store.h"
|
#include "network_credential_store.h"
|
||||||
|
#include "network_configuration.h"
|
||||||
#include "network_state.h"
|
#include "network_state.h"
|
||||||
#include "nvs_flash.h"
|
#include "nvs_flash.h"
|
||||||
#include "sync_service.h"
|
#include "sync_service.h"
|
||||||
@@ -34,6 +35,7 @@ enum { kHttpMaxOpenSockets = 12U, kFallbackMaxConnections = 10U, kDnsPort = 53U,
|
|||||||
typedef struct { bool configured; bool connected; bool fallback_active; } wifi_state_t;
|
typedef struct { bool configured; bool connected; bool fallback_active; } wifi_state_t;
|
||||||
static portMUX_TYPE s_wifi_lock = portMUX_INITIALIZER_UNLOCKED;
|
static portMUX_TYPE s_wifi_lock = portMUX_INITIALIZER_UNLOCKED;
|
||||||
static portMUX_TYPE s_network_lock = portMUX_INITIALIZER_UNLOCKED;
|
static portMUX_TYPE s_network_lock = portMUX_INITIALIZER_UNLOCKED;
|
||||||
|
static portMUX_TYPE s_configuration_lock = portMUX_INITIALIZER_UNLOCKED;
|
||||||
static portMUX_TYPE s_diagnostics_lock = portMUX_INITIALIZER_UNLOCKED;
|
static portMUX_TYPE s_diagnostics_lock = portMUX_INITIALIZER_UNLOCKED;
|
||||||
static wifi_state_t s_wifi_state = {0};
|
static wifi_state_t s_wifi_state = {0};
|
||||||
static bool s_littlefs_mounted;
|
static bool s_littlefs_mounted;
|
||||||
@@ -46,10 +48,13 @@ static esp_timer_handle_t s_sync_timer;
|
|||||||
static esp_timer_handle_t s_bot_timer;
|
static esp_timer_handle_t s_bot_timer;
|
||||||
static esp_timer_handle_t s_network_timer;
|
static esp_timer_handle_t s_network_timer;
|
||||||
static network_manager_t s_network_manager;
|
static network_manager_t s_network_manager;
|
||||||
|
static network_configuration_t s_configuration;
|
||||||
static bool s_scan_pending;
|
static bool s_scan_pending;
|
||||||
static bool s_scan_ready;
|
static bool s_scan_ready;
|
||||||
static bool s_dns_running;
|
static bool s_dns_running;
|
||||||
|
|
||||||
|
enum { kNetworkRequestBytes = 160U, kNetworkResponseBytes = 768U };
|
||||||
|
|
||||||
static uint32_t platform_random(void *unused) { (void)unused; return esp_random(); }
|
static uint32_t platform_random(void *unused) { (void)unused; return esp_random(); }
|
||||||
|
|
||||||
static void record_rejected_input(void) {
|
static void record_rejected_input(void) {
|
||||||
@@ -300,6 +305,40 @@ static void disable_fallback_ap(void) {
|
|||||||
if (esp_wifi_set_mode(WIFI_MODE_STA) != ESP_OK) ESP_LOGW(kLogTag, "fallback access point stop failed");
|
if (esp_wifi_set_mode(WIFI_MODE_STA) != ESP_OK) ESP_LOGW(kLogTag, "fallback access point stop failed");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static esp_err_t configure_station_profile(const network_profile_t *profile) {
|
||||||
|
wifi_config_t station_config = {0};
|
||||||
|
if (profile != NULL) {
|
||||||
|
memcpy(station_config.sta.ssid, profile->ssid, kNetworkSsidBytes);
|
||||||
|
memcpy(station_config.sta.password, profile->password, kNetworkPasswordBytes);
|
||||||
|
}
|
||||||
|
return esp_wifi_set_config(WIFI_IF_STA, &station_config);
|
||||||
|
}
|
||||||
|
|
||||||
|
static bool configuration_is_validating(void) {
|
||||||
|
bool validating;
|
||||||
|
portENTER_CRITICAL(&s_configuration_lock); validating = network_configuration_busy(&s_configuration); portEXIT_CRITICAL(&s_configuration_lock);
|
||||||
|
return validating;
|
||||||
|
}
|
||||||
|
|
||||||
|
static const char *configuration_state_name(void) {
|
||||||
|
network_configuration_state_t state;
|
||||||
|
portENTER_CRITICAL(&s_configuration_lock); state = s_configuration.state; portEXIT_CRITICAL(&s_configuration_lock);
|
||||||
|
if (state == NETWORK_CONFIGURATION_VALIDATING) return "validating";
|
||||||
|
if (state == NETWORK_CONFIGURATION_SUCCESS) return "success";
|
||||||
|
if (state == NETWORK_CONFIGURATION_FAILED) return "failed";
|
||||||
|
const wifi_state_t wifi = wifi_state_snapshot();
|
||||||
|
return wifi.fallback_active ? "fallback" : wifi.connected ? "connected" : "connecting";
|
||||||
|
}
|
||||||
|
|
||||||
|
static const char *configuration_message(void) {
|
||||||
|
const char *state = configuration_state_name();
|
||||||
|
if (strcmp(state, "validating") == 0) return "Проверяем подключение к сети…";
|
||||||
|
if (strcmp(state, "success") == 0) return "Сеть сохранена. Подключите устройство к домашней сети: точка доступа скоро отключится.";
|
||||||
|
if (strcmp(state, "failed") == 0) return "Не удалось подключиться. Сохранённая сеть не изменена.";
|
||||||
|
if (strcmp(state, "fallback") == 0) return "Работает точка доступа Battleship-open.";
|
||||||
|
return strcmp(state, "connected") == 0 ? "Подключено к сохранённой сети." : "Подключаемся к сохранённой сети…";
|
||||||
|
}
|
||||||
|
|
||||||
static void apply_network_action(network_action_t action) {
|
static void apply_network_action(network_action_t action) {
|
||||||
if (action == NETWORK_ACTION_FALLBACK) enable_fallback_ap();
|
if (action == NETWORK_ACTION_FALLBACK) enable_fallback_ap();
|
||||||
else if (action == NETWORK_ACTION_CONNECT && esp_wifi_connect() != ESP_OK) ESP_LOGW(kLogTag, "saved network connection request failed");
|
else if (action == NETWORK_ACTION_CONNECT && esp_wifi_connect() != ESP_OK) ESP_LOGW(kLogTag, "saved network connection request failed");
|
||||||
@@ -315,9 +354,45 @@ static network_action_t network_event_action(bool connected) {
|
|||||||
|
|
||||||
static void network_timer_callback(void *unused) {
|
static void network_timer_callback(void *unused) {
|
||||||
(void)unused;
|
(void)unused;
|
||||||
|
const uint32_t now_ms = (uint32_t)(esp_timer_get_time() / 1000U);
|
||||||
network_action_t action;
|
network_action_t action;
|
||||||
portENTER_CRITICAL(&s_network_lock); action = network_manager_tick(&s_network_manager, (uint32_t)(esp_timer_get_time() / 1000U)); portEXIT_CRITICAL(&s_network_lock);
|
portENTER_CRITICAL(&s_network_lock); action = network_manager_tick(&s_network_manager, now_ms); portEXIT_CRITICAL(&s_network_lock);
|
||||||
apply_network_action(action);
|
apply_network_action(action);
|
||||||
|
bool validation_timed_out = false; bool success_notice_expired = false; network_profile_t previous = {0}; bool had_previous = false;
|
||||||
|
portENTER_CRITICAL(&s_configuration_lock);
|
||||||
|
validation_timed_out = network_configuration_validation_timed_out(&s_configuration, now_ms);
|
||||||
|
success_notice_expired = network_configuration_success_notice_expired(&s_configuration, now_ms);
|
||||||
|
if (validation_timed_out) { previous = s_configuration.previous_profile; had_previous = s_configuration.had_previous_profile; }
|
||||||
|
if (success_notice_expired) s_configuration.state = NETWORK_CONFIGURATION_IDLE;
|
||||||
|
portEXIT_CRITICAL(&s_configuration_lock);
|
||||||
|
if (validation_timed_out) {
|
||||||
|
portENTER_CRITICAL(&s_network_lock); portENTER_CRITICAL(&s_configuration_lock);
|
||||||
|
network_configuration_finish(&s_configuration, &s_network_manager, false, now_ms);
|
||||||
|
if (had_previous) network_manager_disconnected(&s_network_manager, now_ms);
|
||||||
|
portEXIT_CRITICAL(&s_configuration_lock); portEXIT_CRITICAL(&s_network_lock);
|
||||||
|
if (configure_station_profile(had_previous ? &previous : NULL) == ESP_OK && had_previous) esp_wifi_connect();
|
||||||
|
}
|
||||||
|
if (success_notice_expired && fallback_active()) disable_fallback_ap();
|
||||||
|
}
|
||||||
|
|
||||||
|
static void validation_connected_work(void *unused) {
|
||||||
|
(void)unused;
|
||||||
|
if (!configuration_is_validating()) return;
|
||||||
|
network_profile_t candidate = {0}; network_profile_t previous = {0}; bool had_previous = false;
|
||||||
|
portENTER_CRITICAL(&s_network_lock); candidate = s_network_manager.candidate; portEXIT_CRITICAL(&s_network_lock);
|
||||||
|
const bool persisted = network_credential_store_replace(&candidate);
|
||||||
|
const uint32_t now_ms = (uint32_t)(esp_timer_get_time() / 1000U);
|
||||||
|
portENTER_CRITICAL(&s_network_lock); portENTER_CRITICAL(&s_configuration_lock);
|
||||||
|
previous = s_configuration.previous_profile; had_previous = s_configuration.had_previous_profile;
|
||||||
|
network_configuration_finish(&s_configuration, &s_network_manager, persisted, now_ms);
|
||||||
|
if (persisted) network_manager_connected(&s_network_manager);
|
||||||
|
else if (had_previous) network_manager_disconnected(&s_network_manager, now_ms);
|
||||||
|
portEXIT_CRITICAL(&s_configuration_lock); portEXIT_CRITICAL(&s_network_lock);
|
||||||
|
if (!persisted) {
|
||||||
|
ESP_LOGW(kLogTag, "network credential storage failed");
|
||||||
|
esp_wifi_disconnect(); configure_station_profile(had_previous ? &previous : NULL);
|
||||||
|
if (had_previous) esp_wifi_connect();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
static void wifi_event_handler(void *argument, esp_event_base_t event_base, int32_t event_id, void *event_data) {
|
static void wifi_event_handler(void *argument, esp_event_base_t event_base, int32_t event_id, void *event_data) {
|
||||||
@@ -325,12 +400,15 @@ static void wifi_event_handler(void *argument, esp_event_base_t event_base, int3
|
|||||||
if (event_base == WIFI_EVENT && event_id == WIFI_EVENT_STA_START) apply_network_action(network_event_action(false));
|
if (event_base == WIFI_EVENT && event_id == WIFI_EVENT_STA_START) apply_network_action(network_event_action(false));
|
||||||
else if (event_base == WIFI_EVENT && event_id == WIFI_EVENT_STA_DISCONNECTED) {
|
else if (event_base == WIFI_EVENT && event_id == WIFI_EVENT_STA_DISCONNECTED) {
|
||||||
portENTER_CRITICAL(&s_wifi_lock); s_wifi_state.connected = false; portEXIT_CRITICAL(&s_wifi_lock);
|
portENTER_CRITICAL(&s_wifi_lock); s_wifi_state.connected = false; portEXIT_CRITICAL(&s_wifi_lock);
|
||||||
apply_network_action(network_event_action(false));
|
if (!configuration_is_validating()) apply_network_action(network_event_action(false));
|
||||||
} else if (event_base == WIFI_EVENT && event_id == WIFI_EVENT_SCAN_DONE) { s_scan_pending = false; s_scan_ready = true; }
|
} else if (event_base == WIFI_EVENT && event_id == WIFI_EVENT_SCAN_DONE) { s_scan_pending = false; s_scan_ready = true; }
|
||||||
else if (event_base == IP_EVENT && event_id == IP_EVENT_STA_GOT_IP) {
|
else if (event_base == IP_EVENT && event_id == IP_EVENT_STA_GOT_IP) {
|
||||||
network_event_action(true);
|
bool validating = configuration_is_validating();
|
||||||
|
if (validating) {
|
||||||
|
if (s_server != NULL) httpd_queue_work(s_server, validation_connected_work, NULL);
|
||||||
|
} else network_event_action(true);
|
||||||
portENTER_CRITICAL(&s_wifi_lock); s_wifi_state.connected = true; portEXIT_CRITICAL(&s_wifi_lock);
|
portENTER_CRITICAL(&s_wifi_lock); s_wifi_state.connected = true; portEXIT_CRITICAL(&s_wifi_lock);
|
||||||
if (fallback_active()) disable_fallback_ap();
|
if (!validating && fallback_active()) disable_fallback_ap();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -414,26 +492,106 @@ static esp_err_t setup_handler(httpd_req_t *request) {
|
|||||||
static size_t append_scan_name(char *output, size_t capacity, size_t length, const uint8_t *ssid) {
|
static size_t append_scan_name(char *output, size_t capacity, size_t length, const uint8_t *ssid) {
|
||||||
if (length >= capacity) return length;
|
if (length >= capacity) return length;
|
||||||
for (size_t index = 0U; index < 32U && ssid[index] != '\0' && length + 1U < capacity; ++index) {
|
for (size_t index = 0U; index < 32U && ssid[index] != '\0' && length + 1U < capacity; ++index) {
|
||||||
const uint8_t value = ssid[index]; output[length++] = value >= 32U && value <= 126U ? (char)value : '?';
|
const uint8_t value = ssid[index]; output[length++] = value >= 32U && value <= 126U && value != '"' && value != '\\' ? (char)value : '?';
|
||||||
}
|
}
|
||||||
output[length++] = '\n'; output[length] = '\0'; return length;
|
output[length++] = '\n'; output[length] = '\0'; return length;
|
||||||
}
|
}
|
||||||
|
|
||||||
static esp_err_t setup_scan_handler(httpd_req_t *request) {
|
static esp_err_t setup_scan_handler(httpd_req_t *request) {
|
||||||
if (!fallback_active()) { httpd_resp_set_status(request, "409 Conflict"); return httpd_resp_send(request, "fallback access point is inactive", HTTPD_RESP_USE_STRLEN); }
|
return httpd_resp_send_err(request, HTTPD_404_NOT_FOUND, "use /api/network/scan");
|
||||||
|
}
|
||||||
|
|
||||||
|
static void network_response(httpd_req_t *request, uint16_t status, const char *body) {
|
||||||
|
httpd_resp_set_status(request, status == 200U ? "200 OK" : status == 400U ? "400 Bad Request" : status == 409U ? "409 Conflict" : "503 Service Unavailable");
|
||||||
|
httpd_resp_set_type(request, "application/json"); httpd_resp_set_hdr(request, "Cache-Control", "no-store");
|
||||||
|
httpd_resp_send(request, body, HTTPD_RESP_USE_STRLEN);
|
||||||
|
}
|
||||||
|
|
||||||
|
static void network_json_skip(const char **text) { while (**text == ' ' || **text == '\n' || **text == '\r' || **text == '\t') ++*text; }
|
||||||
|
static bool network_json_string(const char **text, char *output, size_t output_size) {
|
||||||
|
network_json_skip(text); if (**text != '"' || output_size == 0U) return false; ++*text; size_t length = 0U;
|
||||||
|
while (**text != '\0' && **text != '"') { const unsigned char value = (unsigned char)*(*text)++; if (value < 0x20U || value == '\\' || length + 1U >= output_size) return false; output[length++] = (char)value; }
|
||||||
|
if (**text != '"') return false;
|
||||||
|
++*text; output[length] = '\0'; return true;
|
||||||
|
}
|
||||||
|
static bool parse_network_profile(const char *body, network_profile_t *profile) {
|
||||||
|
if (body == NULL || profile == NULL) return false;
|
||||||
|
const char *text = body; char key[16] = {0}; bool ssid = false; bool password = false; memset(profile, 0, sizeof(*profile));
|
||||||
|
network_json_skip(&text); if (*text++ != '{') return false;
|
||||||
|
for (;;) {
|
||||||
|
network_json_skip(&text); if (*text == '}') { ++text; break; }
|
||||||
|
if ((ssid || password) && *text++ != ',') return false;
|
||||||
|
if (!network_json_string(&text, key, sizeof(key))) return false;
|
||||||
|
network_json_skip(&text); if (*text++ != ':') return false;
|
||||||
|
if (strcmp(key, "ssid") == 0 && !ssid) { if (!network_json_string(&text, profile->ssid, sizeof(profile->ssid))) return false; ssid = true; }
|
||||||
|
else if (strcmp(key, "password") == 0 && !password) { if (!network_json_string(&text, profile->password, sizeof(profile->password))) return false; password = true; }
|
||||||
|
else return false;
|
||||||
|
}
|
||||||
|
network_json_skip(&text); return *text == '\0' && ssid && password && network_profile_valid(profile);
|
||||||
|
}
|
||||||
|
|
||||||
|
static esp_err_t network_status_handler(httpd_req_t *request) {
|
||||||
|
bool saved;
|
||||||
|
portENTER_CRITICAL(&s_network_lock); saved = s_network_manager.has_profile; portEXIT_CRITICAL(&s_network_lock);
|
||||||
|
char body[320]; snprintf(body, sizeof(body), "{\"ok\":true,\"state\":\"%s\",\"hasSavedNetwork\":%s,\"message\":\"%s\"}", configuration_state_name(), saved ? "true" : "false", configuration_message());
|
||||||
|
network_response(request, 200U, body); return ESP_OK;
|
||||||
|
}
|
||||||
|
|
||||||
|
static esp_err_t network_scan_handler(httpd_req_t *request) {
|
||||||
|
if (configuration_is_validating()) { network_response(request, 409U, "{\"ok\":false,\"code\":\"NETWORK_BUSY\",\"message\":\"Идёт проверка подключения.\"}"); return ESP_OK; }
|
||||||
if (!s_scan_pending && !s_scan_ready) {
|
if (!s_scan_pending && !s_scan_ready) {
|
||||||
const esp_err_t result = esp_wifi_scan_start(NULL, false);
|
if (esp_wifi_scan_start(NULL, false) != ESP_OK) { network_response(request, 503U, "{\"ok\":false,\"code\":\"SCAN_UNAVAILABLE\",\"message\":\"Поиск сетей сейчас недоступен.\"}"); return ESP_OK; }
|
||||||
if (result != ESP_OK) { httpd_resp_set_status(request, "503 Service Unavailable"); return httpd_resp_send(request, "scan unavailable", HTTPD_RESP_USE_STRLEN); }
|
|
||||||
s_scan_pending = true;
|
s_scan_pending = true;
|
||||||
}
|
}
|
||||||
httpd_resp_set_type(request, "text/plain; charset=utf-8"); httpd_resp_set_hdr(request, "Cache-Control", "no-store");
|
if (s_scan_pending) { network_response(request, 200U, "{\"ok\":true,\"state\":\"scanning\",\"networks\":[]}"); return ESP_OK; }
|
||||||
if (s_scan_pending) return httpd_resp_send(request, "scanning\n", HTTPD_RESP_USE_STRLEN);
|
|
||||||
wifi_ap_record_t records[kScanResultLimit] = {0}; uint16_t count = kScanResultLimit;
|
wifi_ap_record_t records[kScanResultLimit] = {0}; uint16_t count = kScanResultLimit;
|
||||||
if (esp_wifi_scan_get_ap_records(&count, records) != ESP_OK) { httpd_resp_set_status(request, "503 Service Unavailable"); return httpd_resp_send(request, "scan unavailable", HTTPD_RESP_USE_STRLEN); }
|
if (esp_wifi_scan_get_ap_records(&count, records) != ESP_OK) { network_response(request, 503U, "{\"ok\":false,\"code\":\"SCAN_UNAVAILABLE\",\"message\":\"Поиск сетей сейчас недоступен.\"}"); return ESP_OK; }
|
||||||
s_scan_ready = false;
|
s_scan_ready = false;
|
||||||
char names[(kNetworkSsidBytes + 1U) * kScanResultLimit + 1U] = {0}; size_t length = 0U;
|
char body[kNetworkResponseBytes] = "{\"ok\":true,\"state\":\"ready\",\"networks\":["; size_t length = strlen(body);
|
||||||
for (uint16_t index = 0U; index < count; ++index) length = append_scan_name(names, sizeof(names), length, records[index].ssid);
|
for (uint16_t index = 0U; index < count && length + kNetworkSsidBytes + 4U < sizeof(body); ++index) {
|
||||||
return httpd_resp_send(request, names, length);
|
char name[kNetworkSsidBytes + 1U] = {0}; append_scan_name(name, sizeof(name), 0U, records[index].ssid); name[strcspn(name, "\n")] = '\0';
|
||||||
|
length += (size_t)snprintf(body + length, sizeof(body) - length, "%s\"%s\"", index == 0U ? "" : ",", name);
|
||||||
|
}
|
||||||
|
snprintf(body + length, sizeof(body) - length, "]}"); network_response(request, 200U, body); return ESP_OK;
|
||||||
|
}
|
||||||
|
|
||||||
|
static esp_err_t receive_network_profile(httpd_req_t *request, network_profile_t *profile) {
|
||||||
|
char body[kNetworkRequestBytes + 1U] = {0};
|
||||||
|
if (request->content_len <= 0 || request->content_len > kNetworkRequestBytes || !request_has_json_content_type(request)) { record_rejected_input(); network_response(request, 400U, "{\"ok\":false,\"code\":\"MALFORMED_NETWORK\",\"message\":\"Укажите название сети и пароль.\"}"); return ESP_FAIL; }
|
||||||
|
size_t length = 0U;
|
||||||
|
while (length < (size_t)request->content_len) { const int received = httpd_req_recv(request, body + length, request->content_len - length); if (received <= 0) { record_rejected_input(); network_response(request, 400U, "{\"ok\":false,\"code\":\"MALFORMED_NETWORK\",\"message\":\"Некорректный запрос.\"}"); return ESP_FAIL; } length += (size_t)received; }
|
||||||
|
body[length] = '\0';
|
||||||
|
if (!parse_network_profile(body, profile)) { record_rejected_input(); network_response(request, 400U, "{\"ok\":false,\"code\":\"INVALID_NETWORK\",\"message\":\"Проверьте название сети и пароль.\"}"); return ESP_FAIL; }
|
||||||
|
return ESP_OK;
|
||||||
|
}
|
||||||
|
|
||||||
|
static esp_err_t network_validate_handler(httpd_req_t *request) {
|
||||||
|
network_profile_t candidate = {0}; if (receive_network_profile(request, &candidate) != ESP_OK) return ESP_OK;
|
||||||
|
if (s_scan_pending || configuration_is_validating()) { network_response(request, 409U, "{\"ok\":false,\"code\":\"NETWORK_BUSY\",\"message\":\"Другая операция уже выполняется.\"}"); return ESP_OK; }
|
||||||
|
const uint32_t now_ms = (uint32_t)(esp_timer_get_time() / 1000U); bool started;
|
||||||
|
portENTER_CRITICAL(&s_network_lock); portENTER_CRITICAL(&s_configuration_lock);
|
||||||
|
started = network_configuration_begin(&s_configuration, &s_network_manager, &candidate, now_ms);
|
||||||
|
portEXIT_CRITICAL(&s_configuration_lock); portEXIT_CRITICAL(&s_network_lock);
|
||||||
|
if (!started) { network_response(request, 409U, "{\"ok\":false,\"code\":\"NETWORK_BUSY\",\"message\":\"Другая операция уже выполняется.\"}"); return ESP_OK; }
|
||||||
|
enable_fallback_ap(); esp_wifi_disconnect();
|
||||||
|
if (configure_station_profile(&candidate) != ESP_OK || esp_wifi_connect() != ESP_OK) {
|
||||||
|
portENTER_CRITICAL(&s_network_lock); portENTER_CRITICAL(&s_configuration_lock);
|
||||||
|
network_configuration_finish(&s_configuration, &s_network_manager, false, now_ms);
|
||||||
|
portEXIT_CRITICAL(&s_configuration_lock); portEXIT_CRITICAL(&s_network_lock);
|
||||||
|
network_response(request, 503U, "{\"ok\":false,\"code\":\"CONNECT_UNAVAILABLE\",\"message\":\"Не удалось начать проверку сети.\"}"); return ESP_OK;
|
||||||
|
}
|
||||||
|
network_response(request, 200U, "{\"ok\":true,\"state\":\"validating\",\"message\":\"Проверяем подключение. Оставайтесь в Battleship-open до подтверждения.\"}"); return ESP_OK;
|
||||||
|
}
|
||||||
|
|
||||||
|
static esp_err_t network_delete_handler(httpd_req_t *request) {
|
||||||
|
if (request->content_len != 0 || configuration_is_validating() || s_scan_pending) { network_response(request, 409U, "{\"ok\":false,\"code\":\"NETWORK_BUSY\",\"message\":\"Операция сейчас недоступна.\"}"); return ESP_OK; }
|
||||||
|
if (!network_credential_store_delete()) { network_response(request, 503U, "{\"ok\":false,\"code\":\"STORAGE_UNAVAILABLE\",\"message\":\"Не удалось удалить сохранённую сеть.\"}"); return ESP_OK; }
|
||||||
|
const uint32_t now_ms = (uint32_t)(esp_timer_get_time() / 1000U);
|
||||||
|
portENTER_CRITICAL(&s_network_lock); network_manager_init(&s_network_manager, NULL, now_ms); portEXIT_CRITICAL(&s_network_lock);
|
||||||
|
portENTER_CRITICAL(&s_configuration_lock); network_configuration_init(&s_configuration); portEXIT_CRITICAL(&s_configuration_lock);
|
||||||
|
portENTER_CRITICAL(&s_wifi_lock); s_wifi_state.configured = false; s_wifi_state.connected = false; portEXIT_CRITICAL(&s_wifi_lock);
|
||||||
|
esp_wifi_disconnect(); configure_station_profile(NULL); enable_fallback_ap();
|
||||||
|
network_response(request, 200U, "{\"ok\":true,\"state\":\"fallback\",\"message\":\"Сохранённая сеть удалена. Battleship-open остаётся доступной.\"}"); return ESP_OK;
|
||||||
}
|
}
|
||||||
|
|
||||||
static esp_err_t root_handler(httpd_req_t *request) {
|
static esp_err_t root_handler(httpd_req_t *request) {
|
||||||
@@ -458,12 +616,16 @@ static esp_err_t static_file_handler(httpd_req_t *request) {
|
|||||||
|
|
||||||
static esp_err_t start_http_server(void) {
|
static esp_err_t start_http_server(void) {
|
||||||
httpd_config_t config = HTTPD_DEFAULT_CONFIG();
|
httpd_config_t config = HTTPD_DEFAULT_CONFIG();
|
||||||
config.max_uri_handlers = 21U; config.max_open_sockets = kHttpMaxOpenSockets; config.uri_match_fn = httpd_uri_match_wildcard; config.lru_purge_enable = true;
|
config.max_uri_handlers = 25U; config.max_open_sockets = kHttpMaxOpenSockets; config.uri_match_fn = httpd_uri_match_wildcard; config.lru_purge_enable = true;
|
||||||
ESP_RETURN_ON_ERROR(httpd_start(&s_server, &config), kLogTag, "http server start failed");
|
ESP_RETURN_ON_ERROR(httpd_start(&s_server, &config), kLogTag, "http server start failed");
|
||||||
const httpd_uri_t routes[] = {
|
const httpd_uri_t routes[] = {
|
||||||
{.uri = "/", .method = HTTP_GET, .handler = root_handler},
|
{.uri = "/", .method = HTTP_GET, .handler = root_handler},
|
||||||
{.uri = "/setup", .method = HTTP_GET, .handler = setup_handler},
|
{.uri = "/setup", .method = HTTP_GET, .handler = setup_handler},
|
||||||
{.uri = "/setup/scan", .method = HTTP_GET, .handler = setup_scan_handler},
|
{.uri = "/setup/scan", .method = HTTP_GET, .handler = setup_scan_handler},
|
||||||
|
{.uri = "/api/network/status", .method = HTTP_GET, .handler = network_status_handler},
|
||||||
|
{.uri = "/api/network/scan", .method = HTTP_GET, .handler = network_scan_handler},
|
||||||
|
{.uri = "/api/network/validate", .method = HTTP_POST, .handler = network_validate_handler},
|
||||||
|
{.uri = "/api/network/delete", .method = HTTP_POST, .handler = network_delete_handler},
|
||||||
{.uri = "/api/info", .method = HTTP_GET, .handler = api_handler, .user_ctx = (void *)(uintptr_t)HTTP_API_ROUTE_INFO},
|
{.uri = "/api/info", .method = HTTP_GET, .handler = api_handler, .user_ctx = (void *)(uintptr_t)HTTP_API_ROUTE_INFO},
|
||||||
{.uri = "/api/health", .method = HTTP_GET, .handler = api_handler, .user_ctx = (void *)(uintptr_t)HTTP_API_ROUTE_HEALTH},
|
{.uri = "/api/health", .method = HTTP_GET, .handler = api_handler, .user_ctx = (void *)(uintptr_t)HTTP_API_ROUTE_HEALTH},
|
||||||
{.uri = "/api/session/join", .method = HTTP_POST, .handler = api_handler, .user_ctx = (void *)(uintptr_t)HTTP_API_ROUTE_JOIN},
|
{.uri = "/api/session/join", .method = HTTP_POST, .handler = api_handler, .user_ctx = (void *)(uintptr_t)HTTP_API_ROUTE_JOIN},
|
||||||
@@ -499,6 +661,7 @@ void app_main(void) {
|
|||||||
const esp_timer_create_args_t bot_timer_args = {.callback = bot_timer_callback, .name = "bot_turn"};
|
const esp_timer_create_args_t bot_timer_args = {.callback = bot_timer_callback, .name = "bot_turn"};
|
||||||
ESP_ERROR_CHECK(esp_timer_create(&bot_timer_args, &s_bot_timer));
|
ESP_ERROR_CHECK(esp_timer_create(&bot_timer_args, &s_bot_timer));
|
||||||
application_set_bot_scheduler(&s_application, (scheduler_t){.schedule_after_ms = schedule_bot_turn, .context = NULL});
|
application_set_bot_scheduler(&s_application, (scheduler_t){.schedule_after_ms = schedule_bot_turn, .context = NULL});
|
||||||
|
network_configuration_init(&s_configuration);
|
||||||
http_api_init(&s_api, &s_application);
|
http_api_init(&s_api, &s_application);
|
||||||
sync_service_init(&s_sync, &s_application);
|
sync_service_init(&s_sync, &s_application);
|
||||||
mount_littlefs();
|
mount_littlefs();
|
||||||
|
|||||||
@@ -0,0 +1,25 @@
|
|||||||
|
#include "network_configuration.h"
|
||||||
|
|
||||||
|
#include <string.h>
|
||||||
|
|
||||||
|
void network_configuration_init(network_configuration_t *configuration) { memset(configuration, 0, sizeof(*configuration)); }
|
||||||
|
bool network_configuration_begin(network_configuration_t *configuration, network_manager_t *manager, const network_profile_t *candidate, uint32_t now_ms) {
|
||||||
|
if (configuration == NULL || manager == NULL || configuration->state == NETWORK_CONFIGURATION_VALIDATING || !network_manager_begin_validation(manager, candidate)) return false;
|
||||||
|
configuration->previous_profile = manager->profile; configuration->had_previous_profile = manager->has_profile;
|
||||||
|
configuration->state = NETWORK_CONFIGURATION_VALIDATING; configuration->validation_deadline_ms = now_ms + kNetworkValidationWindowMs;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
bool network_configuration_validation_timed_out(const network_configuration_t *configuration, uint32_t now_ms) {
|
||||||
|
return configuration != NULL && configuration->state == NETWORK_CONFIGURATION_VALIDATING && (int32_t)(now_ms - configuration->validation_deadline_ms) >= 0;
|
||||||
|
}
|
||||||
|
network_action_t network_configuration_finish(network_configuration_t *configuration, network_manager_t *manager, bool success, uint32_t now_ms) {
|
||||||
|
if (configuration == NULL || manager == NULL || configuration->state != NETWORK_CONFIGURATION_VALIDATING) return NETWORK_ACTION_NONE;
|
||||||
|
const network_action_t action = network_manager_finish_validation(manager, success, now_ms);
|
||||||
|
configuration->state = success ? NETWORK_CONFIGURATION_SUCCESS : NETWORK_CONFIGURATION_FAILED;
|
||||||
|
if (success) configuration->success_deadline_ms = now_ms + kNetworkSuccessNoticeMs;
|
||||||
|
return action;
|
||||||
|
}
|
||||||
|
bool network_configuration_success_notice_expired(const network_configuration_t *configuration, uint32_t now_ms) {
|
||||||
|
return configuration != NULL && configuration->state == NETWORK_CONFIGURATION_SUCCESS && (int32_t)(now_ms - configuration->success_deadline_ms) >= 0;
|
||||||
|
}
|
||||||
|
bool network_configuration_busy(const network_configuration_t *configuration) { return configuration != NULL && configuration->state == NETWORK_CONFIGURATION_VALIDATING; }
|
||||||
+6
-2
@@ -1,7 +1,7 @@
|
|||||||
CC ?= cc
|
CC ?= cc
|
||||||
CFLAGS ?= -std=c11 -Wall -Wextra -Werror -I../../include
|
CFLAGS ?= -std=c11 -Wall -Wextra -Werror -I../../include
|
||||||
|
|
||||||
all: test_command_queue test_game_domain test_bot_player test_game_lifecycle test_state_presenter test_http_api test_sync_service test_human_game_integration test_bot_game_integration test_robustness test_network_foundation test_captive_portal
|
all: test_command_queue test_game_domain test_bot_player test_game_lifecycle test_state_presenter test_http_api test_sync_service test_human_game_integration test_bot_game_integration test_robustness test_network_foundation test_captive_portal test_network_configuration
|
||||||
|
|
||||||
test_command_queue: test_command_queue.c ../../src/command_queue.c
|
test_command_queue: test_command_queue.c ../../src/command_queue.c
|
||||||
$(CC) $(CFLAGS) $^ -o $@
|
$(CC) $(CFLAGS) $^ -o $@
|
||||||
@@ -19,6 +19,7 @@ run: all
|
|||||||
./test_robustness
|
./test_robustness
|
||||||
./test_network_foundation
|
./test_network_foundation
|
||||||
./test_captive_portal
|
./test_captive_portal
|
||||||
|
./test_network_configuration
|
||||||
|
|
||||||
test_game_domain: test_game_domain.c ../../src/fleet_generator.c ../../src/game_engine.c
|
test_game_domain: test_game_domain.c ../../src/fleet_generator.c ../../src/game_engine.c
|
||||||
$(CC) $(CFLAGS) $^ -o $@
|
$(CC) $(CFLAGS) $^ -o $@
|
||||||
@@ -54,5 +55,8 @@ test_network_foundation: test_network_foundation.c ../../src/network_state.c ../
|
|||||||
test_captive_portal: test_captive_portal.c ../../src/captive_portal.c
|
test_captive_portal: test_captive_portal.c ../../src/captive_portal.c
|
||||||
$(CC) $(CFLAGS) $^ -o $@
|
$(CC) $(CFLAGS) $^ -o $@
|
||||||
|
|
||||||
|
test_network_configuration: test_network_configuration.c ../../src/network_configuration.c ../../src/network_state.c
|
||||||
|
$(CC) $(CFLAGS) $^ -o $@
|
||||||
|
|
||||||
clean:
|
clean:
|
||||||
rm -f test_command_queue test_game_domain test_bot_player test_game_lifecycle test_state_presenter test_http_api test_sync_service test_human_game_integration test_bot_game_integration test_robustness test_network_foundation test_captive_portal
|
rm -f test_command_queue test_game_domain test_bot_player test_game_lifecycle test_state_presenter test_http_api test_sync_service test_human_game_integration test_bot_game_integration test_robustness test_network_foundation test_captive_portal test_network_configuration
|
||||||
|
|||||||
@@ -0,0 +1,16 @@
|
|||||||
|
#include <assert.h>
|
||||||
|
#include <stdio.h>
|
||||||
|
#include <string.h>
|
||||||
|
#include "network_configuration.h"
|
||||||
|
|
||||||
|
static network_profile_t profile(const char *ssid, const char *password) { network_profile_t value = {0}; snprintf(value.ssid, sizeof(value.ssid), "%s", ssid); snprintf(value.password, sizeof(value.password), "%s", password); return value; }
|
||||||
|
int main(void) {
|
||||||
|
network_manager_t manager; network_configuration_t configuration; network_profile_t old = profile("Old", "old-pass"); network_profile_t next = profile("New", "new-pass");
|
||||||
|
network_manager_init(&manager, &old, 0U); network_configuration_init(&configuration);
|
||||||
|
assert(network_configuration_begin(&configuration, &manager, &next, 10U)); assert(network_configuration_busy(&configuration));
|
||||||
|
assert(!network_configuration_begin(&configuration, &manager, &next, 11U)); assert(!network_configuration_validation_timed_out(&configuration, 30009U)); assert(network_configuration_validation_timed_out(&configuration, 30010U));
|
||||||
|
assert(network_configuration_finish(&configuration, &manager, false, 30010U) == NETWORK_ACTION_NONE); assert(strcmp(manager.profile.ssid, "Old") == 0);
|
||||||
|
assert(network_configuration_begin(&configuration, &manager, &next, 40000U)); assert(network_configuration_finish(&configuration, &manager, true, 40001U) == NETWORK_ACTION_CONNECT); assert(strcmp(manager.profile.ssid, "New") == 0);
|
||||||
|
assert(!network_configuration_success_notice_expired(&configuration, 55000U)); assert(network_configuration_success_notice_expired(&configuration, 55001U));
|
||||||
|
puts("network configuration tests passed"); return 0;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user