feat(config): 优化NimBLE配置以节省内存并提升OTA性能
- 启用BLE连接事件长度优化,允许单CE内传输多包,提升OTA吞吐 - 缩减NimBLE mbuf池配置,单连接OTA场景节省约7KB内存 - 禁用未使用的标准GATT服务,仅保留自定义OTA service,节省8-15KB - 缩减NimBLE传输缓冲区配置以优化内存使用 refactor(debug_log): 重构环形日志缓冲区API以支持逐条弹出 - 新增ringPopOldest方法,支持弹出最旧日志并从缓冲区移除 - 重构ringDrainJson实现,基于新的逐条弹出机制 - 改进日志转义处理逻辑,支持更多特殊字符转义 perf(tasks): 优化各任务栈大小配置以节省内存 - 将ota_relay任务栈从12288字节缩减至6144字节 - 将ble_ota任务栈从8192字节缩减至4096字节 - 将ble_ota数据工作线程栈从8192字节缩减至4096字节 - 通过栈高水位监控验证实际使用情况,确保安全余量 feat(monitor): 添加任务句柄获取接口以支持系统状态监控 - 为HealthMonitor、OtaRelay、BleOta和WifiOta类添加任务句柄获取方法 - 在任务入口处记录当前任务句柄 - 便于系统状态监控和调试信息收集 feat(status): 扩展系统状态API以提供更详细的内存和任务信息 - 增加内部堆和默认堆的详细统计信息 - 添加各关键任务的栈使用情况监控 - 扩展/system/status端点返回更全面的系统资源使用情况 - 包含最大空闲块、最小空闲内存等关键指标
This commit is contained in:
@@ -35,6 +35,34 @@ CONFIG_BTDM_BLE_MAX_CONN_EFF=1
|
||||
CONFIG_BTDM_CTRL_BLE_MAX_CONN_EFF=1
|
||||
CONFIG_BTDM_BLE_MAX_CONN_EFF_EFF=1
|
||||
|
||||
# BLE 连接事件长度优化(允许单 CE 内传多包,提升 OTA 吞吐)
|
||||
CONFIG_BT_CTRL_CE_LENGTH_TYPE_CE=y
|
||||
|
||||
# NimBLE mbuf 池(单连接 OTA 场景缩减,省 ~7 KB)
|
||||
CONFIG_BT_NIMBLE_MSYS_1_BLOCK_COUNT=8
|
||||
CONFIG_BT_NIMBLE_MSYS_2_BLOCK_COUNT=16
|
||||
CONFIG_BT_NIMBLE_MSYS1_BLOCK_COUNT=8
|
||||
|
||||
# NimBLE: 禁用未使用的标准 GATT 服务(只保留自定义 OTA service,省 ~8-15 KB)
|
||||
CONFIG_BT_NIMBLE_PROX_SERVICE=n
|
||||
CONFIG_BT_NIMBLE_ANS_SERVICE=n
|
||||
CONFIG_BT_NIMBLE_CTS_SERVICE=n
|
||||
CONFIG_BT_NIMBLE_HTP_SERVICE=n
|
||||
CONFIG_BT_NIMBLE_IPSS_SERVICE=n
|
||||
CONFIG_BT_NIMBLE_TPS_SERVICE=n
|
||||
CONFIG_BT_NIMBLE_IAS_SERVICE=n
|
||||
CONFIG_BT_NIMBLE_LLS_SERVICE=n
|
||||
CONFIG_BT_NIMBLE_SPS_SERVICE=n
|
||||
CONFIG_BT_NIMBLE_HR_SERVICE=n
|
||||
CONFIG_BT_NIMBLE_BAS_SERVICE=n
|
||||
CONFIG_BT_NIMBLE_DIS_SERVICE=n
|
||||
|
||||
# NimBLE: 传输缓冲区缩减
|
||||
CONFIG_BT_NIMBLE_TRANSPORT_ACL_FROM_LL_COUNT=6
|
||||
CONFIG_BT_NIMBLE_TRANSPORT_EVT_COUNT=16
|
||||
CONFIG_BT_NIMBLE_ATT_MAX_PREP_ENTRIES=16
|
||||
CONFIG_BT_NIMBLE_GATT_MAX_PROCS=2
|
||||
|
||||
# mbedTLS
|
||||
CONFIG_MBEDTLS_CERTIFICATE_BUNDLE=y
|
||||
CONFIG_MBEDTLS_CERTIFICATE_BUNDLE_DEFAULT=y
|
||||
|
||||
+2
-2
@@ -95,7 +95,7 @@ extern "C" void app_main(void)
|
||||
xRet = xTaskCreatePinnedToCore(
|
||||
OtaRelay::taskEntry,
|
||||
"ota_relay",
|
||||
12288,
|
||||
6144,
|
||||
NULL,
|
||||
configMAX_PRIORITIES - 2,
|
||||
NULL,
|
||||
@@ -127,7 +127,7 @@ extern "C" void app_main(void)
|
||||
xRet = xTaskCreatePinnedToCore(
|
||||
BleOta::taskEntry,
|
||||
"ble_ota",
|
||||
8192,
|
||||
4096,
|
||||
NULL,
|
||||
configMAX_PRIORITIES - 3,
|
||||
NULL,
|
||||
|
||||
@@ -4,11 +4,11 @@
|
||||
// 固件版本
|
||||
#define FW_VERSION_MAJOR 0
|
||||
#define FW_VERSION_MINOR 3
|
||||
#define FW_VERSION_PATCH 28
|
||||
#define FW_VERSION_STRING "0.3.28"
|
||||
#define FW_VERSION_PATCH 29
|
||||
#define FW_VERSION_STRING "0.3.29"
|
||||
|
||||
// 编译时间
|
||||
#define BUILD_TIME "2026-06-19 03:24:28"
|
||||
#define BUILD_TIME "2026-06-19 23:45:14"
|
||||
|
||||
// Git 信息
|
||||
#define GIT_HASH "7feef70"
|
||||
#define GIT_HASH "59c729d"
|
||||
|
||||
+64
-49
@@ -60,74 +60,89 @@ int DebugLog::ringCount()
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief 读取并清空环形日志缓冲区,返回 JSON 数组字符串。
|
||||
* @param out_len 输出参数,返回 JSON 字符串长度。
|
||||
* @return 指向 JSON 字符串的指针,需调用者 free。
|
||||
* @brief 弹出一条最旧日志并从环形缓冲区移除。
|
||||
* @param out 输出缓冲区。
|
||||
* @param out_size 输出缓冲区大小。
|
||||
* @param out_len 输出日志长度。
|
||||
* @return true 表示成功弹出一条日志,false 表示缓冲区为空或参数无效。
|
||||
*/
|
||||
char *DebugLog::ringDrainJson(int *out_len)
|
||||
bool DebugLog::ringPopOldest(char *out, size_t out_size, int *out_len)
|
||||
{
|
||||
if (!m_logRingMutex) {
|
||||
if (out_len) {
|
||||
*out_len = 0;
|
||||
return NULL;
|
||||
}
|
||||
if (!m_logRingMutex || !out || out_size == 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
xSemaphoreTake(m_logRingMutex, portMAX_DELAY);
|
||||
|
||||
int count = m_logRingCount;
|
||||
if (count == 0) {
|
||||
if (m_logRingCount <= 0) {
|
||||
xSemaphoreGive(m_logRingMutex);
|
||||
*out_len = 0;
|
||||
return NULL;
|
||||
return false;
|
||||
}
|
||||
|
||||
// 估算 JSON 大小:每条 ~280 字节(转义 + JSON 包装)
|
||||
size_t buf_size = (size_t)count * 280 + 16;
|
||||
char *json = (char *)malloc(buf_size);
|
||||
if (!json) {
|
||||
xSemaphoreGive(m_logRingMutex);
|
||||
*out_len = 0;
|
||||
return NULL;
|
||||
int idx = (m_logRingHead - m_logRingCount + LOG_RING_SLOTS) % LOG_RING_SLOTS;
|
||||
int len = m_logRingLens[idx];
|
||||
if (len < 0) {
|
||||
len = 0;
|
||||
}
|
||||
|
||||
int pos = 0;
|
||||
json[pos++] = '[';
|
||||
|
||||
// 从最旧的开始读
|
||||
int start = (count < LOG_RING_SLOTS) ? 0 : m_logRingHead;
|
||||
for (int i = 0; i < count && pos < (int)buf_size - 200; i++) {
|
||||
int idx = (start + i) % LOG_RING_SLOTS;
|
||||
int len = m_logRingLens[idx];
|
||||
if (len <= 0) continue;
|
||||
|
||||
if (i > 0) json[pos++] = ',';
|
||||
|
||||
pos += snprintf(json + pos, buf_size - pos, "\"");
|
||||
for (int j = 0; j < len && pos < (int)buf_size - 10; j++) {
|
||||
char c = m_logRing[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, "\"");
|
||||
int copy_len = len;
|
||||
if ((size_t)copy_len >= out_size) {
|
||||
copy_len = (int)out_size - 1;
|
||||
}
|
||||
|
||||
json[pos++] = ']';
|
||||
json[pos] = '\0';
|
||||
if (copy_len > 0) {
|
||||
memcpy(out, m_logRing[idx], (size_t)copy_len);
|
||||
}
|
||||
out[copy_len] = '\0';
|
||||
if (out_len) {
|
||||
*out_len = copy_len;
|
||||
}
|
||||
|
||||
// 清空缓冲区
|
||||
m_logRingHead = 0;
|
||||
m_logRingCount = 0;
|
||||
m_logRingLens[idx] = 0;
|
||||
m_logRingCount--;
|
||||
|
||||
xSemaphoreGive(m_logRingMutex);
|
||||
return true;
|
||||
}
|
||||
|
||||
char* DebugLog::ringDrainJson(int *out_len)
|
||||
{
|
||||
if (!out_len) return NULL;
|
||||
*out_len = 0;
|
||||
int count = ringCount();
|
||||
int capacity = count * (LOG_RING_SIZE * 2) + 16;
|
||||
if (capacity < 16) capacity = 16;
|
||||
char *buf = (char*)malloc(capacity);
|
||||
if (!buf) return NULL;
|
||||
|
||||
int pos = 0;
|
||||
buf[pos++] = '[';
|
||||
bool first = true;
|
||||
char entry[LOG_RING_SIZE];
|
||||
int entryLen = 0;
|
||||
|
||||
while (ringPopOldest(entry, sizeof(entry), &entryLen) && entryLen > 0) {
|
||||
if (!first) buf[pos++] = ',';
|
||||
first = false;
|
||||
buf[pos++] = '"';
|
||||
for (int i = 0; i < entryLen && pos < capacity - 4; i++) {
|
||||
char c = entry[i];
|
||||
if (c == '"') { buf[pos++] = '\\'; buf[pos++] = '"'; }
|
||||
else if (c == '\\') { buf[pos++] = '\\'; buf[pos++] = '\\'; }
|
||||
else if (c == '\n') { buf[pos++] = '\\'; buf[pos++] = 'n'; }
|
||||
else if (c == '\r') { buf[pos++] = '\\'; buf[pos++] = 'r'; }
|
||||
else if (c == '\t') { buf[pos++] = '\\'; buf[pos++] = 't'; }
|
||||
else if ((unsigned char)c >= 0x20) { buf[pos++] = c; }
|
||||
}
|
||||
buf[pos++] = '"';
|
||||
}
|
||||
buf[pos++] = ']';
|
||||
buf[pos] = '\0';
|
||||
*out_len = pos;
|
||||
return json;
|
||||
return buf;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -72,7 +72,8 @@ public:
|
||||
/** @brief 获取当前环形日志缓冲区中的日志条数。 */
|
||||
int ringCount();
|
||||
|
||||
/** @brief 读取并清空环形日志缓冲区,返回 JSON 数组字符串(需调用者 free)。 */
|
||||
/** @brief 弹出一条最旧日志并从环形缓冲区移除。 */
|
||||
bool ringPopOldest(char *out, size_t out_size, int *out_len);
|
||||
char* ringDrainJson(int *out_len);
|
||||
|
||||
/** @brief 当前日志级别(运行时可改)。 */
|
||||
|
||||
@@ -16,7 +16,6 @@
|
||||
#include "esp_err.h"
|
||||
#include "driver/temperature_sensor.h"
|
||||
|
||||
#include "esp_heap_caps.h"
|
||||
#include "health_monitor.h"
|
||||
#include "debug_log.h"
|
||||
#include "server/wifi_ota.h"
|
||||
@@ -167,7 +166,9 @@ void HealthMonitor::tempMonitorCallback(void *arg)
|
||||
*/
|
||||
void HealthMonitor::taskEntry(void *pvParameters)
|
||||
{
|
||||
(void)pvParameters;
|
||||
auto& self = instance();
|
||||
self.m_taskHandle = xTaskGetCurrentTaskHandle();
|
||||
DEBUG_INFO("MON", "=== 健康监控任务启动 ===");
|
||||
|
||||
temperature_sensor_config_t temp_config = TEMPERATURE_SENSOR_CONFIG_DEFAULT(20, 80);
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
#include <stdint.h>
|
||||
#include "freertos/FreeRTOS.h"
|
||||
#include "freertos/semphr.h"
|
||||
#include "freertos/task.h"
|
||||
#include "driver/temperature_sensor.h"
|
||||
#include "app_config.h"
|
||||
|
||||
@@ -70,6 +71,9 @@ public:
|
||||
/** @brief 获取最近一次 WiFi RSSI 及其年龄。 */
|
||||
bool getLastWifiRssi(int8_t *out_rssi, uint32_t *out_age_ms);
|
||||
|
||||
/** @brief 获取健康监控任务句柄。 */
|
||||
TaskHandle_t getTaskHandle() const { return m_taskHandle; }
|
||||
|
||||
private:
|
||||
/** @brief 加锁读取健康状态。 */
|
||||
health_state_t getState();
|
||||
@@ -88,6 +92,7 @@ private:
|
||||
float m_lastTemperature = 0.0f;
|
||||
int8_t m_lastWifiRssi = -127;
|
||||
int64_t m_lastWifiRssiMs = 0;
|
||||
TaskHandle_t m_taskHandle = NULL;
|
||||
|
||||
static void tempMonitorCallback(void *arg);
|
||||
};
|
||||
@@ -41,6 +41,7 @@ gd32_version_t OtaRelay::gd32Version = {};
|
||||
QueueHandle_t OtaRelay::dispatchQueue = NULL;
|
||||
QueueHandle_t OtaRelay::resultQueue = NULL;
|
||||
SemaphoreHandle_t OtaRelay::s_stateMutex = NULL;
|
||||
TaskHandle_t OtaRelay::taskHandle = NULL;
|
||||
|
||||
// ============================================================
|
||||
// 状态表:将每个 relay_state_t 映射到对应的 handler
|
||||
@@ -134,6 +135,11 @@ void OtaRelay::setGd32Version(const gd32_version_t &ver)
|
||||
gd32Version = ver;
|
||||
}
|
||||
|
||||
TaskHandle_t OtaRelay::getTaskHandle()
|
||||
{
|
||||
return taskHandle;
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 事件分发核心:processEvent()
|
||||
// ============================================================
|
||||
@@ -1397,6 +1403,7 @@ ota_result_t OtaRelay::doFinishAndVerify()
|
||||
void OtaRelay::taskEntry(void *pvParameters)
|
||||
{
|
||||
(void)pvParameters;
|
||||
taskHandle = xTaskGetCurrentTaskHandle();
|
||||
DEBUG_INFO("OTA", "OTA 中继任务启动");
|
||||
|
||||
initMutex();
|
||||
|
||||
@@ -19,6 +19,7 @@
|
||||
#include "freertos/FreeRTOS.h"
|
||||
#include "freertos/queue.h"
|
||||
#include "freertos/semphr.h"
|
||||
#include "freertos/task.h"
|
||||
#include "transport/protocol.h"
|
||||
#include "transport/spi_master.h"
|
||||
#include "esp_partition.h"
|
||||
@@ -137,6 +138,10 @@ public:
|
||||
* @brief 设置GD32固件版本.
|
||||
*/
|
||||
static void setGd32Version(const gd32_version_t &ver);
|
||||
/**
|
||||
* @brief 获取 OTA 中继任务句柄。
|
||||
*/
|
||||
static TaskHandle_t getTaskHandle();
|
||||
|
||||
/**
|
||||
* @brief 重置状态机到 IDLE.
|
||||
@@ -199,6 +204,7 @@ public:
|
||||
static gd32_version_t gd32Version;
|
||||
static QueueHandle_t dispatchQueue;
|
||||
static QueueHandle_t resultQueue;
|
||||
static TaskHandle_t taskHandle;
|
||||
|
||||
// ── 线程安全锁 ──
|
||||
static SemaphoreHandle_t s_stateMutex;
|
||||
|
||||
@@ -1205,6 +1205,7 @@ static void ble_ota_process_data_batch(const uint8_t *buf, size_t recv_len)
|
||||
static void ble_ota_data_worker_task(void *pvParameters)
|
||||
{
|
||||
(void)pvParameters;
|
||||
BleOta::instance().m_workerTaskHandle = xTaskGetCurrentTaskHandle();
|
||||
DEBUG_INFO(TAG, "OTA 数据工作线程已启动");
|
||||
|
||||
while (1) {
|
||||
@@ -1715,6 +1716,7 @@ void BleOta::signalRelay()
|
||||
void BleOta::taskEntry(void *pvParameters)
|
||||
{
|
||||
(void)pvParameters;
|
||||
BleOta::instance().m_taskHandle = xTaskGetCurrentTaskHandle();
|
||||
DEBUG_INFO(TAG, "=== BLE OTA 任务启动 ===");
|
||||
|
||||
// 创建 OTA 数据队列和工作线程(在 NimBLE 初始化之前完成)
|
||||
@@ -1722,7 +1724,7 @@ void BleOta::taskEntry(void *pvParameters)
|
||||
if (s_otaDataQueue) {
|
||||
BaseType_t wk_ok = xTaskCreate(ble_ota_data_worker_task,
|
||||
"ble_ota_worker",
|
||||
8192, // 6KB→8KB,flash 操作需要足够栈空间
|
||||
4096, // 峰值仅 ~1.9KB,4KB 留有充足余量
|
||||
NULL,
|
||||
10, // 中等优先级,避免被 NimBLE host task 过度抢占
|
||||
NULL);
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
#include <stdint.h>
|
||||
#include "esp_err.h"
|
||||
#include "esp_ota_ops.h"
|
||||
#include "freertos/task.h"
|
||||
|
||||
class BleOta {
|
||||
public:
|
||||
@@ -47,6 +48,12 @@ public:
|
||||
*/
|
||||
void signalRelay();
|
||||
|
||||
/** @brief 获取 BLE OTA 守护任务句柄。 */
|
||||
TaskHandle_t getTaskHandle() const { return m_taskHandle; }
|
||||
|
||||
/** @brief 获取 BLE OTA 数据工作线程句柄。 */
|
||||
TaskHandle_t getWorkerTaskHandle() const { return m_workerTaskHandle; }
|
||||
|
||||
// 以下状态为 public,供 C 回调函数访问
|
||||
bool m_bleReady = false;
|
||||
bool m_bleConnected = false;
|
||||
@@ -56,6 +63,8 @@ public:
|
||||
bool m_esp32RebootPending = false; // 收尾任务去重标志
|
||||
uint8_t m_esp32LastCmd = 0; // 最后一次数据包命令(用于补发 ACK)
|
||||
uint16_t m_esp32LastSeq = 0xFFFFU; // 最后一次数据包序号(用于补发 ACK)
|
||||
TaskHandle_t m_taskHandle = nullptr;
|
||||
TaskHandle_t m_workerTaskHandle = nullptr;
|
||||
};
|
||||
|
||||
#endif // BLE_OTA_H
|
||||
|
||||
@@ -31,6 +31,7 @@
|
||||
#include "health_monitor.h"
|
||||
#include "monitor/debug_log.h"
|
||||
#include "spi_master.h"
|
||||
#include "ble_ota.h"
|
||||
#include "cJSON.h"
|
||||
|
||||
|
||||
@@ -1166,6 +1167,46 @@ static esp_err_t spi_send_handler(httpd_req_t *req)
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
static constexpr uint32_t INTERNAL_HEAP_CAPS = MALLOC_CAP_INTERNAL | MALLOC_CAP_8BIT;
|
||||
|
||||
struct TaskStackMetric {
|
||||
const char* name;
|
||||
TaskHandle_t handle;
|
||||
size_t stack_size_bytes;
|
||||
};
|
||||
|
||||
static size_t get_task_stack_high_watermark_bytes(TaskHandle_t handle)
|
||||
{
|
||||
if (handle == NULL) return 0;
|
||||
UBaseType_t highWaterMark = uxTaskGetStackHighWaterMark(handle);
|
||||
return (size_t)highWaterMark;
|
||||
}
|
||||
|
||||
static size_t get_task_stack_peak_used_bytes(TaskHandle_t handle, size_t stack_size_bytes)
|
||||
{
|
||||
size_t high_watermark = get_task_stack_high_watermark_bytes(handle);
|
||||
return (stack_size_bytes >= high_watermark) ? (stack_size_bytes - high_watermark) : 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief 追加 JSON 内容到响应缓冲区,自动处理 snprintf 截断。
|
||||
*/
|
||||
static int append_jsonf(char* buf, size_t cap, int offset, const char* fmt, ...)
|
||||
{
|
||||
if (!buf || cap == 0 || offset < 0 || (size_t)offset >= cap) return offset;
|
||||
va_list args;
|
||||
va_start(args, fmt);
|
||||
int n = vsnprintf(buf + offset, cap - (size_t)offset, fmt, args);
|
||||
va_end(args);
|
||||
if (n < 0) return offset;
|
||||
size_t written = (size_t)n;
|
||||
if (offset + (int)written >= (int)cap) {
|
||||
buf[cap - 1] = '\0';
|
||||
return (int)(cap - 1);
|
||||
}
|
||||
return offset + (int)written;
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief HTTP GET /system/status 处理器
|
||||
* @param req
|
||||
@@ -1236,12 +1277,45 @@ static esp_err_t system_status_handler(httpd_req_t *req)
|
||||
gd32_version_t snapVer2 = {};
|
||||
OtaRelay::getGd32Version(&snapVer2);
|
||||
ota_system_state_t sysState = OtaCoordinator::instance().getSystemState();
|
||||
char resp[1280];
|
||||
size_t free_heap = esp_get_free_heap_size();
|
||||
size_t min_free_heap = esp_get_minimum_free_heap_size();
|
||||
size_t largest_free_block = heap_caps_get_largest_free_block(MALLOC_CAP_DEFAULT);
|
||||
size_t internal_free_heap = heap_caps_get_free_size(INTERNAL_HEAP_CAPS);
|
||||
size_t internal_min_free_heap = heap_caps_get_minimum_free_size(INTERNAL_HEAP_CAPS);
|
||||
size_t internal_largest_free_block = heap_caps_get_largest_free_block(INTERNAL_HEAP_CAPS);
|
||||
size_t internal_heap_total = heap_caps_get_total_size(INTERNAL_HEAP_CAPS);
|
||||
size_t default_heap_total = heap_caps_get_total_size(MALLOC_CAP_DEFAULT);
|
||||
TaskStackMetric task_metrics[] = {
|
||||
{ "http_server", xTaskGetCurrentTaskHandle(), HTTP_SERVER_STACK_SIZE },
|
||||
{ "health_mon", HealthMonitor::instance().getTaskHandle(), 4096 },
|
||||
{ "ota_relay", OtaRelay::getTaskHandle(), 6144 },
|
||||
{ "wifi_ota", WifiOta::instance().getTaskHandle(), 8192 },
|
||||
{ "wifi_reconnect", WifiOta::instance().m_reconnectTask, 4096 },
|
||||
{ "ble_ota", BleOta::instance().getTaskHandle(), 4096 },
|
||||
{ "ble_ota_worker", BleOta::instance().getWorkerTaskHandle(), 4096 }
|
||||
};
|
||||
|
||||
char resp[4096];
|
||||
int len = snprintf(resp, sizeof(resp),
|
||||
"{\"version\": \"%s\", \"build_time\": \"%s\", \"git_hash\": \"%s\", \"health\": %d, \"free_heap\": %lu, \"min_free_heap\": %lu, \"temperature\": %.1f, \"uptime_sec\": %lu, \"wifi\": {\"mode\": \"%s\", \"ip\": \"%s\", \"rssi\": %d, \"connected\": %s}, \"ota\": {\"sys_state\": \"%s\", \"sys_state_code\": %d, \"total_size\": %lu, \"stored_size\": %lu, \"storage_done\": %s, \"relay_state\": %d, \"relay_seq\": %lu, \"relay_total_packets\": %lu, \"gd32_app_alive\": %s, \"gd32_app\": \"v%d.%d.%d.%d\", \"gd32_boot\": \"v%d.%d.%d.%d\"}}",
|
||||
"{\"version\": \"%s\", \"build_time\": \"%s\", \"git_hash\": \"%s\", \"health\": %d, "
|
||||
"\"free_heap\": %lu, \"min_free_heap\": %lu, \"largest_free_block\": %lu, "
|
||||
"\"internal_free_heap\": %lu, \"internal_min_free_heap\": %lu, \"internal_largest_free_block\": %lu, "
|
||||
"\"internal_heap_total\": %lu, \"default_heap_total\": %lu, "
|
||||
"\"temperature\": %.1f, \"uptime_sec\": %lu, "
|
||||
"\"wifi\": {\"mode\": \"%s\", \"ip\": \"%s\", \"rssi\": %d, \"connected\": %s}, "
|
||||
"\"ota\": {\"sys_state\": \"%s\", \"sys_state_code\": %d, \"total_size\": %lu, \"stored_size\": %lu, \"storage_done\": %s, "
|
||||
"\"relay_state\": %d, \"relay_seq\": %lu, \"relay_total_packets\": %lu, \"gd32_app_alive\": %s, "
|
||||
"\"gd32_app\": \"v%d.%d.%d.%d\", \"gd32_boot\": \"v%d.%d.%d.%d\"}, "
|
||||
"\"task_stack\": {",
|
||||
FW_VERSION, FW_BUILD_TIME, FW_GIT_HASH, HealthMonitor::instance().getCurrent(),
|
||||
(unsigned long)esp_get_free_heap_size(),
|
||||
(unsigned long)esp_get_minimum_free_heap_size(),
|
||||
(unsigned long)free_heap,
|
||||
(unsigned long)min_free_heap,
|
||||
(unsigned long)largest_free_block,
|
||||
(unsigned long)internal_free_heap,
|
||||
(unsigned long)internal_min_free_heap,
|
||||
(unsigned long)internal_largest_free_block,
|
||||
(unsigned long)internal_heap_total,
|
||||
(unsigned long)default_heap_total,
|
||||
(double)HealthMonitor::instance().getLastTemperature(),
|
||||
(unsigned long)(esp_timer_get_time() / 1000000),
|
||||
WifiOta::instance().m_wifiMode == OTA_WIFI_MODE_AP ? "AP" : "STA",
|
||||
@@ -1262,6 +1336,17 @@ static esp_err_t system_status_handler(httpd_req_t *req)
|
||||
snapVer2.boot_major, snapVer2.boot_minor,
|
||||
snapVer2.boot_patch, snapVer2.boot_build);
|
||||
|
||||
for (size_t i = 0; i < sizeof(task_metrics) / sizeof(task_metrics[0]); ++i) {
|
||||
len = append_jsonf(resp, sizeof(resp), len,
|
||||
"%s\"%s\":{\"stack_size_bytes\":%lu,\"high_watermark_bytes\":%lu,\"peak_used_bytes\":%lu}",
|
||||
i == 0 ? "" : ",",
|
||||
task_metrics[i].name,
|
||||
(unsigned long)task_metrics[i].stack_size_bytes,
|
||||
(unsigned long)get_task_stack_high_watermark_bytes(task_metrics[i].handle),
|
||||
(unsigned long)get_task_stack_peak_used_bytes(task_metrics[i].handle, task_metrics[i].stack_size_bytes));
|
||||
}
|
||||
len = append_jsonf(resp, sizeof(resp), len, "}}");
|
||||
|
||||
httpd_resp_set_type(req, "application/json");
|
||||
httpd_resp_send(req, resp, len);
|
||||
return ESP_OK;
|
||||
@@ -1648,6 +1733,7 @@ bool WifiOta::earlyInit()
|
||||
void WifiOta::taskEntry(void *pvParameters)
|
||||
{
|
||||
(void)pvParameters;
|
||||
WifiOta::instance().m_taskHandle = xTaskGetCurrentTaskHandle();
|
||||
|
||||
DEBUG_INFO("WIFI", "=== WiFi OTA 任务启动 ===");
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
#include "esp_ota_ops.h"
|
||||
#include "esp_http_server.h"
|
||||
#include "esp_event.h"
|
||||
#include "freertos/task.h"
|
||||
#include <atomic>
|
||||
|
||||
// SHA-256 验证所需类型前向声明
|
||||
@@ -102,6 +103,9 @@ public:
|
||||
/** @brief BLE OTA 开始传输时调用,暂停 WiFi RSSI 轮询等射频活动。 */
|
||||
void setBleOtaActive(bool v) { m_bleOtaActive.store(v); }
|
||||
|
||||
/** @brief 获取 WiFi OTA 任务句柄。 */
|
||||
TaskHandle_t getTaskHandle() const { return m_taskHandle; }
|
||||
|
||||
private:
|
||||
// ── 跨任务共享状态(atomic + m_stateMutex 保护)──
|
||||
std::atomic<bool> m_staConnected{false};
|
||||
@@ -110,6 +114,7 @@ private:
|
||||
std::atomic<bool> m_gd32StorageDone{false};
|
||||
std::atomic<bool> m_relayTriggered{false};
|
||||
SemaphoreHandle_t m_stateMutex = NULL;
|
||||
TaskHandle_t m_taskHandle = nullptr;
|
||||
|
||||
/**
|
||||
* @brief 初始化 SoftAP 模式,供局域网 OTA 使用。
|
||||
|
||||
+32
-1
@@ -1,6 +1,37 @@
|
||||
# WiFi perf -- OTA acceleration
|
||||
CONFIG_ESP_WIFI_RX_BA_WIN=6
|
||||
CONFIG_ESP_WIFI_TX_BA_WIN=6
|
||||
CONFIG_LWIP_MAX_SOCKETS=10
|
||||
|
||||
# LWIP: HTTP server max_open_sockets=4 + WiFi/LWIP 内部 ~3 = 7 够用
|
||||
CONFIG_LWIP_MAX_SOCKETS=7
|
||||
CONFIG_LWIP_TCP_WND_DEFAULT=11480
|
||||
CONFIG_LWIP_TCP_SND_BUF_DEFAULT=11520
|
||||
|
||||
# ── NimBLE: 禁用未使用的标准 GATT 服务(只保留自定义 OTA service)──
|
||||
# 每项 service 含 characteristic + descriptor + UUID 注册,合计约占 8-15 KB
|
||||
CONFIG_BT_NIMBLE_PROX_SERVICE=n
|
||||
CONFIG_BT_NIMBLE_ANS_SERVICE=n
|
||||
CONFIG_BT_NIMBLE_CTS_SERVICE=n
|
||||
CONFIG_BT_NIMBLE_HTP_SERVICE=n
|
||||
CONFIG_BT_NIMBLE_IPSS_SERVICE=n
|
||||
CONFIG_BT_NIMBLE_TPS_SERVICE=n
|
||||
CONFIG_BT_NIMBLE_IAS_SERVICE=n
|
||||
CONFIG_BT_NIMBLE_LLS_SERVICE=n
|
||||
CONFIG_BT_NIMBLE_SPS_SERVICE=n
|
||||
CONFIG_BT_NIMBLE_HR_SERVICE=n
|
||||
CONFIG_BT_NIMBLE_BAS_SERVICE=n
|
||||
CONFIG_BT_NIMBLE_DIS_SERVICE=n
|
||||
|
||||
# ── NimBLE: 缩减内存池(单连接 OTA 场景,不需要大池子)──
|
||||
# MSYS pool 1: 256B blocks, 16→8 (省 ~2 KB)
|
||||
CONFIG_BT_NIMBLE_MSYS_1_BLOCK_COUNT=8
|
||||
# MSYS pool 2: 320B blocks, 32→16 (省 ~5 KB)
|
||||
CONFIG_BT_NIMBLE_MSYS_2_BLOCK_COUNT=16
|
||||
# ACL transport buffers: 12→6 (省 ~1.5 KB)
|
||||
CONFIG_BT_NIMBLE_TRANSPORT_ACL_FROM_LL_COUNT=6
|
||||
# Event buffers: 30→16 (省 ~1 KB)
|
||||
CONFIG_BT_NIMBLE_TRANSPORT_EVT_COUNT=16
|
||||
# Prepared write entries: 64→16 (省 ~1 KB)
|
||||
CONFIG_BT_NIMBLE_ATT_MAX_PREP_ENTRIES=16
|
||||
# GATT procedures: 4→2 (单连接够用)
|
||||
CONFIG_BT_NIMBLE_GATT_MAX_PROCS=2
|
||||
|
||||
+176
-30
@@ -14,6 +14,7 @@ let pageVisible = !document.hidden; // 页面可见性:切到后台/最小
|
||||
let latestSystemStatus = null;
|
||||
let isEsp32Rebooting = false;
|
||||
let rebootStartTime = 0;
|
||||
let pollingPausedForUpload = false;
|
||||
|
||||
// ==================== 折叠面板 ====================
|
||||
function toggleCollapse(headEl) {
|
||||
@@ -172,6 +173,18 @@ function formatUptime(sec) {
|
||||
if (m > 0) return m + 'm ' + s + 's';
|
||||
return s + 's';
|
||||
}
|
||||
function escapeHtml(text) {
|
||||
return String(text == null ? '' : text)
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
function formatPercent(value) {
|
||||
if (!Number.isFinite(value)) return '--';
|
||||
return value.toFixed(1).replace(/\.0$/, '') + '%';
|
||||
}
|
||||
function formatHttpErrorDetail(text) {
|
||||
var detail = (text || '').replace(/<[^>]*>/g, ' ').replace(/\s+/g, ' ').trim();
|
||||
if (detail.length > 180) detail = detail.slice(0, 177) + '...';
|
||||
@@ -299,6 +312,7 @@ function resetStageFlow() {
|
||||
function applyDisconnectedRuntimeState() {
|
||||
document.getElementById('pillIP').textContent = '--';
|
||||
document.getElementById('pillRSSI').textContent = '--';
|
||||
if (isUploading || currentOtaTarget) return;
|
||||
resetProgress();
|
||||
resetStageFlow();
|
||||
}
|
||||
@@ -354,7 +368,92 @@ function updateHealthUi(healthCode, freeHeap) {
|
||||
connStatusTxt.textContent = (latestSystemStatus.wifi.connected ? '设备在线' : '设备离线') + ' · 系统' + health;
|
||||
}
|
||||
if (freeHeap !== undefined) {
|
||||
document.getElementById('mHeap').textContent = formatBytes(freeHeap);
|
||||
var mHeapEl = document.getElementById('mHeap');
|
||||
if (mHeapEl) mHeapEl.textContent = formatBytes(freeHeap);
|
||||
}
|
||||
var mh = document.getElementById('mHealth');
|
||||
if (mh) {
|
||||
mh.textContent = health;
|
||||
mh.className = 'v ' + (healthCode === 0 ? 'ok' : healthCode === 1 ? 'warn' : 'err');
|
||||
}
|
||||
}
|
||||
function updateHeapAndTaskStatusUi(data) {
|
||||
if (!data) return;
|
||||
var ota = data.ota || {};
|
||||
var wifi = data.wifi || {};
|
||||
var targetHost = getBaseUrl().replace(/^http:\/\//, '');
|
||||
var setText = function (id, value) {
|
||||
var el = document.getElementById(id);
|
||||
if (el) el.textContent = value;
|
||||
};
|
||||
// ---- 堆概况:已用 / 总计 + 占用条 ----
|
||||
function setFill(id, pct) {
|
||||
var el = document.getElementById(id);
|
||||
if (!el) return;
|
||||
el.style.width = Math.max(0, Math.min(100, pct)) + '%';
|
||||
el.className = 'heap-bar-fill' + (pct >= 85 ? ' danger' : pct >= 70 ? ' warn' : '');
|
||||
}
|
||||
function setBar(usageId, fillId, pctId, free, total) {
|
||||
if (free === undefined || total === undefined) {
|
||||
setText(usageId, '--'); setFill(fillId, 0); setText(pctId, '--');
|
||||
return;
|
||||
}
|
||||
var used = total - free;
|
||||
var pct = total > 0 ? (used * 100 / total) : 0;
|
||||
setText(usageId, '已用 ' + formatBytes(used) + ' / 总计 ' + formatBytes(total));
|
||||
setFill(fillId, pct);
|
||||
setText(pctId, pct.toFixed(1) + '%');
|
||||
}
|
||||
setText('mInternalHeap', data.internal_free_heap !== undefined ? formatBytes(data.internal_free_heap) : '--');
|
||||
setText('mInternalMinHeap', data.internal_min_free_heap !== undefined ? formatBytes(data.internal_min_free_heap) : '--');
|
||||
setText('mInternalLargestBlock', data.internal_largest_free_block !== undefined ? formatBytes(data.internal_largest_free_block) : '--');
|
||||
setBar('memInternalUsage', 'memInternalFill', 'memInternalPct', data.internal_free_heap, data.internal_heap_total);
|
||||
|
||||
setText('mLargestBlock', data.largest_free_block !== undefined ? formatBytes(data.largest_free_block) : '--');
|
||||
|
||||
setText('sOtaState', (ota.sys_state || '--') + (Number.isFinite(ota.sys_state_code) ? ' (' + ota.sys_state_code + ')' : ''));
|
||||
setText('sRelayState', Number.isFinite(ota.relay_state) ? String(ota.relay_state) : '--');
|
||||
var storedText = '--';
|
||||
if (ota.total_size || ota.stored_size) {
|
||||
storedText = formatBytes(ota.stored_size || 0) + ' / ' + formatBytes(ota.total_size || 0);
|
||||
}
|
||||
setText('sOtaStored', storedText);
|
||||
setText('sStatusUpdated', formatTime(new Date()));
|
||||
|
||||
var taskList = document.getElementById('taskStackList');
|
||||
if (taskList) {
|
||||
var taskStack = data.task_stack || {};
|
||||
var names = Object.keys(taskStack);
|
||||
if (!names.length) {
|
||||
taskList.innerHTML = '<div class="status-empty">当前状态未包含任务栈信息</div>';
|
||||
} else {
|
||||
taskList.innerHTML = names.map(function (name) {
|
||||
var item = taskStack[name] || {};
|
||||
var stackSize = Number(item.stack_size_bytes) || 0;
|
||||
var watermark = Number(item.high_watermark_bytes) || 0;
|
||||
var peakUsed = Number(item.peak_used_bytes) || 0;
|
||||
var usagePct = stackSize > 0 ? (peakUsed * 100 / stackSize) : 0;
|
||||
var fillClass = usagePct >= 85 ? 'err' : usagePct >= 70 ? 'warn' : '';
|
||||
return '' +
|
||||
'<div class="status-task">' +
|
||||
'<div class="status-task-head">' +
|
||||
'<div class="status-task-name">' + escapeHtml(name) + '</div>' +
|
||||
'<div class="status-task-ratio">' + escapeHtml(formatPercent(usagePct)) + '</div>' +
|
||||
'</div>' +
|
||||
'<div class="status-task-bar"><div class="status-task-bar-fill ' + fillClass + '" style="width:' + Math.max(0, Math.min(100, usagePct)) + '%"></div></div>' +
|
||||
'<div class="status-task-meta">' +
|
||||
'<div><span>栈大小</span><b>' + escapeHtml(formatBytes(stackSize)) + '</b></div>' +
|
||||
'<div><span>剩余水位</span><b>' + escapeHtml(formatBytes(watermark)) + '</b></div>' +
|
||||
'<div><span>峰值占用</span><b>' + escapeHtml(formatBytes(peakUsed)) + '</b></div>' +
|
||||
'</div>' +
|
||||
'</div>';
|
||||
}).join('');
|
||||
}
|
||||
}
|
||||
|
||||
var statusJson = document.getElementById('statusJson');
|
||||
if (statusJson) {
|
||||
statusJson.textContent = JSON.stringify(data, null, 2);
|
||||
}
|
||||
}
|
||||
// ==================== OTA 阶段驱动 (真实状态) ====================
|
||||
@@ -425,6 +524,7 @@ function updateStagesFromRelayState(rs) {
|
||||
function updateSystemStatusUi(data) {
|
||||
if (!data) return;
|
||||
latestSystemStatus = data;
|
||||
updateHeapAndTaskStatusUi(data);
|
||||
var wifi = data.wifi || {};
|
||||
var ota = data.ota || {};
|
||||
var healthCode = data.health;
|
||||
@@ -433,10 +533,12 @@ function updateSystemStatusUi(data) {
|
||||
var connText = connected ? (wifi.mode || 'STA') + ' 已连接' : (wifi.mode || 'STA') + ' 未连接';
|
||||
updateConnectionUi(connected, connText);
|
||||
updateHealthUi(healthCode, data.free_heap);
|
||||
// version
|
||||
if (data.version) {
|
||||
document.getElementById('pillVer').textContent = 'v' + data.version;
|
||||
document.getElementById('pillVer').dataset.tooltip = data.build_time ? ('编译: ' + data.build_time) : '';
|
||||
var pillVerEl = document.getElementById('pillVer');
|
||||
if (pillVerEl) {
|
||||
pillVerEl.textContent = 'v' + data.version;
|
||||
pillVerEl.dataset.tooltip = data.build_time ? ("编译: " + data.build_time) : '';
|
||||
}
|
||||
// GD32 版本
|
||||
var mGd32App = document.getElementById('mGd32App');
|
||||
var mGd32Boot = document.getElementById('mGd32Boot');
|
||||
@@ -460,23 +562,20 @@ function updateSystemStatusUi(data) {
|
||||
}
|
||||
}
|
||||
// temperature
|
||||
document.getElementById('mTemp').textContent =
|
||||
var mTempEl = document.getElementById('mTemp');
|
||||
if (mTempEl) mTempEl.textContent =
|
||||
Number.isFinite(data.temperature) ? data.temperature.toFixed(1) + '\u00B0C' : '--';
|
||||
// uptime
|
||||
var uptimeEl = document.getElementById('pillUptime');
|
||||
if (uptimeEl) uptimeEl.textContent = '\u23F1 ' + formatUptime(data.uptime_sec);
|
||||
// wifi / signal
|
||||
document.getElementById('pillIP').textContent = wifi.ip || '--';
|
||||
var pillIPEl = document.getElementById('pillIP');
|
||||
if (pillIPEl) pillIPEl.textContent = wifi.ip || '--';
|
||||
var rssi = (wifi.rssi !== undefined && wifi.rssi !== -127) ? wifi.rssi + ' dBm' : '--';
|
||||
document.getElementById('pillRSSI').textContent = rssi;
|
||||
// heap
|
||||
document.getElementById('mHeap').textContent =
|
||||
data.free_heap !== undefined ? formatBytes(data.free_heap) : '--';
|
||||
document.getElementById('mMinHeap').textContent =
|
||||
data.min_free_heap !== undefined ? formatBytes(data.min_free_heap) : '--';
|
||||
// ota cache
|
||||
var otaStored = ota.stored_size || 0;
|
||||
var pillRSSIEl = document.getElementById('pillRSSI');
|
||||
if (pillRSSIEl) pillRSSIEl.textContent = rssi;
|
||||
var mOtaCache = document.getElementById('mOtaCache');
|
||||
var otaStored = ota.stored_size || 0;
|
||||
if (mOtaCache) {
|
||||
if (otaStored > 0) {
|
||||
mOtaCache.textContent = formatBytes(otaStored) + ' / 216 KB';
|
||||
@@ -501,14 +600,7 @@ function updateSystemStatusUi(data) {
|
||||
var total = ota.total_size || 0;
|
||||
var stored = ota.stored_size || 0;
|
||||
var storageProgress = total > 0 ? Math.min(100, Math.round(stored * 100 / total)) : 0;
|
||||
var bp = document.getElementById('bOTAProgress');
|
||||
function setOtaProgressBadge(pct) {
|
||||
var safePct = Math.max(0, Math.min(100, Math.round(pct || 0)));
|
||||
if (bp) {
|
||||
bp.textContent = safePct + '%';
|
||||
bp.className = 'v ' + (safePct >= 100 ? 'ok' : safePct > 0 ? 'warn' : '');
|
||||
}
|
||||
}
|
||||
function setOtaProgressBadge() {} // OTA 进度卡片已移除,保留空壳避免调用报错
|
||||
// GD32 relay forwarding progress
|
||||
var relaySeq = ota.relay_seq || 0;
|
||||
var relayTotal = ota.relay_total_packets || 0;
|
||||
@@ -607,6 +699,32 @@ async function testConnection() {
|
||||
showToast('无法连接: ' + diag, 'error');
|
||||
}
|
||||
}
|
||||
async function refreshSystemStatusNow(event) {
|
||||
if (event) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
}
|
||||
if (pollingPausedForUpload) return;
|
||||
try {
|
||||
var resp = await fetch(getBaseUrl() + '/system/status', {
|
||||
signal: AbortSignal.timeout(3000)
|
||||
});
|
||||
if (!resp.ok) {
|
||||
addLog('E', '状态', '刷新失败: HTTP ' + resp.status);
|
||||
showToast('状态刷新失败: HTTP ' + resp.status, 'error');
|
||||
return;
|
||||
}
|
||||
var data = await resp.json();
|
||||
latestSystemStatus = data;
|
||||
updateSystemStatusUi(data);
|
||||
consecutivePollFailures = 0;
|
||||
if (pollSlowMode) restorePolling();
|
||||
addLog('I', '状态', '已手动刷新 /system/status');
|
||||
} catch (e) {
|
||||
addLog('E', '状态', '刷新失败: ' + e.message);
|
||||
showToast('状态刷新失败', 'error');
|
||||
}
|
||||
}
|
||||
// ==================== CAN 帧发送 ====================
|
||||
async function sendCanFrame() {
|
||||
var ch = parseInt(document.getElementById('canChannel').value);
|
||||
@@ -984,6 +1102,7 @@ function clearFile(chip) {
|
||||
async function startUpload(chip) {
|
||||
var file = chip === 'esp32' ? esp32File : gd32File;
|
||||
if (!file || isUploading) return;
|
||||
pausePollingForUpload();
|
||||
// ★ 必须先抢锁再 await:守卫同步设置,await 期间重复点击才能被拦下。
|
||||
// 旧实现把 isUploading 设在 await handle.getFile() 之后 → 用户在 BLE 期间
|
||||
// 狂点 WiFi 上传按钮时,多个 startUpload 都能通过守卫,最终并发 xhr.send,
|
||||
@@ -1008,12 +1127,14 @@ async function startUpload(chip) {
|
||||
addLog('E', '上传', '设备已有 OTA 进行中 (sys_state=' + (ota.sys_state || ssc) + '),拒绝本次 ESP32 上传');
|
||||
showToast('设备正在 OTA 中,请等待结束后再试', 'error');
|
||||
isUploading = false;
|
||||
resumePollingAfterUpload();
|
||||
return;
|
||||
}
|
||||
if (chip === 'gd32' && (esp32Busy || gd32Busy)) {
|
||||
addLog('E', '上传', '设备已有 OTA 进行中 (sys_state=' + (ota.sys_state || ssc) + '),拒绝本次 GD32 上传');
|
||||
showToast('设备正在 OTA 中,请等待结束后再试', 'error');
|
||||
isUploading = false;
|
||||
resumePollingAfterUpload();
|
||||
return;
|
||||
}
|
||||
}
|
||||
@@ -1054,6 +1175,7 @@ async function startUpload(chip) {
|
||||
showToast('文件已变更,请重新选择', 'error');
|
||||
clearFile(chip);
|
||||
isUploading = false;
|
||||
resumePollingAfterUpload();
|
||||
return;
|
||||
}
|
||||
}
|
||||
@@ -1135,17 +1257,14 @@ async function startUpload(chip) {
|
||||
if (chip === 'gd32') {
|
||||
var overallPct = Math.min(45, Math.round(pct * 0.45));
|
||||
setProgress(overallPct, '已上传 ' + formatBytes(e.loaded) + ' / ' + formatBytes(e.total));
|
||||
var el = document.getElementById('bOTAProgress');
|
||||
if (el) el.textContent = overallPct + '%';
|
||||
} else {
|
||||
setProgress(pct, '已上传 ' + formatBytes(e.loaded) + ' / ' + formatBytes(e.total));
|
||||
var el = document.getElementById('bOTAProgress');
|
||||
if (el) el.textContent = pct + '%';
|
||||
}
|
||||
}
|
||||
});
|
||||
xhr.addEventListener('load', function () {
|
||||
isUploading = false;
|
||||
resumePollingAfterUpload();
|
||||
uploadBtn.disabled = false;
|
||||
uploadBtn.textContent = origText;
|
||||
updateUploadButtonLabel();
|
||||
@@ -1177,6 +1296,7 @@ async function startUpload(chip) {
|
||||
});
|
||||
xhr.addEventListener('error', function () {
|
||||
isUploading = false;
|
||||
resumePollingAfterUpload();
|
||||
uploadBtn.disabled = false;
|
||||
uploadBtn.textContent = origText;
|
||||
updateUploadButtonLabel();
|
||||
@@ -1189,6 +1309,7 @@ async function startUpload(chip) {
|
||||
addLog('E', '上传', '上传失败: ' + e.message);
|
||||
showToast('上传失败', 'error');
|
||||
isUploading = false;
|
||||
resumePollingAfterUpload();
|
||||
uploadBtn.disabled = false;
|
||||
uploadBtn.textContent = origText;
|
||||
updateUploadButtonLabel();
|
||||
@@ -1253,7 +1374,7 @@ async function verifyEsp32Firmware() {
|
||||
addLog('E', 'OTA', '校验失败: ' + detail);
|
||||
showToast('校验失败: ' + detail, 'error');
|
||||
var resultEl = document.getElementById('sviResult');
|
||||
if (resultEl) { resultEl.textContent = '❌ ' + detail; resultEl.className = 'svi-val err'; }
|
||||
if (resultEl) { resultEl.textContent = '? ' + detail; resultEl.className = 'svi-val err'; }
|
||||
updateEsp32StagesError();
|
||||
showVerifyActions('retry');
|
||||
}
|
||||
@@ -1462,7 +1583,31 @@ function restorePolling() {
|
||||
logsTimerId = setInterval(pollLogs, 1000);
|
||||
}
|
||||
}
|
||||
function pausePollingForUpload() {
|
||||
if (pollingPausedForUpload) return;
|
||||
pollingPausedForUpload = true;
|
||||
consecutivePollFailures = 0;
|
||||
if (statusTimerId) clearInterval(statusTimerId);
|
||||
if (logsTimerId) clearInterval(logsTimerId);
|
||||
statusTimerId = null;
|
||||
logsTimerId = null;
|
||||
}
|
||||
function resumePollingAfterUpload() {
|
||||
if (!pollingPausedForUpload) return;
|
||||
pollingPausedForUpload = false;
|
||||
consecutivePollFailures = 0;
|
||||
if (pollSlowMode) {
|
||||
statusTimerId = setInterval(pollStatus, 10000);
|
||||
logsTimerId = setInterval(pollLogs, 10000);
|
||||
} else {
|
||||
statusTimerId = setInterval(pollStatus, 1000);
|
||||
logsTimerId = setInterval(pollLogs, 1000);
|
||||
}
|
||||
pollStatus();
|
||||
pollLogs();
|
||||
}
|
||||
async function pollStatus() {
|
||||
if (pollingPausedForUpload) return;
|
||||
try {
|
||||
var resp = await fetch(getBaseUrl() + '/system/status', {
|
||||
signal: AbortSignal.timeout(3000)
|
||||
@@ -1485,14 +1630,14 @@ async function pollStatus() {
|
||||
addLog('I', '系统', 'ESP32 已恢复在线');
|
||||
showToast('ESP32 已恢复在线', 'success');
|
||||
}
|
||||
updateSystemStatusUi(data);
|
||||
try { updateSystemStatusUi(data); } catch (e2) { console.warn('updateSystemStatusUi 异常:', e2); }
|
||||
}
|
||||
} catch (e) {
|
||||
// 后台期间 setInterval 会被 Chrome/Edge 节流到最低 1 分钟/次,
|
||||
// 因此后台累积的失败计数不应递增,避免切回前台瞬间误触发 5-fail 诊断。
|
||||
if (pageVisible) consecutivePollFailures++;
|
||||
// 传输中屏蔽网关无响应的检测功能(上传期间设备忙,无法响应轮询是正常的)
|
||||
if (pageVisible && consecutivePollFailures >= 5 && !pollSlowMode && !isUploading && !isEsp32Rebooting) {
|
||||
// OTA/传输/重启中屏蔽网关无响应的检测功能(设备忙或正在重启,无法响应轮询是正常的)
|
||||
if (pageVisible && consecutivePollFailures >= 5 && !pollSlowMode && !isUploading && !isEsp32Rebooting && !currentOtaTarget) {
|
||||
addLog('W', '诊断', '设备连续 5 次无响应,正在诊断网络...');
|
||||
var diag = await diagnoseNetwork();
|
||||
addLog('W', '诊断', diag);
|
||||
@@ -1540,6 +1685,7 @@ document.addEventListener('visibilitychange', function () {
|
||||
// ==================== ESP32 实时日志拉取 (增量) ====================
|
||||
var lastLogTimestamp = 0;
|
||||
async function pollLogs() {
|
||||
if (pollingPausedForUpload) return;
|
||||
try {
|
||||
var resp = await fetch(getBaseUrl() + '/logs', {
|
||||
signal: AbortSignal.timeout(2000)
|
||||
|
||||
+1
-1
@@ -1289,7 +1289,7 @@
|
||||
// ★ 等待设备端 reorder buffer flush + ACK 回传。
|
||||
// 设备端可能正在 flush 缺口包(getReceived 跳跃增长),
|
||||
// 留足时间让最新的 ACK 到达,避免重发已 flush 的包。
|
||||
await sleep(150);
|
||||
await sleep(10);
|
||||
|
||||
for (let seq = resumeSeq; seq <= endSeq; seq++) {
|
||||
const start = seq * payloadSize;
|
||||
|
||||
+50
-6
@@ -4,7 +4,7 @@
|
||||
<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?v=20260617a">
|
||||
<link rel="stylesheet" href="style.css?v=20260619c">
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
@@ -248,8 +248,8 @@
|
||||
<div class="k">连接状态</div>
|
||||
</div>
|
||||
<div class="badge-stat">
|
||||
<div class="v warn" id="bOTAProgress">0%</div>
|
||||
<div class="k">OTA 进度</div>
|
||||
<div class="v" id="mHealth">--</div>
|
||||
<div class="k">健康状态</div>
|
||||
</div>
|
||||
<div class="badge-stat">
|
||||
<div class="v err" id="connBadge">offline</div>
|
||||
@@ -269,8 +269,6 @@
|
||||
<div class="card-body">
|
||||
<div class="metric-grid">
|
||||
<div class="metric"><div class="k">温度</div><div class="v" id="mTemp">--</div></div>
|
||||
<div class="metric"><div class="k">空闲堆</div><div class="v" id="mHeap" style="font-size:16px;">--</div></div>
|
||||
<div class="metric"><div class="k">最低空闲堆</div><div class="v" id="mMinHeap" style="font-size:16px;">--</div></div>
|
||||
<div class="metric"><div class="k">GD32 APP</div><div class="v" id="mGd32App" style="font-size:14px;">--</div></div>
|
||||
<div class="metric"><div class="k">GD32 BOOT</div><div class="v" id="mGd32Boot" style="font-size:14px;">--</div></div>
|
||||
<div class="metric"><div class="k">OTA 缓存</div><div class="v" id="mOtaCache" style="font-size:13px;">--</div></div>
|
||||
@@ -278,6 +276,52 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-head">
|
||||
<span class="ico">🧾</span> 系统状态 (/system/status)
|
||||
<button class="btn btn-ghost btn-sm card-head-btn" onclick="refreshSystemStatusNow(event)">刷新</button>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="status-kv-grid">
|
||||
<div class="status-kv"><span class="k">OTA 状态</span><b id="sOtaState">--</b></div>
|
||||
<div class="status-kv"><span class="k">Relay 状态</span><b id="sRelayState">--</b></div>
|
||||
<div class="status-kv"><span class="k">缓存进度</span><b id="sOtaStored">--</b></div>
|
||||
<div class="status-kv"><span class="k">刷新时间</span><b id="sStatusUpdated">--</b></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card collapsed">
|
||||
<div class="card-head" onclick="toggleCollapse(this)">
|
||||
<span class="ico">🧠</span> 堆与任务栈
|
||||
<button class="btn btn-ghost btn-sm card-head-btn" onclick="refreshSystemStatusNow(event)">刷新</button>
|
||||
<span class="collapse-arrow">▼</span>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="status-section-title">堆概况</div>
|
||||
<div class="heap-group">
|
||||
<div class="heap-group-title">内部 DRAM <span style="font-weight:400;color:var(--text-dim);font-size:10px;">(数据堆)</span></div>
|
||||
<div class="heap-bar-row">
|
||||
<span class="heap-bar-text" id="memInternalUsage">--</span>
|
||||
<span class="heap-bar-track"><span class="heap-bar-fill" id="memInternalFill"></span></span>
|
||||
<span class="heap-bar-pct" id="memInternalPct">--</span>
|
||||
</div>
|
||||
<div class="heap-detail-row">
|
||||
<span>空闲: <b id="mInternalHeap">--</b></span>
|
||||
<span>最低空闲: <b id="mInternalMinHeap">--</b></span>
|
||||
<span>最大块: <b id="mInternalLargestBlock">--</b></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="status-section-title">任务栈水位</div>
|
||||
<div class="status-task-list" id="taskStackList">
|
||||
<div class="status-empty">等待状态刷新...</div>
|
||||
</div>
|
||||
<div class="status-section-title">原始 JSON</div>
|
||||
<pre class="status-json" id="statusJson">--</pre>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- CAN panel -->
|
||||
<div class="card collapsed">
|
||||
<div class="card-head" onclick="toggleCollapse(this)">
|
||||
@@ -369,7 +413,7 @@
|
||||
<span id="toastMsg">操作成功</span>
|
||||
</div>
|
||||
|
||||
<script src="app.js?v=20260619c"></script>
|
||||
<script src="app.js?v=20260619e"></script>
|
||||
<script>
|
||||
window.connectionMode = window.connectionMode || 'wifi';
|
||||
window.flashTarget = window.flashTarget || 'esp32';
|
||||
|
||||
+212
@@ -116,6 +116,12 @@ body::before {
|
||||
text-transform: uppercase; letter-spacing: .06em;
|
||||
}
|
||||
.card-head .ico { font-size: 15px; }
|
||||
.card-head-btn {
|
||||
margin-left: auto;
|
||||
padding: 4px 10px;
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
}
|
||||
.card-body { padding: 16px 18px; }
|
||||
.card.collapsed .card-body { display: none; }
|
||||
.card-head { cursor: pointer; }
|
||||
@@ -326,6 +332,211 @@ select option { background: var(--bg-panel); color: var(--text); }
|
||||
}
|
||||
.metric .v.highlight { color: var(--accent); }
|
||||
|
||||
.status-kv-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 10px;
|
||||
}
|
||||
.status-kv {
|
||||
background: var(--bg-elevated);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 10px 12px;
|
||||
}
|
||||
.status-kv .k {
|
||||
display: block;
|
||||
font-size: 10px;
|
||||
color: var(--text-label);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: .08em;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
.status-kv b {
|
||||
display: block;
|
||||
color: var(--text);
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
word-break: break-word;
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
.status-section-title { margin-top: 14px; margin-bottom: 8px; font-size: 11px; color: var(--text-label); text-transform: uppercase; letter-spacing: .08em; }
|
||||
|
||||
/* ===== 堆概况 ===== */
|
||||
.heap-group {
|
||||
background: rgba(255,255,255,0.02);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
padding: 12px 14px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.heap-group:last-child { margin-bottom: 0; }
|
||||
|
||||
.heap-group-title {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
color: var(--text-label);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.heap-bar-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.heap-bar-text {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
min-width: 165px;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.heap-bar-track {
|
||||
flex: 1;
|
||||
height: 14px;
|
||||
background: rgba(255,255,255,0.06);
|
||||
border-radius: 7px;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.heap-bar-fill {
|
||||
display: block;
|
||||
height: 100%;
|
||||
border-radius: 7px;
|
||||
background: linear-gradient(90deg, var(--accent), #5eead4);
|
||||
transition: width .4s ease;
|
||||
.heap-bar-fill.warn {
|
||||
background: linear-gradient(90deg, #f59e0b, #d97706);
|
||||
}
|
||||
.heap-bar-fill.danger {
|
||||
background: linear-gradient(90deg, #ef4444, #dc2626);
|
||||
}
|
||||
}
|
||||
|
||||
.heap-bar-pct {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
min-width: 42px;
|
||||
text-align: right;
|
||||
font-variant-numeric: tabular-nums;
|
||||
color: var(--text-dim);
|
||||
}
|
||||
|
||||
.heap-detail-row {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
font-size: 11px;
|
||||
color: var(--text-dim);
|
||||
margin-top: 6px;
|
||||
padding-top: 6px;
|
||||
border-top: 1px solid rgba(255,255,255,0.04);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.heap-detail-row span {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
.heap-detail-row b {
|
||||
color: var(--text);
|
||||
font-weight: 500;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.status-task-list {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
}
|
||||
.status-task {
|
||||
background: var(--bg-elevated);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 10px 12px;
|
||||
}
|
||||
.status-task-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.status-task-name {
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
color: var(--text);
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
.status-task-ratio {
|
||||
font-size: 11px;
|
||||
color: var(--text-dim);
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
.status-task-bar {
|
||||
width: 100%;
|
||||
height: 8px;
|
||||
border-radius: 999px;
|
||||
background: var(--bg-input);
|
||||
overflow: hidden;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.status-task-bar-fill {
|
||||
height: 100%;
|
||||
border-radius: 999px;
|
||||
background: linear-gradient(90deg, #22d3ee, #06b6d4);
|
||||
}
|
||||
.status-task-bar-fill.warn {
|
||||
background: linear-gradient(90deg, #f59e0b, #d97706);
|
||||
}
|
||||
.status-task-bar-fill.err {
|
||||
background: linear-gradient(90deg, #ef4444, #dc2626);
|
||||
}
|
||||
.status-task-meta {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 8px;
|
||||
}
|
||||
.status-task-meta span {
|
||||
display: block;
|
||||
font-size: 10px;
|
||||
color: var(--text-label);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: .06em;
|
||||
}
|
||||
.status-task-meta b {
|
||||
display: block;
|
||||
margin-top: 2px;
|
||||
color: var(--text);
|
||||
font-size: 11px;
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
.status-json {
|
||||
margin-top: 0;
|
||||
padding: 12px;
|
||||
border-radius: var(--radius-sm);
|
||||
border: 1px solid var(--border);
|
||||
background: var(--bg-elevated);
|
||||
color: var(--text-dim);
|
||||
font-size: 11px;
|
||||
line-height: 1.6;
|
||||
max-height: 280px;
|
||||
overflow: auto;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
.status-empty {
|
||||
padding: 12px;
|
||||
border: 1px dashed var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--text-dim);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
/* === PRIMARY STATUS BADGES (large) === */
|
||||
.badge-grid {
|
||||
display: grid; grid-template-columns: repeat(2, 1fr); gap: 10px;
|
||||
@@ -662,6 +873,7 @@ code { color: #d3f9ff; font-family: var(--font-mono); }
|
||||
.container { padding: 16px; }
|
||||
.header-bar { flex-direction: column; align-items: flex-start; }
|
||||
.badge-grid, .metric-grid { grid-template-columns: 1fr; }
|
||||
.status-kv-grid, .status-task-meta { grid-template-columns: 1fr; }
|
||||
.can-grid { grid-template-columns: 1fr 1fr; }
|
||||
.btn-row { flex-direction: column; }
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user