- Add led_init() call during firmware initialization in app_main() - Add led_stop_blink() call on WebSocket disconnect - LED GPIO configurable via CONFIG_SPAXEL_LED_GPIO (default GPIO8) - Blink runs in FreeRTOS task at ~5Hz (100ms on/100ms off) - Any running blink is cancelled when new identify message received Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
803 lines
25 KiB
C
803 lines
25 KiB
C
#include "websocket.h"
|
|
#include "spaxel.h"
|
|
#include "csi.h"
|
|
#include "wifi.h"
|
|
#include "ntp.h"
|
|
#include "led.h"
|
|
#include "esp_log.h"
|
|
#include "esp_timer.h"
|
|
#include "esp_system.h"
|
|
#include "esp_ota_ops.h"
|
|
#include "esp_http_client.h"
|
|
#include "mbedtls/sha256.h"
|
|
#include "freertos/FreeRTOS.h"
|
|
#include "freertos/task.h"
|
|
#include "freertos/semaphore.h"
|
|
#include "cJSON.h"
|
|
#include <string.h>
|
|
#include <strings.h>
|
|
|
|
// ESP-IDF WebSocket client
|
|
#include "esp_websocket_client.h"
|
|
|
|
static const char *TAG = "ws";
|
|
|
|
static esp_websocket_client_handle_t s_ws = NULL;
|
|
static SemaphoreHandle_t s_tx_mutex = NULL;
|
|
static volatile bool s_connected = false;
|
|
|
|
// OTA state
|
|
static char s_ota_url[256] = {0};
|
|
static char s_ota_sha256[65] = {0};
|
|
static char s_ota_version[32] = {0};
|
|
|
|
// OTA rollback confirmation — set once we receive a role message after connecting.
|
|
// Cancels the automatic rollback timer in the ESP-IDF OTA framework.
|
|
static bool s_ota_confirmed = false;
|
|
|
|
// One-shot timer: if role is not received within 60 s of connection,
|
|
// the new OTA partition stays unconfirmed and the bootloader will
|
|
// roll back to the previous partition on the next reset.
|
|
static esp_timer_handle_t s_ota_valid_timer = NULL;
|
|
|
|
#define SPAXEL_OTA_VALID_TIMEOUT_S 60
|
|
|
|
static void ota_validation_timeout_cb(void *arg) {
|
|
if (!s_ota_confirmed) {
|
|
ESP_LOGW(TAG, "OTA validation: timed out, rollback on next reset");
|
|
}
|
|
}
|
|
|
|
static bool is_running_ota_partition(void) {
|
|
const esp_partition_t *running = esp_ota_get_running_partition();
|
|
return running != NULL &&
|
|
running->type == ESP_PARTITION_TYPE_APP &&
|
|
running->subtype != ESP_PARTITION_SUBTYPE_APP_FACTORY;
|
|
}
|
|
|
|
static void start_ota_validation_timer(void) {
|
|
if (s_ota_valid_timer == NULL) {
|
|
esp_timer_create_args_t timer_args = {
|
|
.callback = ota_validation_timeout_cb,
|
|
.name = "ota_valid",
|
|
};
|
|
esp_timer_create(&timer_args, &s_ota_valid_timer);
|
|
}
|
|
esp_timer_start_once(s_ota_valid_timer, SPAXEL_OTA_VALID_TIMEOUT_S * 1000000ULL);
|
|
ESP_LOGI(TAG, "OTA validation: waiting for role message (timeout %ds)",
|
|
SPAXEL_OTA_VALID_TIMEOUT_S);
|
|
}
|
|
|
|
static void stop_ota_validation_timer(void) {
|
|
if (s_ota_valid_timer != NULL) {
|
|
esp_timer_stop(s_ota_valid_timer);
|
|
}
|
|
}
|
|
|
|
// Forward declarations
|
|
static void ws_event_handler(void *args, esp_event_base_t base,
|
|
int32_t id, void *data);
|
|
static void handle_role_msg(cJSON *root);
|
|
static void handle_config_msg(cJSON *root);
|
|
static void handle_ota_msg(cJSON *root);
|
|
static void handle_reboot_msg(cJSON *root);
|
|
static void handle_identify_msg(cJSON *root);
|
|
static void ota_task(void *arg);
|
|
|
|
esp_err_t websocket_init(void) {
|
|
s_tx_mutex = xSemaphoreCreateMutex();
|
|
if (!s_tx_mutex) {
|
|
ESP_LOGE(TAG, "Failed to create TX mutex");
|
|
return ESP_ERR_NO_MEM;
|
|
}
|
|
return ESP_OK;
|
|
}
|
|
|
|
bool websocket_connect(const char *host, uint16_t port) {
|
|
if (s_ws) {
|
|
websocket_disconnect();
|
|
vTaskDelay(pdMS_TO_TICKS(100));
|
|
}
|
|
|
|
// Build WebSocket URI
|
|
char uri[128];
|
|
snprintf(uri, sizeof(uri), "ws://%s:%d%s", host, port, SPAXEL_WS_PATH);
|
|
|
|
ESP_LOGI(TAG, "Connecting to %s", uri);
|
|
|
|
// Configure WebSocket client
|
|
esp_websocket_client_config_t cfg = {
|
|
.uri = uri,
|
|
.reconnect_timeout_ms = 5000,
|
|
.network_timeout_ms = 30000,
|
|
.ping_interval_sec = 30,
|
|
.task_stack = 8192,
|
|
.buffer_size = 2048,
|
|
};
|
|
|
|
// Add auth header if we have a token
|
|
// Note: esp_websocket_client doesn't directly support custom headers,
|
|
// so we'd use the URI query param or implement a custom handshake
|
|
// For now, we'll use a simplified approach
|
|
|
|
s_ws = esp_websocket_client_init(&cfg);
|
|
if (!s_ws) {
|
|
ESP_LOGE(TAG, "Failed to init WebSocket client");
|
|
return false;
|
|
}
|
|
|
|
// Register event handlers
|
|
esp_websocket_register_events(s_ws, WEBSOCKET_EVENT_ANY, ws_event_handler, NULL);
|
|
|
|
// Connect
|
|
esp_err_t err = esp_websocket_client_start(s_ws);
|
|
if (err != ESP_OK) {
|
|
ESP_LOGE(TAG, "Failed to start WebSocket: %s", esp_err_to_name(err));
|
|
esp_websocket_client_destroy(s_ws);
|
|
s_ws = NULL;
|
|
return false;
|
|
}
|
|
|
|
// Wait for connection
|
|
int timeout = 50; // 5 seconds
|
|
while (!s_connected && timeout-- > 0) {
|
|
vTaskDelay(pdMS_TO_TICKS(100));
|
|
}
|
|
|
|
if (!s_connected) {
|
|
ESP_LOGE(TAG, "WebSocket connection timeout");
|
|
websocket_disconnect();
|
|
return false;
|
|
}
|
|
|
|
// Send hello message
|
|
websocket_send_hello();
|
|
|
|
// If booting from an unconfirmed OTA partition, start a 60 s timer.
|
|
// If the mothership doesn't send a role message within that window,
|
|
// the partition stays unconfirmed and the bootloader will roll back
|
|
// to the previous firmware on the next reset.
|
|
if (!s_ota_confirmed && is_running_ota_partition()) {
|
|
start_ota_validation_timer();
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
void websocket_disconnect(void) {
|
|
if (s_ws) {
|
|
esp_websocket_client_stop(s_ws);
|
|
esp_websocket_client_destroy(s_ws);
|
|
s_ws = NULL;
|
|
}
|
|
s_connected = false;
|
|
stop_ota_validation_timer();
|
|
|
|
// Stop any running LED blink on disconnect
|
|
led_stop_blink();
|
|
}
|
|
|
|
bool websocket_is_connected(void) {
|
|
return s_connected && s_ws != NULL;
|
|
}
|
|
|
|
static void ws_event_handler(void *args, esp_event_base_t base,
|
|
int32_t id, void *data) {
|
|
esp_websocket_event_data_t *event = (esp_websocket_event_data_t *)data;
|
|
|
|
switch (id) {
|
|
case WEBSOCKET_EVENT_CONNECTED:
|
|
ESP_LOGI(TAG, "WebSocket connected");
|
|
s_connected = true;
|
|
break;
|
|
|
|
case WEBSOCKET_EVENT_DISCONNECTED:
|
|
ESP_LOGW(TAG, "WebSocket disconnected");
|
|
s_connected = false;
|
|
xEventGroupSetBits(g_state.events, SPAXEL_EVENT_WS_DISCONNECTED);
|
|
break;
|
|
|
|
case WEBSOCKET_EVENT_DATA:
|
|
// Handle incoming message
|
|
if (event->data_len > 0 && event->op_code == 0x01) {
|
|
// Text frame (JSON)
|
|
char *json = malloc(event->data_len + 1);
|
|
if (json) {
|
|
memcpy(json, event->data_ptr, event->data_len);
|
|
json[event->data_len] = '\0';
|
|
websocket_handle_message(json, event->data_len);
|
|
free(json);
|
|
}
|
|
}
|
|
break;
|
|
|
|
case WEBSOCKET_EVENT_ERROR:
|
|
ESP_LOGE(TAG, "WebSocket error");
|
|
break;
|
|
|
|
default:
|
|
break;
|
|
}
|
|
}
|
|
|
|
esp_err_t websocket_send_csi(const uint8_t *peer_mac, uint64_t timestamp_us,
|
|
int8_t rssi, int8_t noise_floor, uint8_t channel,
|
|
const int8_t *iq_data, uint8_t n_sub) {
|
|
if (!s_connected || !s_ws) {
|
|
return ESP_ERR_INVALID_STATE;
|
|
}
|
|
|
|
// Build binary frame
|
|
// Header: 24 bytes
|
|
// Payload: n_sub * 2 bytes
|
|
size_t frame_len = SPAXEL_FRAME_HEADER_SIZE + (n_sub * 2);
|
|
uint8_t *frame = malloc(frame_len);
|
|
if (!frame) {
|
|
return ESP_ERR_NO_MEM;
|
|
}
|
|
|
|
// Pack header (little-endian)
|
|
memcpy(frame + 0, g_state.mac, 6); // node_mac
|
|
memcpy(frame + 6, peer_mac, 6); // peer_mac
|
|
memcpy(frame + 12, ×tamp_us, 8); // timestamp_us
|
|
frame[20] = (uint8_t)rssi; // rssi (int8 as uint8)
|
|
frame[21] = (uint8_t)noise_floor; // noise_floor
|
|
frame[22] = channel; // channel
|
|
frame[23] = n_sub; // n_sub
|
|
|
|
// Pack I/Q payload
|
|
memcpy(frame + 24, iq_data, n_sub * 2);
|
|
|
|
// Send binary frame
|
|
xSemaphoreTake(s_tx_mutex, portMAX_DELAY);
|
|
int sent = esp_websocket_client_send_bin(s_ws, (char *)frame, frame_len,
|
|
portMAX_DELAY);
|
|
xSemaphoreGive(s_tx_mutex);
|
|
|
|
free(frame);
|
|
|
|
return (sent == frame_len) ? ESP_OK : ESP_FAIL;
|
|
}
|
|
|
|
esp_err_t websocket_send_hello(void) {
|
|
if (!s_connected || !s_ws) {
|
|
return ESP_ERR_INVALID_STATE;
|
|
}
|
|
|
|
cJSON *root = cJSON_CreateObject();
|
|
cJSON_AddStringToObject(root, "type", "hello");
|
|
|
|
char mac_str[18];
|
|
mac_to_str(g_state.mac, mac_str, sizeof(mac_str));
|
|
cJSON_AddStringToObject(root, "mac", mac_str);
|
|
|
|
if (strlen(g_state.node_id) > 0) {
|
|
cJSON_AddStringToObject(root, "node_id", g_state.node_id);
|
|
}
|
|
|
|
// Firmware version (from build)
|
|
cJSON_AddStringToObject(root, "firmware_version", "1.0.0");
|
|
|
|
// Capabilities
|
|
cJSON *caps = cJSON_CreateArray();
|
|
cJSON_AddItemToArray(caps, cJSON_CreateString("csi"));
|
|
cJSON_AddItemToArray(caps, cJSON_CreateString("ble"));
|
|
cJSON_AddItemToArray(caps, cJSON_CreateString("tx"));
|
|
cJSON_AddItemToArray(caps, cJSON_CreateString("rx"));
|
|
cJSON_AddItemToObject(root, "capabilities", caps);
|
|
|
|
cJSON_AddStringToObject(root, "chip", "ESP32-S3");
|
|
cJSON_AddNumberToObject(root, "flash_mb", 16);
|
|
cJSON_AddNumberToObject(root, "uptime_ms", esp_timer_get_time() / 1000);
|
|
|
|
// AP BSSID and channel (for passive radar auto-detection)
|
|
uint8_t ap_bssid[6];
|
|
if (wifi_get_ap_bssid(ap_bssid)) {
|
|
char bssid_str[18];
|
|
mac_to_str(ap_bssid, bssid_str, sizeof(bssid_str));
|
|
cJSON_AddStringToObject(root, "ap_bssid", bssid_str);
|
|
cJSON_AddNumberToObject(root, "ap_channel", wifi_get_channel());
|
|
}
|
|
|
|
char *json = cJSON_PrintUnformatted(root);
|
|
cJSON_Delete(root);
|
|
|
|
if (!json) {
|
|
return ESP_ERR_NO_MEM;
|
|
}
|
|
|
|
xSemaphoreTake(s_tx_mutex, portMAX_DELAY);
|
|
int sent = esp_websocket_client_send_text(s_ws, json, strlen(json),
|
|
portMAX_DELAY);
|
|
xSemaphoreGive(s_tx_mutex);
|
|
|
|
free(json);
|
|
return (sent > 0) ? ESP_OK : ESP_FAIL;
|
|
}
|
|
|
|
esp_err_t websocket_send_health(void) {
|
|
if (!s_connected || !s_ws) {
|
|
return ESP_ERR_INVALID_STATE;
|
|
}
|
|
|
|
cJSON *root = cJSON_CreateObject();
|
|
cJSON_AddStringToObject(root, "type", "health");
|
|
|
|
char mac_str[18];
|
|
mac_to_str(g_state.mac, mac_str, sizeof(mac_str));
|
|
cJSON_AddStringToObject(root, "mac", mac_str);
|
|
|
|
cJSON_AddNumberToObject(root, "timestamp_ms",
|
|
esp_timer_get_time() / 1000);
|
|
cJSON_AddNumberToObject(root, "free_heap_bytes",
|
|
esp_get_free_heap_size());
|
|
cJSON_AddNumberToObject(root, "wifi_rssi_dbm", wifi_get_rssi());
|
|
cJSON_AddNumberToObject(root, "uptime_ms",
|
|
esp_timer_get_time() / 1000);
|
|
|
|
// Temperature (if available)
|
|
extern float esp_temp_get_celsius(void);
|
|
cJSON_AddNumberToObject(root, "temperature_c", esp_temp_get_celsius());
|
|
|
|
cJSON_AddNumberToObject(root, "csi_rate_hz", g_state.packet_rate);
|
|
cJSON_AddNumberToObject(root, "wifi_channel", wifi_get_channel());
|
|
|
|
// Get IP address
|
|
esp_netif_t *netif = esp_netif_get_handle_from_ifkey("WIFI_STA_DEF");
|
|
if (netif) {
|
|
esp_netif_ip_info_t ip_info;
|
|
if (esp_netif_get_ip_info(netif, &ip_info) == ESP_OK) {
|
|
char ip_str[16];
|
|
snprintf(ip_str, sizeof(ip_str), IPSTR, IP2STR(&ip_info.ip));
|
|
cJSON_AddStringToObject(root, "ip", ip_str);
|
|
}
|
|
}
|
|
|
|
// NTP sync status
|
|
cJSON_AddBoolToObject(root, "ntp_synced", ntp_is_synced());
|
|
|
|
char *json = cJSON_PrintUnformatted(root);
|
|
cJSON_Delete(root);
|
|
|
|
if (!json) {
|
|
return ESP_ERR_NO_MEM;
|
|
}
|
|
|
|
xSemaphoreTake(s_tx_mutex, portMAX_DELAY);
|
|
int sent = esp_websocket_client_send_text(s_ws, json, strlen(json),
|
|
portMAX_DELAY);
|
|
xSemaphoreGive(s_tx_mutex);
|
|
|
|
free(json);
|
|
return (sent > 0) ? ESP_OK : ESP_FAIL;
|
|
}
|
|
|
|
esp_err_t websocket_send_ble(const char *devices_json) {
|
|
if (!s_connected || !s_ws || !devices_json) {
|
|
return ESP_ERR_INVALID_STATE;
|
|
}
|
|
|
|
// Build BLE message
|
|
cJSON *root = cJSON_CreateObject();
|
|
cJSON_AddStringToObject(root, "type", "ble");
|
|
|
|
char mac_str[18];
|
|
mac_to_str(g_state.mac, mac_str, sizeof(mac_str));
|
|
cJSON_AddStringToObject(root, "mac", mac_str);
|
|
|
|
cJSON_AddNumberToObject(root, "timestamp_ms",
|
|
esp_timer_get_time() / 1000);
|
|
|
|
// Parse and add devices array
|
|
cJSON *devices = cJSON_Parse(devices_json);
|
|
if (devices) {
|
|
cJSON_AddItemToObject(root, "devices", devices);
|
|
}
|
|
|
|
char *json = cJSON_PrintUnformatted(root);
|
|
cJSON_Delete(root);
|
|
|
|
if (!json) {
|
|
return ESP_ERR_NO_MEM;
|
|
}
|
|
|
|
xSemaphoreTake(s_tx_mutex, portMAX_DELAY);
|
|
int sent = esp_websocket_client_send_text(s_ws, json, strlen(json),
|
|
portMAX_DELAY);
|
|
xSemaphoreGive(s_tx_mutex);
|
|
|
|
free(json);
|
|
return (sent > 0) ? ESP_OK : ESP_FAIL;
|
|
}
|
|
|
|
esp_err_t websocket_send_motion_hint(float variance) {
|
|
if (!s_connected || !s_ws) {
|
|
return ESP_ERR_INVALID_STATE;
|
|
}
|
|
|
|
// Rate-limit to at most 1 hint per second.
|
|
static int64_t s_last_hint_us = 0;
|
|
int64_t now_us = esp_timer_get_time();
|
|
if (now_us - s_last_hint_us < 1000000) {
|
|
return ESP_OK;
|
|
}
|
|
s_last_hint_us = now_us;
|
|
|
|
cJSON *root = cJSON_CreateObject();
|
|
cJSON_AddStringToObject(root, "type", "motion_hint");
|
|
|
|
char mac_str[18];
|
|
mac_to_str(g_state.mac, mac_str, sizeof(mac_str));
|
|
cJSON_AddStringToObject(root, "mac", mac_str);
|
|
|
|
cJSON_AddNumberToObject(root, "timestamp_ms", now_us / 1000);
|
|
cJSON_AddNumberToObject(root, "variance", variance);
|
|
|
|
char *json = cJSON_PrintUnformatted(root);
|
|
cJSON_Delete(root);
|
|
|
|
if (!json) {
|
|
return ESP_ERR_NO_MEM;
|
|
}
|
|
|
|
xSemaphoreTake(s_tx_mutex, portMAX_DELAY);
|
|
int sent = esp_websocket_client_send_text(s_ws, json, strlen(json),
|
|
portMAX_DELAY);
|
|
xSemaphoreGive(s_tx_mutex);
|
|
|
|
free(json);
|
|
return (sent > 0) ? ESP_OK : ESP_FAIL;
|
|
}
|
|
|
|
esp_err_t websocket_send_ota_status(const char *state, uint8_t progress_pct,
|
|
const char *error) {
|
|
if (!s_connected || !s_ws) {
|
|
return ESP_ERR_INVALID_STATE;
|
|
}
|
|
|
|
cJSON *root = cJSON_CreateObject();
|
|
cJSON_AddStringToObject(root, "type", "ota_status");
|
|
|
|
char mac_str[18];
|
|
mac_to_str(g_state.mac, mac_str, sizeof(mac_str));
|
|
cJSON_AddStringToObject(root, "mac", mac_str);
|
|
|
|
cJSON_AddStringToObject(root, "state", state);
|
|
cJSON_AddNumberToObject(root, "progress_pct", progress_pct);
|
|
|
|
if (error) {
|
|
cJSON_AddStringToObject(root, "error", error);
|
|
}
|
|
|
|
char *json = cJSON_PrintUnformatted(root);
|
|
cJSON_Delete(root);
|
|
|
|
if (!json) {
|
|
return ESP_ERR_NO_MEM;
|
|
}
|
|
|
|
xSemaphoreTake(s_tx_mutex, portMAX_DELAY);
|
|
int sent = esp_websocket_client_send_text(s_ws, json, strlen(json),
|
|
portMAX_DELAY);
|
|
xSemaphoreGive(s_tx_mutex);
|
|
|
|
free(json);
|
|
return (sent > 0) ? ESP_OK : ESP_FAIL;
|
|
}
|
|
|
|
void websocket_handle_message(const char *json, size_t len) {
|
|
cJSON *root = cJSON_ParseWithLength(json, len);
|
|
if (!root) {
|
|
ESP_LOGW(TAG, "Failed to parse JSON message");
|
|
return;
|
|
}
|
|
|
|
cJSON *type = cJSON_GetObjectItem(root, "type");
|
|
if (!type || !cJSON_IsString(type)) {
|
|
cJSON_Delete(root);
|
|
return;
|
|
}
|
|
|
|
ESP_LOGD(TAG, "Received message type: %s", type->valuestring);
|
|
|
|
if (strcmp(type->valuestring, "role") == 0) {
|
|
handle_role_msg(root);
|
|
} else if (strcmp(type->valuestring, "config") == 0) {
|
|
handle_config_msg(root);
|
|
} else if (strcmp(type->valuestring, "ota") == 0) {
|
|
handle_ota_msg(root);
|
|
} else if (strcmp(type->valuestring, "reboot") == 0) {
|
|
handle_reboot_msg(root);
|
|
} else if (strcmp(type->valuestring, "identify") == 0) {
|
|
handle_identify_msg(root);
|
|
} else if (strcmp(type->valuestring, "reject") == 0) {
|
|
cJSON *reason = cJSON_GetObjectItem(root, "reason");
|
|
ESP_LOGE(TAG, "Rejected by mothership: %s",
|
|
reason ? reason->valuestring : "unknown");
|
|
websocket_disconnect();
|
|
}
|
|
// Unknown types are silently ignored (forward-compatible)
|
|
|
|
cJSON_Delete(root);
|
|
}
|
|
|
|
static void handle_role_msg(cJSON *root) {
|
|
cJSON *role = cJSON_GetObjectItem(root, "role");
|
|
if (!role || !cJSON_IsString(role)) {
|
|
return;
|
|
}
|
|
|
|
// Confirm OTA partition valid on first role message received after boot.
|
|
// This means we successfully connected and the mothership accepted us.
|
|
if (!s_ota_confirmed) {
|
|
s_ota_confirmed = true;
|
|
stop_ota_validation_timer();
|
|
if (is_running_ota_partition()) {
|
|
esp_err_t err = esp_ota_mark_app_valid_cancel_rollback();
|
|
if (err == ESP_OK) {
|
|
ESP_LOGI(TAG, "OTA validation: marked valid after role received");
|
|
} else {
|
|
ESP_LOGW(TAG, "OTA validation: failed to mark valid: %s",
|
|
esp_err_to_name(err));
|
|
}
|
|
}
|
|
}
|
|
|
|
const char *role_str = role->valuestring;
|
|
node_role_t new_role = g_state.role;
|
|
|
|
if (strcmp(role_str, "tx") == 0) {
|
|
new_role = NODE_ROLE_TX;
|
|
} else if (strcmp(role_str, "rx") == 0) {
|
|
new_role = NODE_ROLE_RX;
|
|
} else if (strcmp(role_str, "tx_rx") == 0) {
|
|
new_role = NODE_ROLE_TX_RX;
|
|
} else if (strcmp(role_str, "passive") == 0) {
|
|
new_role = NODE_ROLE_PASSIVE;
|
|
// Get passive BSSID
|
|
cJSON *bssid = cJSON_GetObjectItem(root, "passive_bssid");
|
|
if (bssid && cJSON_IsString(bssid)) {
|
|
str_to_mac(bssid->valuestring, g_state.passive_bssid);
|
|
}
|
|
} else if (strcmp(role_str, "idle") == 0) {
|
|
new_role = NODE_ROLE_IDLE;
|
|
}
|
|
|
|
if (new_role != g_state.role) {
|
|
g_state.role = new_role;
|
|
xEventGroupSetBits(g_state.events, SPAXEL_EVENT_ROLE_CHANGED);
|
|
ESP_LOGI(TAG, "Role changed to: %s", node_role_str(new_role));
|
|
}
|
|
}
|
|
|
|
static void handle_config_msg(cJSON *root) {
|
|
bool changed = false;
|
|
|
|
// Rate
|
|
cJSON *rate = cJSON_GetObjectItem(root, "rate_hz");
|
|
if (rate && cJSON_IsNumber(rate)) {
|
|
uint8_t new_rate = (uint8_t)rate->valueint;
|
|
if (new_rate >= 1 && new_rate <= 100 && new_rate != g_state.packet_rate) {
|
|
g_state.packet_rate = new_rate;
|
|
changed = true;
|
|
ESP_LOGI(TAG, "Rate changed to: %d Hz", new_rate);
|
|
}
|
|
}
|
|
|
|
// Variance threshold (for on-device motion hints)
|
|
cJSON *var_thresh = cJSON_GetObjectItem(root, "variance_threshold");
|
|
if (var_thresh && cJSON_IsNumber(var_thresh)) {
|
|
// Store in global for CSI module to use
|
|
extern float g_variance_threshold;
|
|
g_variance_threshold = (float)var_thresh->valuedouble;
|
|
}
|
|
|
|
// NTP server (runtime reconfiguration)
|
|
cJSON *ntp = cJSON_GetObjectItem(root, "ntp_server");
|
|
if (ntp && cJSON_IsString(ntp) && strlen(ntp->valuestring) > 0) {
|
|
strncpy(g_state.ntp_server, ntp->valuestring, sizeof(g_state.ntp_server) - 1);
|
|
g_state.ntp_server[sizeof(g_state.ntp_server) - 1] = '\0';
|
|
ESP_LOGI(TAG, "NTP server changed to: %s", g_state.ntp_server);
|
|
ntp_start_sync(g_state.ntp_server);
|
|
}
|
|
|
|
if (changed) {
|
|
csi_set_rate(g_state.packet_rate);
|
|
}
|
|
}
|
|
|
|
static void handle_ota_msg(cJSON *root) {
|
|
cJSON *url = cJSON_GetObjectItem(root, "url");
|
|
cJSON *sha256 = cJSON_GetObjectItem(root, "sha256");
|
|
cJSON *version = cJSON_GetObjectItem(root, "version");
|
|
|
|
if (!url || !cJSON_IsString(url)) {
|
|
ESP_LOGW(TAG, "OTA message missing URL");
|
|
return;
|
|
}
|
|
|
|
strncpy(s_ota_url, url->valuestring, sizeof(s_ota_url) - 1);
|
|
if (sha256 && cJSON_IsString(sha256)) {
|
|
strncpy(s_ota_sha256, sha256->valuestring, sizeof(s_ota_sha256) - 1);
|
|
}
|
|
if (version && cJSON_IsString(version)) {
|
|
strncpy(s_ota_version, version->valuestring, sizeof(s_ota_version) - 1);
|
|
}
|
|
|
|
ESP_LOGI(TAG, "OTA triggered: %s", s_ota_url);
|
|
|
|
// Start OTA task
|
|
xTaskCreate(ota_task, "ota", 16384, NULL, 5, NULL);
|
|
}
|
|
|
|
static void handle_reboot_msg(cJSON *root) {
|
|
cJSON *delay = cJSON_GetObjectItem(root, "delay_ms");
|
|
uint32_t delay_ms = 1000;
|
|
|
|
if (delay && cJSON_IsNumber(delay)) {
|
|
delay_ms = (uint32_t)delay->valueint;
|
|
}
|
|
|
|
ESP_LOGI(TAG, "Reboot requested in %lu ms", delay_ms);
|
|
vTaskDelay(pdMS_TO_TICKS(delay_ms));
|
|
esp_restart();
|
|
}
|
|
|
|
static void handle_identify_msg(cJSON *root) {
|
|
cJSON *duration = cJSON_GetObjectItem(root, "duration_ms");
|
|
uint32_t duration_ms = 5000;
|
|
|
|
if (duration && cJSON_IsNumber(duration)) {
|
|
duration_ms = (uint32_t)duration->valueint;
|
|
}
|
|
|
|
ESP_LOGI(TAG, "Identify: blinking LED for %lu ms", duration_ms);
|
|
|
|
// Start LED blink
|
|
esp_err_t err = led_blink_identify(duration_ms);
|
|
if (err != ESP_OK) {
|
|
ESP_LOGE(TAG, "Failed to start LED blink: %s", esp_err_to_name(err));
|
|
}
|
|
}
|
|
|
|
static void ota_task(void *arg) {
|
|
ESP_LOGI(TAG, "Starting OTA download: %s", s_ota_url);
|
|
|
|
websocket_send_ota_status("downloading", 0, NULL);
|
|
|
|
esp_http_client_config_t http_cfg = {
|
|
.url = s_ota_url,
|
|
.timeout_ms = 30000,
|
|
.buffer_size = 4096,
|
|
};
|
|
|
|
esp_http_client_handle_t http = esp_http_client_init(&http_cfg);
|
|
if (!http) {
|
|
websocket_send_ota_status("failed", 0, "download_failed");
|
|
vTaskDelete(NULL);
|
|
return;
|
|
}
|
|
|
|
esp_err_t err = esp_http_client_open(http, 0);
|
|
if (err != ESP_OK) {
|
|
esp_http_client_cleanup(http);
|
|
websocket_send_ota_status("failed", 0, "download_failed");
|
|
vTaskDelete(NULL);
|
|
return;
|
|
}
|
|
|
|
int content_len = esp_http_client_fetch_headers(http);
|
|
if (content_len <= 0) {
|
|
esp_http_client_cleanup(http);
|
|
websocket_send_ota_status("failed", 0, "download_failed");
|
|
vTaskDelete(NULL);
|
|
return;
|
|
}
|
|
|
|
// Find next OTA partition
|
|
const esp_partition_t *update_part = esp_ota_get_next_update_partition(NULL);
|
|
if (!update_part) {
|
|
esp_http_client_cleanup(http);
|
|
websocket_send_ota_status("failed", 0, "no_partition");
|
|
vTaskDelete(NULL);
|
|
return;
|
|
}
|
|
|
|
// Begin OTA
|
|
esp_ota_handle_t ota_handle;
|
|
err = esp_ota_begin(update_part, OTA_SIZE_UNKNOWN, &ota_handle);
|
|
if (err != ESP_OK) {
|
|
esp_http_client_cleanup(http);
|
|
websocket_send_ota_status("failed", 0, "write_failed");
|
|
vTaskDelete(NULL);
|
|
return;
|
|
}
|
|
|
|
// Initialize SHA-256 for verification
|
|
mbedtls_sha256_context sha_ctx;
|
|
bool do_sha_verify = (strlen(s_ota_sha256) == 64);
|
|
if (do_sha_verify) {
|
|
mbedtls_sha256_init(&sha_ctx);
|
|
mbedtls_sha256_starts(&sha_ctx, 0); // 0 = SHA-256
|
|
}
|
|
|
|
// Download and write
|
|
char *buf = malloc(4096);
|
|
int total_read = 0;
|
|
int read;
|
|
|
|
while ((read = esp_http_client_read(http, buf, 4096)) > 0) {
|
|
// Update SHA-256 hash if verifying
|
|
if (do_sha_verify) {
|
|
mbedtls_sha256_update(&sha_ctx, (unsigned char *)buf, read);
|
|
}
|
|
|
|
err = esp_ota_write(ota_handle, buf, read);
|
|
if (err != ESP_OK) {
|
|
free(buf);
|
|
if (do_sha_verify) mbedtls_sha256_free(&sha_ctx);
|
|
esp_ota_abort(ota_handle);
|
|
esp_http_client_cleanup(http);
|
|
websocket_send_ota_status("failed", 0, "write_failed");
|
|
vTaskDelete(NULL);
|
|
return;
|
|
}
|
|
total_read += read;
|
|
|
|
uint8_t progress = (uint8_t)((total_read * 100) / content_len);
|
|
websocket_send_ota_status("downloading", progress, NULL);
|
|
}
|
|
|
|
free(buf);
|
|
esp_http_client_cleanup(http);
|
|
|
|
// Verify and complete
|
|
websocket_send_ota_status("verifying", 100, NULL);
|
|
|
|
// SHA-256 verification
|
|
if (do_sha_verify) {
|
|
unsigned char hash[32];
|
|
mbedtls_sha256_finish(&sha_ctx, hash);
|
|
mbedtls_sha256_free(&sha_ctx);
|
|
|
|
// Convert binary hash to hex string
|
|
char hash_hex[65];
|
|
for (int i = 0; i < 32; i++) {
|
|
sprintf(hash_hex + (i * 2), "%02x", hash[i]);
|
|
}
|
|
hash_hex[64] = '\0';
|
|
|
|
// Compare with expected hash (case-insensitive)
|
|
if (strcasecmp(hash_hex, s_ota_sha256) != 0) {
|
|
ESP_LOGE(TAG, "SHA-256 mismatch: expected %s, got %s", s_ota_sha256, hash_hex);
|
|
esp_ota_abort(ota_handle);
|
|
websocket_send_ota_status("failed", 0, "sha256_mismatch");
|
|
vTaskDelete(NULL);
|
|
return;
|
|
}
|
|
ESP_LOGI(TAG, "SHA-256 verified: %s", hash_hex);
|
|
}
|
|
|
|
err = esp_ota_end(ota_handle);
|
|
if (err != ESP_OK) {
|
|
websocket_send_ota_status("failed", 0, "verify_failed");
|
|
vTaskDelete(NULL);
|
|
return;
|
|
}
|
|
|
|
// Set boot partition
|
|
err = esp_ota_set_boot_partition(update_part);
|
|
if (err != ESP_OK) {
|
|
websocket_send_ota_status("failed", 0, "boot_partition_failed");
|
|
vTaskDelete(NULL);
|
|
return;
|
|
}
|
|
|
|
ESP_LOGI(TAG, "OTA complete, rebooting");
|
|
websocket_send_ota_status("rebooting", 100, NULL);
|
|
|
|
vTaskDelay(pdMS_TO_TICKS(1000));
|
|
esp_restart();
|
|
|
|
vTaskDelete(NULL);
|
|
}
|