feat(ota-relay): 更新ESP32-C3 OTA中继固件配置和功能增强

- 修改WiFi配置默认为STA模式连接路由器,并调整相关参数
- 优化调试日志系统,添加环形缓冲区支持HTTP日志拉取功能
- 重构健康监控,提高温度监控频率至1秒并添加电压监控定时器
- 更新分区表配置以适应4MB Flash布局
- 添加OTA更新组件依赖和蓝牙支持
- 修复UDP socket延迟创建时机,确保网络接口初始化正确
- 调整WiFi信号强度告警阈值,提升连接稳定性
- 更新README文档中的IP地址配置示例
- 修改VSCode端口配置为COM13
This commit is contained in:
小龙虾
2026-05-05 03:28:44 +08:00
parent 064eb7e03f
commit 478e5e5a31
25 changed files with 8342 additions and 1819 deletions
+1 -1
View File
@@ -3,7 +3,7 @@
"idf.openOcdConfigs": [
"board/esp32c3-bridge.cfg"
],
"idf.portWin": "COM11",
"idf.portWin": "COM13",
"idf.currentSetup": "F:\\software\\.espressif\\v6.0\\esp-idf",
"idf.customExtraVars": {
"IDF_TARGET": "esp32c3"
+2 -2
View File
@@ -50,7 +50,7 @@ ESP32_COMM/
CONFIG_WIFI_MODE_IS_STA = 0
WIFI_AP_SSID = "ESP32_OTA_RELAY" // 默认热点名称
WIFI_AP_PASSWORD = "12345678" // 默认密码
// 上位机连接热点后访问: http://192.168.4.1
// 上位机连接热点后访问: http://192.168.31.1
// STA 模式(连接路由器)
CONFIG_WIFI_MODE_IS_STA = 1
@@ -98,7 +98,7 @@ GD32 通信帧格式:`[通道编号: 1B] [扩展帧ID: 4B BE] [数据: 1~64B]`
### CAN 帧发送示例
```bash
curl -X POST http://192.168.4.1/can/send \
curl -X POST http://192.168.31.1/can/send \
-H "Content-Type: application/json" \
-d '{"ch": 0, "id": 12345678, "dlc": 8, "data": "0102030405060708"}'
```
@@ -0,0 +1,233 @@
/*
* health_monitor.cpp - 系统健康监控
* 包含:健康状态管理 + WiFi RSSI 监控 + 温度监控 + 电压监控
*/
#include <stdio.h>
#include <string.h>
#include "freertos/FreeRTOS.h"
#include "freertos/task.h"
#include "freertos/semphr.h"
#include "esp_wifi.h"
#include "esp_timer.h"
#include "esp_err.h"
#include "driver/temperature_sensor.h"
#include "health_monitor.h"
#include "debug_log.h"
health_state_t g_health_state = SYS_NORMAL;
static SemaphoreHandle_t g_health_mutex = NULL;
static temperature_sensor_handle_t s_temp_handle = NULL;
// ----------------------------------------
// 健康状态管理
// ----------------------------------------
void health_monitor_init(void)
{
g_health_mutex = xSemaphoreCreateMutex();
if (g_health_mutex == NULL) {
DEBUG_ERR("HEALTH", "创建健康状态互斥锁失败");
}
}
health_state_t health_get_current(void)
{
health_state_t state;
if (g_health_mutex) {
xSemaphoreTake(g_health_mutex, portMAX_DELAY);
}
state = g_health_state;
if (g_health_mutex) {
xSemaphoreGive(g_health_mutex);
}
return state;
}
// ----------------------------------------
// 温度监控(定时器回调)
// ----------------------------------------
static void temp_monitor_callback(void *arg)
{
float temp = 0.0f;
esp_err_t err = temperature_sensor_get_celsius(s_temp_handle, &temp);
if (err != ESP_OK) {
DEBUG_WARN("TEMP", "读取温度失败: %s", esp_err_to_name(err));
return;
}
DEBUG_DBG("TEMP", "芯片温度: %.1f°C", temp);
if (temp >= TEMP_CRITICAL) {
g_health_state = SYS_SAFE_SHUTDOWN;
DEBUG_ERR("TEMP", "!!! 危险 !!! 温度 %.1f°C >= %d°C,触发安全关机", temp, TEMP_CRITICAL);
DEBUG_ERR("TEMP", "健康状态变更为: SYS_SAFE_SHUTDOWN");
} else if (temp >= TEMP_WARNING) {
if (g_health_state < SYS_WARNING) {
g_health_state = SYS_WARNING;
DEBUG_WARN("TEMP", "警告: 温度 %.1f°C >= %d°C", temp, TEMP_WARNING);
DEBUG_WARN("TEMP", "健康状态变更为: SYS_WARNING");
}
}
}
// ----------------------------------------
// 电压监控(启动时调用一次)
// ----------------------------------------
static void voltage_monitor_callback(void *arg)
{
float voltage = 3.30f; // ESP32-C3 无内置电压传感器,使用估算
DEBUG_DBG("VOLT", "VDD 电压: %.2fV (估算)", voltage);
if (voltage < 2.3f) {
g_health_state = SYS_SAFE_SHUTDOWN;
DEBUG_ERR("VOLT", "!!! 危险 !!! 电压 %.2fV < 2.3V,触发安全关机", voltage);
DEBUG_ERR("VOLT", "健康状态变更为: SYS_SAFE_SHUTDOWN");
} else if (voltage < 2.5f) {
if (g_health_state < SYS_WARNING) {
g_health_state = SYS_WARNING;
DEBUG_WARN("VOLT", "警告: 电压 %.2fV < 2.5V", voltage);
DEBUG_WARN("VOLT", "健康状态变更为: SYS_WARNING");
}
} else {
DEBUG_INFO("VOLT", "电压正常: %.2fV", voltage);
}
}
// ----------------------------------------
// 温度电压监控任务入口
// ----------------------------------------
void health_monitor_task(void *pvParameters)
{
DEBUG_INFO("MON", "=== 健康监控任务启动 ===");
// 初始化内置温度传感器(测量范围 20°C ~ 80°C)
temperature_sensor_config_t temp_config = TEMPERATURE_SENSOR_CONFIG_DEFAULT(20, 80);
esp_err_t err = temperature_sensor_install(&temp_config, &s_temp_handle);
if (err != ESP_OK) {
DEBUG_ERR("MON", "温度传感器初始化失败: %s", esp_err_to_name(err));
} else {
ESP_ERROR_CHECK(temperature_sensor_enable(s_temp_handle));
DEBUG_INFO("MON", "温度传感器已启用 (范围 20°C ~ 80°C)");
}
// 定时器,每5秒读取一次温度
esp_timer_handle_t temp_timer;
esp_timer_create_args_t timer_args = {};
timer_args.callback = &temp_monitor_callback;
timer_args.arg = nullptr;
timer_args.name = "temp_monitor";
timer_args.dispatch_method = ESP_TIMER_TASK;
timer_args.skip_unhandled_events = false;
esp_timer_create(&timer_args, &temp_timer);
esp_timer_start_periodic(temp_timer, 5 * 1000000); // 5秒
DEBUG_INFO("MON", "温度监控定时器已启动 (间隔 5 秒)");
// 启动时检查一次电压
voltage_monitor_callback(NULL);
// 每5秒打印一次健康状态
uint32_t loop_count = 0;
while (1) {
loop_count++;
uint32_t free_heap = esp_get_free_heap_size();
const char *state_str;
switch (g_health_state) {
case SYS_NORMAL: state_str = "正常"; break;
case SYS_WARNING: state_str = "警告"; break;
case SYS_CRITICAL: state_str = "危险"; break;
case SYS_SAFE_SHUTDOWN: state_str = "安全关机"; break;
default: state_str = "未知"; break;
}
DEBUG_INFO("MON", "--- 健康状态 #%lu ---", (unsigned long)loop_count);
DEBUG_INFO("MON", "健康状态: %s (%d)", state_str, g_health_state);
DEBUG_INFO("MON", "空闲堆: %lu 字节", (unsigned long)free_heap);
// 堆内存警告
if (free_heap < HEAP_WARNING_MIN) {
DEBUG_WARN("MON", "空闲堆内存不足: %lu < %d", (unsigned long)free_heap, HEAP_WARNING_MIN);
}
DEBUG_INFO("MON", "===================");
vTaskDelay(pdMS_TO_TICKS(5000));
}
}
// ----------------------------------------
// 健康事件日志
// ----------------------------------------
void health_log_event(const char *event, int value)
{
DEBUG_INFO("HEALTH", "[健康事件] %s: %d", event, value);
}
// ----------------------------------------
// OTA 传输前的健康检查
// ----------------------------------------
health_status_t health_check_before_ota(void)
{
health_status_t status = HEALTH_MON_OK;
if (g_health_state >= SYS_SAFE_SHUTDOWN) {
DEBUG_WARN("HEALTH", "OTA 前检查: SYS_SAFE_SHUTDOWN,禁止传输");
return HEALTH_MON_SHUTDOWN;
} else if (g_health_state >= SYS_CRITICAL) {
DEBUG_WARN("HEALTH", "OTA 前检查: SYS_CRITICAL,禁止传输");
return HEALTH_MON_CRITICAL;
} else if (g_health_state >= SYS_WARNING) {
status = HEALTH_MON_WARNING;
DEBUG_WARN("HEALTH", "OTA 前检查: SYS_WARNING,继续传输但需注意");
}
wifi_ap_record_t ap_info;
if (esp_wifi_sta_get_ap_info(&ap_info) == ESP_OK) {
if (ap_info.rssi < WIFI_RSSI_CRITICAL) {
g_health_state = SYS_CRITICAL;
DEBUG_WARN("HEALTH", "OTA 前检查: WiFi RSSI %d < %d (危险阈值)",
ap_info.rssi, WIFI_RSSI_CRITICAL);
return HEALTH_MON_CRITICAL;
} else if (ap_info.rssi < WIFI_RSSI_NORMAL) {
if (status < HEALTH_MON_WARNING) {
status = HEALTH_MON_WARNING;
}
DEBUG_WARN("HEALTH", "OTA 前检查: WiFi RSSI %d < %d (警告阈值)",
ap_info.rssi, WIFI_RSSI_NORMAL);
}
}
if (status == HEALTH_MON_OK) {
DEBUG_DBG("HEALTH", "OTA 前检查: 正常");
}
return status;
}
health_status_t health_update_from_wifi_rssi(int8_t rssi)
{
if (rssi < WIFI_RSSI_CRITICAL) {
g_health_state = SYS_CRITICAL;
DEBUG_WARN("HEALTH", "WiFi RSSI 危险: %d dBm < %d dBm", rssi, WIFI_RSSI_CRITICAL);
health_log_event("WIFI危险", rssi);
return HEALTH_MON_CRITICAL;
} else if (rssi < WIFI_RSSI_NORMAL) {
if (g_health_state < SYS_WARNING) {
g_health_state = SYS_WARNING;
DEBUG_WARN("HEALTH", "WiFi RSSI 警告: %d dBm < %d dBm", rssi, WIFI_RSSI_NORMAL);
health_log_event("WIFI警告", rssi);
}
return HEALTH_MON_WARNING;
}
if (g_health_state == SYS_WARNING && rssi >= WIFI_RSSI_NORMAL) {
g_health_state = SYS_NORMAL;
DEBUG_INFO("HEALTH", "WiFi RSSI 恢复: %d dBm >= %d dBm", rssi, WIFI_RSSI_NORMAL);
health_log_event("WIFI恢复", rssi);
}
return HEALTH_MON_OK;
}
@@ -0,0 +1,37 @@
/*
* health_monitor.h - 系统健康监控头文件
*/
#pragma once
#include <stdint.h>
#include "app_config.h"
// T9.3: 健康状态等级
typedef enum {
SYS_NORMAL = 0, // 正常
SYS_WARNING = 1, // 警告
SYS_CRITICAL = 2, // 危险
SYS_SAFE_SHUTDOWN = 3 // 安全关机
} health_state_t;
typedef enum {
HEALTH_MON_OK = 0,
HEALTH_MON_WARNING = 1,
HEALTH_MON_CRITICAL = 2,
HEALTH_MON_SHUTDOWN = 3
} health_status_t;
// 外部共享状态(互斥锁保护)
extern health_state_t g_health_state;
// WiFi RSSI 阈值(来自 app_config.h)
#define WIFI_RSSI_NORMAL WIFI_RSSI_WARNING
#define WIFI_RSSI_WEAK WIFI_RSSI_CRITICAL
// 函数声明
void health_monitor_init(void);
void health_monitor_task(void *pvParameters);
health_status_t health_check_before_ota(void);
void health_log_event(const char *event, int value);
health_state_t health_get_current(void);
health_status_t health_update_from_wifi_rssi(int8_t rssi);
@@ -0,0 +1,972 @@
/*
* wifi_ota.c - WiFi OTA 服务器实现
* T4.1 - T4.5
*/
#include <string.h>
#include "freertos/FreeRTOS.h"
#include "freertos/task.h"
#include "freertos/semphr.h"
#include "esp_wifi.h"
#include "esp_netif.h"
#include "esp_http_server.h"
#include "esp_system.h"
#include "esp_timer.h"
#include "esp_ota_ops.h"
#include "nvs_flash.h"
#include "lwip/netdb.h"
#include "esp_netif.h"
#include "esp_netif_types.h"
#include "esp_event.h"
#include "esp_mac.h"
#include "app_config.h"
#include "wifi_ota.h"
#include "ota_relay.h"
#include "health_monitor.h"
#include "monitor/debug_log.h"
#include "spi_master.h"
#include "cJSON.h"
static ota_wifi_mode_t s_wifi_mode = OTA_WIFI_MODE_AP;
static bool s_relay_triggered = false;
static bool s_sta_connected = false;
static int s_sta_retry_count = 0;
static SemaphoreHandle_t s_sta_connected_sem = NULL;
static SemaphoreHandle_t s_sta_got_ip_sem = NULL;
// ESP32 OTA 状态
static bool s_esp32_ota_active = false;
static esp_ota_handle_t s_esp32_ota_handle = 0;
static const esp_partition_t *s_esp32_update_partition = NULL;
static size_t s_esp32_received = 0;
// GD32 OTA 存储状态
static bool s_gd32_storage_done = false;
static httpd_handle_t s_http_server = NULL;
static void wifi_set_static_ip(void);
// ----------------------------------------
// WiFi/IP 事件回调(事件驱动连接)
// ----------------------------------------
static void wifi_event_handler(void* arg, esp_event_base_t event_base,
int32_t event_id, void* event_data)
{
if (event_base == WIFI_EVENT) {
switch (event_id) {
case WIFI_EVENT_STA_START:
DEBUG_INFO("WIFI", "STA 已启动,开始连接...");
esp_wifi_connect();
break;
case WIFI_EVENT_STA_CONNECTED:
DEBUG_INFO("WIFI", "STA 已关联到 AP");
s_sta_connected = true;
s_sta_retry_count = 0;
if (s_sta_connected_sem) {
xSemaphoreGive(s_sta_connected_sem);
}
break;
case WIFI_EVENT_STA_DISCONNECTED: {
wifi_event_sta_disconnected_t* disconn = (wifi_event_sta_disconnected_t*)event_data;
DEBUG_WARN("WIFI", "STA 断开, reason=%d, retry=%d", disconn->reason, s_sta_retry_count);
s_sta_connected = false;
if (s_sta_retry_count < WIFI_STA_MAX_RETRY) {
s_sta_retry_count++;
DEBUG_INFO("WIFI", "重连第 %d 次...", s_sta_retry_count);
esp_wifi_connect();
} else {
DEBUG_ERR("WIFI", "STA 重连失败 %d 次,放弃", s_sta_retry_count);
}
break;
}
case WIFI_EVENT_AP_STACONNECTED: {
wifi_event_ap_staconnected_t* event = (wifi_event_ap_staconnected_t*)event_data;
DEBUG_INFO("WIFI", "AP: 客户端 " MACSTR " 连入, AID=%d", MAC2STR(event->mac), event->aid);
break;
}
case WIFI_EVENT_AP_STADISCONNECTED: {
wifi_event_ap_stadisconnected_t* event = (wifi_event_ap_stadisconnected_t*)event_data;
DEBUG_INFO("WIFI", "AP: 客户端 " MACSTR " 离开, AID=%d, reason=%d",
MAC2STR(event->mac), event->aid, event->reason);
break;
}
default:
break;
}
} else if (event_base == IP_EVENT) {
switch (event_id) {
case IP_EVENT_STA_GOT_IP: {
ip_event_got_ip_t* event = (ip_event_got_ip_t*)event_data;
DEBUG_INFO("WIFI", "STA 获取 IP: " IPSTR, IP2STR(&event->ip_info.ip));
if (s_sta_got_ip_sem) {
xSemaphoreGive(s_sta_got_ip_sem);
}
break;
}
default:
break;
}
}
}
// ----------------------------------------
// WiFi AP 模式初始化
// ----------------------------------------
static esp_err_t wifi_init_ap(void)
{
DEBUG_INFO("WIFI", "=== 初始化 WiFi AP 模式 ===");
s_wifi_mode = OTA_WIFI_MODE_AP;
// 创建 AP netif 并设置自定义 IP
esp_netif_t *ap_netif = esp_netif_get_handle_from_ifkey("WIFI_AP_DEF");
if (ap_netif == NULL) {
ap_netif = esp_netif_create_default_wifi_ap();
}
if (ap_netif != NULL) {
// 停止 DHCP 服务器(必须先停才能改 IP)
esp_netif_dhcps_stop(ap_netif);
esp_netif_ip_info_t ip_info;
memset(&ip_info, 0, sizeof(ip_info));
// 方案 A:SoftAP 使用 192.168.31.0/24 网段
// ESP32 自己作为 AP 网关,电脑连入后拿到 192.168.31.x
inet_pton(AF_INET, "192.168.31.1", &ip_info.ip);
inet_pton(AF_INET, "192.168.31.1", &ip_info.gw);
inet_pton(AF_INET, "255.255.255.0", &ip_info.netmask);
ESP_ERROR_CHECK(esp_netif_set_ip_info(ap_netif, &ip_info));
// 重新启动 DHCP 服务器,给连接的客户端分配 IP
esp_netif_dhcps_start(ap_netif);
DEBUG_INFO("WIFI", "AP IP 已设置: 192.168.31.1");
}
wifi_config_t wifi_config = {};
memcpy(wifi_config.ap.ssid, WIFI_AP_SSID, strlen(WIFI_AP_SSID) + 1);
wifi_config.ap.ssid_len = strlen(WIFI_AP_SSID);
memcpy(wifi_config.ap.password, WIFI_AP_PASSWORD, strlen(WIFI_AP_PASSWORD) + 1);
wifi_config.ap.max_connection = WIFI_AP_MAX_CONNECT;
wifi_config.ap.authmode = WIFI_AUTH_WPA_WPA2_PSK;
wifi_config.ap.channel = WIFI_AP_CHANNEL;
DEBUG_INFO("WIFI", "AP SSID: %s, Channel: %d", WIFI_AP_SSID, WIFI_AP_CHANNEL);
ESP_ERROR_CHECK(esp_wifi_set_mode(WIFI_MODE_AP));
ESP_ERROR_CHECK(esp_wifi_set_config(WIFI_IF_AP, &wifi_config));
ESP_ERROR_CHECK(esp_wifi_start());
DEBUG_INFO("WIFI", "WiFi AP 已启动: %s", WIFI_AP_SSID);
DEBUG_INFO("WIFI", "AP IP 地址: 192.168.31.1");
DEBUG_INFO("WIFI", "AP 网关地址: 192.168.31.1");
DEBUG_INFO("WIFI", "最大连接数: %d", WIFI_AP_MAX_CONNECT);
return ESP_OK;
}
// ----------------------------------------
// WiFi STA 模式初始化
// ----------------------------------------
static esp_err_t wifi_init_sta(void)
{
DEBUG_INFO("WIFI", "=== 初始化 WiFi STA 模式 ===");
s_wifi_mode = OTA_WIFI_MODE_STA;
s_sta_retry_count = 0;
// ★ 设置国家代码:中国,允许信道 1-13
// ESP32 默认国家是 US(只允许 1-11),中国路由可能用 12/13
wifi_country_t country = {};
memcpy(country.cc, "CN", 3);
country.schan = 1;
country.nchan = 13;
country.policy = WIFI_COUNTRY_POLICY_MANUAL;
wifi_config_t wifi_config = {};
wifi_config.sta.scan_method = WIFI_ALL_CHANNEL_SCAN;
wifi_config.sta.sort_method = WIFI_CONNECT_AP_BY_SIGNAL;
wifi_config.sta.threshold.authmode = WIFI_AUTH_WPA2_PSK;
wifi_config.sta.pmf_cfg.capable = true;
wifi_config.sta.pmf_cfg.required = false;
wifi_config.sta.listen_interval = 3;
memcpy(wifi_config.sta.ssid, WIFI_STA_SSID, strlen(WIFI_STA_SSID) + 1);
memcpy(wifi_config.sta.password, WIFI_STA_PASSWORD, strlen(WIFI_STA_PASSWORD) + 1);
DEBUG_INFO("WIFI", "STA SSID: %s (全信道扫描, CN 国家代码)", WIFI_STA_SSID);
ESP_ERROR_CHECK(esp_wifi_set_country(&country));
ESP_ERROR_CHECK(esp_wifi_set_mode(WIFI_MODE_STA));
ESP_ERROR_CHECK(esp_wifi_set_config(WIFI_IF_STA, &wifi_config));
// 静态 IP 必须在 start/connect 之前配置,避免与 STA_START 自动连接发生竞态
wifi_set_static_ip();
ESP_ERROR_CHECK(esp_wifi_start());
ESP_ERROR_CHECK(esp_wifi_set_ps(WIFI_PS_NONE));
DEBUG_INFO("WIFI", "WiFi STA 启动, SSID: %s, 事件驱动连接...", WIFI_STA_SSID);
return ESP_OK;
}
// 等待 STA 连接成功并获取 IP(静态 IP 等 CONNECTED,DHCP 等 GOT_IP)
static esp_err_t wifi_wait_sta_connected(char *ip_str, size_t len)
{
SemaphoreHandle_t wait_sem = WIFI_STA_USE_STATIC_IP ? s_sta_connected_sem : s_sta_got_ip_sem;
const char *wait_name = WIFI_STA_USE_STATIC_IP ? "STA_CONNECTED" : "STA_GOT_IP";
if (wait_sem == NULL) {
DEBUG_ERR("WIFI", "%s 信号量未创建", wait_name);
return ESP_ERR_INVALID_STATE;
}
TickType_t timeout_ticks = pdMS_TO_TICKS(WIFI_STA_CONNECT_TIMEOUT_MS);
if (xSemaphoreTake(wait_sem, timeout_ticks) == pdTRUE) {
esp_netif_ip_info_t ip_info;
esp_netif_t *netif = esp_netif_get_handle_from_ifkey("WIFI_STA_DEF");
if (netif != NULL && esp_netif_get_ip_info(netif, &ip_info) == ESP_OK) {
if (ip_str) {
snprintf(ip_str, len, IPSTR, IP2STR(&ip_info.ip));
}
DEBUG_INFO("WIFI", "%s 等待成功, 当前 IP: " IPSTR, wait_name, IP2STR(&ip_info.ip));
}
s_sta_connected = true;
return ESP_OK;
}
DEBUG_ERR("WIFI", "等待 %s 超时 (%d ms), 重连次数: %d", wait_name, WIFI_STA_CONNECT_TIMEOUT_MS, s_sta_retry_count);
return ESP_ERR_TIMEOUT;
}
// 设置 STA 静态 IP
static void wifi_set_static_ip(void)
{
if (WIFI_STA_USE_STATIC_IP) {
esp_netif_t *sta_netif = esp_netif_get_handle_from_ifkey("WIFI_STA_DEF");
if (sta_netif) {
DEBUG_INFO("WIFI", "STA 使用静态 IP 模式,停止 DHCP Client");
ESP_ERROR_CHECK(esp_netif_dhcpc_stop(sta_netif));
esp_netif_ip_info_t info;
memset(&info, 0, sizeof(info));
inet_pton(AF_INET, WIFI_STA_STATIC_IP, &info.ip);
inet_pton(AF_INET, WIFI_STA_STATIC_GW, &info.gw);
inet_pton(AF_INET, WIFI_STA_STATIC_NETMASK, &info.netmask);
ESP_ERROR_CHECK(esp_netif_set_ip_info(sta_netif, &info));
DEBUG_INFO("WIFI", "静态 IP 已设置: %s", WIFI_STA_STATIC_IP);
// 设置 DNS
esp_netif_dns_info_t dns_info;
memset(&dns_info, 0, sizeof(dns_info));
inet_pton(AF_INET, WIFI_STA_STATIC_DNS, &dns_info.ip.u_addr.ip4.addr);
ESP_ERROR_CHECK(esp_netif_set_dns_info(sta_netif, ESP_NETIF_DNS_MAIN, &dns_info));
DEBUG_INFO("WIFI", "DNS 已设置: %s", WIFI_STA_STATIC_DNS);
}
} else {
DEBUG_INFO("WIFI", "使用 DHCP 获取 IP");
}
}
// ----------------------------------------
// ESP32 OTA 内部函数
// ----------------------------------------
static esp_err_t esp32_ota_begin(size_t total_size)
{
const esp_partition_t *boot_partition = esp_ota_get_running_partition();
DEBUG_INFO("OTA", "当前启动分区: %s, size=%u, address=0x%lx",
boot_partition->label, boot_partition->size, boot_partition->address);
s_esp32_update_partition = esp_partition_find_first(
ESP_PARTITION_TYPE_APP, ESP_PARTITION_SUBTYPE_APP_OTA_0, NULL);
if (s_esp32_update_partition == NULL ||
s_esp32_update_partition->address == boot_partition->address) {
s_esp32_update_partition = esp_partition_find_first(
ESP_PARTITION_TYPE_APP, ESP_PARTITION_SUBTYPE_APP_OTA_1, NULL);
}
if (s_esp32_update_partition == NULL) {
DEBUG_ERR("OTA", "找不到可用的 OTA 分区");
return ESP_FAIL;
}
DEBUG_INFO("OTA", "OTA 目标分区: %s, size=%u, address=0x%lx",
s_esp32_update_partition->label,
s_esp32_update_partition->size,
s_esp32_update_partition->address);
if (total_size > s_esp32_update_partition->size) {
DEBUG_ERR("OTA", "固件太大: %u > 分区大小: %u",
total_size, s_esp32_update_partition->size);
return ESP_FAIL;
}
esp_err_t err = esp_ota_begin(s_esp32_update_partition, total_size, &s_esp32_ota_handle);
if (err != ESP_OK) {
DEBUG_ERR("OTA", "esp_ota_begin 失败: %s", esp_err_to_name(err));
s_esp32_update_partition = NULL;
return err;
}
s_esp32_received = 0;
s_esp32_ota_active = true;
DEBUG_INFO("OTA", "ESP32 OTA 开始,目标分区: %s", s_esp32_update_partition->label);
return ESP_OK;
}
static esp_err_t esp32_ota_write(const uint8_t *data, size_t len)
{
if (!s_esp32_ota_active || s_esp32_update_partition == NULL) {
return ESP_ERR_INVALID_STATE;
}
esp_err_t err = esp_ota_write(s_esp32_ota_handle, data, len);
if (err == ESP_OK) {
s_esp32_received += len;
}
return err;
}
// HTTP POST /ota/update - 完成 ESP32 OTA 并重启
static esp_err_t esp32_ota_update_handler(httpd_req_t *req)
{
(void)req;
if (!s_esp32_ota_active) {
httpd_resp_send_err(req, HTTPD_400_BAD_REQUEST, "OTA not started");
return ESP_FAIL;
}
DEBUG_INFO("OTA", "ESP32 OTA 完成, 共 %u 字节, 设置启动分区...", s_esp32_received);
esp_err_t err = esp_ota_end(s_esp32_ota_handle);
if (err != ESP_OK) {
DEBUG_ERR("OTA", "esp_ota_end 失败: %s", esp_err_to_name(err));
s_esp32_ota_active = false;
httpd_resp_send_err(req, HTTPD_500_INTERNAL_SERVER_ERROR, esp_err_to_name(err));
return ESP_FAIL;
}
err = esp_ota_set_boot_partition(s_esp32_update_partition);
if (err != ESP_OK) {
DEBUG_ERR("OTA", "esp_ota_set_boot_partition 失败: %s", esp_err_to_name(err));
s_esp32_ota_active = false;
httpd_resp_send_err(req, HTTPD_500_INTERNAL_SERVER_ERROR, esp_err_to_name(err));
return ESP_FAIL;
}
s_esp32_ota_active = false;
DEBUG_INFO("OTA", "ESP32 OTA 完成,重启中...");
const char resp[] = "{\"status\": \"ok\", \"message\": \"OTA finished, rebooting\"}";
httpd_resp_set_type(req, "application/json");
httpd_resp_send(req, resp, strlen(resp));
vTaskDelay(pdMS_TO_TICKS(500));
esp_restart();
return ESP_OK;
}
// HTTP GET /ota/update - 查询 ESP32 OTA 状态
static esp_err_t esp32_ota_status_handler(httpd_req_t *req)
{
char resp[128];
if (s_esp32_ota_active) {
snprintf(resp, sizeof(resp),
"{\"active\": true, \"received\": %u, \"partition\": \"%s\"}",
(unsigned int)s_esp32_received,
s_esp32_update_partition ? s_esp32_update_partition->label : "null");
} else {
snprintf(resp, sizeof(resp), "{\"active\": false}");
}
httpd_resp_set_type(req, "application/json");
httpd_resp_send(req, resp, strlen(resp));
return ESP_OK;
}
// ----------------------------------------
// 统一 /ota/upload 处理器(自动识别目标)
// ----------------------------------------
static esp_err_t ota_upload_handler(httpd_req_t *req)
{
DEBUG_INFO("WIFI", "=== HTTP: POST /ota/upload ===");
DEBUG_INFO("WIFI", "收到固件上传请求, 内容长度: %d 字节", req->content_len);
// 先读取一小段判断是 JSON 还是 binary
char header[256];
int header_read = httpd_req_recv(req, header, sizeof(header) - 1);
if (header_read <= 0) {
httpd_resp_send_err(req, HTTPD_500_INTERNAL_SERVER_ERROR, "接收失败");
return ESP_FAIL;
}
header[header_read] = '\0';
bool is_esp32_ota = false;
uint32_t total_size = 0;
// 尝试解析 JSON
if (header_read > 0 && header[0] == '{') {
cJSON *root = cJSON_Parse(header);
if (root) {
cJSON *type_item = cJSON_GetObjectItem(root, "type");
if (type_item && cJSON_IsString(type_item) &&
strcmp(cJSON_GetStringValue(type_item), "esp32") == 0) {
is_esp32_ota = true;
DEBUG_INFO("WIFI", "检测到 ESP32 OTA");
}
cJSON *size_item = cJSON_GetObjectItem(root, "total_size");
if (size_item) {
total_size = (uint32_t)cJSON_GetNumberValue(size_item);
}
cJSON_Delete(root);
}
}
if (is_esp32_ota) {
// --- ESP32 OTA ---
if (total_size == 0) {
httpd_resp_send_err(req, HTTPD_400_BAD_REQUEST, "missing total_size");
return ESP_FAIL;
}
esp_err_t err = esp32_ota_begin(total_size);
if (err != ESP_OK) {
httpd_resp_send_err(req, HTTPD_500_INTERNAL_SERVER_ERROR, "OTA begin failed");
return ESP_FAIL;
}
// 先处理已读的 header
err = esp32_ota_write((const uint8_t *)header, header_read);
if (err != ESP_OK) {
DEBUG_ERR("WIFI", "OTA write 失败");
return ESP_FAIL;
}
// 继续读剩余数据
size_t total_received = header_read;
while (total_received < req->content_len) {
size_t to_read = req->content_len - total_received;
if (to_read > 4096) to_read = 4096;
int ret = httpd_req_recv(req, header, to_read);
if (ret <= 0) break;
esp_err_t err = esp32_ota_write((const uint8_t *)header, ret);
if (err != ESP_OK) break;
total_received += ret;
if (total_size > 0 && s_esp32_received % (total_size / 10 + 1) == 0) {
DEBUG_INFO("WIFI", "ESP32 OTA 进度: %u / %u (%u%%)",
(unsigned int)s_esp32_received, total_size,
(unsigned int)((s_esp32_received * 100) / total_size));
}
}
DEBUG_INFO("WIFI", "ESP32 OTA 存储完成: %u 字节", (unsigned int)s_esp32_received);
const char resp[] = "{\"status\": \"ok\", \"message\": \"stored, call POST /ota/update to finish\", \"target\": \"esp32\"}";
httpd_resp_set_type(req, "application/json");
httpd_resp_send(req, resp, strlen(resp));
return ESP_OK;
}
// --- GD32 OTA(原逻辑)---
if (req->content_len > OTA_MAX_FIRMWARE_SIZE) {
DEBUG_ERR("WIFI", "固件太大: %d > %d", req->content_len, OTA_MAX_FIRMWARE_SIZE);
httpd_resp_send_err(req, HTTPD_500_INTERNAL_SERVER_ERROR, "固件太大");
return ESP_FAIL;
}
uint8_t *buf = (uint8_t *)malloc(OTA_BLOCK_SIZE);
int received = 0;
// 先处理已读的 header
if (header_read >= 6) {
uint16_t seq = header[0] | (header[1] << 8);
uint32_t fw_size = header[2] | (header[3] << 8) | (header[4] << 16) | (header[5] << 24);
if (seq == 0) {
ota_meta_t meta = { .total_size = fw_size, .stored_size = 0, .last_seq = 0,
.chunk_count = (fw_size + 4095) / 4096, .checksum = {0} };
meta_save(&meta);
DEBUG_INFO("WIFI", "开始 GD32 固件存储: %u 字节", fw_size);
}
}
// 继续读剩余数据
while (received + header_read < req->content_len) {
size_t to_read = req->content_len - received - header_read;
if (to_read > OTA_BLOCK_SIZE) to_read = OTA_BLOCK_SIZE;
int ret = httpd_req_recv(req, (char *)buf, to_read);
if (ret <= 0) break;
uint32_t flash_offset = ((received + header_read) / OTA_BLOCK_SIZE) * OTA_BLOCK_SIZE;
esp_err_t err = flash_write_chunk(flash_offset, buf, ret);
if (err == ESP_OK) {
meta_update(flash_offset + ret, (received + header_read) / OTA_BLOCK_SIZE);
}
received += ret;
uint32_t progress = ((received + header_read) * 100) / req->content_len;
if (progress % 10 == 0) {
DEBUG_INFO("WIFI", "GD32 上传进度: %d / %d (%u%%)",
received + header_read, req->content_len, progress);
}
}
free(buf);
DEBUG_INFO("WIFI", "GD32 固件存储完成: %d 字节", received + header_read);
ota_meta_t meta;
meta_load(&meta);
if (meta.stored_size >= meta.total_size) {
s_gd32_storage_done = true;
DEBUG_INFO("WIFI", "GD32 固件已存储,触发转发...");
wifi_ota_signal_relay();
}
const char resp[] = "{\"status\": \"ok\", \"progress\": 100, \"target\": \"gd32\"}";
httpd_resp_set_type(req, "application/json");
httpd_resp_send(req, resp, strlen(resp));
return ESP_OK;
}
// HTTP POST /ota/relay 处理器
static esp_err_t ota_relay_trigger_handler(httpd_req_t *req)
{
(void)req;
DEBUG_INFO("WIFI", "=== HTTP: POST /ota/relay ===");
DEBUG_INFO("WIFI", "收到触发 OTA 中继请求");
wifi_ota_signal_relay();
const char resp[] = "{\"status\": \"ok\", \"message\": \"OTA relay triggered\"}";
httpd_resp_set_type(req, "application/json");
httpd_resp_send(req, resp, strlen(resp));
return ESP_OK;
}
// HTTP GET /ota/status 处理器
static esp_err_t ota_status_handler(httpd_req_t *req)
{
DEBUG_INFO("WIFI", "=== HTTP: GET /ota/status ===");
ota_meta_t meta;
meta_load(&meta);
char resp[256];
int len = snprintf(resp, sizeof(resp),
"{\"total_size\": %lu, \"stored_size\": %lu, \"last_seq\": %lu, "
"\"storage_done\": %s, \"relay_state\": %d}",
(unsigned long)meta.total_size, (unsigned long)meta.stored_size,
(unsigned long)meta.last_seq,
s_gd32_storage_done ? "true" : "false",
g_relay_state);
DEBUG_INFO("WIFI", "OTA 状态: total=%lu, stored=%lu, done=%s",
(unsigned long)meta.total_size, (unsigned long)meta.stored_size,
s_gd32_storage_done ? "true" : "false");
httpd_resp_set_type(req, "application/json");
httpd_resp_send(req, resp, len);
return ESP_OK;
}
// HTTP GET /health 处理器
static esp_err_t health_handler(httpd_req_t *req)
{
DEBUG_INFO("WIFI", "=== HTTP: GET /health ===");
health_state_t state = health_get_current();
uint32_t free_heap = esp_get_free_heap_size();
char resp[128];
int len = snprintf(resp, sizeof(resp),
"{\"health\": %d, \"free_heap\": %lu}",
state, (unsigned long)free_heap);
DEBUG_INFO("WIFI", "健康状态: %d, 空闲堆: %lu", state, (unsigned long)free_heap);
httpd_resp_set_type(req, "application/json");
httpd_resp_send(req, resp, len);
return ESP_OK;
}
// HTTP POST /can/send 处理器
// 上位机发送 CAN 帧,经 ESP32 转发到 GD32
// 请求体: {"ch": 0, "id": 123456, "data": "0102030405060708", "dlc": 8}
// data 为 hex 字符串(可不填,自动用 dlc 决定实际发送长度)
static esp_err_t can_send_handler(httpd_req_t *req)
{
char buf[256] = {0};
int ret = httpd_req_recv(req, buf, sizeof(buf) - 1);
if (ret <= 0) {
httpd_resp_send_err(req, HTTPD_400_BAD_REQUEST, "接收数据失败");
return ESP_FAIL;
}
buf[ret] = '\0';
cJSON *root = cJSON_Parse(buf);
if (!root) {
httpd_resp_send_err(req, HTTPD_400_BAD_REQUEST, "JSON 解析失败");
return ESP_FAIL;
}
cJSON *ch_item = cJSON_GetObjectItem(root, "ch");
cJSON *id_item = cJSON_GetObjectItem(root, "id");
cJSON *data_item = cJSON_GetObjectItem(root, "data");
cJSON *dlc_item = cJSON_GetObjectItem(root, "dlc");
if (!ch_item || !id_item) {
cJSON_Delete(root);
httpd_resp_send_err(req, HTTPD_400_BAD_REQUEST, "缺少 ch 或 id 字段");
return ESP_FAIL;
}
can_channel_t ch = (can_channel_t)cJSON_GetNumberValue(ch_item);
uint32_t ext_id = (uint32_t)cJSON_GetNumberValue(id_item);
uint8_t dlc = dlc_item ? (uint8_t)cJSON_GetNumberValue(dlc_item) : 0;
uint8_t tx_data[CAN_MAX_DLC] = {0};
uint8_t tx_dlc = 0;
if (data_item && cJSON_IsString(data_item)) {
const char *hex_str = cJSON_GetStringValue(data_item);
size_t hex_len = strlen(hex_str);
for (size_t i = 0; i < hex_len / 2 && i < CAN_MAX_DLC; i++) {
char byte_str[3] = { hex_str[i * 2], hex_str[i * 2 + 1], '\0' };
tx_data[i] = (uint8_t)strtol(byte_str, NULL, 16);
tx_dlc++;
}
}
if (tx_dlc == 0 && dlc > 0) {
tx_dlc = (dlc > CAN_MAX_DLC) ? CAN_MAX_DLC : dlc;
}
cJSON_Delete(root);
// 发送 CAN 帧到 GD32
uint8_t resp_data[CAN_MAX_DLC] = {0};
int resp_len = gd32_can_send(ch, ext_id, tx_data, tx_dlc,
resp_data, sizeof(resp_data), 1000);
char resp[512];
if (resp_len >= 0) {
// 将响应 data 转为 hex 字符串
char resp_hex[CAN_MAX_DLC * 2 + 1] = {0};
for (int i = 0; i < resp_len; i++) {
sprintf(&resp_hex[i * 2], "%02X", resp_data[i]);
}
snprintf(resp, sizeof(resp),
"{\"status\": \"ok\", \"ch\": %d, \"id\": %lu, \"sent\": %d, \"recv_len\": %d, \"recv\": \"%s\"}",
ch, (unsigned long)ext_id, tx_dlc, resp_len, resp_hex);
} else {
snprintf(resp, sizeof(resp),
"{\"status\": \"error\", \"code\": %d}", resp_len);
}
httpd_resp_set_type(req, "application/json");
httpd_resp_send(req, resp, strlen(resp));
return ESP_OK;
}
// HTTP GET /can/status 处理器
static esp_err_t can_status_handler(httpd_req_t *req)
{
char resp[128];
snprintf(resp, sizeof(resp),
"{\"channels\": 3, \"max_dlc\": %d, \"spi_init\": %s}",
CAN_MAX_DLC,
g_spi_device != NULL ? "true" : "false");
httpd_resp_set_type(req, "application/json");
httpd_resp_send(req, resp, strlen(resp));
return ESP_OK;
}
// HTTP GET /wifi/status 处理器
static esp_err_t wifi_status_handler(httpd_req_t *req)
{
char resp[256];
char ip_str[16] = "0.0.0.0";
int8_t rssi = -127;
if (s_wifi_mode == OTA_WIFI_MODE_STA && s_sta_connected) {
esp_netif_ip_info_t ip_info;
esp_netif_t *sta_netif = esp_netif_get_handle_from_ifkey("WIFI_STA_DEF");
if (sta_netif && esp_netif_get_ip_info(sta_netif, &ip_info) == ESP_OK) {
snprintf(ip_str, sizeof(ip_str), IPSTR, IP2STR(&ip_info.ip));
}
wifi_ap_record_t ap_info;
if (esp_wifi_sta_get_ap_info(&ap_info) == ESP_OK) {
rssi = ap_info.rssi;
}
}
int len = snprintf(resp, sizeof(resp),
"{\"mode\": \"%s\", \"ip\": \"%s\", \"rssi\": %d, \"connected\": %s}",
s_wifi_mode == OTA_WIFI_MODE_AP ? "AP" : "STA",
ip_str, rssi,
(s_wifi_mode == OTA_WIFI_MODE_STA) ? (s_sta_connected ? "true" : "false") : "N/A");
httpd_resp_set_type(req, "application/json");
httpd_resp_send(req, resp, len);
return ESP_OK;
}
// ----------------------------------------
// 启动 HTTP OTA 服务器
// ----------------------------------------
static esp_err_t http_ota_server_start(void)
{
httpd_config_t config = HTTPD_DEFAULT_CONFIG();
config.server_port = HTTP_SERVER_PORT;
config.stack_size = HTTP_SERVER_STACK_SIZE;
config.max_uri_handlers = 16; // 默认只有8个槽位,我们有9个端点,不够
DEBUG_INFO("WIFI", "=== 启动 HTTP OTA 服务器 ===");
DEBUG_INFO("WIFI", "HTTP 服务器端口: %d", config.server_port);
esp_err_t err = httpd_start(&s_http_server, &config);
if (err != ESP_OK) {
DEBUG_ERR("WIFI", "HTTP 服务器启动失败: %s", esp_err_to_name(err));
return err;
}
httpd_uri_t upload_uri = {
.uri = "/ota/upload",
.method = HTTP_POST,
.handler = ota_upload_handler,
.user_ctx = NULL
};
httpd_register_uri_handler(s_http_server, &upload_uri);
httpd_uri_t relay_uri = {
.uri = "/ota/relay",
.method = HTTP_POST,
.handler = ota_relay_trigger_handler,
.user_ctx = NULL
};
httpd_register_uri_handler(s_http_server, &relay_uri);
httpd_uri_t status_uri = {
.uri = "/ota/status",
.method = HTTP_GET,
.handler = ota_status_handler,
.user_ctx = NULL
};
httpd_register_uri_handler(s_http_server, &status_uri);
httpd_uri_t esp32_update_uri = {
.uri = "/ota/update",
.method = HTTP_POST,
.handler = esp32_ota_update_handler,
.user_ctx = NULL
};
httpd_register_uri_handler(s_http_server, &esp32_update_uri);
httpd_uri_t esp32_status_uri = {
.uri = "/ota/esp32_status",
.method = HTTP_GET,
.handler = esp32_ota_status_handler,
.user_ctx = NULL
};
httpd_register_uri_handler(s_http_server, &esp32_status_uri);
httpd_uri_t health_uri = {
.uri = "/health",
.method = HTTP_GET,
.handler = health_handler,
.user_ctx = NULL
};
httpd_register_uri_handler(s_http_server, &health_uri);
httpd_uri_t wifi_status_uri = {
.uri = "/wifi/status",
.method = HTTP_GET,
.handler = wifi_status_handler,
.user_ctx = NULL
};
httpd_register_uri_handler(s_http_server, &wifi_status_uri);
// CAN 帧转发
httpd_uri_t can_send_uri = {
.uri = "/can/send",
.method = HTTP_POST,
.handler = can_send_handler,
.user_ctx = NULL
};
httpd_register_uri_handler(s_http_server, &can_send_uri);
httpd_uri_t can_status_uri = {
.uri = "/can/status",
.method = HTTP_GET,
.handler = can_status_handler,
.user_ctx = NULL
};
httpd_register_uri_handler(s_http_server, &can_status_uri);
DEBUG_INFO("WIFI", "HTTP 服务器已启动");
DEBUG_INFO("WIFI", "注册端点: /ota/upload, /ota/relay, /ota/update, /ota/status, /ota/esp32_status, /health, /wifi/status, /can/send, /can/status");
return ESP_OK;
}
// ----------------------------------------
// 公开 API
// ----------------------------------------
void wifi_ota_signal_relay(void)
{
if (s_relay_triggered) {
DEBUG_WARN("WIFI", "转发已经触发过");
return;
}
s_relay_triggered = true;
if (g_ota_dispatch_queue) {
uint32_t msg = 1;
xQueueSend(g_ota_dispatch_queue, &msg, pdMS_TO_TICKS(100));
DEBUG_INFO("WIFI", "OTA 调度信号已发送到中继任务");
}
}
ota_wifi_mode_t wifi_get_mode(void)
{
return s_wifi_mode;
}
int8_t wifi_get_rssi(void)
{
if (s_wifi_mode != OTA_WIFI_MODE_STA || !s_sta_connected) {
return -127;
}
wifi_ap_record_t ap_info;
if (esp_wifi_sta_get_ap_info(&ap_info) == ESP_OK) {
return ap_info.rssi;
}
return -127;
}
bool wifi_is_connected(void)
{
return s_sta_connected;
}
// ----------------------------------------
// WiFi OTA FreeRTOS 任务
// ----------------------------------------
void wifi_ota_task(void *pvParameters)
{
(void)pvParameters;
DEBUG_INFO("WIFI", "=== WiFi OTA 任务启动 ===");
// 初始化 NVS
esp_err_t err = nvs_flash_init();
if (err != ESP_OK) {
DEBUG_ERR("WIFI", "NVS 初始化失败: %s", esp_err_to_name(err));
} else {
DEBUG_INFO("WIFI", "NVS 初始化成功");
}
// 初始化网络接口(可安全重复调用)
esp_netif_init();
DEBUG_INFO("WIFI", "网络接口初始化成功");
// 创建默认事件循环(必须在 esp_wifi_init 之前)
esp_err_t ev_ret = esp_event_loop_create_default();
if (ev_ret == ESP_OK) {
DEBUG_INFO("WIFI", "默认事件循环已创建");
} else if (ev_ret == ESP_ERR_INVALID_STATE) {
DEBUG_INFO("WIFI", "默认事件循环已存在,跳过");
} else {
DEBUG_ERR("WIFI", "创建事件循环失败: %s", esp_err_to_name(ev_ret));
}
// ★ 创建信号量(用于事件驱动等待)
s_sta_connected_sem = xSemaphoreCreateBinary();
s_sta_got_ip_sem = xSemaphoreCreateBinary();
if (!s_sta_connected_sem || !s_sta_got_ip_sem) {
DEBUG_ERR("WIFI", "创建信号量失败");
}
// ★ 注册 WiFi/IP 事件回调(必须在 esp_wifi_init 之前)
ESP_ERROR_CHECK(esp_event_handler_register(WIFI_EVENT, ESP_EVENT_ANY_ID, &wifi_event_handler, NULL));
ESP_ERROR_CHECK(esp_event_handler_register(IP_EVENT, ESP_EVENT_ANY_ID, &wifi_event_handler, NULL));
DEBUG_INFO("WIFI", "WiFi/IP 事件回调已注册");
// ★ 创建 STA netif(必须在 esp_wifi_init 之前!)
// esp_netif_create_default_wifi_sta() 注册默认 STA 事件处理
// 如果在 esp_wifi_init() 之后调用,部分内部 handler 不会正确绑定
esp_netif_t *sta_netif = esp_netif_get_handle_from_ifkey("WIFI_STA_DEF");
if (sta_netif == NULL) {
sta_netif = esp_netif_create_default_wifi_sta();
if (sta_netif == NULL) {
DEBUG_ERR("WIFI", "创建 STA netif 失败");
} else {
DEBUG_INFO("WIFI", "STA netif 已创建");
}
} else {
DEBUG_INFO("WIFI", "STA netif 已存在,跳过创建");
}
// 初始化 WiFi driver(必须在 netif 创建之后)
wifi_init_config_t wifi_cfg = WIFI_INIT_CONFIG_DEFAULT();
ESP_ERROR_CHECK(esp_wifi_init(&wifi_cfg));
DEBUG_INFO("WIFI", "WiFi driver 初始化成功");
// STA 优先,失败自动切 AP(事件驱动连接 + 自动重连)
// 注意:CONFIG_WIFI_MODE 是 constexpr,不能用 #if(预处理器不认识 C++ 变量)
if (CONFIG_WIFI_MODE == 1) {
DEBUG_INFO("WIFI", "工作模式: STA (优先)");
err = wifi_init_sta();
if (err == ESP_OK) {
char ip_str[16] = {0};
// 等待连接成功
err = wifi_wait_sta_connected(ip_str, sizeof(ip_str));
if (err == ESP_OK) {
DEBUG_INFO("WIFI", "STA 已连接, IP: %s", ip_str);
} else {
DEBUG_WARN("WIFI", "STA 连接超时,切换到 AP 模式...");
esp_wifi_stop();
s_wifi_mode = OTA_WIFI_MODE_AP;
wifi_init_ap();
}
}
} else {
DEBUG_INFO("WIFI", "工作模式: AP");
wifi_init_ap();
}
// 启动 HTTP 服务器
err = http_ota_server_start();
if (err != ESP_OK) {
DEBUG_ERR("WIFI", "HTTP 服务器启动失败");
}
DEBUG_INFO("WIFI", "=== WiFi OTA 服务器就绪 ===");
// 定期检查 WiFi 状态
while (1) {
int8_t rssi = wifi_get_rssi();
if (rssi != -127) {
health_update_from_wifi_rssi(rssi);
if (rssi < WIFI_RSSI_CRITICAL) {
DEBUG_WARN("WIFI", "WiFi RSSI 危险: %d dBm (阈值: %d)", rssi, WIFI_RSSI_CRITICAL);
} else if (rssi < WIFI_RSSI_WARNING) {
DEBUG_WARN("WIFI", "WiFi RSSI 较弱: %d dBm", rssi);
}
}
vTaskDelay(pdMS_TO_TICKS(10000));
}
}
+7 -6
View File
@@ -1,7 +1,8 @@
# ESP32-C3FH4 OTA Relay - Partition Table
# ESP32-C3FH4 OTA Relay - Partition Table (4MB Flash)
# Name, Type, SubType, Offset, Size, Label
otadata, data, ota, 0x9000, 0x2000,
gd32_ota, data, spiffs, 0xB000, 0x40000,
app0, app, ota_0, 0x4B000, 0x330000,
app1, app, ota_1, 0x3DC000, 0x330000,
spiffs, data, spiffs, 0x670000, 0x190000,
nvs, data, nvs, 0x9000, 0x6000,
otadata, data, ota, 0xF000, 0x2000,
gd32_ota, data, spiffs, 0x11000, 0x3F000,
app0, app, ota_0, 0x50000, 0x140000,
app1, app, ota_1, 0x190000, 0x140000,
spiffs, data, spiffs, 0x2D0000, 0x110000,
1 # ESP32-C3FH4 OTA Relay - Partition Table # ESP32-C3FH4 OTA Relay - Partition Table (4MB Flash)
2 # Name, Type, SubType, Offset, Size, Label # Name, Type, SubType, Offset, Size, Label
3 otadata, data, ota, 0x9000, 0x2000, nvs, data, nvs, 0x9000, 0x6000,
4 gd32_ota, data, spiffs, 0xB000, 0x40000, otadata, data, ota, 0xF000, 0x2000,
5 app0, app, ota_0, 0x4B000, 0x330000, gd32_ota, data, spiffs, 0x11000, 0x3F000,
6 app1, app, ota_1, 0x3DC000, 0x330000, app0, app, ota_0, 0x50000, 0x140000,
7 spiffs, data, spiffs, 0x670000, 0x190000, app1, app, ota_1, 0x190000, 0x140000,
8 spiffs, data, spiffs, 0x2D0000, 0x110000,
+2
View File
@@ -17,6 +17,7 @@ idf_component_register(
"monitor"
"include"
REQUIRES
app_update
cjson
esp_driver_spi
esp_driver_tsens
@@ -31,4 +32,5 @@ idf_component_register(
nvs_flash
freertos
mbedtls
bt
)
-20
View File
@@ -1,20 +0,0 @@
menu "ESP32_COMM Configuration"
config ESP_WIFI_SSID
string "WiFi SSID"
default "ESP32_OTA_RELAY"
config ESP_WIFI_PASSWORD
string "WiFi Password"
default "12345678"
config ESP_WIFI_CHANNEL
int "WiFi Channel"
range 1 13
default 6
config ESP_MAX_STA_CONN
int "Max STA connections"
default 4
endmenu
+17 -10
View File
@@ -9,8 +9,8 @@
#include "freertos/task.h"
#include "esp_chip_info.h"
#include "esp_system.h"
#include "esp_clk_tree.h"
#include "esp_netif.h"
#include "esp_event.h"
#include "monitor/health_monitor.h"
#include "relay/ota_relay.h"
@@ -25,23 +25,30 @@ static void status_monitor_task(void *pvParameters);
extern "C" void app_main(void)
{
// 【关键】在所有任务创建之前初始化网络接口
// esp_timer 内部依赖 tcpip 线程,health_monitor_task 的 esp_timer_create
// 会在 scheduler 启动后立即执行。如果 esp_netif_init() 没有提前调用,
// tcpip 线程的 mailbox 还未创建,此时 esp_timer 发消息给 tcpip 线程就会崩溃。
// esp_netif_init() 可以安全调用多次,后续 wifi_ota_task 中的调用是多余的但不报错。
ESP_ERROR_CHECK(esp_netif_init());
esp_chip_info_t chip_info;
esp_chip_info(&chip_info);
// 初始化调试日志(UDP 输出到上位机 + UART0 串口)
// 初始化网络接口 + 事件循环(必须最早)
ESP_ERROR_CHECK(esp_netif_init());
ESP_ERROR_CHECK(esp_event_loop_create_default());
// 初始化调试日志(UART0 先就绪,UDP socket 延迟到首次发送时创建)
// 必须在 wifi_early_init 之前,这样 WiFi 初始化日志也能输出到上位机
debug_log_init();
// ★ WiFi 提前初始化(调度器启动前)
// 包括:NVS → netif 创建 → event handler 注册 → WiFi driver init → 配置 → connect
// 连接尝试立即开始,事件排队等调度器启动后处理
bool wifi_sta_ok = wifi_early_init();
health_monitor_init();
DEBUG_INFO("MAIN", "健康监控状态已初始化");
DEBUG_INFO("MAIN", "========================================");
DEBUG_INFO("MAIN", "ESP32-C3 OTA Relay v1.0 启动");
DEBUG_INFO("MAIN", "Chip: %s, Rev %d", CONFIG_IDF_TARGET, chip_info.revision);
DEBUG_INFO("MAIN", "Free heap: %lu bytes", esp_get_free_heap_size());
DEBUG_INFO("MAIN", "CPU Freq: %d MHz", CONFIG_ESP_DEFAULT_CPU_FREQ_MHZ);
DEBUG_INFO("MAIN", "WiFi: %s", wifi_sta_ok ? "STA 连接中" : "将使用 AP 模式");
DEBUG_INFO("MAIN", "========================================");
// 健康监控任务(温度 + 电压 + WiFi RSSI)
-2
View File
@@ -1,2 +0,0 @@
## IDF Component Manager Manifest File
dependencies: {}
+14 -9
View File
@@ -7,12 +7,17 @@
#pragma once
#include <cstdint>
// ============================================================
// 固件版本
// ============================================================
inline constexpr const char *FW_VERSION = "0.0.6";
// ============================================================
// WiFi 工作模式选择
// ============================================================
// 0 = AP 模式(设备当热点)
// 1 = STA 模式(连接路由器)
inline constexpr uint8_t CONFIG_WIFI_MODE = 0;
inline constexpr uint8_t CONFIG_WIFI_MODE = 1;
// ============================================================
// WiFi STA 模式配置(连接路由器)
@@ -20,16 +25,16 @@ inline constexpr uint8_t CONFIG_WIFI_MODE = 0;
inline constexpr const char* WIFI_STA_SSID = "004舰桥";
inline constexpr const char* WIFI_STA_PASSWORD = "q13362875622";
inline constexpr uint32_t WIFI_STA_CONNECT_TIMEOUT_MS = 30000;
inline constexpr uint8_t WIFI_STA_MAX_RETRY = 5; // 最大重连次数,超过后切换 AP 模式
// STA 静态 IP
inline constexpr uint8_t WIFI_STA_USE_STATIC_IP = 1; // 设为 1 启用静态 IP
inline constexpr uint8_t WIFI_STA_USE_STATIC_IP = 0; // 先关闭静态 IP,使用 DHCP 排查连接问题
#if WIFI_STA_USE_STATIC_IP
inline constexpr const char* WIFI_STA_STATIC_IP = "192.168.1.100";
inline constexpr const char* WIFI_STA_STATIC_GW = "192.168.1.1";
// 静态 IP 配置(始终定义,由 WIFI_STA_USE_STATIC_IP 在运行时决定是否使用)
inline constexpr const char* WIFI_STA_STATIC_IP = "192.168.31.32";
inline constexpr const char* WIFI_STA_STATIC_GW = "192.168.31.1";
inline constexpr const char* WIFI_STA_STATIC_NETMASK = "255.255.255.0";
inline constexpr const char* WIFI_STA_STATIC_DNS = "192.168.1.1";
#endif
inline constexpr const char* WIFI_STA_STATIC_DNS = "192.168.31.1";
// ============================================================
// WiFi AP 模式配置(设备当热点)
@@ -54,8 +59,8 @@ inline constexpr size_t OTA_MAX_FIRMWARE_SIZE = 2 * 1024 * 1024; // 2MB
// ============================================================
// 健康监控阈值
// ============================================================
inline constexpr int32_t WIFI_RSSI_CRITICAL = -75;
inline constexpr int32_t WIFI_RSSI_WARNING = -65;
inline constexpr int32_t WIFI_RSSI_CRITICAL = -85;
inline constexpr int32_t WIFI_RSSI_WARNING = -75;
inline constexpr int32_t TEMP_WARNING = 80;
inline constexpr int32_t TEMP_CRITICAL = 100;
inline constexpr size_t HEAP_WARNING_MIN = 10240;
+137 -34
View File
@@ -1,11 +1,17 @@
/*
* debug_log.c - WiFi 调试日志输出实现
*
* 通过 UDP socket 发送调试日志到上位机
* 通过 UDP socket 将调试日志发送到上位机
* 上位机需监听 <DEBUG_LOG_IP>:<DEBUG_LOG_PORT>
*
* 支持双通道输出:UDP + UART0 串口
* UART0: TX=GPIO21, RX=GPIO20, 115200 波特率
*
* 时序说明:
* esp_netif_init() 创建 tcpip 线程后才可安全使用 socket
* 本模块在首次 debug_log_send() 时才创建 socket(延迟创建)
* WiFi 连接前 sendto() 会失败(无路由),日志仅走 UART
* WiFi 连接后 UDP 立即生效,无需额外操作
*/
#include <string.h>
#include <stdio.h>
@@ -13,6 +19,7 @@
#include <netdb.h>
#include <arpa/inet.h>
#include "freertos/task.h"
#include "freertos/semphr.h"
#include "esp_log.h"
#include "esp_err.h"
#include "driver/uart.h"
@@ -27,6 +34,116 @@ static int s_udp_sock = -1;
static struct sockaddr_in s_debug_addr;
static bool s_initialized = false;
// ============================================================
// 日志环形缓冲区(供 HTTP /logs 拉取)
// ============================================================
#define LOG_RING_SLOTS 64
#define LOG_RING_SIZE 256
static char s_log_ring[LOG_RING_SLOTS][LOG_RING_SIZE];
static int s_log_ring_lens[LOG_RING_SLOTS];
static int s_log_ring_head = 0; // 下次写入位置
static int s_log_ring_count = 0; // 已存条数(超过 LOG_RING_SLOTS 后不再增长,覆盖旧数据)
static SemaphoreHandle_t s_log_ring_mutex = NULL;
// 获取日志缓冲区中的条数
int debug_log_ring_count(void)
{
if (!s_log_ring_mutex) return 0;
xSemaphoreTake(s_log_ring_mutex, portMAX_DELAY);
int count = s_log_ring_count;
xSemaphoreGive(s_log_ring_mutex);
return count;
}
// 读取并清空日志缓冲区,返回 JSON 数组字符串
// 调用者负责 free 返回值
char *debug_log_ring_drain_json(int *out_len)
{
if (!s_log_ring_mutex) {
*out_len = 0;
return NULL;
}
xSemaphoreTake(s_log_ring_mutex, portMAX_DELAY);
int count = s_log_ring_count;
if (count == 0) {
xSemaphoreGive(s_log_ring_mutex);
*out_len = 0;
return NULL;
}
// 估算 JSON 大小:每条 ~280 字节(转义 + JSON 包装)
size_t buf_size = (size_t)count * 280 + 16;
char *json = (char *)malloc(buf_size);
if (!json) {
xSemaphoreGive(s_log_ring_mutex);
*out_len = 0;
return NULL;
}
int pos = 0;
json[pos++] = '[';
// 从最旧的开始读
int start = (count < LOG_RING_SLOTS) ? 0 : s_log_ring_head;
for (int i = 0; i < count && pos < (int)buf_size - 200; i++) {
int idx = (start + i) % LOG_RING_SLOTS;
int len = s_log_ring_lens[idx];
if (len <= 0) continue;
if (i > 0) json[pos++] = ',';
// JSON 转义日志内容
pos += snprintf(json + pos, buf_size - pos, "\"");
for (int j = 0; j < len && pos < (int)buf_size - 10; j++) {
char c = s_log_ring[idx][j];
if (c == '"' || c == '\\') {
json[pos++] = '\\';
json[pos++] = c;
} else if (c == '\n') {
json[pos++] = '\\';
json[pos++] = 'n';
} else if (c >= 32) {
json[pos++] = c;
}
}
pos += snprintf(json + pos, buf_size - pos, "\"");
}
json[pos++] = ']';
json[pos] = '\0';
// 清空缓冲区
s_log_ring_head = 0;
s_log_ring_count = 0;
xSemaphoreGive(s_log_ring_mutex);
*out_len = pos;
return json;
}
// 写入一条日志到环形缓冲区
static void log_ring_push(const char *data, int len)
{
if (!s_log_ring_mutex || len <= 0) return;
xSemaphoreTake(s_log_ring_mutex, portMAX_DELAY);
int copy_len = len;
if (copy_len >= LOG_RING_SIZE) copy_len = LOG_RING_SIZE - 1;
memcpy(s_log_ring[s_log_ring_head], data, copy_len);
s_log_ring[s_log_ring_head][copy_len] = '\0';
s_log_ring_lens[s_log_ring_head] = copy_len;
s_log_ring_head = (s_log_ring_head + 1) % LOG_RING_SLOTS;
if (s_log_ring_count < LOG_RING_SLOTS) s_log_ring_count++;
xSemaphoreGive(s_log_ring_mutex);
}
// UART0 状态
static bool s_uart_initialized = false;
static uart_port_t s_uart_port = UART_NUM_0;
@@ -95,37 +212,37 @@ esp_err_t uart_debug_send(const uint8_t *data, size_t len)
// UDP 调试日志
// ============================================================
// 初始化 UDP socket(注意:socket 创建延迟到 debug_log_send 首次调用时)
// 因为 esp_netif_init() 会在 wifi_ota_task 里调用,提前创建 socket 会导致 tcpip 线程未就绪而崩溃
// 初始化(UART 立即可用,UDP socket 延迟到首次发送时创建)
esp_err_t debug_log_init(void)
{
if (s_initialized) {
return ESP_OK;
}
// 初始化 UART0(不需要网络)
uart_debug_init();
// 延迟创建 socket:先只初始化目标地址,socket 在 debug_log_send 首次发 UDP 时创建
// 日志环形缓冲区互斥锁
s_log_ring_mutex = xSemaphoreCreateMutex();
// 目标地址:单播到上位机
memset(&s_debug_addr, 0, sizeof(s_debug_addr));
s_debug_addr.sin_family = AF_INET;
s_debug_addr.sin_port = htons(DEBUG_LOG_PORT);
s_debug_addr.sin_addr.s_addr = inet_addr(DEBUG_LOG_IP);
s_initialized = true;
ESP_LOGI(TAG, "调试日志已初始化(UDP socket 延迟创建),上位机 IP=%s:%d", DEBUG_LOG_IP, DEBUG_LOG_PORT);
ESP_LOGI(TAG, "调试日志已初始化,上位机 %s:%d", DEBUG_LOG_IP, DEBUG_LOG_PORT);
return ESP_OK;
}
// 延迟创建 UDP socket(仅在首次发送 UDP 时调用)
// 延迟创建 UDP socket(首次发送时调用)
static esp_err_t ensure_udp_socket(void)
{
if (s_udp_sock >= 0) {
return ESP_OK; // 已创建
return ESP_OK;
}
// 创建 UDP socket
s_udp_sock = socket(AF_INET, SOCK_DGRAM, 0);
if (s_udp_sock < 0) {
ESP_LOGE(TAG, "创建 UDP socket 失败");
@@ -136,49 +253,39 @@ static esp_err_t ensure_udp_socket(void)
return ESP_OK;
}
// 发送原始数据到上位机(UDP + UART 双通道)
// 发送原始数据(UDP 单播 + UART 双通道)
esp_err_t debug_log_send(const uint8_t *data, size_t len)
{
esp_err_t udp_ret = ESP_FAIL;
esp_err_t uart_ret = ESP_FAIL;
// UART 通道发送
// UART 通道
if (s_uart_initialized) {
uart_ret = uart_debug_send(data, len);
}
// UDP 通道发送(延迟创建 socket)
// UDP 通道(WiFi 未连接时 sendto 失败,静默跳过)
if (s_initialized) {
// 首次 UDP 发送前才创建 socket
if (s_udp_sock < 0) {
if (ensure_udp_socket() != ESP_OK) {
// socket 创建失败,走 UART 通道
} else {
udp_ret = ESP_OK; // 标记为已就绪
}
ensure_udp_socket();
}
if (s_udp_sock >= 0) {
if (len > 1024) {
len = 1024; // 限制单次发送大小
}
int ret = sendto(s_udp_sock, data, len, 0,
size_t send_len = len > 1024 ? 1024 : len;
int ret = sendto(s_udp_sock, data, send_len, 0,
(struct sockaddr *)&s_debug_addr, sizeof(s_debug_addr));
if (ret < 0) {
udp_ret = ESP_FAIL;
} else {
if (ret >= 0) {
udp_ret = ESP_OK;
}
}
}
// 至少一个通道成功即返回成功
// 同时写入环形缓冲区(供 HTTP /logs 拉取)
log_ring_push((const char *)data, (int)len);
if (udp_ret == ESP_OK || uart_ret == ESP_OK) {
return ESP_OK;
}
return ESP_FAIL;
}
@@ -193,7 +300,6 @@ void debug_log(debug_log_level_t level, const char *tag, const char *fmt, ...)
return;
}
// 格式化日志头
char prefix[32];
const char *level_str;
switch (level) {
@@ -205,17 +311,14 @@ void debug_log(debug_log_level_t level, const char *tag, const char *fmt, ...)
}
snprintf(prefix, sizeof(prefix), "[%s][%s]", level_str, tag);
// 格式化消息体
va_list args;
va_start(args, fmt);
char msg[512];
vsnprintf(msg, sizeof(msg), fmt, args);
va_end(args);
// 组装完整日志帧
char log_buf[600];
int total = snprintf(log_buf, sizeof(log_buf), "%s %s\n", prefix, msg);
// 发送(末尾 \n 会被保留)
debug_log_send((const uint8_t *)log_buf, total);
}
}
+6 -2
View File
@@ -1,8 +1,8 @@
/*
* debug_log.h - WiFi 调试日志输出
*
* 通过 UDP socket 将调试信息发送到上位机
* 上位机监听 <udp_ip>/<udp_port> 接收日志
* 通过 UDP socket 将调试日志发送到上位机
* 上位机监听 <DEBUG_LOG_PORT> 接收日志
*
* 支持双通道输出:UDP + UART0 串口
* UART0: TX=GPIO21, RX=GPIO20, 115200 波特率
@@ -50,6 +50,10 @@ esp_err_t debug_log_send(const uint8_t *data, size_t len);
// 格式化日志输出(类似 printf)
void debug_log(debug_log_level_t level, const char *tag, const char *fmt, ...);
// 日志环形缓冲区(供 HTTP /logs 拉取)
int debug_log_ring_count(void);
char *debug_log_ring_drain_json(int *out_len); // 调用者 free
// 宏定义方便调用
#define DEBUG_ERR(tag, fmt, ...) debug_log(DEBUG_LOG_ERR, tag, fmt, ##__VA_ARGS__)
#define DEBUG_WARN(tag, fmt, ...) debug_log(DEBUG_LOG_WARN, tag, fmt, ##__VA_ARGS__)
+34 -29
View File
@@ -19,6 +19,8 @@ health_state_t g_health_state = SYS_NORMAL;
static SemaphoreHandle_t g_health_mutex = NULL;
static temperature_sensor_handle_t s_temp_handle = NULL;
static float s_last_temperature = 0.0f;
static float s_last_voltage = 3.30f;
// ----------------------------------------
// 健康状态管理
@@ -44,6 +46,16 @@ health_state_t health_get_current(void)
return state;
}
float health_get_last_temperature(void)
{
return s_last_temperature;
}
float health_get_last_voltage(void)
{
return s_last_voltage;
}
// ----------------------------------------
// 温度监控(定时器回调)
// ----------------------------------------
@@ -56,7 +68,7 @@ static void temp_monitor_callback(void *arg)
return;
}
DEBUG_DBG("TEMP", "芯片温度: %.1f°C", temp);
s_last_temperature = temp;
if (temp >= TEMP_CRITICAL) {
g_health_state = SYS_SAFE_SHUTDOWN;
@@ -77,7 +89,7 @@ static void temp_monitor_callback(void *arg)
static void voltage_monitor_callback(void *arg)
{
float voltage = 3.30f; // ESP32-C3 无内置电压传感器,使用估算
DEBUG_DBG("VOLT", "VDD 电压: %.2fV (估算)", voltage);
s_last_voltage = voltage;
if (voltage < 2.3f) {
g_health_state = SYS_SAFE_SHUTDOWN;
@@ -89,8 +101,6 @@ static void voltage_monitor_callback(void *arg)
DEBUG_WARN("VOLT", "警告: 电压 %.2fV < 2.5V", voltage);
DEBUG_WARN("VOLT", "健康状态变更为: SYS_WARNING");
}
} else {
DEBUG_INFO("VOLT", "电压正常: %.2fV", voltage);
}
}
@@ -111,7 +121,7 @@ void health_monitor_task(void *pvParameters)
DEBUG_INFO("MON", "温度传感器已启用 (范围 20°C ~ 80°C)");
}
// 定时器,每5秒读取一次温度
// 定时器,每1秒读取一次温度
esp_timer_handle_t temp_timer;
esp_timer_create_args_t timer_args = {};
timer_args.callback = &temp_monitor_callback;
@@ -120,40 +130,35 @@ void health_monitor_task(void *pvParameters)
timer_args.dispatch_method = ESP_TIMER_TASK;
timer_args.skip_unhandled_events = false;
esp_timer_create(&timer_args, &temp_timer);
esp_timer_start_periodic(temp_timer, 5 * 1000000); // 5秒
esp_timer_start_periodic(temp_timer, 1 * 1000000); // 1秒
DEBUG_INFO("MON", "温度监控定时器已启动 (间隔 5 秒)");
DEBUG_INFO("MON", "温度监控定时器已启动 (间隔 1 秒)");
// 启动时检查一次电压
voltage_monitor_callback(NULL);
// 每5秒打印一次健康状态
uint32_t loop_count = 0;
// 每1秒刷新一次估算电压
esp_timer_handle_t volt_timer;
esp_timer_create_args_t volt_timer_args = {};
volt_timer_args.callback = &voltage_monitor_callback;
volt_timer_args.arg = nullptr;
volt_timer_args.name = "volt_monitor";
volt_timer_args.dispatch_method = ESP_TIMER_TASK;
volt_timer_args.skip_unhandled_events = false;
esp_timer_create(&volt_timer_args, &volt_timer);
esp_timer_start_periodic(volt_timer, 1 * 1000000); // 1秒
DEBUG_INFO("MON", "电压监控定时器已启动 (间隔 1 秒)");
// 保活循环:定时器由 esp_timer 任务驱动,此任务只需保持存活
// 状态汇报已由 status_monitor_task 负责,此处只在异常时告警
while (1) {
loop_count++;
uint32_t free_heap = esp_get_free_heap_size();
const char *state_str;
switch (g_health_state) {
case SYS_NORMAL: state_str = "正常"; break;
case SYS_WARNING: state_str = "警告"; break;
case SYS_CRITICAL: state_str = "危险"; break;
case SYS_SAFE_SHUTDOWN: state_str = "安全关机"; break;
default: state_str = "未知"; break;
}
DEBUG_INFO("MON", "--- 健康状态 #%lu ---", (unsigned long)loop_count);
DEBUG_INFO("MON", "健康状态: %s (%d)", state_str, g_health_state);
DEBUG_INFO("MON", "空闲堆: %lu 字节", (unsigned long)free_heap);
// 堆内存警告
if (free_heap < HEAP_WARNING_MIN) {
DEBUG_WARN("MON", "空闲堆内存不足: %lu < %d", (unsigned long)free_heap, HEAP_WARNING_MIN);
}
DEBUG_INFO("MON", "===================");
if (g_health_state >= SYS_CRITICAL) {
DEBUG_ERR("MON", "健康状态危险: %d", g_health_state);
}
vTaskDelay(pdMS_TO_TICKS(5000));
}
}
+2
View File
@@ -35,3 +35,5 @@ health_status_t health_check_before_ota(void);
void health_log_event(const char *event, int value);
health_state_t health_get_current(void);
health_status_t health_update_from_wifi_rssi(int8_t rssi);
float health_get_last_temperature(void);
float health_get_last_voltage(void);
+1 -1
View File
@@ -40,7 +40,7 @@ esp_err_t meta_load(ota_meta_t *meta)
FILE *f = fopen(META_FILE, "r");
if (!f) {
DEBUG_WARN("OTA", "元数据文件不存在,返回默认值");
// 文件不存在是正常状态(首次使用,未刷过 GD32)
return ESP_OK;
}
+538 -70
View File
@@ -1,99 +1,542 @@
/*
* ble_ota.c - BLE OTA GATT 服务器实现
* ble_ota.cpp - BLE OTA GATT 服务器实现 (ESP-IDF 6.0 NimBLE)
* T5.1 - T5.5
*
* 注意: ESP-IDF 6.0 中蓝牙组件有较大变化
* 此文件暂时提供空实现,待 ESP-IDF 6.0 蓝牙 API 稳定后再适配
*
* 通过 BLE GATT 接收上位机固件,存储到 Flash (gd32_ota 分区),
* 接收完成后触发 OTA 中继任务转发到 GD32。
*
* GATT 服务:
* Service UUID: 0xFFFF
* - CTRL (0x0001, Write): 写入 0xF7 触发中继
* - DATA_IN (0x0002, Write): 接收固件数据块
* - DATA_OUT (0x0003, Notify): 推送 GD32 响应
* - VERSION (0x0004, Read): 固件版本
*/
#include <string.h>
#include <inttypes.h>
#include "freertos/FreeRTOS.h"
#include "freertos/task.h"
#include "freertos/semphr.h"
#include "freertos/queue.h"
#include "esp_err.h"
// NimBLE 协议栈
#include "nimble/nimble_port.h"
#include "nimble/nimble_port_freertos.h"
#include "host/ble_hs.h"
#include "host/ble_gap.h"
#include "services/gap/ble_svc_gap.h"
#include "services/gatt/ble_svc_gatt.h"
#include "app_config.h"
#include "ble_ota.h"
#include "ota_relay.h"
#include "health_monitor.h"
#include "monitor/debug_log.h"
static bool s_ble_connected = false;
#define TAG "BLE_OTA"
/* ESP-IDF 6.0: BLE OTA 功能暂时禁用,以下函数保留供后续适配使用 */
#if 0
// 处理写入控制点
static void handle_ctrl_point_write(uint8_t *data, size_t len)
// ============================================================
// 静态状态
// ============================================================
static bool s_ble_ready = false; // NimBLE 同步完成
static bool s_ble_connected = false; // GATT 客户端已连接
static uint16_t s_conn_handle = BLE_HS_CONN_HANDLE_NONE;
static uint16_t s_data_out_val_handle = 0; // DATA_OUT 值句柄 (供通知用)
// ============================================================
// NimBLE 工具: 从 mbuf 读取原始数据
// ============================================================
static int ble_ota_mbuf_to_buf(struct os_mbuf *om, uint8_t *buf, size_t buf_len)
{
if (len < 1) return;
if (!om || !buf) return -1;
uint16_t om_len = OS_MBUF_PKTLEN(om);
uint16_t copy_len = (om_len > buf_len) ? (uint16_t)buf_len : om_len;
os_mbuf_copydata(om, 0, copy_len, buf);
return (int)copy_len;
}
uint8_t cmd = data[0];
DEBUG_DBG("BLE", "控制点写入: cmd=0x%02X, len=%d", cmd, len);
// ============================================================
// GATT 访问回调
// ============================================================
if (cmd == 0xF7 && s_ble_connected) {
/*
* CTRL (0x0001) - Write
* 写入 0xF7 触发 OTA 中继
*/
static int ble_ota_ctrl_access(uint16_t conn_handle, uint16_t attr_handle,
struct ble_gatt_access_ctxt *ctxt, void *arg)
{
(void)conn_handle;
(void)attr_handle;
(void)arg;
if (ctxt->op != BLE_GATT_ACCESS_OP_WRITE_CHR) {
return BLE_ATT_ERR_REQ_NOT_SUPPORTED;
}
uint8_t buf[4] = {0};
int len = ble_ota_mbuf_to_buf(ctxt->om, buf, sizeof(buf));
if (len < 1) {
DEBUG_WARN(TAG, "CTRL 数据为空");
return BLE_ATT_ERR_INVALID_ATTR_VALUE_LEN;
}
DEBUG_INFO(TAG, "CTRL 写入: cmd=0x%02X", buf[0]);
if (buf[0] == 0xF7) {
// 检查固件是否存储完毕
ota_meta_t meta;
meta_load(&meta);
if (meta.stored_size >= meta.total_size) {
esp_err_t err = meta_load(&meta);
if (err == ESP_OK &&
meta.total_size > 0 &&
meta.stored_size >= meta.total_size) {
DEBUG_INFO(TAG, "固件就绪 (%" PRIu32 " / %" PRIu32 " 字节),触发中继",
meta.stored_size, meta.total_size);
ble_ota_signal_relay();
} else {
DEBUG_WARN(TAG, "固件未就绪, 未触发中继 (stored=%" PRIu32 ", total=%" PRIu32 ")",
meta.stored_size, meta.total_size);
}
}
return 0; // BLE_ATT_ERR_OK 在 NimBLE 中就是 0
}
// 处理写入数据输入(大固件分块)
static void handle_data_in_write(uint8_t *data, size_t len)
/*
* DATA_IN (0x0002) - Write
* 数据块格式:
* [0] cmd (0x00=数据)
* [1-2] seq uint16 LE - 分块序号
* [3-6] total_size uint32 LE - 固件总大小 (仅 seq=0 时有效)
* [7..n-4] payload 固件数据
* [n-4..n] crc 校验 (预留)
*/
static int ble_ota_data_in_access(uint16_t conn_handle, uint16_t attr_handle,
struct ble_gatt_access_ctxt *ctxt, void *arg)
{
if (len < 7) return;
(void)conn_handle;
(void)attr_handle;
(void)arg;
uint16_t seq = data[1] | (data[2] << 8);
uint32_t total_size = data[3] | (data[4] << 8) | (data[5] << 16) | (data[6] << 24);
if (seq == 0 && total_size > 0) {
ota_meta_t meta = {
.total_size = total_size,
.stored_size = 0,
.last_seq = 0,
.chunk_count = (total_size + 4095) / 4096
};
meta_save(&meta);
DEBUG_INFO("BLE", "开始固件存储: %" PRIu32 " 字节", total_size);
if (ctxt->op != BLE_GATT_ACCESS_OP_WRITE_CHR) {
return BLE_ATT_ERR_REQ_NOT_SUPPORTED;
}
uint8_t buf[BLE_MTU_SIZE] = {0};
int recv_len = ble_ota_mbuf_to_buf(ctxt->om, buf, sizeof(buf));
if (recv_len < 12) {
DEBUG_WARN(TAG, "DATA_IN 数据太短: %d 字节 (最少 12)", recv_len);
return BLE_ATT_ERR_INVALID_ATTR_VALUE_LEN;
}
// 解析包头
uint8_t cmd = buf[0];
uint16_t seq = (uint16_t)(buf[1] | (buf[2] << 8));
uint32_t total_size = (uint32_t)(buf[3] | (buf[4] << 8) |
(buf[5] << 16) | (buf[6] << 24));
size_t data_offset = 7;
size_t data_len = len - data_offset - 4;
size_t data_len = (size_t)recv_len - data_offset;
uint32_t flash_offset = seq * 4096;
esp_err_t err = flash_write_chunk(flash_offset, &data[data_offset], data_len);
if (err != ESP_OK) {
DEBUG_ERR("BLE", "Flash 写入失败 at seq %" PRIu16, seq);
// 尾 4 字节 CRC 预留,实际 payload = data_len - 4
if (data_len < 4) {
DEBUG_WARN(TAG, "DATA_IN 负载不足: data_len=%zu", data_len);
return BLE_ATT_ERR_INVALID_ATTR_VALUE_LEN;
}
data_len -= 4; // 减去尾部 CRC 预留
DEBUG_DBG(TAG, "DATA_IN: cmd=0x%02X seq=%u total=%" PRIu32 " payload=%zu",
cmd, seq, total_size, data_len);
// --- 首个分块: 创建存储元数据 ---
if (seq == 0 && total_size > 0) {
if (total_size > OTA_MAX_FIRMWARE_SIZE) {
DEBUG_ERR(TAG, "固件太大: %" PRIu32 " > %zu",
total_size, OTA_MAX_FIRMWARE_SIZE);
return BLE_ATT_ERR_UNLIKELY;
}
ota_meta_t meta = {};
meta.total_size = total_size;
meta.stored_size = 0;
meta.last_seq = 0;
meta.chunk_count = (total_size + (OTA_BLOCK_SIZE - 1)) / OTA_BLOCK_SIZE;
esp_err_t err = meta_save(&meta);
if (err != ESP_OK) {
DEBUG_ERR(TAG, "元数据保存失败");
return BLE_ATT_ERR_UNLIKELY;
}
DEBUG_INFO(TAG, "固件存储开始: total=%" PRIu32 " chunk_count=%" PRIu32,
meta.total_size, meta.chunk_count);
}
meta_update(flash_offset + data_len, seq);
// --- 写入 Flash ---
uint32_t flash_offset = seq * OTA_BLOCK_SIZE;
esp_err_t err = flash_write_chunk(flash_offset, &buf[data_offset], data_len);
if (err != ESP_OK) {
DEBUG_ERR(TAG, "Flash 写入失败: seq=%u offset=%" PRIu32 " err=%s",
seq, flash_offset, esp_err_to_name(err));
return BLE_ATT_ERR_UNLIKELY;
}
DEBUG_DBG("BLE", "已存储分块 %" PRIu16 ", offset=%" PRIu32, seq, flash_offset);
// --- 更新元数据 ---
err = meta_update(flash_offset + data_len, seq);
if (err != ESP_OK) {
DEBUG_WARN(TAG, "元数据更新失败 (seq=%u)", seq);
}
// --- 检查是否接收完毕 ---
ota_meta_t meta;
meta_load(&meta);
if (meta.stored_size >= meta.total_size) {
DEBUG_INFO("BLE", "全部固件已存储,触发转发");
ble_ota_signal_relay();
if (meta_load(&meta) == ESP_OK &&
meta.total_size > 0 &&
meta.stored_size >= meta.total_size) {
DEBUG_INFO(TAG, "★ 固件接收完毕: %" PRIu32 " / %" PRIu32 " 字节 (共 %u 块)",
meta.stored_size, meta.total_size, seq + 1);
}
return 0;
}
/*
* VERSION (0x0004) - Read
* 返回固件版本字符串
*/
static int ble_ota_version_access(uint16_t conn_handle, uint16_t attr_handle,
struct ble_gatt_access_ctxt *ctxt, void *arg)
{
(void)conn_handle;
(void)attr_handle;
(void)arg;
if (ctxt->op != BLE_GATT_ACCESS_OP_READ_CHR) {
return BLE_ATT_ERR_REQ_NOT_SUPPORTED;
}
const char *version = "1.0.0";
int rc = os_mbuf_append(ctxt->om, version, strlen(version));
return (rc == 0) ? 0 : BLE_ATT_ERR_UNLIKELY;
}
/*
* DATA_OUT (0x0003) - Notify only
* 访问回调不做读写处理,通知由 ble_ota_notify_response() 发送
*/
static int ble_ota_data_out_access(uint16_t conn_handle, uint16_t attr_handle,
struct ble_gatt_access_ctxt *ctxt, void *arg)
{
(void)conn_handle;
(void)attr_handle;
(void)ctxt;
(void)arg;
return 0;
}
// ============================================================
// GATT 服务定义
// ============================================================
// BLE_UUID16_DECLARE 在 C++ 中产生 "taking address of rvalue" 编译错误。
// ESP-IDF 6.0 NimBLE 的 C++ 兼容写法: 先定义 static ble_uuid16_t 变量, 再取 .u 地址。
static const ble_uuid16_t GATT_SVC_UUID = BLE_UUID16_INIT(BLE_OTA_SERVICE_UUID);
static const ble_uuid16_t GATT_CTRL_UUID = BLE_UUID16_INIT(BLE_OTA_CTRL_UUID);
static const ble_uuid16_t GATT_DATA_IN_UUID = BLE_UUID16_INIT(BLE_OTA_DATA_IN_UUID);
static const ble_uuid16_t GATT_DATA_OUT_UUID = BLE_UUID16_INIT(BLE_OTA_DATA_OUT_UUID);
static const ble_uuid16_t GATT_VERSION_UUID = BLE_UUID16_INIT(BLE_OTA_VERSION_UUID);
// NimBLE C 结构体在 C++ designated initializer 不能显式初始化全部字段。
// 用 pragma 局部关闭 -Wmissing-field-initializers 警告。
#ifdef __cplusplus
#pragma GCC diagnostic push
#pragma GCC diagnostic ignored "-Wmissing-field-initializers"
#endif
static const struct ble_gatt_chr_def gatt_ota_chrs[] = {
{
.uuid = &GATT_CTRL_UUID.u,
.access_cb = ble_ota_ctrl_access,
.flags = BLE_GATT_CHR_F_WRITE,
},
{
.uuid = &GATT_DATA_IN_UUID.u,
.access_cb = ble_ota_data_in_access,
.flags = BLE_GATT_CHR_F_WRITE,
},
{
.uuid = &GATT_DATA_OUT_UUID.u,
.access_cb = ble_ota_data_out_access,
.flags = BLE_GATT_CHR_F_NOTIFY,
.val_handle = &s_data_out_val_handle,
},
{
.uuid = &GATT_VERSION_UUID.u,
.access_cb = ble_ota_version_access,
.flags = BLE_GATT_CHR_F_READ,
},
{} // 终止符
};
static const struct ble_gatt_svc_def gatt_ota_svcs[] = {
{
.type = BLE_GATT_SVC_TYPE_PRIMARY,
.uuid = &GATT_SVC_UUID.u,
.characteristics = (struct ble_gatt_chr_def *)gatt_ota_chrs,
},
{} // 终止符
};
#ifdef __cplusplus
#pragma GCC diagnostic pop
#endif
// ============================================================
// GAP 事件回调
// ============================================================
static int ble_ota_gap_event(struct ble_gap_event *event, void *arg)
{
(void)arg;
switch (event->type) {
case BLE_GAP_EVENT_CONNECT:
if (event->connect.status == 0) {
// 连接成功
s_ble_connected = true;
s_conn_handle = event->connect.conn_handle;
DEBUG_INFO(TAG, "BLE 客户端已连接: handle=%d", s_conn_handle);
} else {
DEBUG_WARN(TAG, "BLE 连接失败: status=%d", event->connect.status);
s_ble_connected = false;
}
return 0;
case BLE_GAP_EVENT_DISCONNECT:
DEBUG_INFO(TAG, "BLE 客户端已断开: reason=%d",
event->disconnect.reason);
s_ble_connected = false;
s_conn_handle = BLE_HS_CONN_HANDLE_NONE;
// 恢复广播以接受新连接
ble_gap_adv_start(BLE_OWN_ADDR_PUBLIC, NULL, BLE_HS_FOREVER,
NULL, ble_ota_gap_event, NULL);
return 0;
case BLE_GAP_EVENT_ADV_COMPLETE:
DEBUG_INFO(TAG, "广播周期结束,重新开始");
ble_gap_adv_start(BLE_OWN_ADDR_PUBLIC, NULL, BLE_HS_FOREVER,
NULL, ble_ota_gap_event, NULL);
return 0;
case BLE_GAP_EVENT_SUBSCRIBE:
DEBUG_INFO(TAG, "CCC 描述符更新: conn=%d attr=%d notify=%d indicate=%d",
event->subscribe.conn_handle,
event->subscribe.attr_handle,
event->subscribe.cur_notify,
event->subscribe.cur_indicate);
return 0;
case BLE_GAP_EVENT_MTU:
DEBUG_INFO(TAG, "MTU 协商: conn=%d mtu=%d",
event->mtu.conn_handle, event->mtu.value);
return 0;
default:
return 0;
}
}
#endif /* ESP_IDF_COMPILATION */
// 启动 BLE GATT 服务器
// 注意: 此函数在 ESP-IDF 6.0 中暂时返回 ESP_ERR_NOT_SUPPORTED
// ============================================================
// BLE 广播
// ============================================================
static void ble_ota_start_advertising(void)
{
uint8_t adv_data[31] = {0};
int adv_pos = 0;
// AD Flags: LE General Discoverable + BR/EDR Not Supported
adv_data[adv_pos++] = 0x02; // 长度
adv_data[adv_pos++] = 0x01; // 类型 = Flags
adv_data[adv_pos++] = 0x06; // 值
// Complete Local Name
const char *name = BLE_DEVICE_NAME;
size_t name_len = strlen(name);
if (name_len > 29 - adv_pos - 2) {
name_len = 29 - adv_pos - 2; // 截断以适配广播包
}
adv_data[adv_pos++] = (uint8_t)(name_len + 1);
adv_data[adv_pos++] = 0x09; // 类型 = Complete Local Name
memcpy(&adv_data[adv_pos], name, name_len);
adv_pos += name_len;
int rc = ble_gap_adv_set_data(adv_data, adv_pos);
if (rc != 0) {
DEBUG_ERR(TAG, "设置广播数据失败: %d", rc);
return;
}
// 扫描响应: 包含 Service UUID 以帮助发现
uint8_t scan_rsp[31] = {0};
int rsp_pos = 0;
scan_rsp[rsp_pos++] = 0x11; // 长度 (2 + 15)
scan_rsp[rsp_pos++] = 0x09; // 类型 = Complete Local Name
name_len = strlen(name);
if (name_len > (size_t)(31 - rsp_pos)) {
name_len = 31 - rsp_pos;
}
memcpy(&scan_rsp[rsp_pos], name, name_len);
rsp_pos += name_len;
rc = ble_gap_adv_rsp_set_data(scan_rsp, rsp_pos);
if (rc != 0) {
DEBUG_WARN(TAG, "设置扫描响应数据失败: %d", rc);
}
// 开始广播
struct ble_gap_adv_params adv_params = {};
adv_params.conn_mode = BLE_GAP_CONN_MODE_UND;
adv_params.disc_mode = BLE_GAP_DISC_MODE_GEN;
// 从 kconfig 获取 BLE 地址类型,默认 Public
#ifdef CONFIG_BLE_ADDR_TYPE_PUBLIC
uint8_t own_addr_type = BLE_OWN_ADDR_PUBLIC;
#else
uint8_t own_addr_type = BLE_OWN_ADDR_RANDOM;
#endif
rc = ble_gap_adv_start(own_addr_type, NULL, BLE_HS_FOREVER,
&adv_params, ble_ota_gap_event, NULL);
if (rc != 0) {
DEBUG_ERR(TAG, "启动广播失败: %d", rc);
} else {
DEBUG_INFO(TAG, "BLE 广播已启动: \"%s\"", name);
}
}
// ============================================================
// NimBLE 同步/重置回调
// ============================================================
static void ble_ota_on_sync(void)
{
DEBUG_INFO(TAG, "NimBLE 协议栈同步完成");
// GATT 服务已在 ble_ota_server_start() 中注册,不再重复注册
// 重复调用 ble_gatts_count_cfg / ble_gatts_add_svcs 会导致 BLE_HS_EBUSY
s_ble_ready = true;
ble_ota_start_advertising();
}
static void ble_ota_on_reset(int reason)
{
DEBUG_WARN(TAG, "NimBLE 协议栈重置: reason=%d", reason);
s_ble_ready = false;
}
// ============================================================
// NimBLE Host 任务(独立 FreeRTOS 任务)
// ============================================================
static void ble_ota_nimble_host_task(void *param)
{
(void)param;
DEBUG_INFO(TAG, "NimBLE Host 任务启动");
// nimble_port_run() 阻塞运行 NimBLE 事件循环
nimble_port_run();
// 正常情况下不会到达这里
DEBUG_INFO(TAG, "NimBLE Host 任务退出");
}
// ============================================================
// 公开 API
// ============================================================
esp_err_t ble_ota_server_start(void)
{
DEBUG_WARN("BLE", "BLE OTA 功能在 ESP-IDF 6.0 中暂时不可用");
DEBUG_WARN("BLE", "请使用 WiFi OTA 功能或等待 BLE API 适配");
return ESP_ERR_NOT_SUPPORTED;
int rc;
DEBUG_INFO(TAG, "=== 初始化 BLE OTA GATT 服务器 ===");
DEBUG_INFO(TAG, " Device: %s", BLE_DEVICE_NAME);
DEBUG_INFO(TAG, " MTU: %" PRIu16, BLE_MTU_SIZE);
DEBUG_INFO(TAG, " Svc: 0x%04X", BLE_OTA_SERVICE_UUID);
// 1. 初始化 NimBLE 底层
esp_err_t err = nimble_port_init();
if (err != ESP_OK) {
DEBUG_ERR(TAG, "nimble_port_init 失败: %s", esp_err_to_name(err));
return err;
}
DEBUG_INFO(TAG, "nimble_port_init 完成");
// 2. 初始化 GAP / GATT 服务(无需 BLE 存储回调 — 不做 bonding)
ble_svc_gap_init();
ble_svc_gatt_init();
// 4. 设置设备名称
rc = ble_svc_gap_device_name_set(BLE_DEVICE_NAME);
if (rc != 0) {
DEBUG_WARN(TAG, "设置设备名称失败: %d", rc);
}
// 5. 配置 Host 回调
// sync_cb: NimBLE 事件循环就绪时调用 (开始广播)
// reset_cb: NimBLE 重置时调用
ble_hs_cfg.sync_cb = ble_ota_on_sync;
ble_hs_cfg.reset_cb = ble_ota_on_reset;
// 不做 bonding,不需要 store 回调
// 6. 预注册 GATT 服务(在 sync 回调中也会再注册一次确保)
rc = ble_gatts_count_cfg(gatt_ota_svcs);
if (rc != 0) {
DEBUG_ERR(TAG, "GATT 服务计数失败: %d", rc);
return ESP_FAIL;
}
rc = ble_gatts_add_svcs(gatt_ota_svcs);
if (rc != 0) {
DEBUG_ERR(TAG, "GATT 服务注册失败: %d", rc);
return ESP_FAIL;
}
// 7. 启动 NimBLE Host 任务(内部创建 FreeRTOS 任务跑 nimble_port_run)
nimble_port_freertos_init(ble_ota_nimble_host_task);
DEBUG_INFO(TAG, "BLE OTA 服务器初始化完成");
return ESP_OK;
}
// 通过 BLE 通知 APP GD32 的响应
esp_err_t ble_ota_notify_response(const uint8_t *data, size_t len)
{
(void)data;
(void)len;
return ESP_ERR_NOT_SUPPORTED;
if (!s_ble_connected) {
return ESP_ERR_NOT_SUPPORTED;
}
if (s_data_out_val_handle == 0) {
DEBUG_WARN(TAG, "DATA_OUT 值句柄未设置");
return ESP_ERR_INVALID_STATE;
}
if (len == 0) return ESP_OK;
// 通过 NimBLE mbuf 发送通知
struct os_mbuf *om = ble_hs_mbuf_from_flat(data, len);
if (om == NULL) {
DEBUG_ERR(TAG, "分配 mbuf 失败 (%zu 字节)", len);
return ESP_ERR_NO_MEM;
}
int rc = ble_gattc_notify_custom(s_conn_handle, s_data_out_val_handle, om);
if (rc != 0) {
DEBUG_WARN(TAG, "通知发送失败: rc=%d", rc);
return ESP_FAIL;
}
DEBUG_DBG(TAG, "通知已发送: %zu 字节", len);
return ESP_OK;
}
bool ble_ota_is_connected(void)
@@ -101,31 +544,56 @@ bool ble_ota_is_connected(void)
return s_ble_connected;
}
// 信号通知 OTA 中继开始转发
void ble_ota_signal_relay(void)
{
if (g_ota_dispatch_queue) {
uint32_t msg = 1;
BaseType_t ret = xQueueSend(g_ota_dispatch_queue, &msg, pdMS_TO_TICKS(100));
if (ret == pdTRUE) {
DEBUG_INFO("BLE", "OTA 调度信号已通过 BLE 发送");
}
if (g_ota_dispatch_queue == NULL) {
DEBUG_ERR(TAG, "OTA 调度队列未初始化");
return;
}
uint32_t msg = 1;
BaseType_t ret = xQueueSend(g_ota_dispatch_queue, &msg, pdMS_TO_TICKS(200));
if (ret != pdTRUE) {
DEBUG_ERR(TAG, "OTA 调度信号发送失败: 队列满");
return;
}
DEBUG_INFO(TAG, "OTA 调度信号已发送 → ota_relay_task");
}
// BLE OTA FreeRTOS 任务
// ============================================================
// BLE OTA FreeRTOS 任务入口
//
// 注意: 此任务的职责是初始化 BLE 服务器并保持存活。
// 实际的 BLE 事件循环在独立的 NimBLE Host 任务中运行。
// 为避免与 ota_relay_task 竞争,此任务不从 g_ota_dispatch_queue 消费。
// ============================================================
void ble_ota_task(void *pvParameters)
{
(void)pvParameters;
DEBUG_WARN("BLE", "BLE OTA 任务启动,但功能暂时不可用");
// 等待调度信号
while (1) {
uint32_t msg;
BaseType_t ret = xQueueReceive(g_ota_dispatch_queue, &msg, pdMS_TO_TICKS(1000));
if (ret == pdTRUE) {
DEBUG_INFO("BLE", "收到 OTA 调度信号,但 BLE 功能不可用");
}
vTaskDelay(pdMS_TO_TICKS(100));
DEBUG_INFO(TAG, "=== BLE OTA 任务启动 ===");
// 初始化并启动 BLE 服务器(同步操作,完成后广播开始)
esp_err_t err = ble_ota_server_start();
if (err != ESP_OK) {
DEBUG_ERR(TAG, "BLE 服务器启动失败: %s → BLE OTA 不可用",
esp_err_to_name(err));
}
}
// 保持任务存活 — 所有 BLE 事件由 NimBLE Host 任务处理
uint32_t heartbeat = 0;
while (1) {
heartbeat++;
// 每 60 秒打印一次心跳(确认任务存活)
if (heartbeat % 6 == 0) {
DEBUG_DBG(TAG, "心跳 #%lu | BLE %s | 连接: %s",
(unsigned long)(heartbeat / 6),
s_ble_ready ? "就绪" : "未就绪",
s_ble_connected ? "已连接" : "未连接");
}
vTaskDelay(pdMS_TO_TICKS(10000));
}
}
+783 -131
View File
File diff suppressed because it is too large Load Diff
+5
View File
@@ -16,6 +16,11 @@ typedef enum {
// WiFi OTA 任务入口
void wifi_ota_task(void *pvParameters);
// ★ WiFi 早期初始化(app_main 调度前调用)
// 包括 NVS、WiFi driver、netif、event handler、配置、连接尝试
// 返回 true = STA 模式启动中,false = 需要在 task 中切换 AP
bool wifi_early_init(void);
// 信号通知 OTA 中继开始(存储完成后调用)
void wifi_ota_signal_relay(void);
-117
View File
@@ -1,117 +0,0 @@
/* WiFi softAP Example
This example code is in the Public Domain (or CC0 licensed, at your option.)
Unless required by applicable law or agreed to in writing, this
software is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR
CONDITIONS OF ANY KIND, either express or implied.
*/
#include <string.h>
#include "freertos/FreeRTOS.h"
#include "freertos/task.h"
#include "esp_mac.h"
#include "esp_wifi.h"
#include "esp_event.h"
#include "esp_log.h"
#include "nvs_flash.h"
#include "lwip/err.h"
#include "lwip/sys.h"
/* The examples use WiFi configuration that you can set via project configuration menu.
If you'd rather not, just change the below entries to strings with
the config you want - ie #define EXAMPLE_WIFI_SSID "mywifissid"
*/
#define EXAMPLE_ESP_WIFI_SSID CONFIG_ESP_WIFI_SSID
#define EXAMPLE_ESP_WIFI_PASS CONFIG_ESP_WIFI_PASSWORD
#define EXAMPLE_ESP_WIFI_CHANNEL CONFIG_ESP_WIFI_CHANNEL
#define EXAMPLE_MAX_STA_CONN CONFIG_ESP_MAX_STA_CONN
#if CONFIG_ESP_GTK_REKEYING_ENABLE
#define EXAMPLE_GTK_REKEY_INTERVAL CONFIG_ESP_GTK_REKEY_INTERVAL
#else
#define EXAMPLE_GTK_REKEY_INTERVAL 0
#endif
static const char *TAG = "wifi softAP";
static void wifi_event_handler(void* arg, esp_event_base_t event_base,
int32_t event_id, void* event_data)
{
if (event_id == WIFI_EVENT_AP_STACONNECTED) {
wifi_event_ap_staconnected_t* event = (wifi_event_ap_staconnected_t*) event_data;
ESP_LOGI(TAG, "station "MACSTR" join, AID=%d",
MAC2STR(event->mac), event->aid);
} else if (event_id == WIFI_EVENT_AP_STADISCONNECTED) {
wifi_event_ap_stadisconnected_t* event = (wifi_event_ap_stadisconnected_t*) event_data;
ESP_LOGI(TAG, "station "MACSTR" leave, AID=%d, reason=%d",
MAC2STR(event->mac), event->aid, event->reason);
}
}
void wifi_init_softap(void)
{
ESP_ERROR_CHECK(esp_netif_init());
ESP_ERROR_CHECK(esp_event_loop_create_default());
esp_netif_create_default_wifi_ap();
wifi_init_config_t cfg = WIFI_INIT_CONFIG_DEFAULT();
ESP_ERROR_CHECK(esp_wifi_init(&cfg));
ESP_ERROR_CHECK(esp_event_handler_instance_register(WIFI_EVENT,
ESP_EVENT_ANY_ID,
&wifi_event_handler,
NULL,
NULL));
wifi_config_t wifi_config = {
.ap = {
.ssid = EXAMPLE_ESP_WIFI_SSID,
.ssid_len = strlen(EXAMPLE_ESP_WIFI_SSID),
.channel = EXAMPLE_ESP_WIFI_CHANNEL,
.password = EXAMPLE_ESP_WIFI_PASS,
.max_connection = EXAMPLE_MAX_STA_CONN,
#ifdef CONFIG_ESP_WIFI_SOFTAP_SAE_SUPPORT
.authmode = WIFI_AUTH_WPA3_PSK,
.sae_pwe_h2e = WPA3_SAE_PWE_BOTH,
#else /* CONFIG_ESP_WIFI_SOFTAP_SAE_SUPPORT */
.authmode = WIFI_AUTH_WPA2_PSK,
#endif
.pmf_cfg = {
.required = true,
},
#ifdef CONFIG_ESP_WIFI_BSS_MAX_IDLE_SUPPORT
.bss_max_idle_cfg = {
.period = WIFI_AP_DEFAULT_MAX_IDLE_PERIOD,
.protected_keep_alive = 1,
},
#endif
.gtk_rekey_interval = EXAMPLE_GTK_REKEY_INTERVAL,
},
};
if (strlen(EXAMPLE_ESP_WIFI_PASS) == 0) {
wifi_config.ap.authmode = WIFI_AUTH_OPEN;
}
ESP_ERROR_CHECK(esp_wifi_set_mode(WIFI_MODE_AP));
ESP_ERROR_CHECK(esp_wifi_set_config(WIFI_IF_AP, &wifi_config));
ESP_ERROR_CHECK(esp_wifi_start());
ESP_LOGI(TAG, "wifi_init_softap finished. SSID:%s password:%s channel:%d",
EXAMPLE_ESP_WIFI_SSID, EXAMPLE_ESP_WIFI_PASS, EXAMPLE_ESP_WIFI_CHANNEL);
}
void app_main(void)
{
//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_ERROR_CHECK(nvs_flash_erase());
ret = nvs_flash_init();
}
ESP_ERROR_CHECK(ret);
ESP_LOGI(TAG, "ESP_WIFI_MODE_AP");
wifi_init_softap();
}
File diff suppressed because it is too large Load Diff
+557
View File
@@ -0,0 +1,557 @@
// ==================== 全局状态 ====================
let currentFile = null;
let uploadProgress = 0;
let isUploading = false;
let logFilter = 'all';
let logEntries = [];
let logSocket = null;
let latestSystemStatus = null;
// ==================== 工具函数 ====================
function formatBytes(bytes) {
if (bytes < 1024) return bytes + ' B';
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB';
return (bytes / (1024 * 1024)).toFixed(2) + ' MB';
}
function formatTime(date) {
return date.toTimeString().split(' ')[0];
}
function showToast(message, type = 'success') {
const toast = document.getElementById('toast');
const icon = document.getElementById('toastIcon');
const msg = document.getElementById('toastMessage');
toast.className = `toast ${type} show`;
icon.textContent = type === 'success' ? '✓' : type === 'error' ? '✕' : '⚠';
msg.textContent = message;
setTimeout(() => toast.classList.remove('show'), 3000);
}
function getEspIp() {
return document.getElementById('espIp').value.trim() || '192.168.31.127';
}
function getBaseUrl() {
return `http://${getEspIp()}`;
}
// ==================== 日志系统 ====================
function addLog(level, tag, message) {
const logContent = document.getElementById('logContent');
const time = formatTime(new Date());
const entry = { time, level, tag, message };
logEntries.push(entry);
if (logFilter !== 'all' && level !== logFilter) {
return;
}
const div = document.createElement('div');
div.className = 'log-entry';
div.innerHTML = `
<span class="log-time">[${time}]</span>
<span class="log-level ${level}">${level}</span>
<span class="log-tag">[${tag}]</span>
<span class="log-msg">${message}</span>
`;
logContent.appendChild(div);
logContent.scrollTop = logContent.scrollHeight;
document.getElementById('logCount').textContent = logEntries.length;
}
function clearLogs() {
document.getElementById('logContent').innerHTML = '';
logEntries = [];
document.getElementById('logCount').textContent = '0';
}
function toggleFilter(level, btn) {
document.querySelectorAll('.filter-btn').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
logFilter = level;
const logContent = document.getElementById('logContent');
logContent.innerHTML = '';
logEntries.forEach(entry => {
if (level === 'all' || entry.level === level) {
const div = document.createElement('div');
div.className = 'log-entry';
div.innerHTML = `
<span class="log-time">[${entry.time}]</span>
<span class="log-level ${entry.level}">${entry.level}</span>
<span class="log-tag">[${entry.tag}]</span>
<span class="log-msg">${entry.message}</span>
`;
logContent.appendChild(div);
}
});
logContent.scrollTop = logContent.scrollHeight;
}
// ==================== 连接管理 ====================
function updateConnectionUi(connected, text = null) {
const badge = document.getElementById('connectionBadge');
const label = document.getElementById('connectionText');
badge.className = connected ? 'badge connected' : 'badge disconnected';
label.textContent = text || (connected ? '已连接' : '未连接');
}
function updateHealthUi(healthCode, freeHeap) {
const healthNames = ['正常', '警告', '危险', '关机'];
const health = healthCode !== undefined ? (healthNames[healthCode] || '未知') : '未知';
document.getElementById('healthText').textContent = `系统${health}`;
document.getElementById('healthBadge').className = healthCode === 0 ? 'badge connected' : 'badge warning';
if (freeHeap !== undefined) {
document.getElementById('statHeap').textContent = formatBytes(freeHeap);
}
}
function updateSystemStatusUi(data) {
if (!data) return;
latestSystemStatus = data;
const wifi = data.wifi || {};
const ota = data.ota || {};
const healthCode = data.health;
const relayStates = {
0: '待机',
1: '等待握手',
2: '握手已发',
3: '挑战已发',
4: '收到握手响应',
5: '会话密钥已发',
6: '认证完成',
7: '大小已发',
8: '大小确认',
9: '接收中',
10: '转发中',
11: '数据包已发',
12: 'ACK正常',
13: '等待重发',
14: '接收完成',
15: '传输完成',
16: '结束已发',
17: 'OTA完成',
255: '错误'
};
updateConnectionUi(!!wifi.connected, wifi.connected ? `${wifi.mode || 'STA'} 已连接` : `${wifi.mode || 'STA'} 未连接`);
updateHealthUi(healthCode, data.free_heap);
// 显示固件版本
if (data.version) {
document.getElementById('fwVersion').textContent = `v${data.version}`;
}
document.getElementById('statTemperature').textContent = Number.isFinite(data.temperature) ? `${data.temperature.toFixed(1)}°C` : '--';
document.getElementById('statVoltage').textContent = Number.isFinite(data.voltage) ? `${data.voltage.toFixed(2)}V` : '--';
document.getElementById('statWifi').textContent = wifi.connected ? (wifi.ip || '已连接') : '未连接';
document.getElementById('statSignal').textContent = (wifi.rssi !== undefined && wifi.rssi !== -127) ? `${wifi.rssi} dBm` : '--';
document.getElementById('statHeap').textContent = data.free_heap !== undefined ? formatBytes(data.free_heap) : '--';
document.getElementById('statMinHeap').textContent = data.min_free_heap !== undefined ? formatBytes(data.min_free_heap) : '--';
const total = ota.total_size || 0;
const stored = ota.stored_size || 0;
const progress = total > 0 ? Math.min(100, Math.round(stored * 100 / total)) : 0;
document.getElementById('statProgress').textContent = `${progress}%`;
document.getElementById('statRelayState').textContent = relayStates[ota.relay_state] ?? '待机';
}
async function testConnection() {
if (!latestSystemStatus) {
addLog('W', '连接', '暂无同步数据,请等待 1 秒自动同步');
showToast('暂无同步数据,请稍等 1 秒', 'warning');
return;
}
const wifi = latestSystemStatus.wifi || {};
const connected = !!wifi.connected;
updateSystemStatusUi(latestSystemStatus);
addLog('I', '连接', connected
? `当前已连接:${wifi.mode || 'STA'} ${wifi.ip || ''}`.trim()
: `当前未连接:${wifi.mode || 'STA'}`);
showToast(connected ? 'ESP32 当前在线' : 'ESP32 当前未连接', connected ? 'success' : 'warning');
}
async function healthCheck() {
if (!latestSystemStatus) {
addLog('W', '健康', '暂无同步数据,请等待 1 秒自动同步');
showToast('暂无同步数据,请稍等 1 秒', 'warning');
return;
}
updateSystemStatusUi(latestSystemStatus);
const data = latestSystemStatus;
const healthNames = ['正常', '警告', '危险', '关机'];
const health = data.health !== undefined ? (healthNames[data.health] || '未知') : '未知';
addLog('I', '健康', `当前状态: ${health}, 空闲堆: ${data.free_heap} bytes, 温度: ${data.temperature}°C, 电压: ${data.voltage}V`);
showToast(`当前健康状态: ${health}`, 'success');
}
// ==================== CAN 帧发送 ====================
async function sendCanFrame() {
const ch = parseInt(document.getElementById('canChannel').value);
const idStr = document.getElementById('canId').value.trim();
const dlc = parseInt(document.getElementById('canDlc').value) || 8;
const dataStr = document.getElementById('canData').value.trim();
let extId = 0;
if (idStr.startsWith('0x') || idStr.startsWith('0X')) {
extId = parseInt(idStr, 16);
} else {
extId = parseInt(idStr, 10);
if (isNaN(extId)) extId = parseInt(idStr, 16);
}
if (isNaN(extId)) {
showToast('ID 格式错误', 'error');
return;
}
let dataHex = dataStr.replace(/\s/g, '');
if (dataHex.length % 2 !== 0) {
showToast('数据长度必须为偶数', 'error');
return;
}
addLog('I', 'CAN', `发送 CH${ch} ID=0x${extId.toString(16).toUpperCase()} DLC=${dlc}`);
try {
const response = await fetch(`${getBaseUrl()}/can/send`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
ch: ch,
id: extId,
dlc: dlc,
data: dataHex
}),
signal: AbortSignal.timeout(5000)
});
const result = await response.json();
if (result.status === 'ok') {
addLog('I', 'CAN', `响应: ${result.recv || '(无数据)'} (${result.recv_len || 0}B)`);
const respDiv = document.getElementById('canResponse');
const respContent = document.getElementById('canRespContent');
respDiv.style.display = 'block';
respContent.textContent = result.recv
? `0x${result.recv} (${result.recv_len} 字节)`
: '无响应数据';
showToast(`CH${ch} 发送成功`, 'success');
} else {
addLog('E', 'CAN', `发送失败: code=${result.code}`);
showToast(`发送失败: code=${result.code}`, 'error');
}
} catch (e) {
addLog('E', 'CAN', `发送失败: ${e.message}`);
showToast('发送失败', 'error');
}
}
function onTargetChipChange() {
const chip = document.getElementById('targetChip').value;
const relayBtn = document.getElementById('relayBtn');
const esp32Actions = document.getElementById('esp32Actions');
if (chip === 'esp32') {
relayBtn.style.display = 'none';
esp32Actions.style.display = 'block';
} else {
relayBtn.style.display = '';
esp32Actions.style.display = 'none';
}
addLog('I', '目标', `切换到: ${chip === 'esp32' ? 'ESP32 自身升级' : 'GD32 Flash 中继'}`);
}
function clearCanFields() {
document.getElementById('canId').value = '0x18DA00F1';
document.getElementById('canDlc').value = '8';
document.getElementById('canData').value = '0102030405060708';
document.getElementById('canResponse').style.display = 'none';
}
// ==================== 文件处理 ====================
function handleFileSelect(event) {
const file = event.target.files[0];
if (file) processFile(file);
}
document.getElementById('uploadZone').addEventListener('dragover', (e) => {
e.preventDefault();
e.currentTarget.classList.add('dragover');
});
document.getElementById('uploadZone').addEventListener('dragleave', (e) => {
e.currentTarget.classList.remove('dragover');
});
document.getElementById('uploadZone').addEventListener('drop', (e) => {
e.preventDefault();
e.currentTarget.classList.remove('dragover');
const file = e.dataTransfer.files[0];
if (file) processFile(file);
});
function processFile(file) {
currentFile = file;
const fileInfo = document.getElementById('fileInfo');
const uploadBtn = document.getElementById('uploadBtn');
document.getElementById('fileName').textContent = file.name;
document.getElementById('fileType').textContent = file.name.endsWith('.hex') ? 'Intel HEX' : 'Binary';
document.getElementById('fileSize').textContent = formatBytes(file.size);
const chunks = Math.ceil(file.size / 4096);
document.getElementById('fileChunks').textContent = chunks + ' 包';
fileInfo.classList.add('visible');
uploadBtn.disabled = false;
addLog('I', '文件', `已选择: ${file.name} (${formatBytes(file.size)})`);
showToast('文件已选择', 'success');
}
function clearFile() {
currentFile = null;
document.getElementById('fileInput').value = '';
document.getElementById('fileInfo').classList.remove('visible');
document.getElementById('uploadBtn').disabled = true;
document.getElementById('progressSection').classList.remove('visible');
addLog('I', '文件', '文件已清除');
}
// ==================== 上传固件 ====================
async function startUpload() {
if (!currentFile || isUploading) return;
const chip = document.getElementById('targetChip').value;
isUploading = true;
const uploadBtn = document.getElementById('uploadBtn');
uploadBtn.disabled = true;
uploadBtn.textContent = '上传中...';
const progressSection = document.getElementById('progressSection');
progressSection.classList.add('visible');
addLog('I', '上传', `开始上传: ${currentFile.name} → ${chip.toUpperCase()}`);
showToast(`开始上传固件到 ${chip.toUpperCase()}`, 'success');
try {
const arrayBuffer = await currentFile.arrayBuffer();
const fileSize = currentFile.size;
let body;
let url = `${getBaseUrl()}/ota/upload`;
if (chip === 'esp32') {
const headerJson = JSON.stringify({
type: 'esp32',
total_size: fileSize
});
const encoder = new TextEncoder();
const headerBytes = encoder.encode(headerJson);
const fileBytes = new Uint8Array(arrayBuffer);
const combined = new Uint8Array(headerBytes.length + fileSize);
combined.set(headerBytes, 0);
combined.set(fileBytes, headerBytes.length);
body = combined.buffer;
} else {
body = arrayBuffer;
}
const xhr = new XMLHttpRequest();
xhr.upload.addEventListener('progress', (e) => {
if (e.lengthComputable) {
const percent = Math.round((e.loaded / e.total) * 100);
uploadProgress = percent;
document.getElementById('progressBar').style.width = percent + '%';
document.getElementById('progressText').textContent =
`已上传 ${formatBytes(e.loaded)} / ${formatBytes(e.total)}`;
document.getElementById('progressPercent').textContent = percent + '%';
document.getElementById('statProgress').textContent = percent + '%';
}
});
xhr.addEventListener('load', () => {
isUploading = false;
uploadBtn.disabled = false;
uploadBtn.textContent = '⬆️ 上传固件';
if (xhr.status === 200) {
addLog('I', '上传', '固件上传完成!');
showToast('固件上传成功', 'success');
if (chip === 'esp32') {
document.getElementById('esp32Actions').style.display = 'block';
// 安全间隔:3 秒后自动调用刷写,留时间确认状态
let countdown = 3;
addLog('I', 'OTA', `${countdown} 秒后自动调用 POST /ota/update 完成升级并重启...`);
const timer = setInterval(() => {
countdown--;
if (countdown > 0) {
addLog('I', 'OTA', `${countdown} 秒后开始刷写...`);
} else {
clearInterval(timer);
addLog('I', 'OTA', '开始刷写!');
triggerEsp32Update();
}
}, 1000);
}
} else {
addLog('E', '上传', `上传失败: HTTP ${xhr.status}`);
showToast('上传失败', 'error');
}
});
xhr.addEventListener('error', () => {
isUploading = false;
uploadBtn.disabled = false;
uploadBtn.textContent = '⬆️ 上传固件';
addLog('E', '上传', '上传过程发生错误');
showToast('上传出错', 'error');
});
xhr.open('POST', url);
xhr.send(body);
} catch (e) {
addLog('E', '上传', `上传失败: ${e.message}`);
showToast('上传失败', 'error');
isUploading = false;
uploadBtn.disabled = false;
uploadBtn.textContent = '⬆️ 上传固件';
}
}
// ==================== ESP32 OTA 完成升级 ====================
async function triggerEsp32Update() {
addLog('I', 'OTA', '=== ESP32 OTA 完成,开始重启 ===');
showToast('ESP32 正在重启...', 'success');
try {
const response = await fetch(`${getBaseUrl()}/ota/update`, {
method: 'POST',
signal: AbortSignal.timeout(5000)
});
if (response.ok) {
const data = await response.json();
addLog('I', 'OTA', `升级完成: ${data.message}`);
addLog('I', 'OTA', 'ESP32 即将重启,新固件将生效,页面将继续通过 1 秒状态同步自动恢复显示');
showToast('ESP32 重启中...', 'success');
}
} catch (e) {
addLog('E', 'OTA', `升级失败: ${e.message}`);
showToast('升级失败', 'error');
}
}
// ==================== 触发 OTA 中继 ====================
async function triggerRelay() {
addLog('I', 'OTA', '触发 GD32 OTA 中继...');
showToast('正在触发 OTA 中继', 'success');
document.getElementById('stage1').className = 'stage active';
document.getElementById('statRelayState').textContent = '进行中';
try {
const response = await fetch(`${getBaseUrl()}/ota/relay`, {
method: 'POST',
signal: AbortSignal.timeout(5000)
});
if (response.ok) {
addLog('I', 'OTA', 'OTA 中继已触发,正在等待 GD32 响应...');
setTimeout(() => {
document.getElementById('stage1').className = 'stage completed';
document.getElementById('stage2').className = 'stage active';
}, 2000);
setTimeout(() => {
document.getElementById('stage2').className = 'stage completed';
document.getElementById('stage3').className = 'stage active';
}, 4000);
setTimeout(() => {
document.getElementById('stage3').className = 'stage completed';
document.getElementById('stage4').className = 'stage active';
}, 6000);
setTimeout(() => {
document.getElementById('stage4').className = 'stage completed';
document.getElementById('statRelayState').textContent = '完成';
addLog('I', 'OTA', '=== OTA 中继完成 ===');
showToast('OTA 流程完成!', 'success');
}, 8000);
} else {
addLog('E', 'OTA', `触发失败: HTTP ${response.status}`);
showToast('触发失败', 'error');
}
} catch (e) {
addLog('E', 'OTA', `触发失败: ${e.message}`);
showToast('触发失败', 'error');
document.getElementById('statRelayState').textContent = '失败';
}
}
// ==================== 轮询状态 ====================
async function pollStatus() {
try {
const response = await fetch(`${getBaseUrl()}/system/status`, {
signal: AbortSignal.timeout(3000)
});
if (response.ok) {
const data = await response.json();
updateSystemStatusUi(data);
}
} catch (e) {
updateConnectionUi(false, '未连接');
}
}
setInterval(pollStatus, 1000);
// ==================== ESP32 实时日志拉取 ====================
async function pollLogs() {
try {
const response = await fetch(`${getBaseUrl()}/logs`, {
signal: AbortSignal.timeout(2000)
});
if (!response.ok) return;
const logs = await response.json();
if (!Array.isArray(logs) || logs.length === 0) return;
// 解析 [I][WIFI] xxx 格式
const levelMap = { 'E': 'E', 'W': 'W', 'I': 'I', 'D': 'D' };
logs.forEach(line => {
// 格式: [I][WIFI] message 或 [E][OTA] message
const m = line.match(/^\[([EWID])\]\[([^\]]+)\]\s*(.*)$/);
if (m) {
addLog(levelMap[m[1]] || 'I', m[2], m[3].trimEnd());
} else {
addLog('I', 'ESP32', line.trimEnd());
}
});
} catch (e) {
// 静默失败,不打扰用户
}
}
setInterval(pollLogs, 1000);
setTimeout(() => addLog('I', '系统', '上位机初始化完成,等待固件文件...'), 500);
+282
View File
@@ -0,0 +1,282 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>ESP32 OTA 中继系统 - 上位机</title>
<link rel="stylesheet" href="style.css">
</head>
<body>
<div class="container">
<!-- 顶部标题栏 -->
<header class="header">
<div class="logo-section">
<div class="logo-icon">📡</div>
<div class="logo-text">
<h1>ESP32 OTA 中继系统</h1>
<p>GD32 固件空中升级管理平台</p>
</div>
</div>
<div class="status-badges">
<div class="badge disconnected" id="connectionBadge">
<span class="dot"></span>
<span id="connectionText">未连接</span>
</div>
<div class="badge" id="healthBadge">
<span class="dot"></span>
<span id="healthText">系统正常</span>
</div>
<div class="badge">
<span id="fwVersion">v--</span>
</div>
</div>
</header>
<!-- 主网格布局 -->
<div class="main-grid">
<!-- 左侧面板 -->
<div class="left-panel">
<!-- 连接配置 -->
<div class="card connection-card">
<div class="card-header">
<div class="card-title">
<div class="icon">🔗</div>
连接配置
</div>
</div>
<div class="form-group">
<label>ESP32 IP 地址</label>
<div class="input-group">
<input type="text" id="espIp" value="192.168.31.127" placeholder="输入 IP 地址">
<button class="btn btn-secondary" onclick="testConnection()">测试</button>
</div>
</div>
<div class="form-group">
<label>目标芯片</label>
<select id="targetChip" onchange="onTargetChipChange()" style="width:100%;background:var(--bg-secondary);border:1px solid var(--border);border-radius:8px;padding:10px 12px;color:var(--text-primary);font-size:13px;">
<option value="gd32">GD32(Flash 中继)</option>
<option value="esp32">ESP32(自身升级)</option>
</select>
</div>
<button class="btn btn-primary" style="width:100%; margin-top:8px;" onclick="healthCheck()">
🔍 执行健康检查
</button>
</div>
<!-- 文件上传 -->
<div class="card">
<div class="card-header">
<div class="card-title">
<div class="icon">📁</div>
固件文件
</div>
</div>
<div class="upload-zone" id="uploadZone" onclick="document.getElementById('fileInput').click()">
<div class="upload-icon">📤</div>
<h3>点击或拖拽文件到此处</h3>
<p>支持 .hex 和 .bin 格式</p>
</div>
<input type="file" id="fileInput" class="file-input" accept=".hex,.bin" onchange="handleFileSelect(event)">
<div class="file-info" id="fileInfo">
<div class="file-header">
<div class="file-name">
<span class="icon">📄</span>
<span id="fileName">-</span>
</div>
<button class="btn btn-secondary" onclick="clearFile()" style="padding:6px 12px;">✕</button>
</div>
<div class="file-meta">
<div class="meta-item"><span>类型:</span> <b id="fileType">-</b></div>
<div class="meta-item"><span>大小:</span> <b id="fileSize">-</b></div>
<div class="meta-item"><span>数据包:</span> <b id="fileChunks">-</b></div>
</div>
</div>
<div class="progress-section" id="progressSection">
<div class="progress-bar-container">
<div class="progress-bar" id="progressBar"></div>
</div>
<div class="progress-info">
<span id="progressText">准备上传...</span>
<span id="progressPercent">0%</span>
</div>
</div>
<div class="action-buttons">
<button class="btn btn-primary" id="uploadBtn" onclick="startUpload()" disabled>
⬆️ 上传固件
</button>
<button class="btn btn-success" id="relayBtn" onclick="triggerRelay()">
🚀 触发 OTA
</button>
</div>
<div id="esp32Actions" style="display:none; margin-top:12px;">
<button class="btn btn-primary" style="width:100%;" onclick="triggerEsp32Update()">
🔄 完成 ESP32 升级(重启)
</button>
</div>
</div>
<!-- OTA 阶段指示 -->
<div class="card">
<div class="card-header">
<div class="card-title">
<div class="icon">📊</div>
OTA 流程阶段
</div>
</div>
<div class="stage-indicator">
<div class="stage" id="stage1">
<div class="stage-icon">1️⃣</div>
<div class="stage-label">握手认证</div>
</div>
<div class="stage" id="stage2">
<div class="stage-icon">2️⃣</div>
<div class="stage-label">传输准备</div>
</div>
<div class="stage" id="stage3">
<div class="stage-icon">3️⃣</div>
<div class="stage-label">数据转发</div>
</div>
<div class="stage" id="stage4">
<div class="stage-icon">4️⃣</div>
<div class="stage-label">完成跳转</div>
</div>
</div>
</div>
</div>
<!-- 右侧面板 -->
<div class="right-panel">
<!-- 实时状态 -->
<div class="card">
<div class="card-header">
<div class="card-title">
<div class="icon">📈</div>
实时状态
</div>
</div>
<div class="status-grid">
<div class="status-item">
<div class="value" id="statTemperature">--</div>
<div class="label">芯片温度</div>
</div>
<div class="status-item">
<div class="value" id="statVoltage">--</div>
<div class="label">电源电压</div>
</div>
<div class="status-item">
<div class="value" id="statWifi">--</div>
<div class="label">WiFi 状态</div>
</div>
<div class="status-item">
<div class="value" id="statSignal">--</div>
<div class="label">信号强度</div>
</div>
<div class="status-item">
<div class="value" id="statHeap">0</div>
<div class="label">空闲堆</div>
</div>
<div class="status-item">
<div class="value" id="statMinHeap">0</div>
<div class="label">最低空闲堆</div>
</div>
<div class="status-item">
<div class="value" id="statProgress">0%</div>
<div class="label">上传进度</div>
</div>
<div class="status-item">
<div class="value" id="statRelayState">待机</div>
<div class="label">中继状态</div>
</div>
</div>
</div>
<!-- CAN 帧发送面板 -->
<div class="card">
<div class="card-header">
<div class="card-title">
<div class="icon">📨</div>
CAN 帧发送
</div>
</div>
<div style="display:grid; grid-template-columns:80px 1fr 80px 1fr; gap:12px; margin-bottom:16px;">
<div class="form-group" style="margin:0;">
<label style="font-size:11px;color:var(--text-muted);margin-bottom:6px;display:block;">通道</label>
<select id="canChannel" style="width:100%;background:var(--bg-secondary);border:1px solid var(--border);border-radius:8px;padding:10px 12px;color:var(--text-primary);font-size:13px;">
<option value="0">CH0</option>
<option value="1">CH1</option>
<option value="2">CH2</option>
</select>
</div>
<div class="form-group" style="margin:0;">
<label style="font-size:11px;color:var(--text-muted);margin-bottom:6px;display:block;">扩展帧 ID</label>
<input type="text" id="canId" value="0x18DA00F1" placeholder="例如: 0x18DA00F1" style="width:100%;background:var(--bg-secondary);border:1px solid var(--border);border-radius:8px;padding:10px 12px;color:var(--text-primary);font-size:13px;">
</div>
<div class="form-group" style="margin:0;">
<label style="font-size:11px;color:var(--text-muted);margin-bottom:6px;display:block;">DLC</label>
<input type="number" id="canDlc" value="8" min="1" max="64" style="width:100%;background:var(--bg-secondary);border:1px solid var(--border);border-radius:8px;padding:10px 12px;color:var(--text-primary);font-size:13px;">
</div>
<div class="form-group" style="margin:0;">
<label style="font-size:11px;color:var(--text-muted);margin-bottom:6px;display:block;">数据 (HEX)</label>
<input type="text" id="canData" value="0102030405060708" placeholder="01020304..." style="width:100%;background:var(--bg-secondary);border:1px solid var(--border);border-radius:8px;padding:10px 12px;color:var(--text-primary);font-size:13px;">
</div>
</div>
<div style="display:flex;gap:12px;">
<button class="btn btn-primary" style="flex:1;" onclick="sendCanFrame()">📤 发送</button>
<button class="btn btn-secondary" onclick="clearCanFields()">清空</button>
</div>
<div id="canResponse" style="margin-top:16px;background:var(--bg-secondary);border-radius:10px;padding:12px 16px;font-family:monospace;font-size:12px;display:none;">
<div style="color:var(--text-muted);margin-bottom:6px;">响应:</div>
<div id="canRespContent" style="color:var(--accent);"></div>
</div>
</div>
<!-- 调试日志 -->
<div class="card log-panel">
<div class="card-header">
<div class="card-title">
<div class="icon">🖥️</div>
调试日志
<span style="font-size:12px;color:var(--text-muted);font-weight:normal;margin-left:8px;">UDP:6666</span>
</div>
</div>
<div class="card-body">
<div class="log-header">
<span style="font-size:13px;color:var(--text-muted);">实时日志输出</span>
<div class="log-filters">
<button class="filter-btn active" onclick="toggleFilter('all', this)">全部</button>
<button class="filter-btn" onclick="toggleFilter('E', this)">错误</button>
<button class="filter-btn" onclick="toggleFilter('W', this)">警告</button>
<button class="filter-btn" onclick="toggleFilter('I', this)">信息</button>
</div>
</div>
<div class="log-content" id="logContent">
<div class="log-entry">
<span class="log-time">[--:--:--]</span>
<span class="log-level I">I</span>
<span class="log-tag">[系统]</span>
<span class="log-msg">等待上位机连接...</span>
</div>
</div>
<div class="log-footer">
<span class="log-stats"><span id="logCount">0</span> 条日志</span>
<button class="btn btn-secondary" onclick="clearLogs()" style="padding:6px 12px;font-size:12px;">清除日志</button>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Toast 提示 -->
<div class="toast" id="toast">
<span class="toast-icon" id="toastIcon">✓</span>
<span class="toast-message" id="toastMessage">操作成功</span>
</div>
<script src="app.js"></script>
</body>
</html>
File diff suppressed because it is too large Load Diff
+680
View File
@@ -0,0 +1,680 @@
:root {
--bg-primary: #0a0e1a;
--bg-secondary: #111827;
--bg-card: #1a2235;
--bg-card-hover: #1f2a40;
--accent: #00d4ff;
--accent-glow: rgba(0, 212, 255, 0.3);
--accent-secondary: #7c3aed;
--success: #10b981;
--warning: #f59e0b;
--danger: #ef4444;
--text-primary: #f1f5f9;
--text-secondary: #94a3b8;
--text-muted: #64748b;
--border: #1e293b;
--gradient-1: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
--gradient-2: linear-gradient(135deg, #00d4ff 0%, #7c3aed 100%);
--gradient-3: linear-gradient(135deg, #0a0e1a 0%, #1a2235 100%);
}
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: 'SF Pro Display', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: var(--bg-primary);
color: var(--text-primary);
min-height: 100vh;
overflow-x: hidden;
}
body::before {
content: '';
position: fixed;
top: 0; left: 0; right: 0; bottom: 0;
background:
radial-gradient(ellipse 80% 50% at 20% 20%, rgba(0, 212, 255, 0.08) 0%, transparent 50%),
radial-gradient(ellipse 60% 40% at 80% 80%, rgba(124, 58, 237, 0.08) 0%, transparent 50%);
pointer-events: none;
z-index: 0;
}
.container {
position: relative;
z-index: 1;
max-width: 1400px;
margin: 0 auto;
padding: 30px 40px;
}
.header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 40px;
padding-bottom: 24px;
border-bottom: 1px solid var(--border);
}
.logo-section {
display: flex;
align-items: center;
gap: 16px;
}
.logo-icon {
width: 56px;
height: 56px;
background: var(--gradient-2);
border-radius: 16px;
display: flex;
align-items: center;
justify-content: center;
font-size: 28px;
box-shadow: 0 8px 32px var(--accent-glow);
}
.logo-text h1 {
font-size: 26px;
font-weight: 700;
background: var(--gradient-2);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
.logo-text p {
font-size: 13px;
color: var(--text-muted);
margin-top: 4px;
}
.status-badges {
display: flex;
gap: 12px;
}
.badge {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 16px;
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: 20px;
font-size: 13px;
transition: all 0.3s;
}
.badge .dot {
width: 8px;
height: 8px;
border-radius: 50%;
animation: pulse 2s infinite;
}
.badge.connected .dot { background: var(--success); }
.badge.disconnected .dot { background: var(--danger); animation: none; }
.badge.warning .dot { background: var(--warning); }
@keyframes pulse {
0%, 100% { opacity: 1; transform: scale(1); }
50% { opacity: 0.5; transform: scale(1.2); }
}
.main-grid {
display: grid;
grid-template-columns: 420px 1fr;
gap: 24px;
}
.left-panel {
display: flex;
flex-direction: column;
gap: 20px;
}
.card {
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: 20px;
padding: 24px;
transition: all 0.3s;
}
.card:hover {
border-color: rgba(0, 212, 255, 0.2);
box-shadow: 0 0 40px rgba(0, 212, 255, 0.05);
}
.card-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 20px;
}
.card-title {
display: flex;
align-items: center;
gap: 10px;
font-size: 16px;
font-weight: 600;
}
.card-title .icon {
width: 36px;
height: 36px;
background: var(--gradient-2);
border-radius: 10px;
display: flex;
align-items: center;
justify-content: center;
font-size: 18px;
}
.connection-card .form-group {
margin-bottom: 16px;
}
.form-group label {
display: block;
font-size: 12px;
color: var(--text-muted);
margin-bottom: 8px;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.input-group {
display: flex;
gap: 8px;
}
input[type="text"] {
flex: 1;
background: var(--bg-secondary);
border: 1px solid var(--border);
border-radius: 10px;
padding: 12px 16px;
color: var(--text-primary);
font-size: 14px;
outline: none;
transition: all 0.3s;
}
input[type="text"]:focus {
border-color: var(--accent);
box-shadow: 0 0 0 3px var(--accent-glow);
}
.btn {
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
padding: 12px 20px;
border: none;
border-radius: 10px;
font-size: 14px;
font-weight: 500;
cursor: pointer;
transition: all 0.3s;
}
.btn-primary {
background: var(--gradient-2);
color: white;
box-shadow: 0 4px 20px var(--accent-glow);
}
.btn-primary:hover {
transform: translateY(-2px);
box-shadow: 0 8px 30px var(--accent-glow);
}
.btn-secondary {
background: var(--bg-secondary);
color: var(--text-secondary);
border: 1px solid var(--border);
}
.btn-secondary:hover {
background: var(--bg-card-hover);
color: var(--text-primary);
}
.btn-success {
background: linear-gradient(135deg, #10b981 0%, #059669 100%);
color: white;
}
.btn-danger {
background: linear-gradient(135deg, #ef4444 0%, #dc2626 100%);
color: white;
}
.btn:disabled {
opacity: 0.5;
cursor: not-allowed;
transform: none !important;
}
.upload-zone {
border: 2px dashed var(--border);
border-radius: 16px;
padding: 40px 20px;
text-align: center;
cursor: pointer;
transition: all 0.3s;
position: relative;
overflow: hidden;
}
.upload-zone::before {
content: '';
position: absolute;
inset: 0;
background: linear-gradient(135deg, rgba(0, 212, 255, 0.05) 0%, rgba(124, 58, 237, 0.05) 100%);
opacity: 0;
transition: opacity 0.3s;
}
.upload-zone:hover::before,
.upload-zone.dragover::before {
opacity: 1;
}
.upload-zone:hover {
border-color: var(--accent);
}
.upload-icon {
width: 64px;
height: 64px;
margin: 0 auto 16px;
background: var(--bg-secondary);
border-radius: 16px;
display: flex;
align-items: center;
justify-content: center;
font-size: 28px;
}
.upload-zone h3 {
font-size: 16px;
margin-bottom: 8px;
}
.upload-zone p {
font-size: 13px;
color: var(--text-muted);
}
.file-input {
display: none;
}
.file-info {
background: var(--bg-secondary);
border-radius: 12px;
padding: 16px;
display: none;
}
.file-info.visible {
display: block;
}
.file-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 12px;
}
.file-name {
display: flex;
align-items: center;
gap: 10px;
font-size: 14px;
font-weight: 500;
}
.file-name .icon {
font-size: 20px;
}
.file-size {
font-size: 12px;
color: var(--text-muted);
}
.file-meta {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 8px;
}
.meta-item {
background: var(--bg-card);
padding: 8px 12px;
border-radius: 8px;
font-size: 12px;
}
.meta-item span {
color: var(--text-muted);
}
.progress-section {
margin-top: 20px;
display: none;
}
.progress-section.visible {
display: block;
}
.progress-bar-container {
background: var(--bg-secondary);
border-radius: 10px;
height: 12px;
overflow: hidden;
margin-bottom: 12px;
}
.progress-bar {
height: 100%;
background: var(--gradient-2);
border-radius: 10px;
width: 0%;
transition: width 0.3s ease;
position: relative;
}
.progress-bar::after {
content: '';
position: absolute;
top: 0; left: 0; right: 0; bottom: 0;
background: linear-gradient(90deg, transparent 0%, rgba(255,255,255,0.2) 50%, transparent 100%);
animation: shimmer 2s infinite;
}
@keyframes shimmer {
0% { transform: translateX(-100%); }
100% { transform: translateX(100%); }
}
.progress-info {
display: flex;
justify-content: space-between;
font-size: 12px;
color: var(--text-muted);
}
.action-buttons {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 12px;
margin-top: 20px;
}
.right-panel {
display: flex;
flex-direction: column;
gap: 20px;
}
.status-grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 12px;
}
.status-item {
background: var(--bg-secondary);
border-radius: 12px;
padding: 16px;
text-align: center;
}
.status-item .value {
font-size: 24px;
font-weight: 700;
background: var(--gradient-2);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
.status-item .label {
font-size: 11px;
color: var(--text-muted);
margin-top: 4px;
text-transform: uppercase;
}
.log-panel {
flex: 1;
display: flex;
flex-direction: column;
min-height: 500px;
}
.log-panel .card-body {
flex: 1;
display: flex;
flex-direction: column;
}
.log-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px 20px;
border-bottom: 1px solid var(--border);
}
.log-filters {
display: flex;
gap: 8px;
}
.filter-btn {
padding: 6px 12px;
border-radius: 20px;
border: 1px solid var(--border);
background: transparent;
color: var(--text-muted);
font-size: 12px;
cursor: pointer;
transition: all 0.3s;
}
.filter-btn:hover,
.filter-btn.active {
background: var(--accent);
color: white;
border-color: var(--accent);
}
.log-content {
flex: 1;
background: var(--bg-secondary);
border-radius: 12px;
margin: 16px;
padding: 16px;
overflow-y: auto;
max-height: 400px;
font-family: 'SF Mono', 'Fira Code', 'Consolas', monospace;
font-size: 12px;
line-height: 1.6;
}
.log-content::-webkit-scrollbar {
width: 6px;
}
.log-content::-webkit-scrollbar-track {
background: var(--bg-secondary);
border-radius: 3px;
}
.log-content::-webkit-scrollbar-thumb {
background: var(--border);
border-radius: 3px;
}
.log-entry {
padding: 4px 0;
border-bottom: 1px solid rgba(255,255,255,0.03);
display: flex;
gap: 8px;
}
.log-time { color: var(--text-muted); }
.log-level {
font-weight: 600;
min-width: 24px;
}
.log-level.E { color: var(--danger); }
.log-level.W { color: var(--warning); }
.log-level.I { color: var(--accent); }
.log-level.D { color: var(--text-muted); }
.log-tag { color: var(--accent-secondary); }
.log-msg { color: var(--text-primary); }
.log-footer {
padding: 12px 20px;
border-top: 1px solid var(--border);
display: flex;
justify-content: space-between;
align-items: center;
}
.log-stats {
font-size: 12px;
color: var(--text-muted);
}
.stage-indicator {
display: flex;
align-items: center;
justify-content: space-between;
padding: 20px 0;
}
.stage {
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
position: relative;
flex: 1;
}
.stage:not(:last-child)::after {
content: '';
position: absolute;
top: 20px;
left: calc(50% + 24px);
width: calc(100% - 48px);
height: 2px;
background: var(--border);
}
.stage.active:not(:last-child)::after,
.stage.completed:not(:last-child)::after {
background: var(--accent);
}
.stage-icon {
width: 40px;
height: 40px;
border-radius: 50%;
background: var(--bg-secondary);
border: 2px solid var(--border);
display: flex;
align-items: center;
justify-content: center;
font-size: 18px;
transition: all 0.3s;
position: relative;
z-index: 1;
}
.stage.active .stage-icon {
background: var(--accent);
border-color: var(--accent);
color: white;
box-shadow: 0 0 20px var(--accent-glow);
}
.stage.completed .stage-icon {
background: var(--success);
border-color: var(--success);
color: white;
}
.stage-label {
font-size: 11px;
color: var(--text-muted);
text-align: center;
}
.stage.active .stage-label,
.stage.completed .stage-label {
color: var(--text-primary);
}
.toast {
position: fixed;
bottom: 30px;
right: 30px;
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: 12px;
padding: 16px 24px;
display: flex;
align-items: center;
gap: 12px;
box-shadow: 0 10px 40px rgba(0,0,0,0.5);
transform: translateY(100px);
opacity: 0;
transition: all 0.3s;
z-index: 1000;
}
.toast.show {
transform: translateY(0);
opacity: 1;
}
.toast.success { border-left: 4px solid var(--success); }
.toast.error { border-left: 4px solid var(--danger); }
.toast.warning { border-left: 4px solid var(--warning); }
.toast-icon {
font-size: 24px;
}
.toast-message {
font-size: 14px;
}
@keyframes fadeIn {
from { opacity: 0; transform: translateY(20px); }
to { opacity: 1; transform: translateY(0); }
}
.card {
animation: fadeIn 0.5s ease forwards;
}
.left-panel .card:nth-child(1) { animation-delay: 0.1s; }
.left-panel .card:nth-child(2) { animation-delay: 0.2s; }
.left-panel .card:nth-child(3) { animation-delay: 0.3s; }
.right-panel .card:nth-child(1) { animation-delay: 0.15s; }
.right-panel .card:nth-child(2) { animation-delay: 0.25s; }