From 3fff959a2bae6b8f2499961b60695b8dbe3cb4d2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=B0=8F=E9=BE=99=E8=99=BE?= Date: Sat, 20 Jun 2026 00:46:04 +0800 Subject: [PATCH] =?UTF-8?q?feat(config):=20=E4=BC=98=E5=8C=96NimBLE?= =?UTF-8?q?=E9=85=8D=E7=BD=AE=E4=BB=A5=E8=8A=82=E7=9C=81=E5=86=85=E5=AD=98?= =?UTF-8?q?=E5=B9=B6=E6=8F=90=E5=8D=87OTA=E6=80=A7=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 启用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端点返回更全面的系统资源使用情况 - 包含最大空闲块、最小空闲内存等关键指标 --- config/sdkconfig.defaults | 28 +++++ main/core/main.cpp | 4 +- main/include/version.h | 8 +- main/monitor/debug_log.cpp | 115 +++++++++-------- main/monitor/debug_log.h | 5 +- main/monitor/health_monitor.cpp | 3 +- main/monitor/health_monitor.h | 7 +- main/relay/ota_relay.cpp | 7 ++ main/relay/ota_relay.h | 6 + main/server/ble_ota.cpp | 4 +- main/server/ble_ota.h | 9 ++ main/server/wifi_ota.cpp | 94 +++++++++++++- main/server/wifi_ota.h | 5 + sdkconfig.defaults | 33 ++++- tools/app.js | 206 ++++++++++++++++++++++++++----- tools/ble_ota.js | 2 +- tools/index.html | 56 ++++++++- tools/style.css | 212 ++++++++++++++++++++++++++++++++ 18 files changed, 701 insertions(+), 103 deletions(-) diff --git a/config/sdkconfig.defaults b/config/sdkconfig.defaults index 8655193..a9a8cda 100644 --- a/config/sdkconfig.defaults +++ b/config/sdkconfig.defaults @@ -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 diff --git a/main/core/main.cpp b/main/core/main.cpp index f3149c9..4ca6e43 100644 --- a/main/core/main.cpp +++ b/main/core/main.cpp @@ -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, diff --git a/main/include/version.h b/main/include/version.h index b57678d..5b700f8 100644 --- a/main/include/version.h +++ b/main/include/version.h @@ -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" diff --git a/main/monitor/debug_log.cpp b/main/monitor/debug_log.cpp index 2ceb003..817f9b7 100644 --- a/main/monitor/debug_log.cpp +++ b/main/monitor/debug_log.cpp @@ -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; } /** @@ -350,4 +365,4 @@ void DebugLog::log(debug_log_level_t level, const char *tag, const char *fmt, .. int total = snprintf(log_buf, sizeof(log_buf), "%s %s\n", prefix, msg); send((const uint8_t *)log_buf, total); -} \ No newline at end of file +} diff --git a/main/monitor/debug_log.h b/main/monitor/debug_log.h index f1cb284..e920b3b 100644 --- a/main/monitor/debug_log.h +++ b/main/monitor/debug_log.h @@ -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 当前日志级别(运行时可改)。 */ @@ -107,4 +108,4 @@ private: #define DEBUG_INFO(tag, fmt, ...) DebugLog::instance().log(DEBUG_LOG_INFO, tag, fmt, ##__VA_ARGS__) #define DEBUG_DBG(tag, fmt, ...) DebugLog::instance().log(DEBUG_LOG_DBG, tag, fmt, ##__VA_ARGS__) -#endif // DEBUG_LOG_H \ No newline at end of file +#endif // DEBUG_LOG_H diff --git a/main/monitor/health_monitor.cpp b/main/monitor/health_monitor.cpp index 8be3d7e..f93b57b 100644 --- a/main/monitor/health_monitor.cpp +++ b/main/monitor/health_monitor.cpp @@ -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); diff --git a/main/monitor/health_monitor.h b/main/monitor/health_monitor.h index c4665ab..2277a1c 100644 --- a/main/monitor/health_monitor.h +++ b/main/monitor/health_monitor.h @@ -8,6 +8,7 @@ #include #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); -}; \ No newline at end of file +}; diff --git a/main/relay/ota_relay.cpp b/main/relay/ota_relay.cpp index 9b58633..29ade54 100644 --- a/main/relay/ota_relay.cpp +++ b/main/relay/ota_relay.cpp @@ -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(); diff --git a/main/relay/ota_relay.h b/main/relay/ota_relay.h index 31f78e7..4b4912b 100644 --- a/main/relay/ota_relay.h +++ b/main/relay/ota_relay.h @@ -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; diff --git a/main/server/ble_ota.cpp b/main/server/ble_ota.cpp index faa0ed0..245afc3 100644 --- a/main/server/ble_ota.cpp +++ b/main/server/ble_ota.cpp @@ -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); diff --git a/main/server/ble_ota.h b/main/server/ble_ota.h index 863bfda..568e951 100644 --- a/main/server/ble_ota.h +++ b/main/server/ble_ota.h @@ -11,6 +11,7 @@ #include #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 diff --git a/main/server/wifi_ota.cpp b/main/server/wifi_ota.cpp index 2ab0f55..b7cfcd5 100644 --- a/main/server/wifi_ota.cpp +++ b/main/server/wifi_ota.cpp @@ -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 任务启动 ==="); diff --git a/main/server/wifi_ota.h b/main/server/wifi_ota.h index 5ffe550..76ba717 100644 --- a/main/server/wifi_ota.h +++ b/main/server/wifi_ota.h @@ -11,6 +11,7 @@ #include "esp_ota_ops.h" #include "esp_http_server.h" #include "esp_event.h" +#include "freertos/task.h" #include // 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 m_staConnected{false}; @@ -110,6 +114,7 @@ private: std::atomic m_gd32StorageDone{false}; std::atomic m_relayTriggered{false}; SemaphoreHandle_t m_stateMutex = NULL; + TaskHandle_t m_taskHandle = nullptr; /** * @brief 初始化 SoftAP 模式,供局域网 OTA 使用。 diff --git a/sdkconfig.defaults b/sdkconfig.defaults index c313fd7..7fa4c39 100644 --- a/sdkconfig.defaults +++ b/sdkconfig.defaults @@ -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 diff --git a/tools/app.js b/tools/app.js index 5ca3d10..a18bdc3 100644 --- a/tools/app.js +++ b/tools/app.js @@ -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, '''); +} +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 = '
当前状态未包含任务栈信息
'; + } 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 '' + + '
' + + '
' + + '
' + escapeHtml(name) + '
' + + '
' + escapeHtml(formatPercent(usagePct)) + '
' + + '
' + + '
' + + '
' + + '
栈大小' + escapeHtml(formatBytes(stackSize)) + '
' + + '
剩余水位' + escapeHtml(formatBytes(watermark)) + '
' + + '
峰值占用' + escapeHtml(formatBytes(peakUsed)) + '
' + + '
' + + '
'; + }).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) diff --git a/tools/ble_ota.js b/tools/ble_ota.js index b10daa2..d38a44b 100644 --- a/tools/ble_ota.js +++ b/tools/ble_ota.js @@ -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; diff --git a/tools/index.html b/tools/index.html index c953d6f..0e3e22f 100644 --- a/tools/index.html +++ b/tools/index.html @@ -4,7 +4,7 @@ ESP32 OTA 中继控制台 - +
@@ -248,8 +248,8 @@
连接状态
-
0%
-
OTA 进度
+
--
+
健康状态
offline
@@ -269,8 +269,6 @@
温度
--
-
空闲堆
--
-
最低空闲堆
--
GD32 APP
--
GD32 BOOT
--
OTA 缓存
--
@@ -278,6 +276,52 @@
+
+
+ 🧾 系统状态 (/system/status) + +
+
+
+
OTA 状态--
+
Relay 状态--
+
缓存进度--
+
刷新时间--
+
+
+
+ + +