Add optional mothership IP override to the provisioning flow so nodes on networks where mDNS is blocked (enterprise WiFi, mesh, VLANs) can connect on first boot without manual intervention. - Add ms_ip field to provisioning Payload and request structs - Firmware writes ms_ip to both NVS_KEY_MS_IP and NVS_KEY_MS_IP_PROV - Discovery prefers provisioned IP on first attempt, falls back to mDNS, then cached IP - Web Serial wizard adds Mothership IP field in Network Troubleshooting - Auto-populates IP when browser accesses dashboard by IP address - Document when/how to use the override in docs/notes/mdns-override.md Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
471 lines
16 KiB
C
471 lines
16 KiB
C
#include "spaxel.h"
|
|
#include "wifi.h"
|
|
#include "websocket.h"
|
|
#include "csi.h"
|
|
#include "ble.h"
|
|
#include "provision.h"
|
|
#include "nvs_migration.h"
|
|
#include "ntp.h"
|
|
#include "led.h"
|
|
#include "esp_log.h"
|
|
#include "esp_system.h"
|
|
#include "esp_timer.h"
|
|
#include "nvs_flash.h"
|
|
#include "nvs.h"
|
|
#include "esp_mac.h"
|
|
#include "freertos/task.h"
|
|
#include <string.h>
|
|
|
|
static const char *TAG = "spaxel";
|
|
|
|
// Global state
|
|
spaxel_state_t g_state = {0};
|
|
|
|
// Forward declarations
|
|
static void state_machine_task(void *arg);
|
|
static esp_err_t load_nvs_config(void);
|
|
static esp_err_t save_nvs_role(node_role_t role);
|
|
static esp_err_t save_nvs_rate(uint8_t rate);
|
|
|
|
const char* node_state_str(node_state_t state) {
|
|
switch (state) {
|
|
case NODE_STATE_BOOT: return "BOOT";
|
|
case NODE_STATE_WIFI_CONNECTING: return "WIFI_CONNECTING";
|
|
case NODE_STATE_MOTHERSHIP_DISCOVERY: return "MOTHERSHIP_DISCOVERY";
|
|
case NODE_STATE_CONNECTED: return "CONNECTED";
|
|
case NODE_STATE_WIFI_LOST: return "WIFI_LOST";
|
|
case NODE_STATE_MOTHERSHIP_UNAVAILABLE: return "MOTHERSHIP_UNAVAILABLE";
|
|
case NODE_STATE_CAPTIVE_PORTAL: return "CAPTIVE_PORTAL";
|
|
default: return "UNKNOWN";
|
|
}
|
|
}
|
|
|
|
const char* node_role_str(node_role_t role) {
|
|
switch (role) {
|
|
case NODE_ROLE_TX: return "tx";
|
|
case NODE_ROLE_RX: return "rx";
|
|
case NODE_ROLE_TX_RX: return "tx_rx";
|
|
case NODE_ROLE_PASSIVE: return "passive";
|
|
case NODE_ROLE_IDLE: return "idle";
|
|
default: return "unknown";
|
|
}
|
|
}
|
|
|
|
void mac_to_str(uint8_t *mac, char *buf, size_t len) {
|
|
snprintf(buf, len, "%02X:%02X:%02X:%02X:%02X:%02X",
|
|
mac[0], mac[1], mac[2], mac[3], mac[4], mac[5]);
|
|
}
|
|
|
|
void str_to_mac(const char *str, uint8_t *mac) {
|
|
int values[6];
|
|
sscanf(str, "%02X:%02X:%02X:%02X:%02X:%02X",
|
|
&values[0], &values[1], &values[2],
|
|
&values[3], &values[4], &values[5]);
|
|
for (int i = 0; i < 6; i++) {
|
|
mac[i] = (uint8_t)values[i];
|
|
}
|
|
}
|
|
|
|
static esp_err_t load_nvs_config(void) {
|
|
nvs_handle_t nvs;
|
|
esp_err_t err = nvs_open(SPAXEL_NAMESPACE, NVS_READONLY, &nvs);
|
|
if (err != ESP_OK) {
|
|
ESP_LOGW(TAG, "Failed to open NVS namespace: %s", esp_err_to_name(err));
|
|
return err;
|
|
}
|
|
|
|
// Check provisioned flag
|
|
g_state.provisioned = false;
|
|
uint8_t provisioned = 0;
|
|
if (nvs_get_u8(nvs, NVS_KEY_PROVISIONED, &provisioned) == ESP_OK) {
|
|
g_state.provisioned = (provisioned == 1);
|
|
}
|
|
|
|
// Load role
|
|
uint8_t role = NODE_ROLE_TX_RX;
|
|
if (nvs_get_u8(nvs, NVS_KEY_ROLE, &role) == ESP_OK) {
|
|
g_state.role = (node_role_t)role;
|
|
} else {
|
|
g_state.role = NODE_ROLE_TX_RX;
|
|
}
|
|
|
|
// Load packet rate
|
|
uint8_t rate = 20;
|
|
if (nvs_get_u8(nvs, NVS_KEY_PKT_RATE, &rate) == ESP_OK) {
|
|
g_state.packet_rate = rate;
|
|
} else {
|
|
g_state.packet_rate = 20;
|
|
}
|
|
|
|
// Load mDNS name
|
|
size_t len = sizeof(g_state.ms_mdns);
|
|
if (nvs_get_str(nvs, NVS_KEY_MS_MDNS, g_state.ms_mdns, &len) != ESP_OK) {
|
|
strncpy(g_state.ms_mdns, "spaxel", sizeof(g_state.ms_mdns));
|
|
}
|
|
|
|
// Load port
|
|
uint16_t port = SPAXEL_MDNS_PORT;
|
|
if (nvs_get_u16(nvs, NVS_KEY_MS_PORT, &port) == ESP_OK) {
|
|
g_state.ms_port = port;
|
|
} else {
|
|
g_state.ms_port = SPAXEL_MDNS_PORT;
|
|
}
|
|
|
|
// Load fallback IP
|
|
len = sizeof(g_state.ms_ip);
|
|
nvs_get_str(nvs, NVS_KEY_MS_IP, g_state.ms_ip, &len);
|
|
|
|
// Load provisioning-time IP override
|
|
len = sizeof(g_state.ms_ip_prov);
|
|
nvs_get_str(nvs, NVS_KEY_MS_IP_PROV, g_state.ms_ip_prov, &len);
|
|
|
|
// Load node ID
|
|
len = sizeof(g_state.node_id);
|
|
nvs_get_str(nvs, NVS_KEY_NODE_ID, g_state.node_id, &len);
|
|
|
|
// Load node token
|
|
len = sizeof(g_state.node_token);
|
|
nvs_get_str(nvs, NVS_KEY_NODE_TOKEN, g_state.node_token, &len);
|
|
|
|
// Load passive BSSID
|
|
size_t blob_len = 6;
|
|
nvs_get_blob(nvs, NVS_KEY_PASSIVE_BSS, g_state.passive_bssid, &blob_len);
|
|
|
|
// Load debug flag
|
|
uint8_t debug = 0;
|
|
if (nvs_get_u8(nvs, NVS_KEY_DEBUG, &debug) == ESP_OK) {
|
|
g_state.debug = (debug == 1);
|
|
}
|
|
|
|
// Load NTP server
|
|
len = sizeof(g_state.ntp_server);
|
|
if (nvs_get_str(nvs, NVS_KEY_NTP_SERVER, g_state.ntp_server, &len) != ESP_OK) {
|
|
strncpy(g_state.ntp_server, "pool.ntp.org", sizeof(g_state.ntp_server));
|
|
}
|
|
|
|
nvs_close(nvs);
|
|
|
|
ESP_LOGI(TAG, "NVS config loaded: provisioned=%d, role=%s, rate=%d Hz",
|
|
g_state.provisioned, node_role_str(g_state.role), g_state.packet_rate);
|
|
|
|
return ESP_OK;
|
|
}
|
|
|
|
static esp_err_t save_nvs_role(node_role_t role) {
|
|
nvs_handle_t nvs;
|
|
esp_err_t err = nvs_open(SPAXEL_NAMESPACE, NVS_READWRITE, &nvs);
|
|
if (err != ESP_OK) return err;
|
|
|
|
err = nvs_set_u8(nvs, NVS_KEY_ROLE, (uint8_t)role);
|
|
if (err == ESP_OK) {
|
|
nvs_commit(nvs);
|
|
g_state.role = role;
|
|
}
|
|
nvs_close(nvs);
|
|
return err;
|
|
}
|
|
|
|
static esp_err_t save_nvs_rate(uint8_t rate) {
|
|
nvs_handle_t nvs;
|
|
esp_err_t err = nvs_open(SPAXEL_NAMESPACE, NVS_READWRITE, &nvs);
|
|
if (err != ESP_OK) return err;
|
|
|
|
err = nvs_set_u8(nvs, NVS_KEY_PKT_RATE, rate);
|
|
if (err == ESP_OK) {
|
|
nvs_commit(nvs);
|
|
g_state.packet_rate = rate;
|
|
}
|
|
nvs_close(nvs);
|
|
return err;
|
|
}
|
|
|
|
static void state_machine_task(void *arg) {
|
|
int wifi_fail_count = 0;
|
|
int discovery_fail_count = 0;
|
|
TickType_t last_state_change = xTaskGetTickCount();
|
|
|
|
while (1) {
|
|
ESP_LOGD(TAG, "State machine: %s", node_state_str(g_state.state));
|
|
|
|
switch (g_state.state) {
|
|
case NODE_STATE_BOOT:
|
|
// Check if provisioned
|
|
if (!g_state.provisioned) {
|
|
ESP_LOGI(TAG, "Not provisioned, starting captive portal");
|
|
g_state.state = NODE_STATE_CAPTIVE_PORTAL;
|
|
} else {
|
|
ESP_LOGI(TAG, "Provisioned, connecting to WiFi");
|
|
g_state.state = NODE_STATE_WIFI_CONNECTING;
|
|
wifi_start_connect();
|
|
}
|
|
break;
|
|
|
|
case NODE_STATE_WIFI_CONNECTING:
|
|
// Wait for WiFi event
|
|
EventBits_t bits = xEventGroupWaitBits(
|
|
g_state.events,
|
|
SPAXEL_EVENT_WIFI_CONNECTED | SPAXEL_EVENT_WIFI_FAILED,
|
|
pdTRUE, pdFALSE,
|
|
pdMS_TO_TICKS(30000)
|
|
);
|
|
|
|
if (bits & SPAXEL_EVENT_WIFI_CONNECTED) {
|
|
ESP_LOGI(TAG, "WiFi connected");
|
|
wifi_fail_count = 0;
|
|
discovery_fail_count = 0;
|
|
|
|
// Initialize NTP after WiFi is up
|
|
ESP_LOGI(TAG, "Starting NTP sync with server: %s", g_state.ntp_server);
|
|
ntp_init();
|
|
ntp_start_sync(g_state.ntp_server);
|
|
if (!ntp_wait_sync(10000)) {
|
|
ESP_LOGW(TAG, "NTP sync failed, proceeding without stagger");
|
|
}
|
|
ntp_start_periodic_resync();
|
|
|
|
g_state.state = NODE_STATE_MOTHERSHIP_DISCOVERY;
|
|
} else if (bits & SPAXEL_EVENT_WIFI_FAILED) {
|
|
wifi_fail_count++;
|
|
ESP_LOGW(TAG, "WiFi failed (attempt %d)", wifi_fail_count);
|
|
if (wifi_fail_count >= 10) {
|
|
ESP_LOGE(TAG, "WiFi failed 10 times, starting captive portal");
|
|
g_state.state = NODE_STATE_CAPTIVE_PORTAL;
|
|
}
|
|
// Exponential backoff handled in wifi.c
|
|
}
|
|
break;
|
|
|
|
case NODE_STATE_MOTHERSHIP_DISCOVERY:
|
|
{
|
|
char ms_ip[64] = {0};
|
|
uint16_t ms_port = g_state.ms_port;
|
|
|
|
// 1. Try provisioned IP override first (for mDNS-less networks)
|
|
if (strlen(g_state.ms_ip_prov) > 0) {
|
|
ESP_LOGI(TAG, "Trying provisioned mothership IP: %s:%d", g_state.ms_ip_prov, ms_port);
|
|
strncpy(g_state.ms_ip, g_state.ms_ip_prov, sizeof(g_state.ms_ip) - 1);
|
|
// Skip mDNS on first attempt if provisioned IP is set
|
|
if (discovery_fail_count == 0) {
|
|
goto attempt_connect;
|
|
}
|
|
}
|
|
|
|
// 2. Try mDNS discovery
|
|
if (wifi_discover_mothership(ms_ip, sizeof(ms_ip), &ms_port)) {
|
|
ESP_LOGI(TAG, "Mothership discovered via mDNS: %s:%d", ms_ip, ms_port);
|
|
strncpy(g_state.ms_ip, ms_ip, sizeof(g_state.ms_ip) - 1);
|
|
g_state.ms_port = ms_port;
|
|
discovery_fail_count = 0;
|
|
} else if (strlen(g_state.ms_ip) > 0) {
|
|
// 3. Fallback to cached IP
|
|
ESP_LOGI(TAG, "Using cached mothership IP: %s", g_state.ms_ip);
|
|
} else {
|
|
discovery_fail_count++;
|
|
ESP_LOGW(TAG, "Mothership discovery failed (attempt %d)", discovery_fail_count);
|
|
|
|
if (discovery_fail_count >= 10) {
|
|
ESP_LOGW(TAG, "Mothership unavailable, continuing in degraded mode");
|
|
g_state.state = NODE_STATE_MOTHERSHIP_UNAVAILABLE;
|
|
break;
|
|
}
|
|
vTaskDelay(pdMS_TO_TICKS(5000));
|
|
break;
|
|
}
|
|
|
|
attempt_connect:
|
|
|
|
// Attempt WebSocket connection
|
|
if (websocket_connect(g_state.ms_ip, g_state.ms_port)) {
|
|
ESP_LOGI(TAG, "WebSocket connected to mothership");
|
|
g_state.state = NODE_STATE_CONNECTED;
|
|
|
|
// Save discovered IP
|
|
nvs_handle_t nvs;
|
|
if (nvs_open(SPAXEL_NAMESPACE, NVS_READWRITE, &nvs) == ESP_OK) {
|
|
nvs_set_str(nvs, NVS_KEY_MS_IP, g_state.ms_ip);
|
|
nvs_commit(nvs);
|
|
nvs_close(nvs);
|
|
}
|
|
} else {
|
|
discovery_fail_count++;
|
|
ESP_LOGW(TAG, "WebSocket connection failed (attempt %d)", discovery_fail_count);
|
|
vTaskDelay(pdMS_TO_TICKS(5000));
|
|
}
|
|
}
|
|
break;
|
|
|
|
case NODE_STATE_CONNECTED:
|
|
// Normal operation - wait for disconnect or commands
|
|
{
|
|
EventBits_t bits = xEventGroupWaitBits(
|
|
g_state.events,
|
|
SPAXEL_EVENT_WS_DISCONNECTED |
|
|
SPAXEL_EVENT_WIFI_FAILED |
|
|
SPAXEL_EVENT_ROLE_CHANGED |
|
|
SPAXEL_EVENT_OTA_TRIGGER |
|
|
SPAXEL_EVENT_REBOOT,
|
|
pdTRUE, pdFALSE,
|
|
pdMS_TO_TICKS(1000)
|
|
);
|
|
|
|
if (bits & SPAXEL_EVENT_REBOOT) {
|
|
ESP_LOGI(TAG, "Reboot requested");
|
|
esp_restart();
|
|
}
|
|
|
|
if (bits & SPAXEL_EVENT_OTA_TRIGGER) {
|
|
ESP_LOGI(TAG, "OTA triggered");
|
|
// OTA handling in websocket.c
|
|
}
|
|
|
|
if (bits & SPAXEL_EVENT_ROLE_CHANGED) {
|
|
ESP_LOGI(TAG, "Role changed, reconfiguring CSI");
|
|
csi_set_role(g_state.role, g_state.passive_bssid);
|
|
}
|
|
|
|
if (bits & SPAXEL_EVENT_WS_DISCONNECTED) {
|
|
ESP_LOGW(TAG, "WebSocket disconnected");
|
|
g_state.state = NODE_STATE_MOTHERSHIP_DISCOVERY;
|
|
}
|
|
|
|
if (bits & SPAXEL_EVENT_WIFI_FAILED) {
|
|
ESP_LOGW(TAG, "WiFi lost");
|
|
g_state.state = NODE_STATE_WIFI_LOST;
|
|
}
|
|
}
|
|
break;
|
|
|
|
case NODE_STATE_WIFI_LOST:
|
|
// Try to reconnect to WiFi
|
|
ESP_LOGI(TAG, "Attempting WiFi reconnect");
|
|
wifi_start_connect();
|
|
|
|
bits = xEventGroupWaitBits(
|
|
g_state.events,
|
|
SPAXEL_EVENT_WIFI_CONNECTED | SPAXEL_EVENT_WIFI_FAILED,
|
|
pdTRUE, pdFALSE,
|
|
pdMS_TO_TICKS(30000)
|
|
);
|
|
|
|
if (bits & SPAXEL_EVENT_WIFI_CONNECTED) {
|
|
ESP_LOGI(TAG, "WiFi reconnected");
|
|
|
|
// Re-sync NTP after reconnect
|
|
ntp_init();
|
|
ntp_start_sync(g_state.ntp_server);
|
|
if (!ntp_wait_sync(10000)) {
|
|
ESP_LOGW(TAG, "NTP resync failed after WiFi reconnect");
|
|
}
|
|
ntp_start_periodic_resync();
|
|
|
|
g_state.state = NODE_STATE_MOTHERSHIP_DISCOVERY;
|
|
} else {
|
|
wifi_fail_count++;
|
|
if (wifi_fail_count >= 10) {
|
|
ESP_LOGE(TAG, "WiFi lost 10 times, starting captive portal");
|
|
g_state.state = NODE_STATE_CAPTIVE_PORTAL;
|
|
}
|
|
}
|
|
break;
|
|
|
|
case NODE_STATE_MOTHERSHIP_UNAVAILABLE:
|
|
// Continue operating at last known role, retry discovery periodically
|
|
ESP_LOGD(TAG, "Operating in degraded mode, retrying discovery in 30s");
|
|
|
|
// Continue CSI capture and BLE scanning
|
|
csi_set_role(g_state.role, g_state.passive_bssid);
|
|
|
|
vTaskDelay(pdMS_TO_TICKS(30000));
|
|
|
|
// Try discovery again
|
|
g_state.state = NODE_STATE_MOTHERSHIP_DISCOVERY;
|
|
discovery_fail_count = 0;
|
|
break;
|
|
|
|
case NODE_STATE_CAPTIVE_PORTAL:
|
|
// Start captive portal AP mode
|
|
ESP_LOGI(TAG, "Starting captive portal");
|
|
wifi_start_captive_portal();
|
|
|
|
// Captive portal runs indefinitely until provisioned
|
|
// Provisioning handler will reboot the device
|
|
vTaskDelay(pdMS_TO_TICKS(60000));
|
|
break;
|
|
}
|
|
|
|
vTaskDelay(pdMS_TO_TICKS(100));
|
|
}
|
|
}
|
|
|
|
// Health reporting task
|
|
static void health_task(void *arg) {
|
|
while (1) {
|
|
vTaskDelay(pdMS_TO_TICKS(SPAXEL_HEALTH_INTERVAL_MS));
|
|
|
|
if (g_state.state == NODE_STATE_CONNECTED) {
|
|
websocket_send_health();
|
|
}
|
|
}
|
|
}
|
|
|
|
void app_main(void) {
|
|
ESP_LOGI(TAG, "SPAXEL Firmware starting...");
|
|
|
|
// Initialize NVS
|
|
esp_err_t ret = nvs_flash_init();
|
|
if (ret == ESP_ERR_NVS_NO_FREE_PAGES || ret == ESP_ERR_NVS_NEW_VERSION_FOUND) {
|
|
ESP_LOGW(TAG, "NVS partition corrupted, erasing...");
|
|
nvs_flash_erase();
|
|
ret = nvs_flash_init();
|
|
}
|
|
ESP_ERROR_CHECK(ret);
|
|
|
|
// Run NVS schema migration if needed
|
|
esp_err_t migration_err = nvs_migration_run();
|
|
if (migration_err != ESP_OK) {
|
|
ESP_LOGE(TAG, "NVS migration failed: %s", esp_err_to_name(migration_err));
|
|
// Continue anyway - NVS should be in a consistent state
|
|
}
|
|
|
|
// Get MAC address
|
|
esp_read_mac(g_state.mac, ESP_MAC_WIFI_STA);
|
|
char mac_str[18];
|
|
mac_to_str(g_state.mac, mac_str, sizeof(mac_str));
|
|
ESP_LOGI(TAG, "Node MAC: %s", mac_str);
|
|
|
|
// Create event group
|
|
g_state.events = xEventGroupCreate();
|
|
|
|
// Load configuration from NVS
|
|
load_nvs_config();
|
|
|
|
// Open serial provisioning window (10 s) — active before normal boot.
|
|
// Host sends {"provision": {...}}\n via Web Serial; firmware replies and proceeds.
|
|
provision_listen_window();
|
|
|
|
// Reload NVS config in case provisioning changed it
|
|
load_nvs_config();
|
|
|
|
// Initialize WiFi
|
|
wifi_init();
|
|
|
|
// Initialize LED
|
|
led_init();
|
|
|
|
// Initialize CSI
|
|
csi_init();
|
|
|
|
// Initialize BLE
|
|
ble_init();
|
|
|
|
// Initialize WebSocket
|
|
websocket_init();
|
|
|
|
// Start state machine
|
|
xTaskCreate(state_machine_task, "state_machine", 8192, NULL, 5, NULL);
|
|
|
|
// Start health reporting
|
|
xTaskCreate(health_task, "health", 4096, NULL, 3, NULL);
|
|
|
|
ESP_LOGI(TAG, "SPAXEL Firmware initialized");
|
|
}
|