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:
Vendored
+1
-1
@@ -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"
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
@@ -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
|
||||
)
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -1,2 +0,0 @@
|
||||
## IDF Component Manager Manifest File
|
||||
dependencies: {}
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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__)
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -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);
|
||||
|
||||
|
||||
@@ -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
@@ -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);
|
||||
@@ -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
@@ -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; }
|
||||
Reference in New Issue
Block a user